feat(traffic): a host's top senders, on its page and as get_host_senders (#6115)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m4s

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:
2026-10-19 14:38:28 -04:00
co-authored by Claude Opus 5.5
parent 613ed4e78b
commit 26bc57ce04
11 changed files with 319 additions and 7 deletions
+46 -2
View File
@@ -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)