feat(traffic): a host's top senders, on its page and as get_host_senders (#6115)
The host page gains a "Who's sending" panel, and the MCP a tool, that put the host's outbound (or inbound) traffic down to its containers and NFS mounts: read in shared time buckets, so the host's busiest bucket says who was sending in it. A swarm service's tasks add up under its name; what none of them accounts for is the host itself. Also fixes CI 9488: per-container net_rx_bps / net_tx_bps join the docker alert catalog, so a rule can catch a container flooding the uplink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,12 @@ import json
|
||||
from steward.hosts import service as host_svc
|
||||
from steward.mcp._context import current_caller, mcp_app
|
||||
from steward.mcp.tools._audit import audit_mcp
|
||||
from steward.mcp.tools._shape import age_s, clamp_limit, db_session, iso, now_utc, pick
|
||||
from steward.mcp.tools._shape import age_s, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd
|
||||
from steward.mcp.tools.overview import finding
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from . import cadence, downtime, maintenance
|
||||
from . import cadence, downtime, maintenance, senders
|
||||
from .models import (HostAgentRegistration, HostDowntime, HostMaintenanceJob, HostMaintenancePlan,
|
||||
HostMaintenanceRun)
|
||||
|
||||
@@ -234,6 +234,49 @@ async def get_maintenance_job(host: str = "", job: str = "", limit: int = 10) ->
|
||||
return {"jobs": rows, "total": len(rows), "truncated": len(jobs) > limit}
|
||||
|
||||
|
||||
async def get_host_senders(host: str, range: str = "1h", direction: str = "tx", limit: int = 10) -> dict:
|
||||
"""Who sent (or received) a host's network traffic: its containers and NFS
|
||||
mounts, ranked against the host's own total.
|
||||
|
||||
host: name or id (list_hosts). range: "1h" (default), "6h", "24h" or "7d"
|
||||
(at most the raw window; the rollup keeps no per-sender detail).
|
||||
direction: "tx" (default, what the host sent) or "rx" (what it received).
|
||||
limit: senders listed (default 10, max 50).
|
||||
|
||||
The window is cut into about 120 buckets (`bucket_s` wide). `host` is the
|
||||
host's physical interfaces: average, peak and the bucket it peaked in.
|
||||
Each sender is a container (a swarm service's tasks added up, by service
|
||||
name) or an NFS mount, with its average over the window, its own peak
|
||||
and when, and `at_host_peak_bps`, what it sent in the host's peak bucket;
|
||||
biggest there first. `unaccounted_*` is what the host carried beyond them
|
||||
all: the host itself (a download, a backup run on the host, swarm ingress
|
||||
passing traffic to another node). A container's traffic to another on
|
||||
the same host counts for it without crossing an interface, so senders
|
||||
can add up to more than the host. Per-container rates need Steward after
|
||||
#6115; NFS bytes need agent 1.25.0. Bytes a second throughout.
|
||||
"""
|
||||
since, key = lookback(range)
|
||||
limit = clamp_limit(limit, 10, 50)
|
||||
async with db_session() as db:
|
||||
h = pick(host, (await db.execute(select(Host))).scalars(), kind="host", list_tool="list_hosts")
|
||||
got = await senders.senders(db, h.name, since, direction=direction, limit=limit)
|
||||
if got["host"] is None:
|
||||
raise ValueError(f"no net_{direction}_bps recorded for host {h.name!r} in the last {key}; "
|
||||
"is its agent reporting? list_agents says.")
|
||||
return {
|
||||
"host_id": h.id, "host_name": h.name, "range": key, "direction": direction,
|
||||
"since": iso(got["since"]), "bucket_s": got["bucket_s"],
|
||||
"host": {"avg_bps": rnd(got["host"]["avg_bps"], 1), "peak_bps": rnd(got["host"]["peak_bps"], 1),
|
||||
"peak_at": iso(got["host"]["peak_at"])},
|
||||
"senders": [{"kind": r["kind"], "name": r["name"], "at_host_peak_bps": rnd(r["at_host_peak_bps"], 1),
|
||||
"avg_bps": rnd(r["avg_bps"], 1), "peak_bps": rnd(r["peak_bps"], 1),
|
||||
"peak_at": iso(r["peak_at"])} for r in got["senders"]],
|
||||
"more": got["more"],
|
||||
"unaccounted_avg_bps": rnd(got["unaccounted_avg_bps"], 1),
|
||||
"unaccounted_at_peak_bps": rnd(got["unaccounted_at_peak_bps"], 1),
|
||||
}
|
||||
|
||||
|
||||
async def run_maintenance(host: str, reboot: str = "if_required") -> dict:
|
||||
"""Update a host now, as the host page's Update now does: the job starts
|
||||
with the agent's next post and get_maintenance_job follows it.
|
||||
@@ -336,4 +379,5 @@ def register_tools(register) -> None:
|
||||
register(list_agents, role=UserRole.viewer, read_only=True)
|
||||
register(list_maintenance_plans, role=UserRole.viewer, read_only=True)
|
||||
register(get_maintenance_job, role=UserRole.viewer, read_only=True)
|
||||
register(get_host_senders, role=UserRole.viewer, read_only=True)
|
||||
register(run_maintenance, role=UserRole.admin, read_only=False)
|
||||
|
||||
Reference in New Issue
Block a user