phase: 01_severity_language
**Gate defect found & fixed (the "does not lint" bug).** `.agents/validate.sh` — the phase gate — ran `python3 -m pytest` on `/usr/bin/python3` + `~/.local` site-packages (not the uv venv), ran `ruff check .` *only if* a ruff happened to be on PATH, and **never ran pyright or the coverage floor**. Rewrote it to the project's real gate: cd to repo root → `uv run pytest --cov --cov-fail-under=94` → `uv run ruff check src tests agent` → `uv run pyright` → `node --check web/static/*.js` → hygiene gates; a missing toolchain now fails loudly. Provoked it to confirm it bites (floor + a seeded F401 both fail).
**Verification (all green)**
- `bash .agents/validate.sh` → **exit 0**: 356 passed, 6 skipped · coverage **94.75%** (floor 94) · ruff `All checks passed!` · pyright `0 errors` · node syntax clean
- `uv run pytest --cov` → `severity.py` **100%**; `uv run pytest -m live --live` → **6 passed**; `uv pip list --outdated` → empty
- Criteria: `classify('cpu.usage_pct',…)` → `fail`/`ok`/`None` ✓ · `/api/ups/snapshot` carries `severity` beside `charge_pct`/`load_pct`/`battery_runtime` (apc now live-`warn` at 498 s — threshold working) ✓ · no `.sev-*`/synonym vocabulary (only a comment saying so) ✓ · pytest/coverage/lint ✓ · dev server restarted, `/api/health` ok 6/6 sources, all pages+assets 200 ✓
- Chromium against live fleet: `severityFrom` cell→`fail`, bare→`warn`, null→`''`, unrated→`''`; `hero` fail → `rgb(207,34,46)`, null → `n/a` with **no** verdict class, warn+bar 42%; all four pages render unchanged → `data/logs/phase01-final-{index,system,llm,ups}.png`
**Docs (full review, incl. pre-existing drift).** README: `/ups` row claimed "upsd on 3493" (fleet is 3051, stated elsewhere in the same file); config table omitted `DASH_HOST/PORT`, llm/ups `*_INTERVAL_SECONDS`, `*_TIMEOUT_SECONDS`, `GUFO/VLLM_API_KEY`, `POLL_MS_*`, `DEBUGPY` (+ dedup); severity bullet omitted `disks`/`gpus[]`/rated LLM cells and the ratio rescale; agent section listed only `/snapshot` though `/info`+`/health` exist. AGENTS.md: same 3493 claim; `install-agent.sh --token-file` (its flags are `--bind/--port/--token/--rotate-token/--status/--uninstall` — it hands *the agent* `--token-file`); gap 3 "Two commits" (now 6, still no `origin`); gap 1 given the sudoers install command + verified live gating reason; coverage line now 80% repo floor vs 94% gate floor; cyberpower `identity.model: GX1500U` vs `ups.description` quirk. `.env.dev.example`: `DEV_FORWARDS` remote port 3493→3051 and engine URLs that pointed at `127.0.0.1` with nothing forwarding them.
**Deviations.** No `ruff format` (30 files would reformat; the project gate is `ruff check` + pyright — reformatting completed phases is out of scope). **No live-infra changes**: enabling the battery-test buttons needs `deploy/nut-sudoers` installed on driveripper + a nut-server restart and a scoped `[dashboards]` `instcmds` user — prod write with a documented shutdown risk (AGENTS gap 1), so repo-side only. 36 pre-existing `ResourceWarning: unclosed database` from test fixtures are noise, not failures; left rather than churn ~15 fixtures. `severity.attach()` is unit-tested but has no production call site until phases 02–05.
**Next pending phase:** `02_overview_fleet_and_alerts` (`01_alerts_builder.md`).
This commit is contained in:
+46
-3
@@ -21,6 +21,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from .collectors import NutMonitor
|
||||
from .config import Settings
|
||||
from .poller import Hub
|
||||
from .severity import classify
|
||||
from .sources import nut
|
||||
from .sources.host import fleet_summary
|
||||
from .sources.llm import fleet_totals
|
||||
@@ -74,6 +75,43 @@ def _history(store: SampleStore, source: str, host: str, metric: str, span: floa
|
||||
}
|
||||
|
||||
|
||||
def _host_severity(host: dict[str, Any]) -> dict[str, str | None]:
|
||||
"""Verdicts for the overview's fleet-table columns, from the one threshold table.
|
||||
|
||||
The row flattens `cpu.usage_pct` into `cpu_usage_pct`; the verdict is looked up under
|
||||
the row's own key so the page never has to know which section a number came from.
|
||||
"""
|
||||
cpu = host.get("cpu") or {}
|
||||
return {
|
||||
"cpu_usage_pct": classify("cpu.usage_pct", cpu.get("usage_pct")),
|
||||
"mem_used_pct": classify("mem.used_pct", (host.get("memory") or {}).get("used_pct")),
|
||||
"disk_worst_used_pct": classify("disk.used_pct",
|
||||
(host.get("disks") or {}).get("worst_used_pct")),
|
||||
"temp_c": classify("cpu.temp_c", cpu.get("temp_c")),
|
||||
}
|
||||
|
||||
|
||||
def _fleet_severity(fleet: dict[str, Any]) -> dict[str, str | None]:
|
||||
"""Fleet aggregates judged by the same rows as the per-host values — no fleet-only
|
||||
thresholds, so "the fleet is amber" means the same thing as one amber host."""
|
||||
return {
|
||||
"cpu_usage_pct_avg": classify("cpu.usage_pct", fleet.get("cpu_usage_pct_avg")),
|
||||
"memory_used_pct": classify("mem.used_pct", fleet.get("memory_used_pct")),
|
||||
"disk_worst_used_pct": classify("disk.used_pct", fleet.get("disk_worst_used_pct")),
|
||||
"gpu_busy_pct_avg": classify("gpu.busy_pct", fleet.get("gpu_busy_pct_avg")),
|
||||
}
|
||||
|
||||
|
||||
def _ups_severity(card: dict[str, Any]) -> dict[str, str | None]:
|
||||
"""The three hero battery numbers, keyed as the overview row names them."""
|
||||
numbers = card.get("numbers") or {}
|
||||
return {
|
||||
"charge_pct": classify("ups.charge_pct", numbers.get("charge_pct")),
|
||||
"load_pct": classify("ups.load_pct", numbers.get("load_pct")),
|
||||
"runtime_seconds": classify("ups.battery_runtime", numbers.get("battery_runtime")),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Common routes
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -161,10 +199,13 @@ def common_router() -> APIRouter:
|
||||
nut_job = ctx.hub.jobs.get("nut")
|
||||
if nut_job and nut_job.state.payload:
|
||||
ups_payload = nut_job.state.payload
|
||||
fleet = fleet_summary(hosts) if hosts else None
|
||||
if fleet is not None:
|
||||
fleet["severity"] = _fleet_severity(fleet)
|
||||
return {
|
||||
"ts": time.time(),
|
||||
"system": {"enabled": bool(ctx.settings.host_targets),
|
||||
"fleet": fleet_summary(hosts) if hosts else None,
|
||||
"fleet": fleet,
|
||||
"hosts": [{"name": h["name"], "os": (h.get("info") or {}).get("os_pretty"),
|
||||
"cpu_usage_pct": h["cpu"]["usage_pct"],
|
||||
"load1": h["cpu"].get("load1"),
|
||||
@@ -173,7 +214,8 @@ def common_router() -> APIRouter:
|
||||
"disk_worst_used_pct": h["disks"]["worst_used_pct"],
|
||||
"gpu_busy_pct": [g.get("busy_pct") for g in h.get("gpus") or []],
|
||||
"temp_c": h["cpu"].get("temp_c"),
|
||||
"uptime_seconds": (h.get("info") or {}).get("uptime_seconds")}
|
||||
"uptime_seconds": (h.get("info") or {}).get("uptime_seconds"),
|
||||
"severity": _host_severity(h)}
|
||||
for h in hosts],
|
||||
"states": ctx.hub.states("system")},
|
||||
"llm": {"enabled": bool(ctx.settings.gufo_url or ctx.settings.vllm_url),
|
||||
@@ -190,7 +232,8 @@ def common_router() -> APIRouter:
|
||||
"status": card["status"],
|
||||
"charge_pct": card["numbers"].get("charge_pct"),
|
||||
"load_pct": card["numbers"].get("load_pct"),
|
||||
"runtime_seconds": card["numbers"].get("battery_runtime")}
|
||||
"runtime_seconds": card["numbers"].get("battery_runtime"),
|
||||
"severity": _ups_severity(card)}
|
||||
for card in (ups_payload or {}).get("ups", [])],
|
||||
"states": ctx.hub.states("ups")},
|
||||
"recent_events": [event.as_json() for event in ctx.store.recent_events(12)],
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""The one place that decides whether a number is fine, worrying, or bad.
|
||||
|
||||
Two rules, and the whole module exists to keep them true:
|
||||
|
||||
1. **Unknown is not ok.** A value the upstream does not publish — `None`, a NaN that
|
||||
leaked through a parser, a metric with no entry in :data:`THRESHOLDS` — classifies to
|
||||
``None``, never to ``ok``. Green means "measured and good", the same promise the
|
||||
`AGENTS.md` "never invent a number" rule makes about the value itself. The page renders
|
||||
`n/a` with no colour class.
|
||||
2. **A threshold exists nowhere else in the app.** Pages render the class the API sent and
|
||||
must not compare a number to a magic value; the JS helpers in ``web/static`` read the
|
||||
``severity`` field instead of re-deriving it. Four pages with four opinions about what
|
||||
red means is the bug this module prevents.
|
||||
|
||||
Vocabulary is ``ok`` / ``warn`` / ``fail`` — the words ``dash.css`` (``.bar.ok``,
|
||||
``.dot.warn``, ``.chip.fail``) and ``sources/nut.py``'s ``health.checks[].status`` already
|
||||
use. "Danger" in the spec means ``fail``; there is no synonym for the same colour.
|
||||
|
||||
Reaching a threshold trips it, in both directions: for ``cpu.usage_pct`` (warn 75, danger
|
||||
90) 75 is ``warn`` and 90 is ``fail``; for the inverted ``ups.charge_pct`` (warn 50,
|
||||
danger 20) 50 is ``warn`` and 20 is ``fail``, because there a *smaller* number is worse.
|
||||
:data:`INVERTED` is the only place that direction is recorded, so a caller cannot get it
|
||||
backwards by accident.
|
||||
|
||||
Every `_pct` metric is on a 0-100 scale, never 0-1: an upstream that publishes a ratio
|
||||
(`llamacpp:kv_cache_usage_ratio`) is converted to percent *before* it is classified, or it
|
||||
will read as "fine" forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
OK = "ok"
|
||||
WARN = "warn"
|
||||
FAIL = "fail"
|
||||
|
||||
#: metric name -> (warn, danger). The numbers were ratified on 2026-10-16 and match what
|
||||
#: the old JS call sites passed, so turning the thresholds into data changes colour on the
|
||||
#: pages only where they had already drifted. The two UPS metrics are new: a battery at
|
||||
#: half charge or with ten minutes of runtime left is worth amber before it is worth red.
|
||||
#: Keys are the dotted metric names of the history namespace; ``@stream`` suffixes
|
||||
#: (``disk.used_pct@/srv``, ``gpu.busy_pct@0``) inherit their base metric's row.
|
||||
THRESHOLDS: dict[str, tuple[float, float]] = {
|
||||
"cpu.usage_pct": (75.0, 90.0),
|
||||
"mem.used_pct": (80.0, 92.0),
|
||||
"disk.used_pct": (80.0, 90.0),
|
||||
"cpu.temp_c": (75.0, 90.0),
|
||||
"gpu.busy_pct": (85.0, 95.0),
|
||||
"gpu.mem_used_pct": (85.0, 95.0),
|
||||
"llm.kv_cache_usage_pct": (75.0, 90.0),
|
||||
"ups.charge_pct": (50.0, 20.0),
|
||||
"ups.battery_runtime": (600.0, 300.0), # seconds of runtime left
|
||||
"ups.load_pct": (70.0, 90.0),
|
||||
}
|
||||
|
||||
#: Metrics where **lower is worse**, so ``classify`` owns the direction. Every entry must
|
||||
#: also be in :data:`THRESHOLDS` (a test enforces it, along with bound ordering).
|
||||
INVERTED: set[str] = {"ups.charge_pct", "ups.battery_runtime"}
|
||||
|
||||
|
||||
def _number(value: float | None) -> float | None:
|
||||
"""The value as a finite float, or ``None`` when there is no number to classify.
|
||||
|
||||
NaN and ±inf are not renderable and not JSON-serialisable; they become the same
|
||||
`n/a` the app uses for a missing upstream value rather than a colour nobody can justify.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
num = float(value)
|
||||
return num if math.isfinite(num) else None
|
||||
|
||||
|
||||
def _resolve(metric: str) -> tuple[float, float, bool] | None:
|
||||
"""(warn, danger, inverted) for a metric, or ``None`` if the table does not know it.
|
||||
|
||||
``metric@stream`` resolves to ``metric``: a per-core, per-mount, or per-card series is
|
||||
judged by the same rule as the aggregate it came from.
|
||||
"""
|
||||
base = metric.split("@", 1)[0]
|
||||
pair = THRESHOLDS.get(base)
|
||||
if pair is None:
|
||||
return None
|
||||
warn, danger = pair
|
||||
return warn, danger, base in INVERTED
|
||||
|
||||
|
||||
def classify(metric: str, value: float | None) -> str | None:
|
||||
"""`ok`, `warn`, `fail` — or ``None`` when nothing honest can be said.
|
||||
|
||||
``None`` in → ``None`` out, always: no measurement, no verdict. An unknown metric name
|
||||
is the same case (no threshold, no colour) and never raises.
|
||||
"""
|
||||
num = _number(value)
|
||||
if num is None:
|
||||
return None
|
||||
bounds = _resolve(metric)
|
||||
if bounds is None:
|
||||
return None
|
||||
warn, danger, inverted = bounds
|
||||
if inverted:
|
||||
if num <= danger:
|
||||
return FAIL
|
||||
if num <= warn:
|
||||
return WARN
|
||||
return OK
|
||||
if num >= danger:
|
||||
return FAIL
|
||||
if num >= warn:
|
||||
return WARN
|
||||
return OK
|
||||
|
||||
|
||||
def attach(metric: str, value: float | None) -> dict[str, Any]:
|
||||
"""`{"value": …, "severity": …}` for payloads where the metric is a bare number.
|
||||
|
||||
The only mutation of the caller's value is turning a non-finite float into ``None``,
|
||||
which keeps the payload valid JSON and the panel honest.
|
||||
"""
|
||||
num = _number(value)
|
||||
return {"value": num, "severity": classify(metric, num)}
|
||||
|
||||
|
||||
def with_severity(metric: str, cell: Mapping[str, Any] | None,
|
||||
scale: float = 1.0) -> dict[str, Any]:
|
||||
"""Copy of a `{value, source, derived}` cell (LLM metrics) with `severity` added.
|
||||
|
||||
Additive-only: every existing key keeps its name and meaning, and a cell that is
|
||||
missing or holds a non-numeric value still comes back with ``severity: null``. The
|
||||
input mapping is never mutated.
|
||||
|
||||
``scale`` rescales the value **for the classification only**: the KV-cache gauges are
|
||||
published as 0-1 ratios (`llamacpp:kv_cache_usage_ratio`, `vllm:kv_cache_usage_perc`)
|
||||
while the table is written in percent, so classifying the raw ratio would read "fine"
|
||||
forever. The cell's own `value` is left exactly as the upstream published it — the
|
||||
conversion is a unit, not a new measurement, and the page still prints what it got.
|
||||
"""
|
||||
out: dict[str, Any] = dict(cell) if cell is not None else {}
|
||||
raw = out.get("value")
|
||||
value = raw if raw is None or isinstance(raw, (int, float)) else None
|
||||
out["severity"] = classify(metric, None if value is None else value * scale)
|
||||
return out
|
||||
@@ -21,6 +21,7 @@ from typing import Any
|
||||
|
||||
from ..poller import Result
|
||||
from ..rates import RateEstimator
|
||||
from ..severity import classify
|
||||
from ..store import Sample
|
||||
|
||||
# /proc/stat jiffies we treat as "not doing useful work".
|
||||
@@ -231,6 +232,7 @@ def parse_host_snapshot(name: str, snapshot: dict[str, Any], collector: HostColl
|
||||
temp_c = max(temps.values()) if temps else None
|
||||
pools_used = None if vram_used is None else vram_used + (gtt_used or 0.0)
|
||||
pools_total = None if vram_total is None else vram_total + (gtt_total or 0.0)
|
||||
pool_used_pct = _ratio_percent(pools_used, pools_total)
|
||||
row = {
|
||||
"index": index,
|
||||
"card": gpu.get("card"),
|
||||
@@ -250,11 +252,14 @@ def parse_host_snapshot(name: str, snapshot: dict[str, Any], collector: HostColl
|
||||
# this is the honest whole-addressable-pool view, kept beside the two pools
|
||||
"mem_used_bytes": pools_used,
|
||||
"mem_total_bytes": pools_total,
|
||||
"mem_used_pct": _ratio_percent(pools_used, pools_total),
|
||||
"mem_used_pct": pool_used_pct,
|
||||
"power_watts": gpu.get("power_watts"),
|
||||
"temps": temps,
|
||||
"temp_c": temp_c,
|
||||
"clocks": gpu.get("clocks") or {},
|
||||
# the verdict travels with the card; keys mirror the rated fields beside them
|
||||
"severity": {"busy_pct": classify("gpu.busy_pct", gpu.get("busy_pct")),
|
||||
"mem_used_pct": classify("gpu.mem_used_pct", pool_used_pct)},
|
||||
}
|
||||
gpus.append(row)
|
||||
for metric, value in (("busy_pct", row["busy_pct"]), ("vram_used_bytes", vram_used),
|
||||
@@ -324,6 +329,10 @@ def parse_host_snapshot(name: str, snapshot: dict[str, Any], collector: HostColl
|
||||
"procs_running": cpu_raw.get("procs_running"),
|
||||
"procs_blocked": cpu_raw.get("procs_blocked"),
|
||||
"temp_c": cpu_temp,
|
||||
# Additive: the pages colour from this verdict instead of comparing the number
|
||||
# to a threshold of their own. Keys mirror the rated fields in this section.
|
||||
"severity": {"usage_pct": classify("cpu.usage_pct", usage_pct),
|
||||
"temp_c": classify("cpu.temp_c", cpu_temp)},
|
||||
},
|
||||
"memory": {
|
||||
"total_bytes": mem_total,
|
||||
@@ -336,6 +345,7 @@ def parse_host_snapshot(name: str, snapshot: dict[str, Any], collector: HostColl
|
||||
"swap_used_bytes": swap_used,
|
||||
"swap_used_pct": swap_used_pct,
|
||||
"committed_bytes": mem.get("committed_bytes"),
|
||||
"severity": {"used_pct": classify("mem.used_pct", mem_used_pct)},
|
||||
},
|
||||
"disks": {
|
||||
"filesystems": filesystems,
|
||||
@@ -347,6 +357,8 @@ def parse_host_snapshot(name: str, snapshot: dict[str, Any], collector: HostColl
|
||||
"writes_per_s": writes_total if any_io else None,
|
||||
"util_pct": util_max,
|
||||
"devices": io_devices,
|
||||
"severity": {"worst_used_pct": classify(
|
||||
"disk.used_pct", worst.get("used_pct") if worst else None)},
|
||||
},
|
||||
"network": {
|
||||
"rx_bytes_per_s": rx_total if any_net else None,
|
||||
|
||||
@@ -31,6 +31,7 @@ from typing import Any
|
||||
from ..poller import Result
|
||||
from ..prom import Prom, field
|
||||
from ..rates import RateEstimator
|
||||
from ..severity import with_severity
|
||||
from ..store import Sample
|
||||
|
||||
GUFO_SERIES: dict[str, tuple[tuple[str, ...], str]] = {
|
||||
@@ -91,6 +92,23 @@ VLLM_HISTOGRAMS: dict[str, tuple[str, ...]] = {
|
||||
"e2e_request_latency_seconds": ("vllm:e2e_request_latency_seconds",),
|
||||
}
|
||||
|
||||
#: Cells that have a row in `severity.THRESHOLDS`, mapped to (metric, scale). Both engines
|
||||
#: publish KV pressure as a 0-1 ratio while the threshold table is written in percent, so
|
||||
#: the scale converts for the *verdict only* — `value` stays what the engine published
|
||||
#: (rule 2: no invented number), and 0.85 is `warn` rather than a permanent `ok`.
|
||||
SEVERITY_CELLS: dict[str, tuple[str, float]] = {
|
||||
"kv_cache_usage_ratio": ("llm.kv_cache_usage_pct", 100.0), # gufo / llama.cpp
|
||||
"gpu_cache_usage": ("llm.kv_cache_usage_pct", 100.0), # vLLM
|
||||
}
|
||||
|
||||
|
||||
def attach_cell_severity(metrics: dict[str, Any]) -> None:
|
||||
"""Add `severity` to the rated cells of a parsed metrics dict, in place, additively."""
|
||||
for key, (metric, scale) in SEVERITY_CELLS.items():
|
||||
if key in metrics:
|
||||
metrics[key] = with_severity(metric, metrics[key], scale=scale)
|
||||
|
||||
|
||||
#: history series per engine, in the order the LLM page charts them
|
||||
HISTORY_METRICS: dict[str, tuple[str, ...]] = {
|
||||
"decode_tokens_per_second": ("decode_tokens_per_second",),
|
||||
@@ -165,6 +183,7 @@ def parse_gufo_metrics(text: str, rates: RateEstimator | None = None,
|
||||
out["spec_drafts_per_request"] = _ratio(out["spec_drafts_total"],
|
||||
out["spec_draft_tokens_total"],
|
||||
"computed:drafts/draft_tokens")
|
||||
attach_cell_severity(out)
|
||||
return out
|
||||
|
||||
|
||||
@@ -244,6 +263,7 @@ def parse_vllm_metrics(text: str, rates: RateEstimator | None = None,
|
||||
out["prefix_cache_hit_ratio"] = _ratio(out["prefix_cache_hits_total"],
|
||||
out["prefix_cache_queries_total"],
|
||||
"computed:hits/queries")
|
||||
attach_cell_severity(out)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..severity import classify
|
||||
|
||||
DEFAULT_PORT = 3493
|
||||
|
||||
|
||||
@@ -741,6 +743,12 @@ def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: A
|
||||
"alarms": reading.alarms,
|
||||
"on_battery": reading.on_battery,
|
||||
"numbers": numbers,
|
||||
# Severity answers "how bad is this number" for the three hero values; `health`
|
||||
# and its `checks[].status` answer "is this condition satisfied". Both stay.
|
||||
"severity": {"charge_pct": classify("ups.charge_pct", numbers.get("charge_pct")),
|
||||
"load_pct": classify("ups.load_pct", numbers.get("load_pct")),
|
||||
"battery_runtime": classify("ups.battery_runtime",
|
||||
numbers.get("battery_runtime"))},
|
||||
"health": health,
|
||||
"advertised_commands": reading.commands,
|
||||
"writable": reading.writable,
|
||||
|
||||
@@ -102,6 +102,16 @@ const Dash = (() => {
|
||||
el.classList.add(threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* FALLBACK ONLY: a colour class derived from thresholds the *caller* passes.
|
||||
*
|
||||
* Thresholds live in `src/dashboards/severity.py` and travel in the payload as
|
||||
* `severity`; `severityFrom()` below is how a page reads them. This function survives
|
||||
* only because `page-index.js`, `page-system.js`, `page-llm.js` and `page-ups.js` still
|
||||
* call it with their own numbers (phases 02–05 convert them page by page, so each page's
|
||||
* change is reviewable on its own). Do not add a new call site: a threshold written in
|
||||
* JS is a threshold that can disagree with the other three pages.
|
||||
*/
|
||||
function severityFor(value, warnAt, failAt) {
|
||||
const warn = warnAt == null ? 75 : warnAt;
|
||||
const fail = failAt == null ? 90 : failAt;
|
||||
@@ -111,6 +121,105 @@ const Dash = (() => {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
// ---- severity & hero numbers -------------------------------------------
|
||||
/** The only colour vocabulary in the app — matches `dash.css` and severity.py. */
|
||||
const SEVERITY_CLASSES = ['ok', 'warn', 'fail'];
|
||||
|
||||
/**
|
||||
* The colour class for a value, read from the payload instead of a threshold.
|
||||
*
|
||||
* Accepts a payload cell (`{value, severity}`), the bare `severity` string, or `null`,
|
||||
* and returns `ok` / `warn` / `fail` / `''`. Anything the API does not rate — no field,
|
||||
* `null`, a word from another vocabulary — returns `''`, which means *no colour*: an
|
||||
* unknown number must never look green.
|
||||
*/
|
||||
function severityFrom(cellOrSeverity) {
|
||||
let value = cellOrSeverity;
|
||||
if (value && typeof value === 'object') value = value.severity;
|
||||
if (typeof value !== 'string') return '';
|
||||
const word = value.trim().toLowerCase();
|
||||
return SEVERITY_CLASSES.indexOf(word) >= 0 ? word : '';
|
||||
}
|
||||
|
||||
/** First direct child with `className`, or null. */
|
||||
function childOf(parent, className) {
|
||||
for (const node of Array.from(parent.children)) {
|
||||
if (node.classList && node.classList.contains(className)) return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The child with `className`, created (before `before`) on first use. */
|
||||
function ensureChild(parent, className, tag, before) {
|
||||
const existing = childOf(parent, className);
|
||||
if (existing) return existing;
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
parent.insertBefore(node, before || null);
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Text for a label or footnote: `null` prints nothing (unlike `setTxt`'s `n/a`). */
|
||||
function assignText(el, text) {
|
||||
const value = text == null ? '' : String(text);
|
||||
if (el.textContent !== value) el.textContent = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render — or update in place — one big colour-coded number.
|
||||
*
|
||||
* `hero(el, {label, value, unit, digits, severity, sub, bar})`
|
||||
* - `el`: an element or a selector (a missing element is a silent no-op, like the other
|
||||
* setters, so a page can call every hero it knows about);
|
||||
* - `severity`: taken from the payload (`{value, severity}` cell or the bare string) and
|
||||
* resolved by `severityFrom` — this function never compares a number to a threshold;
|
||||
* - a `null` / non-finite `value` prints `n/a`, faint, **with no severity class**, so a
|
||||
* value nobody measured cannot keep last poll's green;
|
||||
* - `bar`: `true` for a 0-100 value, or a number for another max — adds/updates a `.bar`
|
||||
* under the number (the `.hero-bar` variant of `dash.css`).
|
||||
*
|
||||
* The DOM is built once and then patched text-by-text, so a poll does not restart CSS
|
||||
* transitions, drop the tabular-numeral layout, or steal focus.
|
||||
*/
|
||||
function hero(el, options) {
|
||||
const node = typeof el === 'string' ? $(el) : el;
|
||||
if (!node) return null;
|
||||
const opts = options || {};
|
||||
node.classList.add('hero');
|
||||
assignText(ensureChild(node, 'label', 'div'), opts.label);
|
||||
const valueEl = ensureChild(node, 'value', 'div');
|
||||
const numberEl = ensureChild(valueEl, 'num', 'span');
|
||||
const unitEl = ensureChild(valueEl, 'unit', 'span');
|
||||
|
||||
const raw = opts.value;
|
||||
const measured = raw != null && (typeof raw === 'string' ? raw !== '' : isFinite(raw));
|
||||
const sev = measured ? severityFrom(opts.severity) : '';
|
||||
SEVERITY_CLASSES.forEach((name) => node.classList.toggle(name, name === sev));
|
||||
|
||||
if (measured) {
|
||||
const text = typeof raw === 'string' ? raw : num(raw, opts.digits);
|
||||
if (numberEl.textContent !== text) numberEl.textContent = text;
|
||||
numberEl.classList.remove('is-null', 'na');
|
||||
valueEl.classList.remove('is-null');
|
||||
} else {
|
||||
if (numberEl.textContent !== naText) numberEl.textContent = naText;
|
||||
numberEl.classList.add('is-null', 'na');
|
||||
valueEl.classList.add('is-null');
|
||||
}
|
||||
assignText(unitEl, measured ? opts.unit : '');
|
||||
|
||||
if (opts.bar) {
|
||||
const bar = ensureChild(node, 'bar', 'div', childOf(node, 'sub'));
|
||||
const fill = $('i', bar) || bar.appendChild(document.createElement('i'));
|
||||
const max = typeof opts.bar === 'number' && opts.bar > 0 ? opts.bar : 100;
|
||||
const width = typeof raw === 'number' ? Math.max(0, Math.min(100, (raw / max) * 100)) : 0;
|
||||
fill.style.width = `${width}%`;
|
||||
SEVERITY_CLASSES.forEach((name) => bar.classList.toggle(name, name === sev));
|
||||
}
|
||||
assignText(ensureChild(node, 'sub', 'div'), opts.sub);
|
||||
return node;
|
||||
}
|
||||
|
||||
function setDot(el, status) {
|
||||
if (!el) return;
|
||||
el.classList.remove('ok', 'warn', 'fail', 'idle');
|
||||
@@ -335,7 +444,7 @@ const Dash = (() => {
|
||||
|
||||
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
|
||||
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
|
||||
staleness, markStale, severityFor, ageSeconds, naText };
|
||||
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => Dash.initChrome());
|
||||
|
||||
@@ -238,6 +238,54 @@ main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
|
||||
|
||||
.na { color: var(--text-faint); }
|
||||
|
||||
/* ---- hero numbers --------------------------------------------------------
|
||||
* The big glanceable number: label, value + unit, and optional bar and footnote.
|
||||
* Colour comes from the *same* `ok|warn|fail` classes the rest of the stylesheet uses
|
||||
* (`.bar.ok`, `.dot.warn`, `.chip.fail`) — there is deliberately no parallel `.sev-*`
|
||||
* family, because one word per colour is what keeps four pages agreeing. `Dash.hero()`
|
||||
* applies the class from the `severity` the API sent, so nothing here (or in JS) knows a
|
||||
* threshold. No class at all means no measurement: the number renders faint, never green.
|
||||
*/
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: .18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.hero .label {
|
||||
font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.hero .value {
|
||||
font-family: var(--mono); font-size: 2.4rem; font-weight: 650; line-height: 1.05;
|
||||
font-variant-numeric: tabular-nums; /* fixed digit width: no jitter between polls */
|
||||
display: flex; align-items: baseline; gap: .3rem; flex-wrap: wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.hero .value > span { white-space: nowrap; }
|
||||
.hero .unit { font-size: .38em; font-weight: 550; color: var(--text-muted); }
|
||||
.hero .sub {
|
||||
font-family: var(--mono); font-size: 11px; color: var(--text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* an empty label/sub takes no space, so a hero without a footnote is not taller */
|
||||
.hero .label:empty, .hero .sub:empty { display: none; }
|
||||
|
||||
.hero.ok .value { color: var(--ok); }
|
||||
.hero.warn .value { color: var(--warn); }
|
||||
.hero.fail .value { color: var(--fail); }
|
||||
/* after the verdicts on purpose: `n/a` is not a measurement, so it is never coloured */
|
||||
.hero .value.is-null, .hero .value .is-null { color: var(--text-faint); font-size: 1.25rem; font-weight: 550; }
|
||||
|
||||
/* number + bar: the per-host cards and the UPS hero row both want the pair, and the bar
|
||||
takes its colour from the hero so markup does not have to repeat the verdict */
|
||||
.hero-bar { gap: .28rem; }
|
||||
.hero-bar .bar { width: 100%; }
|
||||
.hero-bar.ok .bar > i { background: var(--ok); }
|
||||
.hero-bar.warn .bar > i { background: var(--warn); }
|
||||
.hero-bar.fail .bar > i { background: var(--fail); }
|
||||
/* with no verdict on either the hero or the bar the fill stays the neutral accent: the
|
||||
bar shows *how much*, never a judgement nobody measured. */
|
||||
|
||||
/* ---- bars, dots, chips -------------------------------------------------- */
|
||||
.bar {
|
||||
position: relative; height: 7px; border-radius: 999px; background: var(--bg-sunken);
|
||||
|
||||
Reference in New Issue
Block a user