phase: 06_system_page_gpu_verdicts
All criteria verified and the project is fully green.
**Verified (final pass — all 3 phase-06 tasks were already in `complete/`)**
- `classify("gpu.busy_pct", 99)` / `gpu.mem_used_pct` → `None`; `THRESHOLDS` has no `gpu.*` row; `alerts.py`/`api.py` carry no GPU verdict; live browser: deskwork GPU at **91 %** and **98 %** renders `class="hero"`, `color=rgb(22,25,31)` (plain text), no bar, no `.sev-*`.
- GPU temp on the hero `.sub` from live agents: `Radeon 8060S Graphics · VRAM 165.1 MiB / 512.0 MiB · pool 78% · 48 °C`, R9700 `· 40 °C`, gateway prints none (no `n/a`, no `0 °C`).
- Charts collapsed by default: fresh load = 3 panels / 3 toggles / **0 open / 0 `<svg>`**; expand → 3 SVGs (510 px wide), repaints across polls (legend 3.3 %→5.6 %), collapsed siblings keep their own height (803 vs 358/339).
- `gpu.busy_pct@0` still answers from `/api/system/history` (345 points) — verdict gone, data intact.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **550 passed, 8 skipped, 97.91 %**; `uv run ruff check src tests agent` → clean; `uv run pyright` → 0 errors; `node --check` all scripts clean; `uv pip list --outdated` empty.
- `uv run pytest -m live --live` → **8 passed** (real agents/engines/upsd); dev server restarted, `/api/health` all sources `ok`.
- New screenshots: `data/logs/phase06-final-collapsed-default.png`, `data/logs/phase06-final-expanded-gpu-busy-uncoloured.png`.
**Completion criteria:** classify→None ✅ · pytest+94 % gate ✅ · ruff+pyright ✅ · validate.sh ✅ · screenshots of the three behaviours on live data ✅ · AGENTS.md severity section claims no GPU rows ✅
**Docs:** fixed 3 items — (1) `AGENTS.md` pytest count stale (546 → 550); (2) `app.js` `severityFor` docstring still claimed "`page-ups.js` still calls it (phase 05 converts it)", contradicting the code/tests/AGENTS.md (comment-only); rest of README/AGENTS reviewed in full (env table vs `config.py`+`.env.example`, agent routes incl. `/schema`, alerts rules, page descriptions, contracts) — consistent.
**Notable:** fixed a real pre-existing defect in `scripts/dev-server.sh` — `stop` slept 1 s and deleted the pidfile without waiting for exit, so `restart` with a browser open overlapped two servers on one SQLite file, producing a `database is locked` store refusal that `/system` rendered as a red event. Now waits (bounded 20 s) then SIGKILLs; verified a restart with live polling logs no such error/event. No live infra config was changed.
**Next pending phase:** `.agents/phases/todo/07_llm_monitor_style_pages`
This commit is contained in:
+146
-6
@@ -161,10 +161,11 @@ def test_summary_carries_the_verdict_for_every_column_the_page_colours(data_clie
|
||||
row = body["system"]["hosts"][0]
|
||||
assert row["severity"] == {"cpu_usage_pct": "ok", "mem_used_pct": "fail",
|
||||
"disk_worst_used_pct": "fail", "temp_c": "fail"}
|
||||
# the fleet card is judged by the same table, so amber means the same thing at both sizes
|
||||
# the fleet card is judged by the same table, so amber means the same thing at both
|
||||
# sizes — and no GPU verdict, at fleet size or host size (phase 06: busy is the job)
|
||||
assert body["system"]["fleet"]["severity"] == {
|
||||
"cpu_usage_pct_avg": "ok", "memory_used_pct": "fail",
|
||||
"disk_worst_used_pct": "fail", "gpu_busy_pct_avg": "ok",
|
||||
"disk_worst_used_pct": "fail",
|
||||
}
|
||||
for ups in body["ups"]["ups"]:
|
||||
assert set(ups["severity"]) == {"charge_pct", "load_pct", "runtime_seconds"}
|
||||
@@ -311,7 +312,9 @@ def test_snapshot_payloads_carry_their_own_severity_sections(data_client) -> Non
|
||||
assert set(host["cpu"]["severity"]) == {"usage_pct", "temp_c"}
|
||||
assert set(host["memory"]["severity"]) == {"used_pct"}
|
||||
assert set(host["disks"]["severity"]) == {"worst_used_pct"}
|
||||
assert set(host["gpus"][0]["severity"]) == {"busy_pct", "mem_used_pct"}
|
||||
assert "severity" not in host["gpus"][0], (
|
||||
"a GPU verdict came back on /api/system/snapshot — phase 06 removed it"
|
||||
)
|
||||
ups = data_client.get("/api/ups/snapshot").json()
|
||||
for card in ups["ups"]:
|
||||
assert set(card["severity"]) == {"charge_pct", "load_pct", "battery_runtime"}
|
||||
@@ -603,17 +606,48 @@ def test_system_page_colours_from_the_payload_not_from_js_thresholds(client) ->
|
||||
|
||||
Phase 03 task 02's promise: every colour on a host card comes from a `severity` dict
|
||||
`severity.py` produced (`cpu.severity.usage_pct`, `memory.severity.used_pct`,
|
||||
`disks.severity.worst_used_pct`, `gpus[].severity.busy_pct`, `cpu.severity.temp_c`).
|
||||
`disks.severity.worst_used_pct`, `cpu.severity.temp_c`).
|
||||
A threshold written here could disagree with the overview's alerts; a hero with no
|
||||
verdict prints `n/a` uncoloured, which is `Dash.hero()`'s contract.
|
||||
|
||||
The GPU slot names no verdict on purpose (phase 06): `severity.THRESHOLDS` has no
|
||||
`gpu.*` row, the payload carries no `gpus[].severity`, and the hero is built without
|
||||
`severity` or `bar` — a bar under a verdictless number would be a verdict by shape.
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
assert "severityFor(" not in js, "the system page re-invented a threshold"
|
||||
assert "Dash.hero(" in js
|
||||
for hook in ("cpu.severity.usage_pct", "memory.severity.used_pct",
|
||||
"disks.severity.worst_used_pct", "gpus[].severity.busy_pct",
|
||||
"cpu.severity.temp_c"):
|
||||
"disks.severity.worst_used_pct", "cpu.severity.temp_c"):
|
||||
assert hook in js, f"the card no longer names {hook} as a colour source"
|
||||
# and the GPU hero grades nothing: no verdict hook, no utilisation bar
|
||||
assert "gpus[].severity" not in js, "the GPU slot invented a colour source again"
|
||||
gpu_slot = js[js.index("function renderGpuSlot("):js.index("function renderGpuExtra(")]
|
||||
assert "severity:" not in gpu_slot, "the GPU hero passes a severity option again"
|
||||
assert "bar:" not in gpu_slot, "the GPU hero grew a bar back (a verdict by shape)"
|
||||
assert "hero-bar" not in gpu_slot, "the GPU hero rebuilt the bar variant of .hero"
|
||||
|
||||
|
||||
def test_gpu_hero_footnote_prints_the_temperature_the_agent_read(client) -> None:
|
||||
"""TODO.md L9: the GPU temperature is glanceable on the hero row, not only in the table.
|
||||
|
||||
`gpus[].temp_c` is the agent's max over that card's hwmon labels (`sources/host.py`,
|
||||
pinned by `tests/test_system_contract.py`), so the hero needed no new field and the
|
||||
`gpu.temp_c@N` history series kept its name. The null guard *is* the behaviour, so that
|
||||
is what the assertion pins (there is no JS test runner — a locked anchor): a card whose
|
||||
driver exposes no temperature prints none, because `n/a` is for numbers the card is
|
||||
supposed to have and a `0 °C` would be a cool GPU that does not exist.
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
sub = js[js.index("function gpuSub("):js.index("function renderGpuSlot(")]
|
||||
assert "gpu.temp_c" in sub, "the GPU hero stopped printing the temperature"
|
||||
assert "if (gpu.temp_c != null)" in sub, "the temperature lost its null guard"
|
||||
assert sub.count("°C") == 1, (
|
||||
"a second °C in gpuSub is a branch printing a temperature nobody measured"
|
||||
)
|
||||
# "More detail" keeps the per-label breakdown: a surface change, not a de-duplication
|
||||
extra = js[js.index("function renderGpuExtra("):js.index("function renderHostCard(")]
|
||||
assert "Temp" in extra and "gpu.temp_c" in extra, "GPU detail lost its temperature column"
|
||||
|
||||
|
||||
def test_system_host_card_is_six_heroes_without_per_core(client) -> None:
|
||||
@@ -709,6 +743,112 @@ def test_every_system_chart_is_drawn_inside_its_own_host_panel(client) -> None:
|
||||
"a host that left the payload keeps a live panel and drawing charts"
|
||||
|
||||
|
||||
def test_system_history_charts_are_collapsed_behind_a_toggle(client) -> None:
|
||||
"""TODO.md L4: "The historical charts on the system resources page should only be visible
|
||||
if expanded. I only want to see current stats to keep things clean."
|
||||
|
||||
The default `/system` view is the hero row. Pinned on the served script because the card
|
||||
markup is generated in JS and there is no JS test runner (a locked anchor), and the two
|
||||
ways this regresses are invisible otherwise: a `<details open>` (charts visible by
|
||||
default, i.e. the task un-done but the page still pretty) and a paint pass that runs
|
||||
while the stack is shut, which builds three `LineChart`s per host inside a
|
||||
`display:none` container — measured 0 px wide, never drawn, and rebuilt every poll.
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
card = js[js.index("function hostCard("):js.index("function memorySub(")]
|
||||
|
||||
# the stack is *wrapped*, not merely preceded by a heading: it lives inside the <details>
|
||||
box = card.index('<details class="charts-box" data-k="charts">')
|
||||
stack = card.index('<div class="host-charts">')
|
||||
closed = card.index("</details>")
|
||||
assert box < stack < closed, "the chart stack is not inside the toggle"
|
||||
assert closed < card.index("More detail"), "the charts toggle swallowed the detail row too"
|
||||
assert not re.search(r"<details[^>]*\bopen\b", card), \
|
||||
"a panel ships its charts open — the default view is not current stats"
|
||||
assert re.search(r"<summary[^>]*>\s*Charts", card), "the toggle no longer reads Charts"
|
||||
# it also says which window the expanded stack draws (the chips stay in the page head)
|
||||
assert 'data-k="charts-window"' in card, "the toggle does not carry the window"
|
||||
# …and the toggle is what turns the paint pass on
|
||||
assert "addEventListener('toggle'" in card and "chartsToggled(" in card
|
||||
|
||||
setter = js[js.index("function setChartsWindow("):js.index("function memorySub(")]
|
||||
assert "state.rangeKey" in setter, \
|
||||
"the toggle re-derives its window label instead of using the chips' own"
|
||||
handler = js[js.index("Dash.rangeControl("):js.index("state.poller = poll(")]
|
||||
assert "state.rangeKey = key" in handler and "setChartsWindow(" in handler, \
|
||||
"changing the window leaves every toggle labelling the old one"
|
||||
|
||||
# nothing is built or drawn while the stack is shut, in either path that could paint
|
||||
paint = js[js.index("function drawHostCharts("):js.index("async function renderHostCharts(")]
|
||||
assert "if (!chartsOpen(card)) return" in paint, "a closed panel gets painted anyway"
|
||||
assert "hostChartSet(card, host)" in paint, \
|
||||
"the LineChart instances are no longer built by the open pass"
|
||||
toggle = js[js.index("function chartsToggled("):js.index("function drawHostCharts(")]
|
||||
assert "if (!chartsOpen(card)) return" in toggle, "closing the stack still paints"
|
||||
assert "renderHostCharts(card, host)" in toggle and "state.range" in toggle, \
|
||||
"a cached response from another window is drawn under this window's label"
|
||||
|
||||
# the request discipline is unchanged: one per host per refresh, every series named
|
||||
fetch = js[js.index("async function renderHostCharts("):js.index("function windowLabel(")]
|
||||
assert "hostChartSet(" not in fetch, "the poll path still builds charts for closed panels"
|
||||
assert "state.history.set(" in fetch, \
|
||||
"the response is not kept, so the first open is an empty box until the next poll"
|
||||
assert "if (range === state.range) drawHostCharts(" in fetch, \
|
||||
"a response for a window the operator just left is painted under the new label"
|
||||
assert "drawHostCharts(" in fetch, "an open panel is no longer repainted on refresh"
|
||||
|
||||
|
||||
def test_a_pruned_host_takes_its_charts_toggle_with_it(client) -> None:
|
||||
"""A host that leaves the payload must leave no orphan control in the grid.
|
||||
|
||||
The toggle is a child of the panel, which is why `card.remove()` is enough — but that is
|
||||
only true while the markup is built inside `hostCard()`, so the placement is pinned here
|
||||
along with the three per-host Maps the panel owns.
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
card = js[js.index("function hostCard("):js.index("function memorySub(")]
|
||||
assert 'data-k="charts"' in card, "the toggle is no longer part of the panel"
|
||||
pruner = js[js.index("function pruneHosts("):js.index("// ---- snapshot")]
|
||||
assert "state.hosts.get(name).remove()" in pruner
|
||||
for owned in ("state.charts", "state.payloads", "state.history"):
|
||||
assert f"{owned}.delete(name)" in pruner, f"{owned} outlives the panel it belongs to"
|
||||
|
||||
|
||||
def test_the_charts_toggle_is_a_quiet_disclosure_not_a_new_component(client) -> None:
|
||||
"""The collapse adds no palette and no second vocabulary (UI conventions).
|
||||
|
||||
Open/closed is carried by a caret *shape* as well as a colour step, the summary is muted
|
||||
rather than accented, and it introduces no `.sev-*` family — the same rule the hero
|
||||
numbers live by, checked here because a `<summary>` is the first control on the page a
|
||||
designer might be tempted to grade.
|
||||
"""
|
||||
css = client.get("/static/dash.css").text
|
||||
block = css[css.index("details.charts-box { "):css.index(".host-charts {")]
|
||||
assert "details.charts-box > summary::before" in block, "the toggle has no caret shape"
|
||||
assert 'details.charts-box[open] > summary::before' in block, \
|
||||
"open and closed differ only in colour"
|
||||
assert "var(--text-faint)" in block, "the closed toggle is not muted"
|
||||
assert ".sev-" not in block and "var(--accent)" not in block, "the toggle invented a palette"
|
||||
assert 'class="charts-box"' in client.get("/static/page-system.js").text, \
|
||||
"the stylesheet styles a class the page no longer renders"
|
||||
|
||||
|
||||
def test_an_expanded_chart_stack_does_not_grow_its_collapsed_siblings(client) -> None:
|
||||
"""One host open must not stretch the hosts beside it into blank cards.
|
||||
|
||||
`/system` is the same shape `/llm` had to fix: an expanded panel beside a collapsed one
|
||||
grows the collapsed card to match, and a blank panel reads as "no data" (the misreading
|
||||
the unconfigured rules exist to prevent). `.grid.items-start` already exists for exactly
|
||||
this, so the page uses it rather than growing a rule of its own.
|
||||
"""
|
||||
html = _code(client.get("/system").text)
|
||||
hosts_class = re.findall(r'<div class="([^"]*)" id="hosts">', html)
|
||||
assert hosts_class == ["grid auto items-start"], \
|
||||
f"the host grid stretches its cards again: {hosts_class}"
|
||||
assert ".grid.items-start" in client.get("/static/dash.css").text, \
|
||||
"the rule the host grid relies on is gone"
|
||||
|
||||
|
||||
def test_every_system_chart_names_itself_for_assistive_tech(client) -> None:
|
||||
"""A 120px SVG is silence to a screen reader unless the page names it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user