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
+5
View File
@@ -43,6 +43,11 @@ def setup(app: "Quart") -> None:
description="Keep each container's logs within the age window and byte cap.",
required_role=UserRole.viewer,
)
# Each container's CPU (100% is one core) and memory share learn their
# normal (#5742), each with its floor from Settings → Thresholds.
from steward.core.norms import floor_setting, learn_normal
learn_normal("docker", "cpu_pct", floor_setting(app, "container_cpu_pct"))
learn_normal("docker", "mem_pct", floor_setting(app, "container_mem_pct"))
def get_scheduled_tasks() -> list:
+30 -5
View File
@@ -12,6 +12,7 @@ from sqlalchemy import func, select
from steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.core import anomalies as anom
from steward.core.formatting import human_bytes, human_duration, seconds_since
from steward.core.time_range import parse_range, DEFAULT_RANGE
from . import service as docker_svc
@@ -159,16 +160,20 @@ async def rows():
host_name = {h.id: h.name for h in hosts.values()}
swarm_view = ctx["swarm_view"]
capacity = await docker_svc.host_capacity(db, {c.host_id for c in containers})
norms = await docker_svc.container_norms(
db, {(c.host_id, c.name) for c in containers if not c.service_name}, now)
failures = docker_svc.failures_24h(
await docker_svc.failure_events(db, now - timedelta(hours=24)),
swarm_view["services"], host_name)
thresholds = current_app.config.get("THRESHOLDS", {})
service_rows = [service_row(s, now) for s in swarm_view["services"]]
standalone = [c for c in containers if not c.service_name]
container_rows = [
container_row(
c, label=docker_svc.display_name(c), host=host_name.get(c.host_id, c.host_id),
usage=docker_svc.usage(c, *capacity.get(c.host_id, (None, None))),
usage=docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), thresholds=thresholds,
norms=norms.get((c.host_id, c.name))),
ports=json.loads(c.ports_json) if c.ports_json else [],
stale=_is_stale(c, now, stale_after, docker_down),
exit_failed=docker_svc.exit_is_failure(c.exit_code, c.oom_killed), now=now)
@@ -314,6 +319,8 @@ async def widget_resources():
db, {(c.host_id, c.name) for c in busiest}, slots[0], width)
hosts = await docker_svc.host_map(db, {c.host_id for c in busiest})
capacity = await docker_svc.host_capacity(db, {c.host_id for c in busiest})
norms = await docker_svc.container_norms(db, {(c.host_id, c.name) for c in busiest})
thresholds = current_app.config.get("THRESHOLDS", {})
def bars(c, i, scale, floor):
seen = history.get((c.host_id, c.name), {})
@@ -325,7 +332,8 @@ async def widget_resources():
"c": c,
"label": docker_svc.display_name(c),
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id,
"usage": docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), cpu_pct=cpu_of(c)),
"usage": docker_svc.usage(c, *capacity.get(c.host_id, (None, None)), cpu_pct=cpu_of(c),
thresholds=thresholds, norms=norms.get((c.host_id, c.name))),
"cpu_bars": bars(c, 0, 0.01, cpu_floor), # cpu_pct → cores
"mem_bars": bars(c, 1, 1.0, mem_floor * 2**30), # bytes; floor in GiB
} for c in busiest]
@@ -365,7 +373,9 @@ async def host_panel(host_id: str):
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
failures = await docker_svc.recent_failures_by_container(db, now - timedelta(hours=24))
capacity = await docker_svc.host_capacity(db, {host_id})
norms = await docker_svc.container_norms(db, {(c.host_id, c.name) for c in containers}, now)
thresholds = current_app.config.get("THRESHOLDS", {})
replaced = replaced_tasks(containers, services)
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
hour_ago = now - timedelta(hours=1)
@@ -380,7 +390,8 @@ async def host_panel(host_id: str):
rows.append({
"c": c, "label": docker_svc.task_label(c, node_hostname), "problems": problems,
"since": docker_svc.problem_since(problems, events, hour_ago),
"usage": docker_svc.usage(c, *capacity.get(host_id, (None, None))),
"usage": docker_svc.usage(c, *capacity.get(host_id, (None, None)), thresholds=thresholds,
norms=norms.get((c.host_id, c.name))),
"uptime": _uptime(c.started_at) if c.status == "running" else None,
})
shown, folded = host_panel_rows(rows)
@@ -445,6 +456,13 @@ async def container_detail(host_id: str, name: str):
)).scalar()
cpus, mem_total = (await docker_svc.host_capacity(db, [host_id])).get(host_id, (None, None)) \
if c is not None else (None, None)
# Its CPU and memory against their normal, and its anomalies (#5742).
norms = (await docker_svc.container_norms(db, {(host_id, name)})).get((host_id, name))
resource = docker_svc.container_resource(host.name, name) if host is not None else None
anomalies = await anom.views(db, await anom.list_anomalies(
db, source="docker", resource=resource,
since=datetime.now(timezone.utc) - timedelta(days=30), limit=20),
links=False) if resource else []
if c is None:
return await render_template(
@@ -460,7 +478,10 @@ async def container_detail(host_id: str, name: str):
name=name,
label=docker_svc.task_label(c, {c.node_id: node_name} if node_name else None),
node_name=node_name,
usage=docker_svc.usage(c, cpus, mem_total),
usage=docker_svc.usage(c, cpus, mem_total, thresholds=current_app.config.get("THRESHOLDS", {}),
norms=norms),
anomalies=anomalies, anomaly_back=f"/plugins/docker/container/{host_id}/{name}",
anomaly_resource=resource,
exit_failed=docker_svc.exit_is_failure(c.exit_code, c.oom_killed),
ports=json.loads(c.ports_json) if c.ports_json else [],
uptime=_uptime(c.started_at) if c.status == "running" else None,
@@ -888,6 +909,8 @@ async def service_detail(name: str):
s = next((s for s in ctx["swarm_view"]["services"] if s["name"] == name), None)
capacity = await docker_svc.host_capacity(
db, {r["host_id"] for r in (s["replicas"] if s else []) if not r["ghost"]})
norms = await docker_svc.container_norms(
db, {(r["host_id"], r["name"]) for r in (s["replicas"] if s else []) if not r["ghost"]}, now)
if s is None and not task_containers:
return await render_template("docker/service.html", name=name, service=None), 404
@@ -899,7 +922,9 @@ async def service_detail(name: str):
for r in (s["replicas"] if s else []):
c = None if r["ghost"] else by_key.get((r["host_id"], r["name"]))
replicas.append(dict(r, task_id=getattr(c, "task_id", None) if c is not None else None,
usage=docker_svc.usage(c, *capacity.get(r["host_id"], (None, None)))
usage=docker_svc.usage(c, *capacity.get(r["host_id"], (None, None)),
thresholds=current_app.config.get("THRESHOLDS", {}),
norms=norms.get((c.host_id, c.name)))
if c is not None else None))
node_host = {c.node_id: c.host_id for c in ctx["containers"] if c.node_id}
table = _service_tasks(name, tasks, replicas, node_host, ctx["host_name"])
+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]]:
+7 -5
View File
@@ -3,7 +3,9 @@
Docker counts CPU per core (100% = one core busy), so CPU reads as cores,
toned by its share of the host's CPUs when the host's core count is known.
Memory reads 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. -#}
mem_pct is then a share of that limit, otherwise of the host's memory.
Each is toned by usage()'s grade when it has one: within the container's
own normal for the hour reads healthy, and says what is usual (#5742). -#}
{% macro cores(n) -%}
{{ "%.2f"|format(n) if n < 1 else "%.1f"|format(n) }} {{ "core" if n == 1 else "cores" }}
@@ -12,15 +14,15 @@
{% macro cpu(u) -%}
{%- if u.cores is none -%}<span class="tone-dim">—</span>
{%- else -%}
<span class="tone-{{ status_class(u.cpu_share, 'cpu') }}"
title="Docker counts CPU per core: {{ '%.0f'|format(u.cores * 100) }}% of one core{% if u.cpus %}, {{ '%.1f'|format(u.cpu_share) }}% of this host's {{ u.cpus }} core{{ 's' if u.cpus != 1 }}{% endif %}">{{ cores(u.cores) }}</span>
<span class="tone-{{ u.cpu_grade or status_class(u.cpu_share, 'cpu') }}"
title="Docker counts CPU per core: {{ '%.0f'|format(u.cores * 100) }}% of one core{% if u.cpus %}, {{ '%.1f'|format(u.cpu_share) }}% of this host's {{ u.cpus }} core{{ 's' if u.cpus != 1 }}{% endif %}{% if u.cpu_usual %}; {{ u.cpu_usual }}{% endif %}">{{ cores(u.cores) }}</span>
{%- endif -%}
{%- endmacro %}
{% macro mem(u) -%}
{%- if u.mem_bytes is none -%}<span class="tone-dim">—</span>
{%- else -%}
<span class="tone-{{ status_class(u.mem_pct, 'mem') if u.mem_of else 'dim' }}"
title="{% if u.mem_pct is not none and u.mem_of %}{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}{% else %}No memory limit set{% endif %}">{{ u.mem_bytes|bytes }}{% if u.mem_limit %} / {{ u.mem_limit|bytes }}{% endif %}</span>
<span class="tone-{{ u.mem_grade or (status_class(u.mem_pct, 'mem') if u.mem_of else 'dim') }}"
title="{% if u.mem_pct is not none and u.mem_of %}{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}{% if u.mem_usual %}, {{ u.mem_usual }}{% endif %}{% else %}No memory limit set{% endif %}">{{ u.mem_bytes|bytes }}{% if u.mem_limit %} / {{ u.mem_limit|bytes }}{% endif %}</span>
{%- endif -%}
{%- endmacro %}
@@ -1,7 +1,8 @@
{# docker/container_detail.html — one container (#5310), shaped like a
service's page: a header that says whether it is well and what it is; why
it stopped, only while it has; what it moved since it started; its CPU and
memory over time (the one home for what it uses now); its lifecycle.
memory over time (the one home for what it uses now), with its metric
anomalies above when it has any; its lifecycle.
A swarm task is titled by its service and slot, the raw name under it. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, status_badge %}
@@ -63,6 +64,9 @@
</section>
{% endif %}
{# ── Its CPU or memory out of its normal for the hour, and past anomalies (#5742) #}
{% with back=anomaly_back, base_resource=anomaly_resource %}{% include "_anomaly_card.html" %}{% endwith %}
{# ── What it is set up with, and what it moved since it started ─────────── #}
<div class="card">
<dl class="kv">
@@ -33,13 +33,13 @@
{%- if session.user_id %}<a href="/plugins/docker/container/{{ r.c.host_id }}/{{ r.c.name }}">{{ r.label }}</a>{% else %}{{ r.label }}{% endif -%}
</td>
<td class="w-wide-only w-text" title="{{ r.host_name }}">{{ r.host_name }}</td>
<td class="tone-{{ status_class(u.cpu_share, 'cpu') if u.cpus and u.cores is not none else 'dim' }}"
{% if u.cpus and u.cores is not none %}title="{{ '%.1f'|format(u.cpu_share) }}% of {{ r.host_name }}'s {{ u.cpus }} cores"{% endif %}>
<td class="tone-{{ (u.cpu_grade or status_class(u.cpu_share, 'cpu')) if u.cpus and u.cores is not none else 'dim' }}"
{% if u.cpus and u.cores is not none %}title="{{ '%.1f'|format(u.cpu_share) }}% of {{ r.host_name }}'s {{ u.cpus }} cores{% if u.cpu_usual %}; {{ u.cpu_usual }}{% endif %}"{% endif %}>
{{- bars(r.cpu_bars, (cores(r.cpu_bars.peak) ~ " cores") if r.cpu_bars.peak is not none else "", cores(r.cpu_bars.roof) ~ " cores") -}}
<span class="w-fig">{% if u.cores is none %}—{% else %}{{ cores(u.cores) }} cores{% endif %}</span>
</td>
<td class="tone-{{ status_class(u.mem_pct, 'mem') if u.mem_of and u.mem_bytes is not none else 'dim' }}"
{% if u.mem_pct is not none and u.mem_of and u.mem_bytes is not none %}title="{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}"{% endif %}>
<td class="tone-{{ (u.mem_grade or status_class(u.mem_pct, 'mem')) if u.mem_of and u.mem_bytes is not none else 'dim' }}"
{% if u.mem_pct is not none and u.mem_of and u.mem_bytes is not none %}title="{{ '%.1f'|format(u.mem_pct) }}% of {{ u.mem_of }}{% if u.mem_usual %}, {{ u.mem_usual }}{% endif %}"{% endif %}>
{{- bars(r.mem_bars, r.mem_bars.peak|bytes if r.mem_bars.peak is not none else "", r.mem_bars.roof|bytes) -}}
<span class="w-fig">{% if u.mem_bytes is none %}—{% else %}{{ u.mem_bytes|bytes }}{% endif %}</span>
</td>