feat(norms): hosts, containers and monitor response times are graded against their own normal (#5742)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m26s
CI / integration (push) Failing after 1m27s
CI / publish (push) Skipped

Host CPU, memory and load per core, container CPU and memory, and every
monitor's response time learn a normal per hour (milestone 513, System 182).
A reading past its fixed cutoff but within its normal reads healthy and says
what is usual at this hour; one well out of it keeps its grade and is a
metric anomaly. Disk, a monitor down and an agent offline keep their cutoffs.

- Each normal's floor, the smallest departure that counts, is a setting:
  Settings -> Thresholds -> Normals (norms.floor.*). Defaults: host CPU 10
  points, host memory 5, load per core 10, container CPU 10 points of one
  core, container memory 5, monitor response 100 ms; Traefik's 2-point 4xx
  and 50 ms p95 floors move there from constants. learn_normal takes a
  floor function (floor_setting) read at each pass.
- learn_normal(parts=False): a host learns, its cores don't.
- Ingest records load_pct (load_1m / cores x 100), the figure pages grade.
- Shared grading: anomalies.against / graded, worst_reading(normals=),
  hosts' host_grades, docker usage(thresholds=, norms=), monitors'
  latency_grade. Applied to host vitals, the Hosts widget, MCP overview,
  Docker usage, and the monitor list, widget and page.
- The monitor and container pages list their anomalies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:17:04 -04:00
co-authored by Claude Opus 5.5
parent f3cfb89612
commit f3433fe3ce
30 changed files with 638 additions and 93 deletions
+52 -2
View File
@@ -155,7 +155,8 @@ def display_name(c) -> str:
return c.name
def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = None) -> dict:
def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = None, *,
thresholds: dict | None = None, norms: dict | None = None) -> dict:
"""A container's CPU and memory in terms a reader can weigh.
Docker's CPU% is per core (100% = one core busy), so it is shown as cores
@@ -164,11 +165,16 @@ def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = No
is shown in bytes, against a limit only when one is actually set; mem_pct
is then a share of that limit, otherwise of the host's memory. `cpu_pct`
replaces the container's last sample (an average, say) when given.
With `thresholds`, cpu_grade and mem_grade are the grades to show: past
the cutoff but within the container's own normal for the hour (`norms`,
{metric: Norm}, container_norms) is "ok", and cpu_usual / mem_usual say
what is usual (#5742). Without, they are None and the page grades.
"""
cpu = getattr(c, "cpu_pct", None) if cpu_pct is None else cpu_pct
limit = getattr(c, "mem_limit_bytes", None)
limited = bool(limit and mem_total and limit < mem_total * 0.95)
return {
out = {
"cores": None if cpu is None else cpu / 100.0,
"cpu_share": None if cpu is None or not cpus else cpu / cpus,
"cpus": cpus,
@@ -176,7 +182,51 @@ def usage(c, cpus: int | None, mem_total: int | None, cpu_pct: float | None = No
"mem_limit": limit if limited else None,
"mem_pct": getattr(c, "mem_pct", None),
"mem_of": "its limit" if limited else ("the host's memory" if mem_total else None),
"cpu_grade": None, "cpu_usual": None, "mem_grade": None, "mem_usual": None,
}
if thresholds is not None:
_grade(out, cpu, thresholds, norms or {})
return out
# Container figures graded against their own normal (#5742): the metric that
# learns it and its floor's setting (norms.floor.*).
NORMED = {"cpu": ("cpu_pct", "container_cpu_pct"), "mem": ("mem_pct", "container_mem_pct")}
def _grade(u: dict, cpu: float | None, thresholds: dict, norms: dict) -> None:
"""Fill usage()'s grades. CPU is graded as a share of the host but learned
per core (Docker's own figure), so its normal is read in cores."""
from steward.core.anomalies import departs, graded
from steward.core.formatting import status_class
from steward.core.settings import floor_of
grade = status_class(u["cpu_share"], "cpu", thresholds)
norm = norms.get("cpu_pct")
if grade in ("warn", "crit") and norm is not None and cpu is not None \
and not departs(cpu, norm, floor_of(thresholds, "container_cpu_pct")):
grade = "ok"
u["cpu_usual"] = f"usual {norm.usual_hold / 100:.2f} cores at this hour"
u["cpu_grade"] = grade
if u["mem_of"]:
u["mem_grade"], u["mem_usual"] = graded(
u["mem_pct"], "mem", thresholds, norms.get("mem_pct"),
floor_of(thresholds, "container_mem_pct"), "%")
else:
u["mem_grade"] = "dim"
async def container_norms(db, keys, now: datetime | None = None) -> dict:
"""{(host_id, container_name): {metric: Norm}}: each container's CPU and
memory normals for this hour. One without enough history is absent."""
from steward.core.norms import norms_at
resources = await container_resources(db, keys)
got = await norms_at(db, [("docker", r, metric) for r in resources
for metric, _f in NORMED.values()],
now or datetime.now(timezone.utc))
out: dict = {}
for (_s, resource, metric), norm in got.items():
out.setdefault(resources[resource], {})[metric] = norm
return out
async def host_capacity(db, host_ids) -> dict[str, tuple[int | None, int | None]]: