Files
bvandeusenandClaude Opus 5.5 f3433fe3ce
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m26s
CI / integration (push) Failing after 1m27s
CI / publish (push) Skipped
feat(norms): hosts, containers and monitor response times are graded against their own normal (#5742)
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>
2026-10-14 16:17:04 -04:00

337 lines
15 KiB
Python

"""Monitor queries and validation shared by the monitors pages and the MCP tools.
Each function takes an open session (or plain values), with no Quart request
context, so the routes and the /mcp tools (which run outside Quart's
pipeline) call the same code and refuse the same input for the same reasons.
"""
from __future__ import annotations
import ipaddress
import json
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from steward.core import anomalies as anom
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.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType
HTTP_METHODS = ("GET", "HEAD", "POST")
# The config keys each type takes (models/monitors.py documents their meaning).
CONFIG_KEYS: dict[str, tuple[str, ...]] = {
MonitorType.icmp.value: (),
MonitorType.tcp.value: ("port",),
MonitorType.dns.value: ("expected_ip", "resolver"),
MonitorType.http.value: ("method", "expected_status", "content_match", "headers",
"timeout_seconds", "follow_redirects", "verify_ssl"),
}
def _whole(name: str, value, default: int, lo: int, hi: int | None = None) -> int:
if value is None or value == "":
return default
if isinstance(value, bool):
raise ValueError(f"{name} must be a whole number, got {value!r}.")
try:
n = int(value)
except (TypeError, ValueError):
raise ValueError(f"{name} must be a whole number, got {value!r}.") from None
if n < lo or (hi is not None and n > hi):
bounds = f"between {lo} and {hi}" if hi is not None else f"at least {lo}"
raise ValueError(f"{name} must be {bounds}, got {n}.")
return n
def _headers(value) -> dict:
if value is None or value == "":
return {}
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
raise ValueError("headers must be a JSON object of name: value strings.") from None
if not isinstance(value, dict) or not all(isinstance(k, str) and isinstance(v, str)
for k, v in value.items()):
raise ValueError("headers must be an object of name: value strings.")
return value
def _resolver(value) -> str | None:
"""A DNS server's IP address, or None for the container's own resolver."""
value = str(value or "").strip()
if not value:
return None
try:
return str(ipaddress.ip_address(value))
except ValueError:
raise ValueError(f"resolver must be a DNS server's IP address (e.g. 1.1.1.1), got {value!r}.") from None
def normalise_target(mtype: str, target: str) -> str:
"""For http, default a bare host to https://; others pass through."""
target = (target or "").strip()
if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")):
return "https://" + target
return target
def build_config(mtype: str, opts: Mapping) -> dict:
"""The type-specific config from already-typed or form-string values.
Missing keys take their defaults; a key the type does not take, or a
value out of range, raises ValueError saying which and why.
"""
unknown = sorted(set(opts) - set(CONFIG_KEYS[mtype]))
if unknown:
allowed = ", ".join(CONFIG_KEYS[mtype]) or "no config"
raise ValueError(f"a {mtype} monitor does not take {', '.join(unknown)}; it takes {allowed}.")
if mtype == MonitorType.tcp.value:
return {"port": _whole("port", opts.get("port"), 80, 1, 65535)}
if mtype == MonitorType.dns.value:
return {"expected_ip": (str(opts.get("expected_ip") or "").strip() or None),
"resolver": _resolver(opts.get("resolver"))}
if mtype == MonitorType.http.value:
method = str(opts.get("method") or "GET").strip().upper()
if method not in HTTP_METHODS:
raise ValueError(f"method {method!r} is not one of {', '.join(HTTP_METHODS)}.")
return {
"method": method,
"expected_status": _whole("expected_status", opts.get("expected_status"), 200, 100, 599),
"content_match": str(opts.get("content_match") or "").strip(),
"headers": _headers(opts.get("headers")),
"timeout_seconds": _whole("timeout_seconds", opts.get("timeout_seconds"), 10, 1, 60),
"follow_redirects": bool(opts.get("follow_redirects", True)),
"verify_ssl": bool(opts.get("verify_ssl", True)),
}
return {} # icmp
def monitor_fields(mtype: str, *, target: str, name: str = "", check_interval_seconds=0,
opts: Mapping | None = None) -> dict:
"""Validated column values for a new or edited monitor; ValueError says why not."""
mtype = (mtype or "").strip().lower()
if mtype not in MONITOR_TYPE_VALUES:
raise ValueError(f"type {mtype!r} is not one of {', '.join(MONITOR_TYPE_VALUES)}.")
target = normalise_target(mtype, target)
if not target:
raise ValueError("a monitor needs a target: an address, or a URL for http.")
if len(target) > 2048:
raise ValueError("target is longer than 2048 characters.")
return {
"type": mtype,
"target": target,
"name": ((name or "").strip() or target)[:128],
"config_json": json.dumps(build_config(mtype, opts or {})),
"check_interval_seconds": _whole("check_interval_seconds", check_interval_seconds, 0, 0),
}
async def check_name_free(db, mtype: str, name: str, exclude_id: str | None = None) -> None:
"""ValueError when another monitor of this type already has this name.
A monitor's checks are recorded, and its alert rules watch it, by
(type, name), so two monitors sharing both would share one history.
"""
q = select(Monitor.id).where(Monitor.type == mtype, Monitor.name == name)
if exclude_id:
q = q.where(Monitor.id != exclude_id)
if (await db.execute(q.limit(1))).first():
raise ValueError(f"another {mtype} monitor is already named {name!r}; its checks and "
"alert rules go by that name, so pick a different one.")
# Where each state sorts on the Monitors page: what needs looking at first.
STATE_ORDER = {"down": 0, "degraded": 1, "pending": 2, "up": 3, "paused": 4}
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.
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
(Settings → Thresholds) and out of its normal (`norm`, #5742). A
slow-but-tolerable reply is coloured, not sorted.
"""
if not monitor.enabled:
return "paused"
if latest is None:
return "pending"
if not latest.is_up:
return "down"
if (status_class(uptime_pct, "uptime", thresholds) in ("warn", "crit")
or latency_grade(latest, thresholds, norm)[0] == "crit"):
return "degraded"
return "up"
def down_reason(latest) -> str | None:
"""Why a failed check failed, in a few words: the probe's error, else the
HTTP status, else a content mismatch; None when the result says nothing."""
if latest is None:
return None
if latest.error_msg:
return latest.error_msg
if latest.status_code:
return f"HTTP {latest.status_code}"
if latest.content_matched is False:
return "expected content not found"
return None
def problems_first(rows: list[dict]) -> list[dict]:
"""Down, then degraded, then pending, up and paused; by name within each."""
return sorted(rows, key=lambda d: (STATE_ORDER[d["state"]], d["monitor"].name.lower()))
async def monitor_rows(db, thresholds: dict):
"""Per-monitor state: latest result, heartbeat strip, latency trend,
uptime over 24h / 7d / 30d and TLS days, problems first.
Returns (rows, counts). `counts` has up (degraded included), degraded,
down, pending and paused. A monitor is degraded on its 24h uptime.
"""
from steward.core.status import sparkline_svg
monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars())
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
now = datetime.now(timezone.utc)
norms = await latency_norms(db, monitors, now)
data = []
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
windows = uptime.get(m.id, {})
latency, usual = latency_grade(latest, thresholds, norms.get(m.id))
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
times = [r.response_ms for r in rows if r.response_ms is not None]
data.append({
"monitor": m,
"latest": latest,
"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",
"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 "",
"uptime": windows,
"tls_days": tls_days,
"down_since": None,
"down_reason": down_reason(latest) if latest is not None and not latest.is_up else None,
})
# When each down monitor went down (a query per down monitor: few, and indexed).
for d in data:
if d["state"] == "down":
d["down_since"] = await down_since(db, d["monitor"].id)
counts = {k: sum(d["state"] == k for d in data) for k in STATE_ORDER}
counts["up"] += counts["degraded"]
return problems_first(data), counts
async def down_since(db, monitor_id: str):
"""When the monitor's current run of failures began, or None if its latest check is up.
The first failed check after the last success; if it has never succeeded,
its first check.
"""
last_up = (await db.execute(
select(func.max(MonitorResult.checked_at))
.where(MonitorResult.monitor_id == monitor_id, MonitorResult.is_up.is_(True))
)).scalar()
stmt = select(func.min(MonitorResult.checked_at)).where(MonitorResult.monitor_id == monitor_id)
if last_up is not None:
stmt = stmt.where(MonitorResult.checked_at > last_up)
return (await db.execute(stmt)).scalar()
async def monitor_states(db, monitors: list[Monitor]) -> dict[str, dict]:
"""{monitor_id: {latest, recent, uptime, down_since}} for the given monitors.
`recent` is the last HEARTBEAT_COUNT results oldest-first; `uptime` is
{"24h","7d","30d"} percentages; `down_since` is set only for a monitor
whose latest check failed.
"""
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
out: dict[str, dict] = {}
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
out[m.id] = {
"latest": latest,
"recent": rows,
"uptime": uptime.get(m.id, {}),
"down_since": None,
}
if latest is not None and not latest.is_up:
out[m.id]["down_since"] = await down_since(db, m.id)
return out
async def response_history(db, monitor_id: str, now: datetime, hours: int = 24,
points: int = 96) -> list[dict]:
"""The monitor's response time and failed checks over the last `hours`,
in about `points` buckets, oldest first: [{at (epoch ms), ms, down}].
`ms` is the bucket's average (None when no check returned a time, as for
DNS); `down` counts the failed checks in it."""
width = max(60, hours * 3600 // points)
bucket = func.date_bin(func.make_interval(0, 0, 0, 0, 0, 0, width), MonitorResult.checked_at,
func.to_timestamp(0)).label("bucket")
rows = (await db.execute(
select(bucket, func.avg(MonitorResult.response_ms),
func.count().filter(MonitorResult.is_up.is_(False)))
.where(MonitorResult.monitor_id == monitor_id,
MonitorResult.checked_at >= now - timedelta(hours=hours))
.group_by(bucket).order_by(bucket)
)).all()
return [{"at": int(at.timestamp() * 1000), "ms": None if ms is None else round(float(ms), 1),
"down": int(down)} for at, ms, down in rows]
async def recent_failures(db, monitor_id: str, limit: int = 10) -> list[MonitorResult]:
"""The monitor's latest failed checks, newest first."""
return list((await db.execute(
select(MonitorResult)
.where(MonitorResult.monitor_id == monitor_id, MonitorResult.is_up.is_(False))
.order_by(MonitorResult.checked_at.desc())
.limit(limit)
)).scalars())