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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user