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
+16
View File
@@ -111,6 +111,22 @@ Every minute, `metric_anomalies` averages each learned key's last 15 minutes and
- Learning skips every hour an occurrence covers unless its anomaly was marked `normal`, so a new level is never learned without review. - Learning skips every hour an occurrence covers unless its anomaly was marked `normal`, so a new level is never learned without review.
- `data_cleanup` prunes reviewed and lapsed rows at `data.retention_days`; a row still to review is never pruned. - `data_cleanup` prunes reviewed and lapsed rows at `data.retention_days`; a row still to review is never pruned.
What learns a normal, and the floor each departure must clear (Settings → Thresholds → Normals, `norms.floor.*`; a floor may be a function, `floor_setting()`, read at each pass):
| Kind | Metric | Floor (default) |
|---|---|---|
| Host CPU | `host_agent` / `cpu_pct` | 10 points |
| Host memory | `host_agent` / `mem_used_pct` | 5 points |
| Host load per core | `host_agent` / `load_pct` (load_1m ÷ cores × 100, recorded at ingest) | 10 points |
| Container CPU | `docker` / `cpu_pct` (100% is one core) | 10 points of one core |
| Container memory | `docker` / `mem_pct` (of its limit, or of host RAM when it has none) | 5 points |
| Monitor response time | `<type>` / `response_ms` | 100 ms |
| Router 4xx share, p95 | `traefik` / `error_rate_4xx_pct`, `latency_p95_ms` | 2 points, 50 ms |
Host metrics learn with `parts=False`: the host learns, its cores (`<host>:core<N>`) don't. Disk space, a monitor that is down, an agent gone quiet and a router's 5xx share are failures and keep their fixed cutoffs.
Pages grade by the normal through `anomalies.graded()` (hosts' `host_grades` and `worst_reading(normals=)`, `docker.service.usage(thresholds=, norms=)`, `monitors.service.latency_grade`): a reading past its cutoff but within its normal reads healthy and says what is usual at this hour. The host, monitor, container and Traefik pages list their anomalies with `_anomaly_card.html`.
--- ---
## Database Session Pattern ## Database Session Pattern
+1 -1
View File
@@ -89,7 +89,7 @@ The full Traefik page at `/plugins/traefik/` reads top to bottom, for the time r
A router is graded on its last 15 minutes against Settings → Thresholds. When a problem started is read from the router's history over the range. A router is graded on its last 15 minutes against Settings → Thresholds. When a problem started is read from the router's history over the range.
Once a router has 5 days of history, its 4xx share and p95 latency are judged against its own normal for that hour (see [Normals and anomalies](../architecture.md)) instead of the fixed cutoffs. One over the cutoff but within its normal is healthy, and the healthy table says it is usual for that router (an error-pages service that answers 4xx all day, a login wall's steady 401s). One out of its normal is a warning that says what is usual, and a metric anomaly to review; the Traefik page lists the routers' anomalies of the last 30 days. A 4xx share over fewer requests in the 15 minutes than Settings → Thresholds allows (30 by default) isn't judged at all. The 5xx share is a failure and always keeps its fixed cutoff. Latency is learned only from #5755's per-scrape figures, so its normal arrives 5 days after that release. Once a router has 5 days of history, its 4xx share and p95 latency are judged against its own normal for that hour (see [Normals and anomalies](../architecture.md)) instead of the fixed cutoffs. One over the cutoff but within its normal is healthy, and the healthy table says it is usual for that router (an error-pages service that answers 4xx all day, a login wall's steady 401s). One out of its normal is a warning that says what is usual, and a metric anomaly to review; the Traefik page lists the routers' anomalies of the last 30 days. How far out counts is set on Settings → Thresholds → Normals (2 points of 4xx share, 50 ms of p95 by default). A 4xx share over fewer requests in the 15 minutes than Settings → Thresholds allows (30 by default) isn't judged at all. The 5xx share is a failure and always keeps its fixed cutoff. Latency is learned only from #5755's per-scrape figures, so its normal arrives 5 days after that release.
--- ---
+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.", description="Keep each container's logs within the age window and byte cap.",
required_role=UserRole.viewer, 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: 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.auth.middleware import require_role
from steward.models.users import UserRole from steward.models.users import UserRole
from steward.models.hosts import Host 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.formatting import human_bytes, human_duration, seconds_since
from steward.core.time_range import parse_range, DEFAULT_RANGE from steward.core.time_range import parse_range, DEFAULT_RANGE
from . import service as docker_svc from . import service as docker_svc
@@ -159,16 +160,20 @@ async def rows():
host_name = {h.id: h.name for h in hosts.values()} host_name = {h.id: h.name for h in hosts.values()}
swarm_view = ctx["swarm_view"] swarm_view = ctx["swarm_view"]
capacity = await docker_svc.host_capacity(db, {c.host_id for c in containers}) 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( failures = docker_svc.failures_24h(
await docker_svc.failure_events(db, now - timedelta(hours=24)), await docker_svc.failure_events(db, now - timedelta(hours=24)),
swarm_view["services"], host_name) swarm_view["services"], host_name)
thresholds = current_app.config.get("THRESHOLDS", {})
service_rows = [service_row(s, now) for s in swarm_view["services"]] service_rows = [service_row(s, now) for s in swarm_view["services"]]
standalone = [c for c in containers if not c.service_name] standalone = [c for c in containers if not c.service_name]
container_rows = [ container_rows = [
container_row( container_row(
c, label=docker_svc.display_name(c), host=host_name.get(c.host_id, c.host_id), 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 [], ports=json.loads(c.ports_json) if c.ports_json else [],
stale=_is_stale(c, now, stale_after, docker_down), stale=_is_stale(c, now, stale_after, docker_down),
exit_failed=docker_svc.exit_is_failure(c.exit_code, c.oom_killed), now=now) 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) 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}) 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}) 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): def bars(c, i, scale, floor):
seen = history.get((c.host_id, c.name), {}) seen = history.get((c.host_id, c.name), {})
@@ -325,7 +332,8 @@ async def widget_resources():
"c": c, "c": c,
"label": docker_svc.display_name(c), "label": docker_svc.display_name(c),
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id, "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 "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 "mem_bars": bars(c, 1, 1.0, mem_floor * 2**30), # bytes; floor in GiB
} for c in busiest] } for c in busiest]
@@ -365,7 +373,9 @@ async def host_panel(host_id: str):
nodes = list((await db.execute(select(DockerSwarmNode))).scalars()) nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
failures = await docker_svc.recent_failures_by_container(db, now - timedelta(hours=24)) failures = await docker_svc.recent_failures_by_container(db, now - timedelta(hours=24))
capacity = await docker_svc.host_capacity(db, {host_id}) 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) replaced = replaced_tasks(containers, services)
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname} node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
hour_ago = now - timedelta(hours=1) hour_ago = now - timedelta(hours=1)
@@ -380,7 +390,8 @@ async def host_panel(host_id: str):
rows.append({ rows.append({
"c": c, "label": docker_svc.task_label(c, node_hostname), "problems": problems, "c": c, "label": docker_svc.task_label(c, node_hostname), "problems": problems,
"since": docker_svc.problem_since(problems, events, hour_ago), "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, "uptime": _uptime(c.started_at) if c.status == "running" else None,
}) })
shown, folded = host_panel_rows(rows) shown, folded = host_panel_rows(rows)
@@ -445,6 +456,13 @@ async def container_detail(host_id: str, name: str):
)).scalar() )).scalar()
cpus, mem_total = (await docker_svc.host_capacity(db, [host_id])).get(host_id, (None, None)) \ cpus, mem_total = (await docker_svc.host_capacity(db, [host_id])).get(host_id, (None, None)) \
if c is not None else (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: if c is None:
return await render_template( return await render_template(
@@ -460,7 +478,10 @@ async def container_detail(host_id: str, name: str):
name=name, name=name,
label=docker_svc.task_label(c, {c.node_id: node_name} if node_name else None), label=docker_svc.task_label(c, {c.node_id: node_name} if node_name else None),
node_name=node_name, 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), exit_failed=docker_svc.exit_is_failure(c.exit_code, c.oom_killed),
ports=json.loads(c.ports_json) if c.ports_json else [], ports=json.loads(c.ports_json) if c.ports_json else [],
uptime=_uptime(c.started_at) if c.status == "running" else None, 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) s = next((s for s in ctx["swarm_view"]["services"] if s["name"] == name), None)
capacity = await docker_svc.host_capacity( capacity = await docker_svc.host_capacity(
db, {r["host_id"] for r in (s["replicas"] if s else []) if not r["ghost"]}) 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: if s is None and not task_containers:
return await render_template("docker/service.html", name=name, service=None), 404 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 []): for r in (s["replicas"] if s else []):
c = None if r["ghost"] else by_key.get((r["host_id"], r["name"])) 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, 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)) if c is not None else None))
node_host = {c.node_id: c.host_id for c in ctx["containers"] if c.node_id} 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"]) 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 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. """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 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 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` 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. 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 cpu = getattr(c, "cpu_pct", None) if cpu_pct is None else cpu_pct
limit = getattr(c, "mem_limit_bytes", None) limit = getattr(c, "mem_limit_bytes", None)
limited = bool(limit and mem_total and limit < mem_total * 0.95) limited = bool(limit and mem_total and limit < mem_total * 0.95)
return { out = {
"cores": None if cpu is None else cpu / 100.0, "cores": None if cpu is None else cpu / 100.0,
"cpu_share": None if cpu is None or not cpus else cpu / cpus, "cpu_share": None if cpu is None or not cpus else cpu / cpus,
"cpus": 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_limit": limit if limited else None,
"mem_pct": getattr(c, "mem_pct", None), "mem_pct": getattr(c, "mem_pct", None),
"mem_of": "its limit" if limited else ("the host's memory" if mem_total else 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]]: 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, 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. 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; 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) -%} {% macro cores(n) -%}
{{ "%.2f"|format(n) if n < 1 else "%.1f"|format(n) }} {{ "core" if n == 1 else "cores" }} {{ "%.2f"|format(n) if n < 1 else "%.1f"|format(n) }} {{ "core" if n == 1 else "cores" }}
@@ -12,15 +14,15 @@
{% macro cpu(u) -%} {% macro cpu(u) -%}
{%- if u.cores is none -%}<span class="tone-dim">—</span> {%- if u.cores is none -%}<span class="tone-dim">—</span>
{%- else -%} {%- else -%}
<span class="tone-{{ status_class(u.cpu_share, 'cpu') }}" <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 %}">{{ cores(u.cores) }}</span> 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 -%} {%- endif -%}
{%- endmacro %} {%- endmacro %}
{% macro mem(u) -%} {% macro mem(u) -%}
{%- if u.mem_bytes is none -%}<span class="tone-dim">—</span> {%- if u.mem_bytes is none -%}<span class="tone-dim">—</span>
{%- else -%} {%- else -%}
<span class="tone-{{ status_class(u.mem_pct, 'mem') if u.mem_of else 'dim' }}" <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 }}{% else %}No memory limit set{% endif %}">{{ u.mem_bytes|bytes }}{% if u.mem_limit %} / {{ u.mem_limit|bytes }}{% endif %}</span> 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 -%} {%- endif -%}
{%- endmacro %} {%- endmacro %}
@@ -1,7 +1,8 @@
{# docker/container_detail.html — one container (#5310), shaped like a {# 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 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 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. #} A swarm task is titled by its service and slot, the raw name under it. #}
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs, status_badge %} {% from "_macros.html" import crumbs, status_badge %}
@@ -63,6 +64,9 @@
</section> </section>
{% endif %} {% 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 ─────────── #} {# ── What it is set up with, and what it moved since it started ─────────── #}
<div class="card"> <div class="card">
<dl class="kv"> <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 -%} {%- 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>
<td class="w-wide-only w-text" title="{{ r.host_name }}">{{ r.host_name }}</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' }}" <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"{% endif %}> {% 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") -}} {{- 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> <span class="w-fig">{% if u.cores is none %}—{% else %}{{ cores(u.cores) }} cores{% endif %}</span>
</td> </td>
<td class="tone-{{ status_class(u.mem_pct, 'mem') if u.mem_of and u.mem_bytes is not none else 'dim' }}" <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 }}"{% endif %}> {% 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) -}} {{- 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> <span class="w-fig">{% if u.mem_bytes is none %}—{% else %}{{ u.mem_bytes|bytes }}{% endif %}</span>
</td> </td>
+11 -2
View File
@@ -11,6 +11,14 @@ _app: "Quart | None" = None
def setup(app: "Quart") -> None: def setup(app: "Quart") -> None:
global _app global _app
_app = app _app = app
# A host's CPU, memory and load per core learn their normal (#5742), each
# with its floor from Settings → Thresholds. Parts don't: a host's CPU has
# a normal, each of its cores doesn't. Disks stay on their fixed cutoffs:
# a nearly full disk is a failure whatever its history (System 182).
from steward.core.norms import floor_setting, learn_normal
learn_normal("host_agent", "cpu_pct", floor_setting(app, "host_cpu_pct"), parts=False)
learn_normal("host_agent", "mem_used_pct", floor_setting(app, "host_mem_pct"), parts=False)
learn_normal("host_agent", "load_pct", floor_setting(app, "host_load_pct"), parts=False)
def get_scheduled_tasks() -> list: def get_scheduled_tasks() -> list:
@@ -38,9 +46,10 @@ def get_alert_metrics() -> dict[str, list[str]]:
disk_used_pct per mount, resource "<host>:<mount>"). seconds_since_seen disk_used_pct per mount, resource "<host>:<mount>"). seconds_since_seen
comes from the scheduler, not the agent: "agent offline" is > 180. comes from the scheduler, not the agent: "agent offline" is > 180.
clock_off_secs is how far the host's clock is from Steward's, either way clock_off_secs is how far the host's clock is from Steward's, either way
(agent ≥ 1.12.0): "clock drifting" is > 30.""" (agent ≥ 1.12.0): "clock drifting" is > 30. load_pct is load_1m as a %
of the host's cores."""
return {"host_agent": [ return {"host_agent": [
"cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes", "cpu_pct", "mem_used_pct", "mem_available_bytes", "swap_used_bytes",
"disk_used_pct_worst", "disk_used_pct", "load_1m", "load_5m", "load_15m", "disk_used_pct_worst", "disk_used_pct", "load_1m", "load_5m", "load_15m", "load_pct",
"uptime_secs", "seconds_since_seen", "clock_off_secs", "uptime_secs", "seconds_since_seen", "clock_off_secs",
]} ]}
+17 -1
View File
@@ -21,6 +21,7 @@ from steward.core.health import ingest as ingest_tally
from steward.core.metric_series import points as metric_points from steward.core.metric_series import points as metric_points
from steward.core.settings import get_setting, public_base_url from steward.core.settings import get_setting, public_base_url
from steward.core.time_range import parse_range, RANGE_OPTIONS from steward.core.time_range import parse_range, RANGE_OPTIONS
from steward.hosts import service as host_svc
from steward.models.hosts import Host from steward.models.hosts import Host
from steward.models.metrics import PluginMetric from steward.models.metrics import PluginMetric
from .models import HostAgentRegistration from .models import HostAgentRegistration
@@ -118,6 +119,10 @@ def _expand_sample_to_metrics(
for key in ("1m", "5m", "15m"): for key in ("1m", "5m", "15m"):
if key in load: if key in load:
row(f"load_{key}", host_name, load[key]) row(f"load_{key}", host_name, load[key])
# Load per core as a %, the figure the pages grade, so it can learn a
# normal (#5742): a load of 6 is idle on 32 cores and busy on 4.
if "1m" in load and sample.get("cpu_cores"):
row("load_pct", host_name, 100.0 * load["1m"] / len(sample["cpu_cores"]))
if sample.get("uptime_secs") is not None: if sample.get("uptime_secs") is not None:
row("uptime_secs", host_name, sample["uptime_secs"]) row("uptime_secs", host_name, sample["uptime_secs"])
@@ -725,6 +730,7 @@ async def host_vitals(host_id: str):
reg = (await db.execute(select(HostAgentRegistration).where( reg = (await db.execute(select(HostAgentRegistration).where(
HostAgentRegistration.host_id == host_id))).scalar_one_or_none() HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(db, host.name) if reg else {} latest = await _latest_metrics_for_host(db, host.name) if reg else {}
norms = await host_svc.host_norms(db, host.name) if reg else {}
# The last 6 h for the at-a-glance sparklines (cpu/mem/load host-level, # The last 6 h for the at-a-glance sparklines (cpu/mem/load host-level,
# disk from the root mount), averaged into ~120 points in SQL, so the # disk from the root mount), averaged into ~120 points in SQL, so the
@@ -759,13 +765,23 @@ async def host_vitals(host_id: str):
reporting = bool(reg) and ls is not None reporting = bool(reg) and ls is not None
stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds() stale = reporting and (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
cpu, mem = hostlvl.get("cpu_pct"), hostlvl.get("mem_used_pct") cpu, mem = hostlvl.get("cpu_pct"), hostlvl.get("mem_used_pct")
thresholds = current_app.config.get("THRESHOLDS", {})
worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"), worst = worst_reading((("CPU", cpu, "cpu"), ("Memory", mem, "mem"), ("Disk /", disk_root, "disk"),
("Load per core", load_per_core, "load")), ("Load per core", load_per_core, "load")),
current_app.config.get("THRESHOLDS", {})) thresholds, host_svc.host_normals(norms, thresholds))
# Each tile's grade; CPU, memory and load within the host's own normal
# for the hour read healthy, and say what is usual (#5742).
grades = host_svc.host_grades({"cpu": cpu, "mem": mem, "disk": disk_root, "load": load_per_core},
norms, thresholds)
usual = [f"{label} {value:.0f}% is {grades[kind][1]}"
for label, value, kind in (("CPU", cpu, "cpu"), ("Memory", mem, "mem"),
("load per core", load_per_core, "load"))
if grades[kind][1]]
return await render_template( return await render_template(
"_host_vitals.html", "_host_vitals.html",
reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core, reg=reg, cpu=cpu, mem=mem, disk_root=disk_root, load_per_core=load_per_core,
sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst, sparks=sparks, psi=psi, reporting=reporting, stale=stale, worst=worst,
grades=grades, usual=usual,
clock=clock_words(reg.clock_offset_s if reg else None, current_app.config.get("THRESHOLDS", {})), clock=clock_words(reg.clock_offset_s if reg else None, current_app.config.get("THRESHOLDS", {})),
) )
+10 -5
View File
@@ -1,6 +1,7 @@
{# Host health header — live polled into the host hub: live or stale, the worst {# Host health header — live polled into the host hub: live or stale, the worst
reading against Settings → Thresholds, last seen, then CPU / memory / disk / reading against Settings → Thresholds, last seen, then CPU / memory / disk /
load tiles graded by the same cutoffs. Renders nothing until the agent load tiles graded by the same cutoffs (`grades`; CPU, memory and load within
the host's own normal for the hour read healthy, and `usual` says so). Renders nothing until the agent
reports (the Agent panel below then shows provisioning). #} reports (the Agent panel below then shows provisioning). #}
{% from "_macros.html" import status_badge, stat_tile %} {% from "_macros.html" import status_badge, stat_tile %}
{% macro pct(v) %}{{ '%.0f%%'|format(v) if v is not none else '—' }}{% endmacro %} {% macro pct(v) %}{{ '%.0f%%'|format(v) if v is not none else '—' }}{% endmacro %}
@@ -11,6 +12,7 @@
<span class="host-health-summary"> <span class="host-health-summary">
{%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then. {%- if stale %}No report since {{ reg.last_seen_at|ago }}; the readings below are from then.
{%- elif worst %}<span class="tone-{{ worst[2] }}">{{ worst[0] }} {{ pct(worst[1]) }}</span> is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold. {%- elif worst %}<span class="tone-{{ worst[2] }}">{{ worst[0] }} {{ pct(worst[1]) }}</span> is past its {{ "critical" if worst[2] == "crit" else "warning" }} threshold.
{%- elif usual %}Every reading is within its thresholds or its usual level for this hour.
{%- else %}Every reading is within its thresholds.{% endif -%} {%- else %}Every reading is within its thresholds.{% endif -%}
</span> </span>
<span class="problem-detail"> <span class="problem-detail">
@@ -20,11 +22,14 @@
</span> </span>
</div> </div>
<div class="stat-strip host-health-tiles" style="margin-bottom:0;"> <div class="stat-strip host-health-tiles" style="margin-bottom:0;">
{{ stat_tile("CPU", pct(cpu), state=status_class(cpu, 'cpu'), hint=sparks.cpu|safe) }} {{ stat_tile("CPU", pct(cpu), state=grades.cpu[0], hint=sparks.cpu|safe) }}
{{ stat_tile("Memory", pct(mem), state=status_class(mem, 'mem'), hint=sparks.mem|safe) }} {{ stat_tile("Memory", pct(mem), state=grades.mem[0], hint=sparks.mem|safe) }}
{{ stat_tile("Disk /", pct(disk_root), state=status_class(disk_root, 'disk'), hint=sparks.disk|safe) }} {{ stat_tile("Disk /", pct(disk_root), state=grades.disk[0], hint=sparks.disk|safe) }}
{{ stat_tile("Load per core", pct(load_per_core), state=status_class(load_per_core, 'load'), hint=sparks.load|safe) }} {{ stat_tile("Load per core", pct(load_per_core), state=grades.load[0], hint=sparks.load|safe) }}
</div> </div>
{% if usual and not stale %}
<p class="field-hint" style="margin-top:0.6rem;">{{ usual | join("; ") | capitalize }}.</p>
{% endif %}
{% if psi.cpu is not none or psi.mem is not none or psi.io is not none %} {% if psi.cpu is not none or psi.mem is not none or psi.io is not none %}
<p class="field-hint" style="margin-top:0.6rem;" title="Pressure stall: the share of the last 10 s that tasks waited for the resource"> <p class="field-hint" style="margin-top:0.6rem;" title="Pressure stall: the share of the last 10 s that tasks waited for the resource">
Pressure (10 s): cpu {{ pct(psi.cpu) }} · mem {{ pct(psi.mem) }} · io {{ pct(psi.io) }} Pressure (10 s): cpu {{ pct(psi.cpu) }} · mem {{ pct(psi.mem) }} · io {{ pct(psi.io) }}
+4 -4
View File
@@ -26,12 +26,12 @@ def setup(app: "Quart") -> None:
# learned from the scrape-by-scrape figures only: before #5755 it was a # learned from the scrape-by-scrape figures only: before #5755 it was a
# percentile of every response since Traefik started. # percentile of every response since Traefik started.
from datetime import datetime, timezone from datetime import datetime, timezone
from steward.core.norms import learn_normal from steward.core.norms import floor_setting, learn_normal
from .routes import FLOOR_4XX, FLOOR_P95, STATE_WINDOW, min_requests from .routes import STATE_WINDOW, min_requests
gate = ("request_rate", gate = ("request_rate",
lambda: min_requests(app.config.get("THRESHOLDS", {})) / STATE_WINDOW.total_seconds()) lambda: min_requests(app.config.get("THRESHOLDS", {})) / STATE_WINDOW.total_seconds())
learn_normal("traefik", "error_rate_4xx_pct", FLOOR_4XX, gate=gate) learn_normal("traefik", "error_rate_4xx_pct", floor_setting(app, "router_4xx_pct"), gate=gate)
learn_normal("traefik", "latency_p95_ms", FLOOR_P95, gate=gate, learn_normal("traefik", "latency_p95_ms", floor_setting(app, "router_p95_ms"), gate=gate,
since=datetime(2026, 10, 14, 18, 47, tzinfo=timezone.utc)) since=datetime(2026, 10, 14, 18, 47, tzinfo=timezone.utc))
+10 -16
View File
@@ -14,6 +14,7 @@ from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds,
from steward.core import anomalies as anom from steward.core import anomalies as anom
from steward.core.formatting import cert_left, status_class from steward.core.formatting import cert_left, status_class
from steward.core.norms import norms_at from steward.core.norms import norms_at
from steward.core.settings import floor_of
from steward.models.metrics import PluginMetricLatest from steward.models.metrics import PluginMetricLatest
from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric, TraefikStatusCode from .models import TraefikAccessSummary, TraefikCert, TraefikGlobalStat, TraefikMetric, TraefikStatusCode
from .scheduler import router_domains from .scheduler import router_domains
@@ -56,12 +57,11 @@ def error_cutoffs(thresholds: dict) -> tuple[float, float]:
# Each router's 4xx share and p95 are judged against its own normal once it # Each router's 4xx share and p95 are judged against its own normal once it
# has one (#5741, System 182): an error-pages service answers 4xx all day and # has one (#5741, System 182): an error-pages service answers 4xx all day and
# that is its normal, not a problem. Departing by less than the floor is never # that is its normal, not a problem. Departing by less than the floor
# news. A share over fewer than min_requests() in the window is not graded: one # (Settings → Thresholds, norms.floor.router_*) is never news. A share over
# 404 of three requests is 33%. The 5xx share is a failure and keeps its # fewer than min_requests() in the window is not graded: one 404 of three
# fixed cutoff whatever the history or traffic (the operator's ruling). # requests is 33%. The 5xx share is a failure and keeps its fixed cutoff
FLOOR_4XX = 2.0 # percentage points # whatever the history or traffic (the operator's ruling).
FLOOR_P95 = 50.0 # ms
NORMED = ("error_rate_4xx_pct", "latency_p95_ms") NORMED = ("error_rate_4xx_pct", "latency_p95_ms")
@@ -71,14 +71,6 @@ def min_requests(thresholds: dict) -> float:
return float(thresholds.get("router_requests", {}).get("warn", 30)) return float(thresholds.get("router_requests", {}).get("warn", 30))
def _against(value: float, norm, floor: float, unit: str) -> tuple[bool, str]:
"""(departs, what is usual) for a reading over its fixed cutoff."""
if norm is None:
return True, ""
usual = f"usual {norm.usual_hold:.1f}{unit}" if unit == "%" else f"usual {norm.usual_hold:.0f}{unit}"
return anom.departs(value, norm, floor), f"{usual} at this hour"
def router_state(reading, thresholds: dict, over: str = "", norms: dict | None = None) -> tuple[str, str | None]: def router_state(reading, thresholds: dict, over: str = "", norms: dict | None = None) -> tuple[str, str | None]:
"""(grade, why) for a router's reading (recent_window's, or one scrape's): """(grade, why) for a router's reading (recent_window's, or one scrape's):
crit while it serves 5xx past its cutoff, warn on a p95 past the latency crit while it serves 5xx past its cutoff, warn on a p95 past the latency
@@ -93,14 +85,16 @@ def router_state(reading, thresholds: dict, over: str = "", norms: dict | None =
usual = [] usual = []
p95 = reading.latency_p95_ms p95 = reading.latency_p95_ms
if p95 is not None and status_class(p95, "latency", thresholds) == "crit": if p95 is not None and status_class(p95, "latency", thresholds) == "crit":
out, said = _against(p95, norms.get("latency_p95_ms"), FLOOR_P95, " ms") out, said = anom.against(p95, norms.get("latency_p95_ms"),
floor_of(thresholds, "router_p95_ms"), " ms")
if out: if out:
return "warn", f"p95 latency is {p95:.0f} ms{over}" + (f", {said}" if said else "") return "warn", f"p95 latency is {p95:.0f} ms{over}" + (f", {said}" if said else "")
usual.append(f"p95 {p95:.0f} ms is {said}") usual.append(f"p95 {p95:.0f} ms is {said}")
requests = getattr(reading, "requests", None) requests = getattr(reading, "requests", None)
e4 = reading.error_rate_4xx_pct or 0 e4 = reading.error_rate_4xx_pct or 0
if e4 > error_4xx and (requests is None or requests >= min_requests(thresholds)): if e4 > error_4xx and (requests is None or requests >= min_requests(thresholds)):
out, said = _against(e4, norms.get("error_rate_4xx_pct"), FLOOR_4XX, "%") out, said = anom.against(e4, norms.get("error_rate_4xx_pct"),
floor_of(thresholds, "router_4xx_pct"), "%")
if out: if out:
return "warn", f"{e4:.1f}% of responses are 4xx{over}" + (f", {said}" if said else "") return "warn", f"{e4:.1f}% of responses are 4xx{over}" + (f", {said}" if said else "")
usual.append(f"4xx {e4:.1f}% is {said}") usual.append(f"4xx {e4:.1f}% is {said}")
+9
View File
@@ -129,6 +129,10 @@ def create_app(
app.jinja_env.globals["threshold_style"] = ( app.jinja_env.globals["threshold_style"] = (
lambda value, kind: _ts_for(value, kind, app.config.get("THRESHOLDS", {})) lambda value, kind: _ts_for(value, kind, app.config.get("THRESHOLDS", {}))
) )
# `grade_style(grade)` — the same styling for a grade worked out already
# (one graded against its own normal, #5742).
from .core.settings import grade_style as _grade_style
app.jinja_env.globals["grade_style"] = _grade_style
# One way to write a size, a rate, a span and a moment, and to grade a # One way to write a size, a rate, a span and a moment, and to grade a
# reading ('ok' / 'warn' / 'crit' / 'unknown') against the same cutoffs. # reading ('ok' / 'warn' / 'crit' / 'unknown') against the same cutoffs.
@@ -445,6 +449,11 @@ def _register_core_tasks(app: Quart) -> None:
from .backup.job import tick from .backup.job import tick
await tick(app) await tick(app)
# Monitor response times learn their normal (#5742); plugins register
# their own metrics from setup().
from .monitors.service import learn_response_times
learn_response_times(app)
async def run_metric_norms(): async def run_metric_norms():
from .core.norms import run_norms from .core.norms import run_norms
await run_norms(app) await run_norms(app)
+35 -4
View File
@@ -51,6 +51,33 @@ def departs(level: float, norm: Norm, floor: float) -> bool:
return level > norm.usual_hold * DEPART and level - norm.usual_hold >= floor return level > norm.usual_hold * DEPART and level - norm.usual_hold >= floor
def against(value: float, norm: Norm | None, floor: float, unit: str) -> tuple[bool, str]:
"""(departs, what is usual) for a page's reading past its fixed cutoff
(#5741, #5742). Without a normal it departs, and the cutoff stands; within
one it is usual for that resource, and the page says so. `unit` is "%"
(one decimal) or " ms" (whole)."""
if norm is None:
return True, ""
usual = f"{norm.usual_hold:.1f}{unit}" if unit == "%" else f"{norm.usual_hold:.0f}{unit}"
return departs(value, norm, floor), f"usual {usual} at this hour"
def graded(value: float | None, kind: str, thresholds: dict, norm: Norm | None,
floor: float, unit: str) -> tuple[str, str | None]:
"""(status class, why) for a behaviour reading on a page (#5742): graded
against Settings → Thresholds, except that one past its cutoff but within
its own normal for the hour is "ok", and `why` says what is usual. Only
for behaviour (CPU, memory, load, latency); a failure keeps its cutoff
whatever its history (System 182)."""
from steward.core.formatting import status_class
cls = status_class(value, kind, thresholds)
if cls in ("warn", "crit") and norm is not None:
out, said = against(value, norm, floor, unit)
if not out:
return "ok", said
return cls, None
# Each learned key reporting in the window, with the window's average. Keys # Each learned key reporting in the window, with the window's average. Keys
# come from plugin_metric_latest (one row each), and each average is a range # come from plugin_metric_latest (one row each), and each average is a range
# read of the series index, so the pass never scans the raw table. # read of the series index, so the pass never scans the raw table.
@@ -64,18 +91,22 @@ CROSS JOIN LATERAL (
AND p.recorded_at <= :now AND p.recorded_at <= :now
) v ) v
WHERE l.source_module || '/' || l.metric_name = ANY(:keys) WHERE l.source_module || '/' || l.metric_name = ANY(:keys)
AND (strpos(l.resource_name, ':') = 0 OR NOT l.source_module || '/' || l.metric_name = ANY(:whole))
AND l.recorded_at >= :since AND v.level IS NOT NULL AND l.recorded_at >= :since AND v.level IS NOT NULL
""").bindparams(bindparam("keys", type_=ARRAY(String))) """).bindparams(bindparam("keys", type_=ARRAY(String)), bindparam("whole", type_=ARRAY(String)))
async def levels_now(session, now: datetime) -> dict[tuple[str, str, str], float]: async def levels_now(session, now: datetime) -> dict[tuple[str, str, str], float]:
"""{(source, resource, metric): its average over the last WINDOW}, for """{(source, resource, metric): its average over the last WINDOW}, for
every learned metric and every metric one is gated on.""" every learned metric and every metric one is gated on. A metric learned
without its parts reads its whole resources only."""
keys = sorted({f"{s}/{m}" for s, m in LEARNED} keys = sorted({f"{s}/{m}" for s, m in LEARNED}
| {f"{k.source_module}/{k.gate[0]}" for k in LEARNED.values() if k.gate}) | {f"{k.source_module}/{k.gate[0]}" for k in LEARNED.values() if k.gate})
if not keys: if not keys:
return {} return {}
rows = await session.execute(_LEVELS, {"keys": keys, "since": now - WINDOW, "now": now}) whole = sorted(f"{k.source_module}/{k.metric_name}" for k in LEARNED.values() if not k.parts)
rows = await session.execute(_LEVELS, {"keys": keys, "whole": whole,
"since": now - WINDOW, "now": now})
return {(s, r, m): float(level) for s, r, m, level in rows} return {(s, r, m): float(level) for s, r, m, level in rows}
@@ -98,7 +129,7 @@ async def detect(session, now: datetime | None = None) -> dict:
for key, level in levels.items(): for key, level in levels.items():
norm = norms.get(key) norm = norms.get(key)
learned = LEARNED.get((key[0], key[2])) learned = LEARNED.get((key[0], key[2]))
out = norm is not None and learned is not None and departs(level, norm, learned.floor) out = norm is not None and learned is not None and departs(level, norm, learned.least_departure)
current = open_now.pop(key, None) current = open_now.pop(key, None)
if current is not None: if current is not None:
span, anomaly = current span, anomaly = current
+11 -3
View File
@@ -233,16 +233,23 @@ def required(value: Any, what: str) -> Any:
_SEVERITY = {"crit": 2, "warn": 1} _SEVERITY = {"crit": 2, "warn": 1}
def worst_reading(readings, thresholds: dict): def worst_reading(readings, thresholds: dict, normals: dict | None = None):
"""The reading furthest past its cutoff, as (label, value, grade), or None """The reading furthest past its cutoff, as (label, value, grade), or None
when every reading is within its cutoffs or missing. when every reading is within its cutoffs or missing.
`readings` is (label, value, kind) triples, graded like status_class; the `readings` is (label, value, kind) triples, graded like status_class; the
first of equally bad readings wins, so list them in the order a reader first of equally bad readings wins, so list them in the order a reader
should hear about them.""" should hear about them. `normals` ({kind: (Norm, floor)}) are the
resource's own normals for this hour: a reading past its cutoff but
within its normal is not past it (#5742)."""
from steward.core.anomalies import graded
normals = normals or {}
worst, rank = None, 0 worst, rank = None, 0
for label, value, kind in readings: for label, value, kind in readings:
grade = status_class(value, kind, thresholds) if kind in normals:
grade = graded(value, kind, thresholds, *normals[kind], "%")[0]
else:
grade = status_class(value, kind, thresholds)
if _SEVERITY.get(grade, 0) > rank: if _SEVERITY.get(grade, 0) > rank:
worst, rank = (label, value, grade), _SEVERITY[grade] worst, rank = (label, value, grade), _SEVERITY[grade]
return worst return worst
@@ -327,6 +334,7 @@ METRICS: dict[str, tuple[str, str]] = {
"load_1m": ("Load (1 min)", "runnable processes, averaged over a minute"), "load_1m": ("Load (1 min)", "runnable processes, averaged over a minute"),
"load_5m": ("Load (5 min)", "runnable processes, averaged over 5 minutes"), "load_5m": ("Load (5 min)", "runnable processes, averaged over 5 minutes"),
"load_15m": ("Load (15 min)", "runnable processes, averaged over 15 minutes"), "load_15m": ("Load (15 min)", "runnable processes, averaged over 15 minutes"),
"load_pct": ("Load per core", "1-minute load as a % of the host's cores; 100% keeps every core busy"),
"uptime_secs": ("Uptime", "time since the host booted"), "uptime_secs": ("Uptime", "time since the host booted"),
"seconds_since_seen": ("Time since the agent reported", "over 3 minutes means the agent is offline"), "seconds_since_seen": ("Time since the agent reported", "over 3 minutes means the agent is offline"),
"clock_off_secs": ("Clock off by", "how far the host's clock is from Steward's, either way"), "clock_off_secs": ("Clock off by", "how far the host's clock is from Steward's, either way"),
+30 -11
View File
@@ -35,7 +35,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import ARRAY, DateTime, String, bindparam, delete, select, text, tuple_ from sqlalchemy import ARRAY, Boolean, DateTime, String, bindparam, delete, select, text, tuple_
from steward.models.norms import MetricNorm from steward.models.norms import MetricNorm
@@ -51,31 +51,45 @@ USUAL = 0.9 # "usual" is this percentile across past days: one odd d
class Learned: class Learned:
"""A metric that learns a normal. `floor` is the smallest departure, """A metric that learns a normal. `floor` is the smallest departure,
in the metric's own unit, worth calling an anomaly (#5740), so that in the metric's own unit, worth calling an anomaly (#5740), so that
0.1% → 0.3% of a share or 2 ms → 4 ms of latency is never news. 0.1% → 0.3% of a share or 2 ms → 4 ms of latency is never news. It may
be a function, read at each pass, for a floor that is a setting (#5742).
`gate` is (metric, least): the resource is judged only while that metric `gate` is (metric, least): the resource is judged only while that metric
of its own averages at least `least` over the window, as a router's of its own averages at least `least` over the window, as a router's
error share means nothing over a handful of requests (#5741). `least` error share means nothing over a handful of requests (#5741). `least`
may be a function, read at each pass, for a minimum that is a setting. `since`: may be a function, read at each pass, for a minimum that is a setting. `since`:
history before it is not learned from, for a metric whose meaning history before it is not learned from, for a metric whose meaning
changed then.""" changed then. `parts=False` learns only whole resources, not their parts
("<host>:core3"): a host's CPU has a normal, each of its cores doesn't."""
source_module: str source_module: str
metric_name: str metric_name: str
floor: float floor: float | Callable[[], float]
gate: tuple[str, float | Callable[[], float]] | None = None gate: tuple[str, float | Callable[[], float]] | None = None
since: datetime | None = None since: datetime | None = None
parts: bool = True
@property
def least_departure(self) -> float:
return float(self.floor() if callable(self.floor) else self.floor)
LEARNED: dict[tuple[str, str], Learned] = {} LEARNED: dict[tuple[str, str], Learned] = {}
def learn_normal(source_module: str, metric_name: str, floor: float, *, def learn_normal(source_module: str, metric_name: str, floor: float | Callable[[], float], *,
gate: tuple[str, float | Callable[[], float]] | None = None, gate: tuple[str, float | Callable[[], float]] | None = None,
since: datetime | None = None) -> None: since: datetime | None = None, parts: bool = True) -> None:
"""Have Steward learn this metric's normal for every resource reporting """Have Steward learn this metric's normal for every resource reporting
it. A plugin calls this from setup(), as it calls prune_by_age.""" it. A plugin calls this from setup(), as it calls prune_by_age."""
LEARNED[(source_module, metric_name)] = Learned(source_module, metric_name, float(floor), LEARNED[(source_module, metric_name)] = Learned(
gate, since) source_module, metric_name, floor if callable(floor) else float(floor), gate, since, parts)
def floor_setting(app, name: str) -> Callable[[], float]:
"""A floor read from Settings → Thresholds (norms.floor.<name>) at each
pass, so a saved change applies at once."""
from steward.core.settings import floor_of
return lambda: floor_of(app.config.get("THRESHOLDS", {}), name)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -103,20 +117,23 @@ def slot_of(at: datetime, kind: str) -> int:
# own slot and its neighbours'. # own slot and its neighbours'.
_REBUILD = text(""" _REBUILD = text("""
WITH learned AS ( WITH learned AS (
SELECT key, since FROM unnest(CAST(:keys AS text[]), CAST(:starts AS timestamptz[])) AS k(key, since) SELECT key, since, parts
FROM unnest(CAST(:keys AS text[]), CAST(:starts AS timestamptz[]), CAST(:parts AS boolean[]))
AS k(key, since, parts)
), ),
all_hours AS ( all_hours AS (
SELECT h.source_module AS s, h.resource_name AS r, h.metric_name AS m, h.bucket AS b, SELECT h.source_module AS s, h.resource_name AS r, h.metric_name AS m, h.bucket AS b,
COALESCE(h.value_p95, h.value_avg) AS hold, h.value_max AS peak COALESCE(h.value_p95, h.value_avg) AS hold, h.value_max AS peak
FROM plugin_metrics_hourly h FROM plugin_metrics_hourly h
JOIN learned k ON k.key = h.source_module || '/' || h.metric_name JOIN learned k ON k.key = h.source_module || '/' || h.metric_name
WHERE h.bucket >= k.since WHERE h.bucket >= k.since AND (k.parts OR strpos(h.resource_name, ':') = 0)
UNION ALL UNION ALL
SELECT p.source_module, p.resource_name, p.metric_name, date_trunc('hour', p.recorded_at), SELECT p.source_module, p.resource_name, p.metric_name, date_trunc('hour', p.recorded_at),
percentile_cont(0.95) WITHIN GROUP (ORDER BY p.value), max(p.value) percentile_cont(0.95) WITHIN GROUP (ORDER BY p.value), max(p.value)
FROM plugin_metrics p FROM plugin_metrics p
JOIN learned k ON k.key = p.source_module || '/' || p.metric_name JOIN learned k ON k.key = p.source_module || '/' || p.metric_name
WHERE p.recorded_at >= k.since AND p.recorded_at < :this_hour WHERE p.recorded_at >= k.since AND p.recorded_at < :this_hour
AND (k.parts OR strpos(p.resource_name, ':') = 0)
GROUP BY 1, 2, 3, 4 GROUP BY 1, 2, 3, 4
), ),
hours AS ( hours AS (
@@ -157,7 +174,8 @@ SELECT s, r, m, (slot + n.off + slots) % slots, kind,
FROM slotted CROSS JOIN generate_series(-CAST(:neighbours AS int), CAST(:neighbours AS int)) AS n(off) FROM slotted CROSS JOIN generate_series(-CAST(:neighbours AS int), CAST(:neighbours AS int)) AS n(off)
GROUP BY s, r, m, (slot + n.off + slots) % slots, kind, days GROUP BY s, r, m, (slot + n.off + slots) % slots, kind, days
""").bindparams(bindparam("keys", type_=ARRAY(String)), """).bindparams(bindparam("keys", type_=ARRAY(String)),
bindparam("starts", type_=ARRAY(DateTime(timezone=True)))) bindparam("starts", type_=ARRAY(DateTime(timezone=True))),
bindparam("parts", type_=ARRAY(Boolean)))
async def rebuild_norms(session, now: datetime | None = None) -> int: async def rebuild_norms(session, now: datetime | None = None) -> int:
@@ -172,6 +190,7 @@ async def rebuild_norms(session, now: datetime | None = None) -> int:
"keys": [f"{s}/{m}" for s, m in LEARNED], "keys": [f"{s}/{m}" for s, m in LEARNED],
# Each key learns from WEEK_WINDOW back, or from its own `since`. # Each key learns from WEEK_WINDOW back, or from its own `since`.
"starts": [max(week_since, k.since) if k.since else week_since for k in LEARNED.values()], "starts": [max(week_since, k.since) if k.since else week_since for k in LEARNED.values()],
"parts": [k.parts for k in LEARNED.values()],
"day_since": now - DAY_WINDOW, "day_since": now - DAY_WINDOW,
"this_hour": now.replace(minute=0, second=0, microsecond=0), "this_hour": now.replace(minute=0, second=0, microsecond=0),
"week_ready": now - timedelta(days=WEEK_MIN_DAYS), "week_ready": now - timedelta(days=WEEK_MIN_DAYS),
+33
View File
@@ -100,6 +100,19 @@ DEFAULTS: dict[str, Any] = {
# A router's 4xx share over fewer requests than this in 15 minutes isn't # A router's 4xx share over fewer requests than this in 15 minutes isn't
# graded, on the page or for anomalies: one 404 of three is 33% (#5741). # graded, on the page or for anomalies: one 404 of three is 33% (#5741).
"traefik.threshold.min_requests": 30, "traefik.threshold.min_requests": 30,
# The smallest departure from a metric's normal worth calling an anomaly
# (#5742), on top of being a quarter over its usual hold: a share's floor
# is in percentage points, a response time's in ms. 5 points is the
# baseline; bursty figures (CPU, load) get 10 so an ordinary build or
# backup minute isn't news.
"norms.floor.host_cpu_pct": 10,
"norms.floor.host_mem_pct": 5,
"norms.floor.host_load_pct": 10,
"norms.floor.container_cpu_pct": 10,
"norms.floor.container_mem_pct": 5,
"norms.floor.monitor_ms": 100,
"norms.floor.router_4xx_pct": 2,
"norms.floor.router_p95_ms": 50,
# Degraded/critical cutoffs for metric coloring (warn=amber, crit=red). # Degraded/critical cutoffs for metric coloring (warn=amber, crit=red).
# cpu/mem/disk/load are percentages (load is load-per-core %); temp in °C. # cpu/mem/disk/load are percentages (load is load-per-core %); temp in °C.
# uptime is "higher is better" so its warn/crit are floors. Ping latency # uptime is "higher is better" so its warn/crit are floors. Ping latency
@@ -540,9 +553,23 @@ def to_thresholds_cfg(settings: dict[str, Any]) -> dict:
# Fewer requests than this in 15 minutes and a router's 4xx share isn't graded. # Fewer requests than this in 15 minutes and a router's 4xx share isn't graded.
"router_requests": {"warn": g("traefik.threshold.min_requests", 30), "router_requests": {"warn": g("traefik.threshold.min_requests", 30),
"crit": g("traefik.threshold.min_requests", 30), "dir": "low"}, "crit": g("traefik.threshold.min_requests", 30), "dir": "low"},
# Not a cutoff: each normal's floor (norms.floor.*), read by floor_of().
"floors": {name: float(g(f"norms.floor.{name}", DEFAULTS[f"norms.floor.{name}"]))
for name in NORM_FLOORS},
} }
NORM_FLOORS = ("host_cpu_pct", "host_mem_pct", "host_load_pct", "container_cpu_pct",
"container_mem_pct", "monitor_ms", "router_4xx_pct", "router_p95_ms")
def floor_of(thresholds: dict, name: str) -> float:
"""A normal's floor (one of NORM_FLOORS) from to_thresholds_cfg's output:
the smallest departure from it worth calling an anomaly (#5742)."""
got = (thresholds.get("floors") or {}).get(name)
return float(got if got is not None else DEFAULTS[f"norms.floor.{name}"])
_STATUS_STYLE = {"warn": "color:var(--yellow);font-weight:500;", _STATUS_STYLE = {"warn": "color:var(--yellow);font-weight:500;",
"crit": "color:var(--red);font-weight:500;"} "crit": "color:var(--red);font-weight:500;"}
@@ -557,6 +584,12 @@ def threshold_style_for(value: Any, kind: str, thresholds: dict) -> str:
return _STATUS_STYLE.get(status_class(value, kind, thresholds), "") return _STATUS_STYLE.get(status_class(value, kind, thresholds), "")
def grade_style(grade: str | None) -> str:
"""threshold_style_for's style for a grade already worked out ("warn" /
"crit"); empty for anything else."""
return _STATUS_STYLE.get(grade or "", "")
def to_plugins_cfg(settings: dict[str, Any]) -> dict: def to_plugins_cfg(settings: dict[str, Any]) -> dict:
"""Assemble {plugin_name: {...config}} from all plugin.* keys.""" """Assemble {plugin_name: {...config}} from all plugin.* keys."""
result = {} result = {}
+55 -7
View File
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, select from sqlalchemy import and_, case, func, select
from steward.core.norms import norms_at
from steward.core.settings import floor_of
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
from steward.models.hosts import Host from steward.models.hosts import Host
from steward.models.monitors import Monitor, MonitorResult from steward.models.monitors import Monitor, MonitorResult
@@ -249,8 +251,9 @@ async def agent_overview_by_host(
"""Per-host agent glance (cpu/mem/disk/load + freshness) from plugin_metric_latest. """Per-host agent glance (cpu/mem/disk/load + freshness) from plugin_metric_latest.
{host_name: {cpu_pct, mem_used_pct, disk_used_pct_worst, disk_root, load_1m, {host_name: {cpu_pct, mem_used_pct, disk_used_pct_worst, disk_root, load_1m,
cores, load_per_core, last_at, fresh, clock_off_secs}} for each host with any cores, load_per_core, last_at, fresh, clock_off_secs, norms}} for each host
agent samples. Cores are counted from the per-core CPU sub-resources with any agent samples. `norms` is {kind: Norm}, the host's CPU, memory and
load normals for this hour (host_normals grades by them). Cores are counted from the per-core CPU sub-resources
("<host>:core<N>"), as on the host page; load_per_core is load_1m as a % ("<host>:core<N>"), as on the host page; load_per_core is load_1m as a %
of them, the figure Settings → Thresholds grades "load" by. of them, the figure Settings → Thresholds grades "load" by.
@@ -294,10 +297,50 @@ async def agent_overview_by_host(
d["cores"] = cores.get(name) d["cores"] = cores.get(name)
load = d.get("load_1m") load = d.get("load_1m")
d["load_per_core"] = round(load / d["cores"] * 100) if (load is not None and d["cores"]) else None d["load_per_core"] = round(load / d["cores"] * 100) if (load is not None and d["cores"]) else None
d["norms"] = {}
out[name] = d out[name] = d
learned = await norms_at(db, [("host_agent", h, metric) for h in out
for metric, _f in HOST_NORMED.values()], now)
for (_s, host, metric), norm in learned.items():
out[host]["norms"][_KIND_OF[metric]] = norm
return out return out
# The host figures graded against their own normal (#5742): the threshold
# kind, the metric that learns it and its floor's setting (norms.floor.*).
# Disk keeps its cutoff: a nearly full disk is a failure (System 182).
HOST_NORMED = {"cpu": ("cpu_pct", "host_cpu_pct"), "mem": ("mem_used_pct", "host_mem_pct"),
"load": ("load_pct", "host_load_pct")}
_KIND_OF = {metric: kind for kind, (metric, _f) in HOST_NORMED.items()}
def host_normals(norms: dict, thresholds: dict) -> dict:
"""{kind: (Norm, floor)} for worst_reading and anomalies.graded, from an
agent glance's `norms`."""
return {kind: (norm, floor_of(thresholds, HOST_NORMED[kind][1]))
for kind, norm in (norms or {}).items()}
async def host_norms(db, host_name: str, now: datetime | None = None) -> dict:
"""{kind: Norm}: one host's CPU, memory and load normals for this hour."""
learned = await norms_at(db, [("host_agent", host_name, metric)
for metric, _f in HOST_NORMED.values()],
now or datetime.now(timezone.utc))
return {_KIND_OF[metric]: norm for (_s, _h, metric), norm in learned.items()}
def host_grades(values: dict, norms: dict, thresholds: dict) -> dict:
"""{kind: (status class, why)} for a host's readings ({kind: value}):
against Settings → Thresholds, except CPU, memory and load within the
host's own normal, which are "ok" and say what is usual (#5742)."""
from steward.core.anomalies import graded
from steward.core.formatting import status_class
normals = host_normals(norms, thresholds)
return {kind: (graded(value, kind, thresholds, *normals[kind], "%") if kind in normals
else (status_class(value, kind, thresholds), None))
for kind, value in values.items()}
def clock_trouble(agent: dict[str, dict], limit: float) -> dict | None: def clock_trouble(agent: dict[str, dict], limit: float) -> dict | None:
"""Reporting hosts whose clock is more than `limit` seconds from Steward's """Reporting hosts whose clock is more than `limit` seconds from Steward's
(Settings → Thresholds' clock drift warning, milestone 490), (Settings → Thresholds' clock drift warning, milestone 490),
@@ -326,19 +369,24 @@ def host_glance(hosts, status: dict, agent: dict, thresholds: dict) -> list[dict
Each is {host, state, worst, agent}. State is "down" when a linked monitor Each is {host, state, worst, agent}. State is "down" when a linked monitor
is down, "stale" when the agent has stopped reporting, else the grade of is down, "stale" when the agent has stopped reporting, else the grade of
the reading furthest past its cutoff ("crit" / "warn": CPU, memory, disk / the reading furthest past its cutoff ("crit" / "warn": CPU, memory, disk /
or load per core, against Settings → Thresholds), else "up"; "unknown" for or load per core, against Settings → Thresholds; CPU, memory and load
within the host's own normal are not past it), else "up"; "unknown" for
a host with neither monitors nor agent. `worst` is (label, value, grade) a host with neither monitors nor agent. `worst` is (label, value, grade)
or None, from formatting.worst_reading.""" or None, from formatting.worst_reading; `grades` is host_grades' {kind:
(grade, why)} for a fresh agent, else {}."""
from steward.core.formatting import worst_reading from steward.core.formatting import worst_reading
rows = [] rows = []
for h in hosts: for h in hosts:
st, a = status.get(h.id), agent.get(h.name) st, a = status.get(h.id), agent.get(h.name)
worst = None worst, grades = None, {}
if a and a.get("fresh"): if a and a.get("fresh"):
grades = host_grades({"cpu": a.get("cpu_pct"), "mem": a.get("mem_used_pct"),
"disk": a.get("disk_root"), "load": a.get("load_per_core")},
a.get("norms"), thresholds)
worst = worst_reading(( worst = worst_reading((
("CPU", a.get("cpu_pct"), "cpu"), ("Memory", a.get("mem_used_pct"), "mem"), ("CPU", a.get("cpu_pct"), "cpu"), ("Memory", a.get("mem_used_pct"), "mem"),
("Disk /", a.get("disk_root"), "disk"), ("Load", a.get("load_per_core"), "load")), ("Disk /", a.get("disk_root"), "disk"), ("Load", a.get("load_per_core"), "load")),
thresholds) thresholds, host_normals(a.get("norms"), thresholds))
if st and st["state"] == "down": if st and st["state"] == "down":
state = "down" state = "down"
elif a and not a.get("fresh"): elif a and not a.get("fresh"):
@@ -349,5 +397,5 @@ def host_glance(hosts, status: dict, agent: dict, thresholds: dict) -> list[dict
state = "up" state = "up"
else: else:
state = "unknown" state = "unknown"
rows.append({"host": h, "state": state, "worst": worst, "agent": a or {}}) rows.append({"host": h, "state": state, "worst": worst, "agent": a or {}, "grades": grades})
return sorted(rows, key=lambda r: (GLANCE_ORDER[r["state"]], r["host"].name.lower())) return sorted(rows, key=lambda r: (GLANCE_ORDER[r["state"]], r["host"].name.lower()))
+5
View File
@@ -111,6 +111,9 @@ def agent_findings(hosts, agent: dict, thresholds: dict) -> list[dict]:
"host agent has stopped reporting; its metrics are out of date", "host agent has stopped reporting; its metrics are out of date",
since=a.get("last_at"), ids=ids, next=f'get_host(host="{h.id}")')) since=a.get("last_at"), ids=ids, next=f'get_host(host="{h.id}")'))
continue # stale values would only add noise continue # stale values would only add noise
# CPU and memory within the host's own normal for the hour aren't
# findings (#5742); the fullest disk keeps its cutoff.
normals = host_svc.host_normals(a.get("norms"), thresholds)
for metric, kind, label in _AGENT_CHECKS: for metric, kind, label in _AGENT_CHECKS:
value = a.get(metric) value = a.get(metric)
th = thresholds.get(kind) or {} th = thresholds.get(kind) or {}
@@ -122,6 +125,8 @@ def agent_findings(hosts, agent: dict, thresholds: dict) -> list[dict]:
sev = "warning" sev = "warning"
else: else:
continue continue
if kind in normals and not anom.against(value, normals[kind][0], normals[kind][1], "%")[0]:
continue
out.append(finding( out.append(finding(
sev, f"host_{kind}_high", h.name, sev, f"host_{kind}_high", h.name,
f"{label} at {rnd(value, 1)}% (warn {th['warn']}%, crit {th.get('crit')}%)", f"{label} at {rnd(value, 1)}% (warn {th['warn']}%, crit {th.get('crit')}%)",
+8 -3
View File
@@ -6,12 +6,12 @@ Host (host_id) or watch a free-standing custom destination (host_id = NULL).
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from quart import Blueprint, current_app, render_template, request, redirect, url_for from quart import Blueprint, current_app, render_template, request, redirect, url_for
from steward.auth.middleware import require_role from steward.auth.middleware import require_role
from steward.core import renames from steward.core import anomalies as anom, renames
from steward.core.audit import audit_request from steward.core.audit import audit_request
from steward.models.users import UserRole from steward.models.users import UserRole
from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES
@@ -125,6 +125,10 @@ async def detail(monitor_id: str):
st = (await monitor_svc.monitor_states(db, [m]))[m.id] st = (await monitor_svc.monitor_states(db, [m]))[m.id]
failures = await monitor_svc.recent_failures(db, m.id) failures = await monitor_svc.recent_failures(db, m.id)
history = await monitor_svc.response_history(db, m.id, datetime.now(timezone.utc)) history = await monitor_svc.response_history(db, m.id, datetime.now(timezone.utc))
norm = (await monitor_svc.latency_norms(db, [m])).get(m.id)
anomalies = await anom.views(db, await anom.list_anomalies(
db, source=m.type, resource=m.name,
since=datetime.now(timezone.utc) - timedelta(days=30), limit=20), links=False)
host = await db.get(Host, m.host_id) if m.host_id else None host = await db.get(Host, m.host_id) if m.host_id else None
thresholds = current_app.config.get("THRESHOLDS", {}) thresholds = current_app.config.get("THRESHOLDS", {})
latest = st["latest"] latest = st["latest"]
@@ -136,7 +140,8 @@ async def detail(monitor_id: str):
tls_days = (latest.tls_expires_at - datetime.now(timezone.utc)).total_seconds() / 86400.0 tls_days = (latest.tls_expires_at - datetime.now(timezone.utc)).total_seconds() / 86400.0
return await render_template( return await render_template(
"monitors/detail.html", monitor=m, host=host, latest=latest, failures=failures, "monitors/detail.html", monitor=m, host=host, latest=latest, failures=failures,
state=monitor_svc.row_state(m, latest, st["uptime"].get("24h"), thresholds), state=monitor_svc.row_state(m, latest, st["uptime"].get("24h"), thresholds, norm),
latency=monitor_svc.latency_grade(latest, thresholds, norm), anomalies=anomalies,
since=since, since_at_least=since_at_least, uptime=st["uptime"], tls_days=tls_days, since=since, since_at_least=since_at_least, uptime=st["uptime"], tls_days=tls_days,
heartbeat=[{"state": "up" if r.is_up else "down", "title": _heartbeat_title(m.type, r)} heartbeat=[{"state": "up" if r.is_up else "down", "title": _heartbeat_title(m.type, r)}
for r in st["recent"]], for r in st["recent"]],
+42 -4
View File
@@ -13,7 +13,10 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from steward.core import anomalies as anom
from steward.core.formatting import status_class from steward.core.formatting import status_class
from steward.core.norms import Norm, floor_setting, learn_normal, norms_at
from steward.core.settings import floor_of
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType
@@ -145,12 +148,41 @@ async def check_name_free(db, mtype: str, name: str, exclude_id: str | None = No
STATE_ORDER = {"down": 0, "degraded": 1, "pending": 2, "up": 3, "paused": 4} STATE_ORDER = {"down": 0, "degraded": 1, "pending": 2, "up": 3, "paused": 4}
def row_state(monitor: Monitor, latest, uptime_pct, thresholds: dict) -> str: def learn_response_times(app) -> None:
"""Each monitor's response time learns its normal (#5742), by type (the
metric's source), with its floor from Settings → Thresholds. A monitor
down keeps its fixed meaning whatever its history (System 182)."""
for mtype in MONITOR_TYPE_VALUES:
learn_normal(mtype, "response_ms", floor_setting(app, "monitor_ms"))
async def latency_norms(db, monitors: list[Monitor], now: datetime | None = None) -> dict[str, Norm]:
"""{monitor_id: its response time's normal for this hour}; a monitor with
too little history yet is absent, and graded on the fixed cutoffs."""
got = await norms_at(db, [(m.type, m.name, "response_ms") for m in monitors],
now or datetime.now(timezone.utc))
return {m.id: got[(m.type, m.name, "response_ms")] for m in monitors
if (m.type, m.name, "response_ms") in got}
def latency_grade(latest, thresholds: dict, norm: Norm | None = None) -> tuple[str, str | None]:
"""(status class, why) for the latest response time: against the latency
cutoffs, except within its own normal for the hour, which is "ok" and says
what is usual."""
if latest is None or latest.response_ms is None:
return "unknown", None
return anom.graded(latest.response_ms, "latency", thresholds, norm,
floor_of(thresholds, "monitor_ms"), " ms")
def row_state(monitor: Monitor, latest, uptime_pct, thresholds: dict,
norm: Norm | None = None) -> str:
"""paused, pending (never checked), down, degraded or up. """paused, pending (never checked), down, degraded or up.
Degraded is up but worth a look: uptime over the window at or below its Degraded is up but worth a look: uptime over the window at or below its
warning cutoff, or the latest response time past its critical one warning cutoff, or the latest response time past its critical one
(Settings → Thresholds). A slow-but-tolerable reply is coloured, not sorted. (Settings → Thresholds) and out of its normal (`norm`, #5742). A
slow-but-tolerable reply is coloured, not sorted.
""" """
if not monitor.enabled: if not monitor.enabled:
return "paused" return "paused"
@@ -159,7 +191,7 @@ def row_state(monitor: Monitor, latest, uptime_pct, thresholds: dict) -> str:
if not latest.is_up: if not latest.is_up:
return "down" return "down"
if (status_class(uptime_pct, "uptime", thresholds) in ("warn", "crit") if (status_class(uptime_pct, "uptime", thresholds) in ("warn", "crit")
or status_class(latest.response_ms, "latency", thresholds) == "crit"): or latency_grade(latest, thresholds, norm)[0] == "crit"):
return "degraded" return "degraded"
return "up" return "up"
@@ -197,11 +229,13 @@ async def monitor_rows(db, thresholds: dict):
uptime = await _uptime_by_monitor(db, ids) uptime = await _uptime_by_monitor(db, ids)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
norms = await latency_norms(db, monitors, now)
data = [] data = []
for m in monitors: for m in monitors:
rows = recent.get(m.id, []) rows = recent.get(m.id, [])
latest = rows[-1] if rows else None latest = rows[-1] if rows else None
windows = uptime.get(m.id, {}) windows = uptime.get(m.id, {})
latency, usual = latency_grade(latest, thresholds, norms.get(m.id))
tls_days = None tls_days = None
if latest and latest.tls_expires_at: if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
@@ -209,7 +243,11 @@ async def monitor_rows(db, thresholds: dict):
data.append({ data.append({
"monitor": m, "monitor": m,
"latest": latest, "latest": latest,
"state": row_state(m, latest, windows.get("24h"), thresholds), "state": row_state(m, latest, windows.get("24h"), thresholds, norms.get(m.id)),
# The latest response time's grade, and what is usual when that
# quieted it (#5742).
"latency": latency,
"latency_usual": usual,
"heartbeat": [{"state": "up" if r.is_up else "down", "heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows], "title": _heartbeat_title(m.type, r)} for r in rows],
"spark": sparkline_svg(times, width=56, height=16, stroke="currentColor") if len(times) > 1 else "", "spark": sparkline_svg(times, width=56, height=16, stroke="currentColor") if len(times) > 1 else "",
+5 -2
View File
@@ -15,7 +15,7 @@ from steward.core.settings import (
get_stored_plugin_names, find_orphaned_plugins, get_stored_plugin_names, find_orphaned_plugins,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg, to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg, to_access_cfg, DEFAULTS, seal_source_tokens, to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg, to_access_cfg, DEFAULTS, seal_source_tokens,
is_plugin_secret, is_plugin_secret, NORM_FLOORS,
) )
settings_bp = Blueprint("settings", __name__, url_prefix="/settings") settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
@@ -145,6 +145,8 @@ _THRESHOLD_FIELDS = [
("error_5xx_pct", "traefik.threshold.error_5xx_pct", True), ("error_5xx_pct", "traefik.threshold.error_5xx_pct", True),
("error_4xx_pct", "traefik.threshold.error_4xx_pct", True), ("error_4xx_pct", "traefik.threshold.error_4xx_pct", True),
("min_requests", "traefik.threshold.min_requests", False), ("min_requests", "traefik.threshold.min_requests", False),
# Each normal's floor (#5742), named by its own setting key.
*[(f"norms.floor.{name}", f"norms.floor.{name}", True) for name in NORM_FLOORS],
] ]
# Docker time-series retention windows (days). Read fresh by the cleanup task, # Docker time-series retention windows (days). Read fresh by the cleanup task,
@@ -165,7 +167,8 @@ async def thresholds():
settings = await get_all_settings(db) settings = await get_all_settings(db)
from steward.core.plugin_manager import _LOADED_PLUGINS from steward.core.plugin_manager import _LOADED_PLUGINS
return await render_template("settings/thresholds.html", settings=settings, return await render_template("settings/thresholds.html", settings=settings,
traefik_on="traefik" in _LOADED_PLUGINS) traefik_on="traefik" in _LOADED_PLUGINS,
plugins_on=set(_LOADED_PLUGINS))
@settings_bp.post("/thresholds/") @settings_bp.post("/thresholds/")
+8 -4
View File
@@ -1,6 +1,8 @@
{# hosts/overview_widget.html — the dashboard Hosts widget (#5251): a verdict, {# hosts/overview_widget.html — the dashboard Hosts widget (#5251): a verdict,
then one row per host, worst first, with its resources under one header. then one row per host, worst first, with its resources under one header.
Load is shown against the host's cores and graded per core. Reachability Load is shown against the host's cores and graded per core. CPU, memory
and load within the host's own normal for the hour read healthy (#5742),
with what is usual on hover. Reachability
and uptime belong to the Monitors widget, so they aren't repeated here. #} and uptime belong to the Monitors widget, so they aren't repeated here. #}
{% from "_macros.html" import status_dot %} {% from "_macros.html" import status_dot %}
{% if not rows %} {% if not rows %}
@@ -30,10 +32,12 @@
{%- elif not a %}<span class="w-sub">no agent</span>{% endif %} {%- elif not a %}<span class="w-sub">no agent</span>{% endif %}
</td> </td>
{% for v, kind in ((a.get('cpu_pct'), 'cpu'), (a.get('mem_used_pct'), 'mem'), (a.get('disk_root'), 'disk')) %} {% for v, kind in ((a.get('cpu_pct'), 'cpu'), (a.get('mem_used_pct'), 'mem'), (a.get('disk_root'), 'disk')) %}
<td style="{{ threshold_style(v, kind) if fresh and v is not none }}">{% if v is none %}—{% else %}{{ "%.0f"|format(v) }}%{% endif %}</td> {% set g = (r.grades or {}).get(kind) or (none, none) %}
<td style="{{ grade_style(g[0]) }}"{% if g[1] %} title="{{ g[1]|capitalize }}"{% endif %}>{% if v is none %}—{% else %}{{ "%.0f"|format(v) }}%{% endif %}</td>
{% endfor %} {% endfor %}
<td style="{{ threshold_style(a.get('load_per_core'), 'load') if fresh and a.get('load_per_core') is not none }}" {% set g = (r.grades or {}).get('load') or (none, none) %}
title="{% if a.get('cores') %}Load over 1 minute on {{ a.cores }} cores: {{ a.load_per_core }}% of them busy or waiting{% endif %}"> <td style="{{ grade_style(g[0]) }}"
title="{% if a.get('cores') %}Load over 1 minute on {{ a.cores }} cores: {{ a.load_per_core }}% of them busy or waiting{% endif %}{% if g[1] %}; {{ g[1] }}{% endif %}">
{%- if a.get('load_1m') is none %}—{% elif a.get('cores') %}{{ "%.1f"|format(a.load_1m) }} / {{ a.cores }} cores{% else %}{{ "%.1f"|format(a.load_1m) }}{% endif -%} {%- if a.get('load_1m') is none %}—{% elif a.get('cores') %}{{ "%.1f"|format(a.load_1m) }} / {{ a.cores }} cores{% else %}{{ "%.1f"|format(a.load_1m) }}{% endif -%}
</td> </td>
</tr> </tr>
+5 -2
View File
@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, stat_tile %} {% from "_macros.html" import crumbs, page_header, stat_tile %}
{# One monitor: its state and for how long, latency and uptime against {# One monitor: its state and for how long, latency and uptime against
Settings → Thresholds, its response time over the last day, its last Settings → Thresholds (latency against its own normal once it has one,
with its anomalies), its response time over the last day, its last
checks, the failures behind them and how it is set up. #} checks, the failures behind them and how it is set up. #}
{% block title %}{{ monitor.name }} — Monitors — Steward{% endblock %} {% block title %}{{ monitor.name }} — Monitors — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), (monitor.name, "")]) }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), (monitor.name, "")]) }}{% endblock %}
@@ -23,7 +24,7 @@
{% set since_hint = ((("for at least " if since_at_least else "for ") ~ (since|ago|striptags|replace(" ago", ""))) if since and state in ("up", "down", "degraded") else none) %} {% set since_hint = ((("for at least " if since_at_least else "for ") ~ (since|ago|striptags|replace(" ago", ""))) if since and state in ("up", "down", "degraded") else none) %}
{{ stat_tile("State", status_info(state)[1], state=state, hint=since_hint) }} {{ stat_tile("State", status_info(state)[1], state=state, hint=since_hint) }}
{% if latest and latest.response_ms is not none %} {% if latest and latest.response_ms is not none %}
{{ stat_tile("Latency", "%.0f ms"|format(latest.response_ms), state=status_class(latest.response_ms, "latency")) }} {{ stat_tile("Latency", "%.0f ms"|format(latest.response_ms), state=latency[0], hint=latency[1]) }}
{% endif %} {% endif %}
{% for window in ("24h", "7d", "30d") %} {% for window in ("24h", "7d", "30d") %}
{% set pct = uptime.get(window) %} {% set pct = uptime.get(window) %}
@@ -34,6 +35,8 @@
{% endif %} {% endif %}
</div> </div>
{% with back="/monitors/" ~ monitor.id, base_resource=monitor.name %}{% include "_anomaly_card.html" %}{% endwith %}
{% set timed = history | selectattr("ms", "ne", none) | list %} {% set timed = history | selectattr("ms", "ne", none) | list %}
{% if timed|length > 1 %} {% if timed|length > 1 %}
<section class="card" aria-labelledby="m-chart"> <section class="card" aria-labelledby="m-chart">
+1 -1
View File
@@ -51,7 +51,7 @@
{% if d.state in ('down', 'paused', 'pending') %}{{ status_badge(d.state) }} {% if d.state in ('down', 'paused', 'pending') %}{{ status_badge(d.state) }}
{% elif latest.response_ms is not none %} {% elif latest.response_ms is not none %}
{% if d.spark %}<span class="mon-spark" title="Response time over the last 30 checks">{{ d.spark|safe }}</span>{% endif %} {% if d.spark %}<span class="mon-spark" title="Response time over the last 30 checks">{{ d.spark|safe }}</span>{% endif %}
<span class="tone-{{ status_class(latest.response_ms, 'latency') }}">{{ "%.0f"|format(latest.response_ms) }} ms</span> <span class="tone-{{ d.latency }}"{% if d.latency_usual %} title="{{ d.latency_usual|capitalize }}"{% endif %}>{{ "%.0f"|format(latest.response_ms) }} ms</span>
{% elif m.type == 'dns' and latest.resolved_ip %}<span class="tone-ok">{{ latest.resolved_ip }}</span> {% elif m.type == 'dns' and latest.resolved_ip %}<span class="tone-ok">{{ latest.resolved_ip }}</span>
{% else %}{{ status_badge('up') }}{% endif %} {% else %}{{ status_badge('up') }}{% endif %}
</div> </div>
+2 -2
View File
@@ -26,10 +26,10 @@
{{- status_dot(d.state) -}} {{- status_dot(d.state) -}}
{% if session.user_id %}<a href="/monitors/{{ m.id }}">{{ m.name }}</a>{% else %}{{ m.name }}{% endif %} {% if session.user_id %}<a href="/monitors/{{ m.id }}">{{ m.name }}</a>{% else %}{{ m.name }}{% endif %}
{%- if d.state == 'down' %}<span class="w-sub tone-crit">went down {{ d.down_since|ago if d.down_since else '' }}{% if d.down_reason %} · {{ d.down_reason }}{% endif %}</span> {%- if d.state == 'down' %}<span class="w-sub tone-crit">went down {{ d.down_since|ago if d.down_since else '' }}{% if d.down_reason %} · {{ d.down_reason }}{% endif %}</span>
{%- elif d.state == 'degraded' %}<span class="w-sub tone-warn">{% if latest and status_class(latest.response_ms, 'latency') == 'crit' %}slow replies{% else %}checks failing now and then{% endif %}</span> {%- elif d.state == 'degraded' %}<span class="w-sub tone-warn">{% if d.latency == 'crit' %}slow replies{% else %}checks failing now and then{% endif %}</span>
{%- elif d.state == 'pending' %}<span class="w-sub">not checked yet</span>{% endif %} {%- elif d.state == 'pending' %}<span class="w-sub">not checked yet</span>{% endif %}
</td> </td>
<td style="{{ threshold_style(latest.response_ms, 'latency') if latest and latest.is_up and latest.response_ms is not none }}"> <td class="{{ 'tone-' ~ d.latency if d.latency in ('warn', 'crit') }}"{% if d.latency_usual %} title="{{ d.latency_usual|capitalize }}"{% endif %}>
{%- if latest and latest.is_up and latest.response_ms is not none %}{{ "%.0f"|format(latest.response_ms) }} ms{% else %}—{% endif -%} {%- if latest and latest.is_up and latest.response_ms is not none %}{{ "%.0f"|format(latest.response_ms) }} ms{% else %}—{% endif -%}
</td> </td>
{% for k in ("24h", "7d", "30d") %}{% set u = d.uptime.get(k) %} {% for k in ("24h", "7d", "30d") %}{% set u = d.uptime.get(k) %}
+40 -4
View File
@@ -35,12 +35,12 @@
Higher is worse for everything except uptime, where the cutoffs are floors. Higher is worse for everything except uptime, where the cutoffs are floors.
</p> </p>
{{ pair("CPU usage", "cpu_warn", "thresholds.cpu_warn", "cpu_crit", "thresholds.cpu_crit", "%", "Average CPU utilization per host.") }} {{ pair("CPU usage", "cpu_warn", "thresholds.cpu_warn", "cpu_crit", "thresholds.cpu_crit", "%", "Average CPU utilization per host. A host or container with its own normal is judged against that instead (see Normals below).") }}
{{ pair("Memory usage", "mem_warn", "thresholds.mem_warn", "mem_crit", "thresholds.mem_crit", "%", "Memory in use per host.") }} {{ pair("Memory usage", "mem_warn", "thresholds.mem_warn", "mem_crit", "thresholds.mem_crit", "%", "Memory in use per host, or a container's share of its limit. Judged against a normal once there is one.") }}
{{ pair("Disk usage", "disk_warn", "thresholds.disk_warn", "disk_crit", "thresholds.disk_crit", "%", "Filesystem usage.") }} {{ pair("Disk usage", "disk_warn", "thresholds.disk_warn", "disk_crit", "thresholds.disk_crit", "%", "Filesystem usage.") }}
{{ pair("Load", "load_warn", "thresholds.load_warn", "load_crit", "thresholds.load_crit", "% per core", "Load average normalized by core count (100% = one full core per core).") }} {{ pair("Load", "load_warn", "thresholds.load_warn", "load_crit", "thresholds.load_crit", "% per core", "Load average normalized by core count (100% = one full core per core). Judged against a host's normal once it has one.") }}
{{ pair("Temperature", "temp_warn", "thresholds.temp_warn", "temp_crit", "thresholds.temp_crit", "°C", "Hottest reported sensor per host.") }} {{ pair("Temperature", "temp_warn", "thresholds.temp_warn", "temp_crit", "thresholds.temp_crit", "°C", "Hottest reported sensor per host.") }}
{{ pair("Response time", "latency_warn", "ping.threshold.good_ms", "latency_crit", "ping.threshold.warn_ms", "ms", "How long a monitor's check takes to answer. Also grades Traefik routers' p95 (until a router has its own normal) and the UniFi WAN round trip.") }} {{ pair("Response time", "latency_warn", "ping.threshold.good_ms", "latency_crit", "ping.threshold.warn_ms", "ms", "How long a monitor's check takes to answer. Also grades Traefik routers' p95 and the UniFi WAN round trip. A monitor or router with its own normal is judged against that instead.") }}
{{ pair("Uptime / SLA", "uptime_warn", "thresholds.uptime_warn", "uptime_crit", "thresholds.uptime_crit", "%", "Lower is worse — amber below warn, red below crit.", step="0.1") }} {{ pair("Uptime / SLA", "uptime_warn", "thresholds.uptime_warn", "uptime_crit", "thresholds.uptime_crit", "%", "Lower is worse — amber below warn, red below crit.", step="0.1") }}
{{ pair("TLS certificate expiry", "tls_warn", "thresholds.tls_warn_days", "tls_crit", "thresholds.tls_crit_days", "days left", "HTTPS monitors whose certificate expires within these many days are flagged. Lower is worse.") }} {{ pair("TLS certificate expiry", "tls_warn", "thresholds.tls_warn_days", "tls_crit", "thresholds.tls_crit_days", "days left", "HTTPS monitors whose certificate expires within these many days are flagged. Lower is worse.") }}
{{ pair("Clock drift", "clock_warn", "thresholds.clock_warn_s", "clock_crit", "thresholds.clock_crit_s", "seconds", "How far a host's clock may be from Steward's, either way, before its page and the Hosts list call it out.") }} {{ pair("Clock drift", "clock_warn", "thresholds.clock_warn_s", "clock_crit", "thresholds.clock_crit_s", "seconds", "How far a host's clock may be from Steward's, either way, before its page and the Hosts list call it out.") }}
@@ -99,6 +99,42 @@
</div> </div>
{% endmacro %} {% endmacro %}
{# Each normal's floor (#5742): the smallest departure from it worth calling an anomaly. #}
{% macro floor(label, key, unit, hint, step="1") %}
<div class="form-group" style="margin-bottom:1.1rem;">
<label for="{{ key }}">{{ label }} <span style="color:var(--text-muted);font-size:0.8rem;">({{ unit }})</span></label>
<input type="number" id="{{ key }}" name="{{ key }}" min="0" step="{{ step }}"
value="{{ settings[key] }}" style="max-width:110px;margin-top:0.25rem;display:block;">
{% if hint %}<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.3rem;">{{ hint }}</div>{% endif %}
</div>
{% endmacro %}
<div class="card" id="normals" style="max-width:640px;margin-top:1.5rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Normals</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
Once one has 5 days of history, these learn what is normal for each host, container, monitor or router
at each hour, and are judged against that instead of the cutoffs above: a reading over a cutoff but usual
for that hour shows healthy, and one well above its usual level is a warning and an anomaly to review.
Disk space, a monitor that is down, an agent that stopped reporting and a router's 5xx share are failures
and always keep their cutoffs. A reading counts as out of its normal only when it is a quarter above its
usual level <em>and</em> at least this much above it:
</p>
{% if "host_agent" in (plugins_on or ()) %}
{{ floor("Host CPU", "norms.floor.host_cpu_pct", "% points", "CPU is bursty: a build or a backup moves it this much without anything being wrong.") }}
{{ floor("Host memory", "norms.floor.host_mem_pct", "% points", "Memory moves slowly, so a smaller step already means something changed.") }}
{{ floor("Host load per core", "norms.floor.host_load_pct", "% points", "Bursty, like CPU.") }}
{% endif %}
{% if "docker" in (plugins_on or ()) %}
{{ floor("Container CPU", "norms.floor.container_cpu_pct", "% of one core", "10 is a tenth of a core; with many containers, smaller steps are everyday noise.") }}
{{ floor("Container memory", "norms.floor.container_mem_pct", "% points", "Of its limit, or of the host's memory when it has none, so a container without a limit only counts large growth.") }}
{% endif %}
{{ floor("Monitor response time", "norms.floor.monitor_ms", "ms", "One check per interval is noisy; a slower reply than this over its usual is worth a look.") }}
{% if traefik_on %}
{{ floor("Router 4xx share", "norms.floor.router_4xx_pct", "% points", "", step="0.1") }}
{{ floor("Router p95", "norms.floor.router_p95_ms", "ms", "") }}
{% endif %}
</div>
<div class="card" style="max-width:640px;margin-top:1.5rem;"> <div class="card" style="max-width:640px;margin-top:1.5rem;">
<h2 class="section-title" style="margin-bottom:0.5rem;">Metric anomalies</h2> <h2 class="section-title" style="margin-bottom:0.5rem;">Metric anomalies</h2>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;"> <p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:1.25rem;">
+135
View File
@@ -0,0 +1,135 @@
"""Hosts, containers and monitor response times are graded against their own
normal (#5742, System 182): a reading past its fixed cutoff but within its
normal for the hour reads healthy and says what is usual; one well out of it
keeps its grade. How far out counts is each normal's floor, a setting on
Settings → Thresholds. Disk keeps its cutoff whatever the history."""
from types import SimpleNamespace as NS
from quart import render_template, session
from plugins.docker.service import usage
from plugins.host_agent.routes import _expand_sample_to_metrics
from steward.core import norms
from steward.core.anomalies import graded
from steward.core.formatting import worst_reading
from steward.core.norms import Norm, floor_setting
from steward.core.settings import DEFAULTS, NORM_FLOORS, floor_of, to_thresholds_cfg
from steward.hosts.service import host_glance, host_grades
from steward.monitors.service import latency_grade, row_state
TH = to_thresholds_cfg({}) # cpu 80/90, mem 80/90, disk 80/90, load 80/100, latency 50/200 ms
def _norm(hold):
return Norm(kind="day", slot=14, usual_hold=hold, usual_peak=hold * 1.2, days_seen=7)
def test_each_floor_is_a_setting_with_the_chosen_default():
assert {n: floor_of(TH, n) for n in NORM_FLOORS} == {
"host_cpu_pct": 10.0, "host_mem_pct": 5.0, "host_load_pct": 10.0,
"container_cpu_pct": 10.0, "container_mem_pct": 5.0, "monitor_ms": 100.0,
"router_4xx_pct": 2.0, "router_p95_ms": 50.0}
assert floor_of(to_thresholds_cfg({"norms.floor.host_cpu_pct": 25}), "host_cpu_pct") == 25.0
assert floor_of({}, "monitor_ms") == DEFAULTS["norms.floor.monitor_ms"]
def test_a_floor_from_settings_is_read_at_each_pass():
app = NS(config={"THRESHOLDS": to_thresholds_cfg({})})
norms.learn_normal("t5742", "x_pct", floor_setting(app, "host_mem_pct"))
try:
learned = norms.LEARNED[("t5742", "x_pct")]
assert learned.least_departure == 5.0
app.config["THRESHOLDS"] = to_thresholds_cfg({"norms.floor.host_mem_pct": 12})
assert learned.least_departure == 12.0
finally:
norms.LEARNED.pop(("t5742", "x_pct"), None)
def test_past_the_cutoff_but_within_its_normal_reads_healthy():
assert graded(93.0, "cpu", TH, _norm(88.0), 10.0, "%") == ("ok", "usual 88.0% at this hour")
assert graded(93.0, "cpu", TH, None, 10.0, "%") == ("crit", None)
# Out of its normal: a quarter over the usual hold and past the floor.
assert graded(93.0, "cpu", TH, _norm(40.0), 10.0, "%") == ("crit", None)
# Under the cutoff there is nothing to quiet.
assert graded(30.0, "cpu", TH, _norm(5.0), 10.0, "%") == ("ok", None)
def test_a_departure_smaller_than_the_floor_is_still_usual():
# 1.25 × 70 is 87.5 and 91 is over it, but only 21 over: under a 25-point floor.
assert graded(91.0, "cpu", TH, _norm(70.0), 25.0, "%")[0] == "ok"
assert graded(91.0, "cpu", TH, _norm(70.0), 10.0, "%")[0] == "crit"
def test_a_hosts_worst_reading_skips_what_is_usual_for_it():
readings = (("CPU", 95.0, "cpu"), ("Memory", 85.0, "mem"), ("Disk /", 50.0, "disk"))
assert worst_reading(readings, TH) == ("CPU", 95.0, "crit")
usual = {"cpu": (_norm(92.0), 10.0), "mem": (_norm(84.0), 5.0)}
assert worst_reading(readings, TH, usual) is None
def test_disk_keeps_its_cutoff_whatever_the_history():
grades = host_grades({"cpu": 95.0, "disk": 95.0}, {"cpu": _norm(93.0)}, TH)
assert grades["cpu"] == ("ok", "usual 93.0% at this hour")
assert grades["disk"] == ("crit", None)
def test_the_hosts_glance_grades_each_host_by_its_own_normal():
hosts = [NS(id="b", name="builder"), NS(id="w", name="web")]
agent = {"builder": {"cpu_pct": 95.0, "load_per_core": 120, "fresh": True,
"norms": {"cpu": _norm(90.0), "load": _norm(110.0)}},
"web": {"cpu_pct": 95.0, "fresh": True, "norms": {"cpu": _norm(20.0)}}}
rows = {r["host"].name: r for r in host_glance(hosts, {}, agent, TH)}
assert rows["builder"]["state"] == "up" and rows["builder"]["worst"] is None
assert rows["builder"]["grades"]["load"] == ("ok", "usual 110.0% at this hour")
assert rows["web"]["state"] == "crit" and rows["web"]["worst"][0] == "CPU"
def test_a_monitor_slow_as_usual_is_up_not_degraded():
monitor = NS(enabled=True)
slow = NS(is_up=True, response_ms=450.0)
assert row_state(monitor, slow, 100.0, TH) == "degraded"
assert row_state(monitor, slow, 100.0, TH, _norm(400.0)) == "up"
assert latency_grade(slow, TH, _norm(400.0)) == ("ok", "usual 400 ms at this hour")
# 1.25 × 300 = 375, and 150 over is past the 100 ms floor.
assert row_state(monitor, slow, 100.0, TH, _norm(300.0)) == "degraded"
def test_a_monitor_down_stays_down_whatever_its_normal():
down = NS(is_up=False, response_ms=None)
assert row_state(NS(enabled=True), down, 100.0, TH, _norm(400.0)) == "down"
def test_a_containers_cpu_is_learned_per_core_and_said_in_cores():
# 3.8 cores of a 4-core host is 95% of it: crit, unless 3.6 cores is usual.
c = NS(cpu_pct=380.0, mem_pct=50.0, mem_usage_bytes=2**30, mem_limit_bytes=2 * 2**30)
u = usage(c, 4, 64 * 2**30, thresholds=TH, norms={"cpu_pct": _norm(360.0)})
assert u["cpu_grade"] == "ok" and u["cpu_usual"] == "usual 3.60 cores at this hour"
assert usage(c, 4, 64 * 2**30, thresholds=TH)["cpu_grade"] == "crit"
assert usage(c, 4, 64 * 2**30)["cpu_grade"] is None # no thresholds: the page grades
def test_a_containers_memory_within_its_normal_reads_healthy():
c = NS(cpu_pct=1.0, mem_pct=92.0, mem_usage_bytes=2**30, mem_limit_bytes=2 * 2**30)
u = usage(c, 4, 64 * 2**30, thresholds=TH, norms={"mem_pct": _norm(90.0)})
assert (u["mem_grade"], u["mem_usual"]) == ("ok", "usual 90.0% at this hour")
assert usage(c, 4, 64 * 2**30, thresholds=TH)["mem_grade"] == "crit"
def test_ingest_records_load_per_core():
sample = {"load": {"1m": 6.0, "5m": 5.0, "15m": 4.0}, "cpu_cores": [10.0] * 8}
rows = _expand_sample_to_metrics(sample, "h", None)
(load_pct,) = [r for r in rows if r.metric_name == "load_pct"]
assert load_pct.resource_name == "h" and load_pct.value == 75.0
assert not [r for r in _expand_sample_to_metrics({"load": {"1m": 1.0}}, "h", None)
if r.metric_name == "load_pct"] # no cores reported: no figure
async def test_the_thresholds_page_offers_each_running_kinds_floor(app):
async with app.test_request_context("/settings/thresholds/"):
session["user_role"] = "admin"
page = await render_template("settings/thresholds.html", settings=dict(DEFAULTS),
traefik_on=False, plugins_on={"host_agent"})
assert 'id="normals"' in page
assert 'name="norms.floor.host_cpu_pct"' in page and 'name="norms.floor.monitor_ms"' in page
assert 'name="norms.floor.container_cpu_pct"' not in page # docker isn't running
assert 'name="norms.floor.router_4xx_pct"' not in page
+42
View File
@@ -333,3 +333,45 @@ def test_a_metric_learns_only_from_its_since(app, src):
learned, held_back = asyncio.run(_both()) learned, held_back = asyncio.run(_both())
assert learned is not None and learned.usual_hold == 90.0 assert learned is not None and learned.usual_hold == 90.0
assert held_back is None assert held_back is None
def test_a_metric_learned_without_its_parts_ignores_them(app, src):
"""A host's CPU learns a normal and each of its cores doesn't (#5742):
with parts=False, "<host>:core0" is neither learned nor judged, and the
floor may be a function read at each pass."""
from sqlalchemy import select
from steward.core import norms
from steward.core.anomalies import detect, levels_now
from steward.core.norms import norms_at, rebuild_norms
from steward.models.metrics import PluginMetricHourly
from steward.models.norms import MetricAnomaly
host = _res("whole")
part = f"{host}:core0"
rows = [PluginMetricHourly(source_module=src, resource_name=r, metric_name=METRIC,
bucket=NOW.replace(hour=0) - timedelta(days=d) + timedelta(hours=h),
value_avg=10.0, value_max=10.0, value_p95=10.0, sample_count=60)
for r in (host, part) for d in range(1, 8) for h in range(24)]
at = NOW + timedelta(days=3)
async def _go():
norms.learn_normal(src, METRIC, lambda: 5.0, parts=False)
async with app.db_sessionmaker() as s:
async with s.begin():
s.add_all(rows)
async with s.begin():
await rebuild_norms(s, NOW)
learned = await norms_at(s, [(src, host, METRIC), (src, part, METRIC)], NOW.replace(hour=14))
async with s.begin():
await _report(s, src, host, at, 90.0)
await _report(s, src, part, at, 90.0)
levels = await levels_now(s, at)
async with s.begin():
await detect(s, at)
opened = list((await s.execute(select(MetricAnomaly.resource_name).where(
MetricAnomaly.source_module == src))).scalars())
return learned, levels, opened
learned, levels, opened = asyncio.run(_go())
assert set(learned) == {(src, host, METRIC)}
assert (src, host, METRIC) in levels and (src, part, METRIC) not in levels
assert opened == [host] # 90 against a usual 10: the host departs, its core isn't judged