phase: 02_overview_fleet_and_alerts

**Phase 02 final verification pass — all three tasks already complete; verified criteria, fixed 3 defects found live.**

- Verified: `/` = exactly 2 `<section>`s (Warnings & errors + Fleet), alerts render from `/api/summary` verdicts with each alert's own `href`; `/system`, `/llm`, `/ups` unchanged (charts + data live).
- Fixed (`src/dashboards/alerts.py`): a dead source's message printed the name twice — `gateway: UpstreamError: gateway: http://…`; the poller's `UpstreamError:` wrapper is now stripped (other exception names kept). Tests: 2 new in `tests/test_alerts.py`.
- Fixed (`src/dashboards/api.py`): `/api/summary` dropped a configured host/engine that had never answered post-start, so the fleet said `2/2 hosts` while a third was down. Stub rows (`unreachable: true`, all `n/a`) now match `/api/*/snapshot`; `hosts_total=3 / hosts_live=2`. Test: `test_a_host_that_has_never_answered_is_still_a_row_in_the_fleet`.
- Fixed (`web/static/page-index.js`): fleet row of a non-answering host is now dimmed with the shared `.stale` class (invariant 4 on `/`). Test: `test_overview_dims_the_fleet_row_of_a_host_that_stopped_answering`.
- `bash .agents/validate.sh` exit 0: `uv run pytest --cov --cov-fail-under=94` → **439 passed, 6 skipped, 96.82%** (`alerts.py` 100%, `api.py` 100%); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check web/static` clean; hygiene clean; `uv pip list --outdated` empty; `uv run pytest -m live --live` → **6 passed**.
- Live proof (tunnel kill, then restored — all 6 sources `ok` again): killing the gateway forward produced exactly **one** alert, `fail gateway /system … unreachable (ConnectError)`; screenshots `data/logs/phase02-final-{alert,system,llm,ups,recovered}.png`.
- Completion criteria: two panels ✅ · `[]` when healthy ✅ (hermetic `healthy_client` + a live gateway-only instance returned `[]`; the real fleet can never be `[]` — its UPSes permanently warn on battery age/self-test) · one alert per dead host with `/system` link ✅ · pytest/live/coverage/ruff/pyright ✅ · no change on `/system`,`/llm`,`/ups` ✅ · phase 01 verdict vocabulary intact ✅.
- Docs: README `web/` bullet now states the dimmed `n/a` row; AGENTS.md invariant 4, `alerts.py`/`api.py` bullets, UI-conventions stale-row + window-chip ranges (5m…30d vs 1h…90d), metric example `llm.decode_tps` → `llm.decode_tokens_per_second` (verified against `/api/metrics`), test count 435 → 439. Env table, Containerfile, agent/install-script claims, fleet facts re-checked and consistent.
- Notable: left the fleet's real hostnames/domain in AGENTS.md's "real environment" section (deliberate, prior-phase content) even though rule 6 discourages personal hostnames in tracked files — worth a separate decision; no live config was changed beyond stopping/starting a local dev ssh forward.
- Next pending phase: `.agents/phases/todo/03_system_per_host_panels/`.
This commit is contained in:
2026-10-17 00:59:09 -04:00
parent ad993c7b03
commit 8b048599f3
34 changed files with 1955 additions and 225 deletions
+414
View File
@@ -0,0 +1,414 @@
"""What is wrong right now, derived once on the server instead of on every page.
The landing page has one question — *is anything wrong, and where do I look?* — and the
answer must be identical in the browser, in a future mobile view, and in whatever
notifies someone later. So the answer is a function of the state the poller already
keeps and the verdicts `severity.py` / `sources/nut.assess()` already produced, and this
module is the only place that assembles it.
It is a pure projection by design: no I/O, no store, no HTTP, no clock it cannot be given.
That is what makes every rule below unit-testable without a hub, and what keeps a page
from growing its own opinion about what red means.
The rules, and the reason each exists:
* **Never alert for an unconfigured source.** `AGENTS.md` invariant 3: a server the
operator never installed must not raise a red flag, because that destroys the signal
from the panels that matter. `SourceState.configured` is the gate.
* **Stale is not dead.** A source that stopped answering *and* has no last good value is
`fail`. A source that is still serving its last good value but has not refreshed in
three of its own poll intervals is `warn` — saying "dead" while the page shows numbers
would be a lie. Per-source intervals because system polls at 10s and UPS at 5s; one
global number would be either noise or too late.
* **Verdicts come from elsewhere.** Host/LLM breaches read the `severity` dicts phase 01
added; UPS breaches read `health.checks[].status`. Nothing here compares a number to a
threshold of its own — the message *quotes* the bound from `severity.THRESHOLDS` so the
operator can see which line was crossed, and the numbers still live in one file.
* **A dead source is one alert.** Its last good payload is still in the hub, so its stale
threshold breaches would pile three more amber lines on top of the red one that already
names it. Payload-derived alerts are skipped for a source that is not answering.
* **Bounded and deduped.** One alert per (source, metric) — the `id` is that pair — and at
most :data:`MAX_ALERTS` of them, the last being a marker that says how many it hid. A
40-disk fleet must not turn the landing page into a log tail.
* **Sorted fail → warn, then by source**, so the thing that needs action is at the top
without the operator scanning.
"""
from __future__ import annotations
import re
import time
from collections.abc import Mapping, Sequence
from typing import Any
from .config import Settings
from .severity import INVERTED, THRESHOLDS
from .sources.llm import SEVERITY_CELLS
#: The most recent verdicts the landing page shows. Beyond this it is a wall of text and
#: the question it answers becomes "read this list", which is a different page.
MAX_ALERTS = 12
FAIL = "fail"
WARN = "warn"
#: Only these two are alerts. `ok` is not news, and `None`/`unknown` means nothing honest
#: could be said — `AGENTS.md` rule 2, no measurement, no verdict, no colour.
_RANK = {FAIL: 0, WARN: 1}
#: Where each dashboard lives, so every alert can say where to look.
HREF = {"system": "/system", "llm": "/llm", "ups": "/ups"}
DEFAULT_HREF = "/"
#: A source whose last good value is older than this many of its own poll intervals is
#: serving data the operator should not act on. It is still on screen, hence amber.
STALE_FACTOR = 3.0
#: How to say a metric out loud: label, unit, decimals. Keyed by the dotted metric name
#: from `severity.THRESHOLDS`; an unlisted metric prints its own name rather than nothing.
_DISPLAY: dict[str, tuple[str, str, int]] = {
"cpu.usage_pct": ("CPU", "%", 0),
"cpu.temp_c": ("CPU temperature", "°C", 0),
"mem.used_pct": ("memory", "%", 0),
"disk.used_pct": ("disk", "%", 0),
"gpu.busy_pct": ("GPU utilisation", "%", 0),
"gpu.mem_used_pct": ("GPU memory", "%", 0),
"llm.kv_cache_usage_pct": ("KV cache", "%", 0),
}
#: host payload section -> (field, severity metric). Mirrors the `severity` sub-dicts that
#: `sources/host.py` attaches, so a new rated field is one row here.
_HOST_CELLS: tuple[tuple[str, str, str], ...] = (
("cpu", "usage_pct", "cpu.usage_pct"),
("cpu", "temp_c", "cpu.temp_c"),
("memory", "used_pct", "mem.used_pct"),
("disks", "worst_used_pct", "disk.used_pct"),
)
_GPU_CELLS: tuple[tuple[str, str], ...] = (
("busy_pct", "gpu.busy_pct"),
("mem_used_pct", "gpu.mem_used_pct"),
)
# --------------------------------------------------------------------------- #
# formatting helpers (words, not thresholds)
# --------------------------------------------------------------------------- #
def _value(metric: str, number: float, scale: float = 1.0) -> str:
"""`CPU 92%` — the metric said out loud, with the value the upstream published.
Callers only get here with a real number (no measurement, no alert), so there is no
`n/a` case. ``scale`` is a unit for the *display* of a ratio the threshold table keeps
in percent (`llamacpp:kv_cache_usage_ratio` is 0-1); it is the same conversion
`with_severity()` applies to the verdict, so the number and the colour cannot disagree.
"""
label, unit, digits = _DISPLAY.get(metric, (metric, "", 1))
return f"{label} {number * scale:.{digits}f}{unit}"
def _trip(metric: str, verdict: Any) -> str:
"""` (fail ≥90)` — which bound was crossed, quoted from `severity.THRESHOLDS`.
The threshold still lives in one place; this only reads it back so the alert is
actionable without opening the source. Direction comes from `severity.INVERTED`, so a
low battery charge prints `≤` instead of an arithmetic-looking `≥20`.
"""
if verdict not in _RANK:
return ""
base = metric.split("@", 1)[0]
bounds = THRESHOLDS.get(base)
if bounds is None:
return ""
warn, danger = bounds
low_is_worse = base in INVERTED
chosen = danger if verdict == FAIL else warn
return f" ({verdict} {'≤' if low_is_worse else '≥'}{chosen:g})"
#: `poller.Job` records a failure as `"<ExceptionClass>: <message>"`, and collectors build
#: their message as `"<name>: <what happened>"`. Left alone, a dead host prints
#: `gateway: UpstreamError: gateway: http://… unreachable` — the name twice and the one
#: exception class this codebase guarantees everything else raises (`AGENTS.md`: collectors
#: surface a single `UpstreamError` type, so its name is never information). Other class
#: names stay: an unexpected `ValueError` is a bug worth reading about. The full string
#: survives in `/api/health` and the event feed.
_POLLER_CLASS = re.compile(r"^UpstreamError: ")
def _clean_error(label: str, error: str) -> str:
"""`http://h:9121/snapshot unreachable (ConnectError)` from the poller's wrapped string."""
text = error.strip()
while True:
stripped = _POLLER_CLASS.sub("", text, count=1).strip()
if stripped.startswith(f"{label}: "):
stripped = stripped[len(label) + 2:].strip()
if stripped == text:
return text
text = stripped
def _sections(degraded: Any) -> str:
"""The agent's `errors` map (or list) as words: `temperatures (no hwmon sensors)`."""
if isinstance(degraded, Mapping):
return ", ".join(f"{name} ({detail})" if detail else str(name)
for name, detail in degraded.items()) or "nothing"
if isinstance(degraded, (list, tuple)):
return ", ".join(str(item) for item in degraded) or "nothing"
return str(degraded)
def _interval(settings: Settings, dashboard: str) -> float:
"""The source's own poll interval — the unit staleness is measured in."""
if dashboard == "llm":
return float(settings.llm_interval)
if dashboard == "ups":
return float(settings.nut_interval)
if dashboard == "system":
return float(settings.host_interval)
return 60.0
# --------------------------------------------------------------------------- #
# the sink: dedupe, bound, sort
# --------------------------------------------------------------------------- #
class _Sink:
"""Collects alerts keyed by (source, metric), keeping the worst verdict per key."""
def __init__(self, now: float) -> None:
self.now = now
self.items: dict[str, dict[str, Any]] = {}
def add(self, verdict: Any, source: str, key: str, message: str, href: str,
at: float | None = None) -> None:
"""Record one verdict — unless it is `ok`, `unknown`, or `None`, which are not alerts."""
if verdict not in _RANK:
return
alert_id = f"{source}:{key}"
existing = self.items.get(alert_id)
if existing is not None:
# same source + same metric twice (two UPS cards answering as one name, a
# payload re-projected after a state change): one line, and the worse verdict
if _RANK[str(verdict)] < _RANK[existing["severity"]]:
existing["severity"] = str(verdict)
existing["message"] = message
existing["at"] = self.now if at is None else at
return
self.items[alert_id] = {
"id": alert_id,
"severity": str(verdict),
"source": source,
"message": message,
"href": href,
# when this became true, best effort: the last good poll, the outage event, or
# now. The page prints it as "since", so an alert that resets its clock every
# refresh is an alert whose duration nobody can read.
"at": self.now if at is None else at,
}
def result(self) -> list[dict[str, Any]]:
ordered = sorted(self.items.values(),
key=lambda item: (_RANK.get(item["severity"], 9), item["source"],
item["id"]))
if len(ordered) <= MAX_ALERTS:
return ordered
visible, hidden = ordered[:MAX_ALERTS - 1], ordered[MAX_ALERTS - 1:]
failing = sum(1 for item in hidden if item["severity"] == FAIL)
visible.append({
"id": "alerts:suppressed",
# the marker inherits the worst thing it hid: a list that ends in amber while
# three reds are behind it would be a lie of the same shape as hiding them
"severity": hidden[0]["severity"],
"source": "alerts",
"message": (f"{len(hidden)} more not shown "
f"({failing} failing, {len(hidden) - failing} warning)"),
"href": DEFAULT_HREF,
"at": self.now,
})
return visible
# --------------------------------------------------------------------------- #
# the rules
# --------------------------------------------------------------------------- #
def _state_for(states: Sequence[Mapping[str, Any]], name: str) -> Mapping[str, Any] | None:
"""The health record for the job that produced `name` (a host or engine label)."""
for state in states:
if state.get("label") == name or str(state.get("key") or "").endswith(f":{name}"):
return state
return None
def _answering(states: Sequence[Mapping[str, Any]], name: str) -> bool:
"""Is that source currently answering? Unknown (no state at all) counts as yes.
A payload that exists is data; inventing a reachability verdict for a source with no
health record would be this module's opinion, not the poller's.
"""
state = _state_for(states, name)
return True if state is None else bool(state.get("ok"))
def _dashboard_answering(states: Sequence[Mapping[str, Any]], dashboard: str) -> bool:
mine = [state for state in states if state.get("dashboard") == dashboard]
return any(bool(state.get("ok")) for state in mine) if mine else True
def _outage_started(events: Sequence[Mapping[str, Any]], dashboard: str,
label: str) -> float | None:
"""When the poller first said this source was down, from its own transition event.
Best effort by construction: `recent_events` is the short list the summary already
carries, so an outage older than that reads as `at: now`. The alert itself never
depends on the event being there.
"""
matches = [float(event["ts"]) for event in events
if event.get("kind") == "unreachable" and event.get("host") == label
and (event.get("source") == dashboard or event.get("source") is None)
and isinstance(event.get("ts"), (int, float))]
return max(matches) if matches else None
def _source_alerts(sink: _Sink, settings: Settings, states: Sequence[Mapping[str, Any]],
events: Sequence[Mapping[str, Any]]) -> None:
for state in states:
if not state.get("configured"):
continue # invariant 3: never red for a source nobody installed
label = str(state.get("label") or state.get("key") or "source")
dashboard = str(state.get("dashboard") or "")
href = HREF.get(dashboard, DEFAULT_HREF)
key = str(state.get("key") or label)
if not state.get("ok"):
# the alert starts with the name, so the name inside the message is removed —
# "driveripper: driveripper: ..." reads like two problems instead of one
error = _clean_error(label, str(state.get("error") or "")) or "is not answering"
sink.add(FAIL, label, f"{key}:down", f"{label}: {error}", href,
_outage_started(events, dashboard, label) or sink.now)
continue
age = state.get("age_seconds")
interval = _interval(settings, dashboard)
if isinstance(age, (int, float)) and age > interval * STALE_FACTOR:
sink.add(WARN, label, f"{key}:stale",
f"{label} last answered {age:.0f}s ago (every {interval:g}s): the page "
f"is showing the last good value, not a live one",
href, sink.now - age)
def _host_alerts(sink: _Sink, host: Mapping[str, Any],
states: Sequence[Mapping[str, Any]]) -> None:
name = str(host.get("name") or "host")
if host.get("unreachable") or not _answering(states, name):
return # the job-level alert already names this host; one line, not four
at = host.get("ts") if isinstance(host.get("ts"), (int, float)) else None
for section, field, metric in _HOST_CELLS:
block = host.get(section) or {}
number = block.get(field)
verdict = (block.get("severity") or {}).get(field)
# no number, no alert: `n/a` is not a breach (and the verdict is None anyway)
if number is None or verdict not in _RANK:
continue
mount = block.get("worst_mount")
where = f" on {mount}" if mount else ""
sink.add(verdict, name, metric,
f"{name}: {_value(metric, number)}{where}{_trip(metric, verdict)}",
HREF["system"], at)
for gpu in host.get("gpus") or []:
card = str(gpu.get("model") or f"GPU {gpu.get('index', 0)}")
for field, metric in _GPU_CELLS:
number = gpu.get(field)
verdict = (gpu.get("severity") or {}).get(field)
if number is None or verdict not in _RANK:
continue
sink.add(verdict, name, f"{metric}@{gpu.get('index', 0)}",
f"{name}: {_value(metric, number)} on {card}{_trip(metric, verdict)}",
HREF["system"], at)
degraded = host.get("degraded")
if degraded:
# a /proc read failed on the box: the numbers that did come back are real, but the
# snapshot is not the whole picture and the page must not imply that it is
sink.add(WARN, name, "agent.sections",
f"{name}: the agent could not read {_sections(degraded)}", HREF["system"], at)
def _engine_alerts(sink: _Sink, engine: Mapping[str, Any],
states: Sequence[Mapping[str, Any]]) -> None:
name = str(engine.get("engine") or "engine")
if engine.get("unreachable") or not _answering(states, name):
return
at = engine.get("scraped_at") if isinstance(engine.get("scraped_at"), (int, float)) else None
metrics = engine.get("metrics") or {}
# the rated cells are declared next to the field maps they come from (`SEVERITY_CELLS`),
# so a new one is rated by severity.py and alerted here without touching this loop
for key, (metric, scale) in SEVERITY_CELLS.items():
cell = metrics.get(key)
if not isinstance(cell, Mapping):
continue
value, verdict = cell.get("value"), cell.get("severity")
# no number in the cell, no claim (rule 2); no verdict, no colour to justify one
if not isinstance(value, (int, float)) or verdict not in _RANK:
continue
sink.add(verdict, name, f"{metric}@{key}",
f"{name}: {_value(metric, float(value), scale)}{_trip(metric, verdict)}",
HREF["llm"], at)
def _ups_alerts(sink: _Sink, payload: Mapping[str, Any],
states: Sequence[Mapping[str, Any]]) -> None:
if payload.get("simulated"):
# `AGENTS.md`: the simulator must never be silent about what it is. The UPS page
# badges it; the landing page has to say it too, or a modelled battery reads as a
# healthy real one from two clicks away.
sink.add(WARN, "ups", "simulated",
"UPS numbers are simulated — NUT_SIMULATE=1 is set, so these are modelled "
"figures and not your batteries", HREF["ups"])
if not _dashboard_answering(states, "ups"):
return
for card in payload.get("ups") or []:
name = str(card.get("name") or "ups")
at = card.get("ts") if isinstance(card.get("ts"), (int, float)) else None
for check in (card.get("health") or {}).get("checks") or []:
label = str(check.get("name") or "check")
detail = str(check.get("detail") or "").strip()
# `unknown` stays out: a driver that publishes no battery temperature is not a
# fault, and the UPS page already says "not reported by this driver"
sink.add(check.get("status"), name, f"health:{label}",
f"{label}: {detail}" if detail else label, HREF["ups"], at)
def _config_alerts(sink: _Sink, settings: Settings) -> None:
for problem in settings.host_target_problems:
# a typo'd entry is not a dead host: the operator needs to know it is not being
# polled at all, which is otherwise invisible (the host simply is not in the list)
sink.add(WARN, "config", f"HOST_TARGETS:{problem}",
f"HOST_TARGETS: {problem} — that entry is not being polled", HREF["system"])
# --------------------------------------------------------------------------- #
# entry point
# --------------------------------------------------------------------------- #
def build(settings: Settings, hub_states: Sequence[Mapping[str, Any]] = (),
host_payloads: Sequence[Mapping[str, Any]] = (),
engine_payloads: Sequence[Mapping[str, Any]] = (),
ups_payload: Mapping[str, Any] | None = None,
recent_events: Sequence[Mapping[str, Any]] = (), *,
now: float | None = None) -> list[dict[str, Any]]:
"""Everything that is wrong right now, worst first.
Each alert is `{"id", "severity", "source", "message", "href", "at"}`; `id` is
`"<source>:<metric>"` and is stable across refreshes so a page can key DOM off it, and
`at` is a unix timestamp for when the condition was first observed (best effort).
Empty means all clear — there is no `ok` alert, because an empty list is the clearest
possible answer to "what is wrong".
Inputs are the same objects `/api/summary` already assembles (job states, last good
payloads, the UPS payload, the short event list), so answering costs nothing extra and
the answer cannot disagree with the panels underneath it.
"""
sink = _Sink(time.time() if now is None else now)
_source_alerts(sink, settings, hub_states, recent_events)
for host in host_payloads:
_host_alerts(sink, host, hub_states)
for engine in engine_payloads:
_engine_alerts(sink, engine, hub_states)
if ups_payload is not None:
_ups_alerts(sink, ups_payload, hub_states)
_config_alerts(sink, settings)
return sink.result()
+47 -14
View File
@@ -18,6 +18,7 @@ from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from . import alerts as alert_rules
from .collectors import NutMonitor
from .config import Settings
from .poller import Hub
@@ -190,11 +191,30 @@ def common_router() -> APIRouter:
@router.get("/summary")
async def summary(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""One call for the landing page: is each dashboard alive, and what stands out."""
hosts = [job.state.payload for job in ctx.hub.jobs.values()
if job.dashboard == "system" and job.state.payload]
engines = [job.state.payload for job in ctx.hub.jobs.values()
if job.dashboard == "llm" and job.state.payload]
"""One call for the landing page: is anything wrong, and what is the fleet doing.
What `/` renders is `alerts` plus `system.fleet`/`system.hosts` and nothing else.
Everything else here is for **programmatic use**: this is the cheapest whole-fleet
probe that exists, so the live tests, an external check and a notifier later read
`llm.engines`, `ups.ups` and the `states`/`recent_events` no panel paints any more.
Keys are additive-only — `alerts` was added, nothing was renamed, dropped, or given
a new meaning. `tests/test_api.py` pins each path below against a future cleanup.
"""
# A configured job that has never answered gets the same stub `/api/*/snapshot`
# hands out: a host in `HOST_TARGETS` with no payload yet is still in the fleet, and
# dropping its row would turn "dead agent" into "never configured" (invariants 3
# and 4) while quietly shrinking `fleet.hosts_total` to the answering subset.
hosts: list[dict[str, Any]] = [
job.state.payload or {"name": job.label, "ts": job.state.last_attempt,
"unreachable": True}
for job in ctx.hub.jobs.values() if job.dashboard == "system"
]
engines: list[dict[str, Any]] = [
job.state.payload or {"engine": job.label, "metrics": {},
"sessions": {"active": None, "list": []},
"unreachable": True}
for job in ctx.hub.jobs.values() if job.dashboard == "llm"
]
ups_payload = None
nut_job = ctx.hub.jobs.get("nut")
if nut_job and nut_job.state.payload:
@@ -202,28 +222,40 @@ def common_router() -> APIRouter:
fleet = fleet_summary(hosts) if hosts else None
if fleet is not None:
fleet["severity"] = _fleet_severity(fleet)
# one list of events, reused twice: the feed itself, and the timestamp for a source
# that is currently failing (alerts.py reads `kind: unreachable` out of it)
recent_events = [event.as_json() for event in ctx.store.recent_events(12)]
# "what is wrong right now" is a server-side answer (see alerts.py) so the overview,
# a mobile view, and a notifier later all agree on the same verdicts
summary_alerts = alert_rules.build(ctx.settings, ctx.hub.states(), hosts, engines,
ups_payload, recent_events)
return {
"ts": time.time(),
"system": {"enabled": bool(ctx.settings.host_targets),
"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"),
# every read is `.get()` because a stub has none of these sections —
# an unreachable agent renders as `n/a` (rule 2), never as a 500
"hosts": [{"name": h["name"],
"os": (h.get("info") or {}).get("os_pretty"),
"cpu_usage_pct": (h.get("cpu") or {}).get("usage_pct"),
"load1": (h.get("cpu") or {}).get("load1"),
"cores": (h.get("info") or {}).get("cpu_cores_logical"),
"mem_used_pct": h["memory"]["used_pct"],
"disk_worst_used_pct": h["disks"]["worst_used_pct"],
"mem_used_pct": (h.get("memory") or {}).get("used_pct"),
"disk_worst_used_pct": (h.get("disks") or {}).get("worst_used_pct"),
"gpu_busy_pct": [g.get("busy_pct") for g in h.get("gpus") or []],
"temp_c": h["cpu"].get("temp_c"),
"temp_c": (h.get("cpu") or {}).get("temp_c"),
"uptime_seconds": (h.get("info") or {}).get("uptime_seconds"),
"unreachable": bool(h.get("unreachable")),
"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),
"fleet": fleet_totals(engines) if engines else None,
"engines": [{"engine": e["engine"],
"decode_tokens_per_second": e["metrics"].get(
"decode_tokens_per_second": (e.get("metrics") or {}).get(
"decode_tokens_per_second", {}).get("value"),
"active": e["sessions"].get("active")}
"active": (e.get("sessions") or {}).get("active"),
"unreachable": bool(e.get("unreachable"))}
for e in engines],
"states": ctx.hub.states("llm")},
"ups": {"enabled": ctx.settings.nut_enabled,
@@ -236,7 +268,8 @@ def common_router() -> APIRouter:
"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)],
"recent_events": recent_events,
"alerts": summary_alerts,
}
return router
+14 -2
View File
@@ -23,6 +23,18 @@ from typing import Any
from .nut import UpsReading
def _mfr_date(years_ago: float) -> str:
"""A battery manufacture date `years_ago` years back, computed instead of hard-coded.
A fixed date is fine for a year or two and then quietly turns every development page
amber forever (`Battery age: 7.0 years — past the 4-year replacement window`), which is
the same failure `assess()` refuses for a UPS that floats at 90 %: a warning nobody can
clear teaches people to ignore the warnings that matter. The simulator stands in for a
working UPS, so its pack stays a fixed age behind today.
"""
return time.strftime("%Y-%m-%d", time.gmtime(time.time() - years_ago * 365.25 * 86_400))
@dataclass
class SimUps:
"""One fake UPS: nameplate values plus the state a driver would report."""
@@ -180,10 +192,10 @@ class NutSimulator:
self.ups = [
SimUps("cyberpower", "PR1500LCDRT2U", "Cyber Power Systems", "CPS-04213",
nominal_va=1500.0, nominal_w=900.0, load_w=210.0, capacity_ah=17.0,
mfr_date="2022-04-15"),
mfr_date=_mfr_date(2.0)),
SimUps("apc", "Smart-UPS 1500", "APC", "AS-11902",
nominal_va=1500.0, nominal_w=865.0, load_w=140.0, capacity_ah=8.0,
mfr_date="2019-11-02", driver="usbhid-ups",
mfr_date=_mfr_date(3.0), driver="usbhid-ups",
test_result="PASSED", test_date="2026-06-14"),
]
self._last = time.time()
+17 -64
View File
@@ -30,70 +30,29 @@
<div class="sub" id="summary-line">reading fleet…</div>
</div>
<div class="spacer"></div>
<span class="chip" id="config-chip" hidden>loading config</span>
</div>
<div id="banners" class="stack" style="margin-bottom:.85rem"></div>
<section class="grid auto" aria-label="Dashboards">
<article class="card">
<header><h2>System resources</h2><span class="spacer"></span><span class="dot idle" id="dot-system"></span></header>
<!-- Exactly two panels: what is wrong, and what the machines are doing. Their order is
the page's only trick: a problem moves alerts above the fleet table, an empty list
leaves the morning calm. `data-state` is set by page-index.js from /api/summary. -->
<div class="stack" id="overview-panels" data-state="clear">
<section class="card panel-alerts" aria-labelledby="alerts-title">
<header><h2 id="alerts-title">Warnings &amp; errors</h2><span class="spacer"></span>
<span class="faint mono" id="alerts-count"></span></header>
<div class="body">
<div class="metrics">
<div class="metric"><div class="label">Hosts answering</div>
<div class="value"><span id="sys-hosts-live">n/a</span><span class="unit" id="sys-hosts-total"></span></div></div>
<div class="metric"><div class="label">CPU average</div>
<div class="value"><span id="sys-cpu">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
<div class="metric"><div class="label">Memory</div>
<div class="value"><span id="sys-mem">n/a</span><span class="unit" id="sys-mem-unit"></span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
<div class="metric"><div class="label">Worst disk</div>
<div class="value"><span id="sys-disk">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
<div class="metric"><div class="label">GPUs</div>
<div class="value sm" id="sys-gpus">n/a</div></div>
<ul class="alerts" id="alerts" aria-live="polite"></ul>
<div class="empty-state" id="alerts-clear" hidden>
<h3>All clear</h3>
<p>Nothing is failing and no threshold is breached right now. Sources the operator
never configured are not problems, so they are not here either.</p>
</div>
<div class="gap" style="margin-top:.7rem"><a class="action" href="/system" style="text-decoration:none">Open system dashboard →</a></div>
</div>
</article>
</section>
<article class="card">
<header><h2>LLM engines</h2><span class="spacer"></span><span class="dot idle" id="dot-llm"></span></header>
<div class="body">
<div class="metrics">
<div class="metric"><div class="label">Engines answering</div>
<div class="value"><span id="llm-live">n/a</span><span class="unit" id="llm-total"></span></div></div>
<div class="metric"><div class="label">Decode</div>
<div class="value"><span id="llm-decode">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Prompt</div>
<div class="value"><span id="llm-prompt">n/a</span><span class="unit">tok/s</span></div></div>
<div class="metric"><div class="label">Active sessions</div>
<div class="value" id="llm-active">n/a</div></div>
<div class="metric"><div class="label">Queued</div>
<div class="value" id="llm-waiting">n/a</div></div>
<div class="metric"><div class="label" title="highest KV-cache use across engines right now">KV cache now</div>
<div class="value"><span id="llm-kv">n/a</span><span class="unit">%</span></div>
<div class="bar" style="margin-top:.3rem"><i></i></div></div>
</div>
<div class="gap" style="margin-top:.7rem"><a class="action" href="/llm" style="text-decoration:none">Open LLM dashboard →</a></div>
</div>
</article>
<article class="card">
<header><h2>UPS / battery</h2><span class="spacer"></span><span class="dot idle" id="dot-ups"></span></header>
<div class="body">
<div id="ups-list" class="stack">
<div class="muted">loading…</div>
</div>
<div class="gap" style="margin-top:.7rem"><a class="action" href="/ups" style="text-decoration:none">Open UPS dashboard →</a></div>
</div>
</article>
</section>
<section class="grid wide" style="margin-top:.85rem">
<article class="card">
<header><h2>Fleet</h2><span class="spacer"></span><span class="faint mono" id="fleet-note"></span></header>
<section class="card panel-fleet" aria-labelledby="fleet-title">
<header><h2 id="fleet-title">Fleet</h2></header>
<div class="body tight">
<table class="data" id="fleet-table">
<thead><tr><th>Host</th><th>CPU</th><th>Load 1</th><th>Mem used</th><th>Worst disk</th><th>GPU busy</th><th>Temp</th><th>Up</th></tr></thead>
@@ -105,14 +64,8 @@
<code>HOST_TARGETS</code>.</p>
</div>
</div>
</article>
<article class="card">
<header><h2>Recent events</h2><span class="spacer"></span>
<span class="faint">transitions only</span></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</article>
</section>
</section>
</div>
<footer class="pagefoot">
<span id="foot-versions"></span>
+1 -1
View File
@@ -57,7 +57,7 @@
<div class="metric"><div class="label">GPU busy (avg)</div>
<div class="value"><span id="f-gpu">n/a</span><span class="unit">%</span></div></div>
<div class="metric"><div class="label">Power</div>
<div class="value sm" id="f-power" title="No whole-host power source is wired up yet; this stays n/a rather than guessing.">n/a</div></div>
<div class="value sm" title="No whole-host power source is wired up yet; no script writes this, so it stays n/a rather than guessing.">n/a</div></div>
</div>
</div>
</section>
+9 -9
View File
@@ -62,7 +62,10 @@ const Dash = (() => {
if (ts == null) return 'never';
const seconds = Math.max(0, Date.now() / 1000 - ts);
if (seconds < 2) return 'now';
return duration(seconds).replace(/(h|m|s|d|y)$/, '$') + ' ago';
// `duration()` already carries the unit: "2m ago", "5h ago". (It used to be stripped
// with `replace(/(h|m|s|d|y)$/, '$')` — a lone `$` is a literal in a JS replacement,
// so every age printed "2$ ago". The unit is what makes the number mean anything.)
return `${duration(seconds)} ago`;
}
const naText = 'n/a';
@@ -107,10 +110,10 @@ const Dash = (() => {
*
* 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.
* only because `page-system.js`, `page-llm.js` and `page-ups.js` still call it with
* their own numbers (phases 03–05 convert them page by page, so each page's change is
* reviewable on its own — `/` was already converted in phase 02). Do not add a new call
* site: a threshold written in JS is a threshold that can disagree with the other pages.
*/
function severityFor(value, warnAt, failAt) {
const warn = warnAt == null ? 75 : warnAt;
@@ -312,10 +315,7 @@ const Dash = (() => {
const clock = $('#clock');
if (clock) {
const tickClock = () => {
const now = new Date();
const target = document.getElementById('sim-flag');
clock.textContent = now.toLocaleTimeString(undefined, { hour12: false });
void target;
clock.textContent = new Date().toLocaleTimeString(undefined, { hour12: false });
};
tickClock();
setInterval(tickClock, 1000);
+28 -5
View File
@@ -179,8 +179,6 @@ main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
.grid.auto { grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); }
.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(min(480px, 100%), 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); }
.grid.wide { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
@media (max-width: 1150px) { .grid.wide { grid-template-columns: 1fr; } }
.stack { display: grid; gap: .85rem; align-content: start; }
/* ---- cards -------------------------------------------------------------- */
@@ -208,8 +206,6 @@ main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
.card > .body.tight { padding: .45rem .8rem .6rem; }
.card.flat { box-shadow: none; }
.cardset { display: grid; gap: .85rem; }
/* ---- metrics ------------------------------------------------------------ */
.metrics { display: grid; gap: .7rem .9rem; grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); }
.metric { min-width: 0; }
@@ -353,6 +349,11 @@ table.data tbody tr:hover td { background: var(--bg-sunken); }
table.data td.name { font-weight: 600; }
table.data .num { font-family: var(--mono); }
table.data .mute { color: var(--text-muted); }
/* verdict colour on a table cell (overview fleet rows): only a breach paints, `ok` stays
plain text so a healthy table is quiet — the words are the same `ok|warn|fail` the API
sends, never a JS-side threshold */
table.data td.warn { color: var(--warn); }
table.data td.fail { color: var(--fail); }
table.data.scroll { display: block; overflow: auto; max-height: 320px; }
/* ---- charts ------------------------------------------------------------- */
@@ -404,6 +405,29 @@ ul.events .sev.info { color: var(--text-muted); }
ul.events .sev.warn { color: var(--warn); }
ul.events .sev.error { color: var(--fail); }
/* overview alerts: source (link to its dashboard) · message · age. The class on the row
is the server's verdict — `alerts.py` decided, the page only paints it. */
ul.alerts { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
ul.alerts li {
display: grid; grid-template-columns: max-content 1fr max-content; gap: .7rem; align-items: baseline;
padding: .4rem .1rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
font-size: 13px;
}
ul.alerts li:last-child { border-bottom: 0; }
ul.alerts .src { font-weight: 650; white-space: nowrap; }
ul.alerts .msg { color: var(--text-muted); overflow-wrap: anywhere; }
ul.alerts time { font-family: var(--mono); font-size: 11px; color: var(--text-faint); white-space: nowrap; }
ul.alerts li.ok .src { color: var(--ok); }
ul.alerts li.warn .src { color: var(--warn); }
ul.alerts li.fail .src { color: var(--fail); }
/* the overview's two panels swap by verdict, not by re-rendering: an active problem reads
first, an empty list leaves the fleet table on top and the all-clear state below it */
#overview-panels .panel-fleet { order: 1; }
#overview-panels .panel-alerts { order: 2; }
#overview-panels[data-state="alerting"] .panel-alerts { order: 1; }
#overview-panels[data-state="alerting"] .panel-fleet { order: 2; }
ul.checks { list-style: none; margin: 0; padding: 0; display: grid; gap: .4rem; }
ul.checks li { display: grid; grid-template-columns: 18px 1fr; gap: .5rem; align-items: start; }
ul.checks .what { font-weight: 600; font-size: 12.5px; }
@@ -472,7 +496,6 @@ dialog footer { padding: .7rem .9rem; border-top: 1px solid var(--border); displ
.empty-state ul { text-align: left; display: inline-block; margin: .6rem 0 0; }
.stale { opacity: .62; }
.stale-flag { color: var(--warn); font-size: 11px; }
select, input[type="text"] {
background: var(--bg-sunken); color: var(--text); border: 1px solid var(--border);
+90 -91
View File
@@ -1,7 +1,13 @@
/* Overview page: one /api/summary call, plus the config-driven setup hints. */
/* Overview page: exactly two answers — what needs attention, and what the fleet is doing.
*
* One `/api/summary` call feeds both panels; "what needs attention" is the server's list
* (`alerts.py`), never a browser-side opinion. The page owns no thresholds: cells are
* coloured from the `severity` verdicts in the payload via `Dash.severityFrom`, and an
* unrated value gets no colour (which is also what keeps `n/a` from looking green).
*/
'use strict';
const { $, fetchJSON, setTxt, setValue, setBar, setDot, bytes, pct, num, duration, poll } = Dash;
const { $, fetchJSON, num, duration, age, poll } = Dash;
async function loadConfig() {
try {
@@ -48,101 +54,84 @@ async function loadConfig() {
}
}
function renderEvents(events) {
const list = $('#events');
if (!events || !events.length) {
list.innerHTML = '<li class="muted" style="grid-template-columns:1fr">Nothing has changed ' +
'since this server started — the log only records transitions.</li>';
/**
* The warnings/errors panel: one row per alert from `/api/summary`'s `alerts`.
*
* The row is the source (a link to the dashboard that explains it), the server's message,
* and the age. Colour comes from `alert.severity` through `Dash.severityFrom` — the page
* never re-derives a verdict. An empty list is a message ("all clear"), not a blank box,
* and it also moves the panel below the fleet table so a calm morning reads calm.
*/
function renderAlerts(alerts) {
const list = $('#alerts');
const clear = $('#alerts-clear');
const panels = $('#overview-panels');
const items = Array.isArray(alerts) ? alerts : [];
panels.dataset.state = items.length ? 'alerting' : 'clear';
$('#alerts-count').textContent = items.length
? `${items.length} active`
: '';
clear.hidden = items.length > 0;
list.hidden = items.length === 0;
if (!items.length) {
list.innerHTML = '';
return;
}
list.innerHTML = events.map((event) => {
const when = new Date(event.ts * 1000).toLocaleTimeString(undefined,
{ hour: '2-digit', minute: '2-digit', hour12: false });
return `<li><time>${when}</time><span><span class="sev ${event.severity}">${event.host}</span> ` +
`${DashCharts.escapeHtml(event.message.replace(event.host + ':', '').trim() || event.kind)}</span></li>`;
list.innerHTML = items.map((alert) => {
// alerts.py names the source at the head of most messages; the link already says it,
// and "deskwork: deskwork: CPU 92%" reads like two problems instead of one
const source = String(alert.source || 'fleet');
const raw = String(alert.message || '');
const prefix = `${source}:`;
const message = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw;
const sev = Dash.severityFrom(alert.severity);
return `<li${sev ? ` class="${sev}"` : ''}>
<a class="src" href="${DashCharts.escapeHtml(alert.href || '/')}">${DashCharts.escapeHtml(source)}</a>
<span class="msg">${DashCharts.escapeHtml(message)}</span>
<time>${age(alert.at)}</time>
</li>`;
}).join('');
}
function renderSummary(summary) {
const system = summary.system || {};
const fleet = system.fleet || {};
setDot($('#dot-system'), system.enabled ? (fleet.hosts_live === fleet.hosts_total ? 'ok'
: fleet.hosts_live ? 'warn' : 'fail') : 'idle');
setTxt($('#sys-hosts-live'), fleet.hosts_live == null ? 'n/a' : String(fleet.hosts_live));
setTxt($('#sys-hosts-total'), fleet.hosts_total == null ? '' : `of ${fleet.hosts_total}`);
setValue($('#sys-cpu'), fleet.cpu_usage_pct_avg, 0);
setBar($('#sys-cpu').closest('.metric').querySelector('.bar'), fleet.cpu_usage_pct_avg);
if (fleet.memory_used_bytes != null) {
const used = Dash.fmtBytes(fleet.memory_used_bytes, 0);
const total = Dash.fmtBytes(fleet.memory_total_bytes, 0);
// "200 / 253 GiB", not "200 GiB of 253 GiB": the card column is narrow and the long form
// ran into the worst-disk number next to it
setTxt($('#sys-mem'), total ? `${used.text}/${total.text}` : used.text);
setTxt($('#sys-mem-unit'), used.unit);
} else {
setTxt($('#sys-mem'), 'n/a');
setTxt($('#sys-mem-unit'), '');
/**
* The fleet table: one row per host, columns unchanged.
*
* Cell colour comes from the row's `severity` verdicts (`/api/summary`, phase 01). `ok`
* prints as plain text on purpose — an all-green table is noise; only a breach earns a
* colour here, and the alerts panel is where anything rated gets its line.
*
* A host whose agent stopped answering keeps its last good numbers rather than being
* zeroed, so its row is dimmed with the shared `.stale` class (and says so in its
* tooltip): the table must never imply dead numbers are live while the panel above
* says the box is down.
*/
function renderFleet(rows, states) {
const answering = {};
for (const state of states || []) {
const name = String(state.label || state.key || '');
if (name) answering[name] = Boolean(state.ok);
}
setBar($('#sys-mem').closest('.metric').querySelector('.bar'), fleet.memory_used_pct);
setValue($('#sys-disk'), fleet.disk_worst_used_pct, 0);
setBar($('#sys-disk').closest('.metric').querySelector('.bar'), fleet.disk_worst_used_pct,
Dash.severityFor(fleet.disk_worst_used_pct, 80, 92));
setTxt($('#sys-gpus'), fleet.gpu_count == null ? 'n/a'
: `${fleet.gpu_count}${fleet.gpu_busy_pct_avg != null ? ' · ' + num(fleet.gpu_busy_pct_avg, 0) + '%' : ''}`);
const llm = summary.llm || {};
const engines = llm.fleet || {};
setDot($('#dot-llm'), !llm.enabled ? 'idle'
: (engines.engines_live === engines.engines_total ? 'ok' : engines.engines_live ? 'warn' : 'fail'));
setTxt($('#llm-live'), engines.engines_live == null ? 'n/a' : String(engines.engines_live));
setTxt($('#llm-total'), engines.engines_total == null ? '' : `of ${engines.engines_total}`);
setValue($('#llm-decode'), engines.decode_tokens_per_second, 1);
setValue($('#llm-prompt'), engines.prompt_tokens_per_second, 0);
setTxt($('#llm-active'), engines.requests_active == null ? 'n/a' : num(engines.requests_active, 0));
setTxt($('#llm-waiting'), engines.requests_waiting == null ? 'n/a' : num(engines.requests_waiting, 0));
setValue($('#llm-kv'), engines.kv_cache_usage_max == null ? null : engines.kv_cache_usage_max * 100, 0);
setBar($('#llm-kv').closest('.metric').querySelector('.bar'),
engines.kv_cache_usage_max == null ? null : engines.kv_cache_usage_max * 100,
Dash.severityFor(engines.kv_cache_usage_max == null ? null : engines.kv_cache_usage_max * 100, 75, 90));
const ups = summary.ups || {};
setDot($('#dot-ups'), !ups.enabled ? 'idle'
: (ups.ups || []).some((u) => u.overall === 'at risk') ? 'fail'
: (ups.ups || []).some((u) => u.overall !== 'healthy') ? 'warn' : 'ok');
const list = $('#ups-list');
if (!ups.enabled) {
list.innerHTML = '<div class="muted">No NUT server configured.</div>';
} else if (!(ups.ups || []).length) {
list.innerHTML = '<div class="muted">No UPS answering from this upsd yet.</div>';
} else {
list.innerHTML = ups.ups.map((item) => {
const kind = item.overall === 'healthy' ? 'ok' : item.overall === 'at risk' ? 'fail'
: item.overall === 'degraded' ? 'warn' : '';
return `<div class="gap" style="justify-content:space-between">
<span class="gap"><span class="chip ${kind}">${DashCharts.escapeHtml(item.name)}</span>
<span class="mono faint">${DashCharts.escapeHtml(item.status || 'no state')}</span>
${ups.simulated ? '<span class="sim-badge">sim</span>' : ''}</span>
<span class="mono">${item.charge_pct == null ? 'n/a' : num(item.charge_pct, 0) + '%'} ·
${item.load_pct == null ? 'n/a' : num(item.load_pct, 0) + '% load'} ·
${item.runtime_seconds == null ? 'n/a' : duration(item.runtime_seconds) + ' runtime'}</span>
</div>`;
}).join('');
}
const rows = system.hosts || [];
const tbody = $('#fleet-table tbody');
$('#fleet-empty').hidden = rows.length > 0;
if (!rows.length) { tbody.innerHTML = ''; return; }
const verdict = (severity, key) => {
const kind = Dash.severityFrom((severity || {})[key]);
return kind === 'ok' || kind === '' ? '' : ` ${kind}`;
};
tbody.innerHTML = rows.map((host) => {
const sev = host.severity || {};
const gpus = (host.gpu_busy_pct || []).filter((v) => v != null);
return `<tr>
// the row's own flag (a host that has never answered has no payload to be stale about)
// or the job state: either way the numbers below are the last good ones, not live ones
const stale = host.unreachable === true || answering[host.name] === false;
return `<tr${stale ? ' class="stale" title="Last good values — this host is not answering"' : ''}>
<td class="name">${DashCharts.escapeHtml(host.name)}</td>
<td class="num">${host.cpu_usage_pct == null ? 'n/a' : num(host.cpu_usage_pct, 0) + '%'}</td>
<td class="num${verdict(sev, 'cpu_usage_pct')}">${host.cpu_usage_pct == null ? 'n/a' : num(host.cpu_usage_pct, 0) + '%'}</td>
<td class="num mute">${host.load1 == null ? 'n/a' : num(host.load1, 2)}</td>
<td class="num">${host.mem_used_pct == null ? 'n/a' : num(host.mem_used_pct, 0) + '%'}</td>
<td class="num">${host.disk_worst_used_pct == null ? 'n/a' : num(host.disk_worst_used_pct, 0) + '%'}</td>
<td class="num${verdict(sev, 'mem_used_pct')}">${host.mem_used_pct == null ? 'n/a' : num(host.mem_used_pct, 0) + '%'}</td>
<td class="num${verdict(sev, 'disk_worst_used_pct')}">${host.disk_worst_used_pct == null ? 'n/a' : num(host.disk_worst_used_pct, 0) + '%'}</td>
<td class="num">${gpus.length ? num(gpus[0], 0) + '%' : '<span class="mute">—</span>'}</td>
<td class="num">${host.temp_c == null ? '<span class="mute">n/a</span>' : num(host.temp_c, 0) + '°C'}</td>
<td class="num${verdict(sev, 'temp_c')}">${host.temp_c == null ? '<span class="mute">n/a</span>' : num(host.temp_c, 0) + '°C'}</td>
<td class="num mute">${host.uptime_seconds == null ? 'n/a' : duration(host.uptime_seconds)}</td>
</tr>`;
}).join('');
@@ -151,14 +140,24 @@ function renderSummary(summary) {
async function refresh() {
try {
const summary = await fetchJSON('/api/summary');
const total = (summary.system.hosts || []).length;
const live = summary.system.fleet ? summary.system.fleet.hosts_live : 0;
const engines = summary.llm.fleet ? `${summary.llm.fleet.engines_live}/${summary.llm.fleet.engines_total} engines` : 'no engines';
const ups = summary.ups.ups ? `${summary.ups.ups.length} UPS` : 'no UPS';
$('#summary-line').textContent = `${live}/${total} hosts · ${engines} · ${ups} · ` +
`${(summary.recent_events || []).length} recent events`;
renderSummary(summary);
renderEvents(summary.recent_events);
const system = summary.system || {};
const fleet = system.fleet || {};
const hosts = system.hosts || [];
const live = fleet.hosts_live == null ? hosts.length : fleet.hosts_live;
const total = fleet.hosts_total == null ? hosts.length : fleet.hosts_total;
const engines = summary.llm && summary.llm.fleet
? `${summary.llm.fleet.engines_live}/${summary.llm.fleet.engines_total} engines`
: 'no engines';
const ups = summary.ups && (summary.ups.ups || []).length
? `${summary.ups.ups.length} UPS`
: 'no UPS';
const alerts = summary.alerts || [];
const problems = alerts.length ? `${alerts.length} warning${alerts.length === 1 ? '' : 's'}`
: 'all clear';
$('#summary-line').textContent =
`${live}/${total} hosts · ${engines} · ${ups} · ${problems}`;
renderAlerts(alerts);
renderFleet(hosts, system.states);
} catch (err) {
$('#summary-line').textContent = `summary failed: ${err.message}`;
}