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:
2026-10-16 22:22:26 -04:00
parent 4b4962d7fb
commit ad993c7b03
40 changed files with 1384 additions and 25 deletions
+46 -3
View File
@@ -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)],
+144
View File
@@ -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
+13 -1
View File
@@ -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,
+20
View File
@@ -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
+8
View File
@@ -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,
+110 -1
View File
@@ -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());
+48
View File
@@ -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);