phase: 08_events_paging_and_copy
All green. Final report:
**Verified (phase 08 — both task files already in `complete/`; this was the final verification pass)**
- `Dash.eventsPager()` in `app.js` (50/page, `1–50 of N` strip, aria-labelled `‹ prev`/`next ›` disabled at the ends, `WeakMap` page state, reset only when the event *set* changes, `EVENT_FEED_LIMIT`=200) with three call sites; `ul.events` bounded to 320px + `overflow-y:auto`; `/api/events` untouched (`le=500`, no silent clamp).
- Live browser against the real fleet: `/system` `1–50 of 121`, `/llm` `1–50 of 200`, `/ups` `1–50 of 200`; container 320px vs scrollHeight 1543/2368; page 2 → `51–100 of 200` and survived two polls; no empty `.sub`/`.faint` container visible on any page; footers carry only measurements (`rates over Δ11.9s · poll 10s`). Fresh artifacts: `data/logs/08-final-{system-head,llm-page,index-page,ups-feed-page2}.png` (32 phase screenshots already present).
- Grep of live served bytes for all 13 deleted flavour fragments + `actions = -`: none survive in HTML or JS.
- `AGENTS.md` carries both new UI-convention rules (flavour-text rule with the `TODO.md:7` cited examples; the event-feed-window rule) and the updated test count.
**Tests / lint (full project 100% green)**
- `bash .agents/validate.sh` → **EXIT=0**: `uv run pytest --cov --cov-fail-under=94` → **624 passed, 9 skipped, coverage 97.91%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` ×5 clean; repo hygiene clean.
- `uv run pytest -m live --live` (`.env.dev` sourced) → **9 passed**; `uv pip list --outdated` → empty. Re-ran pytest/ruff/pyright/node after the doc edits → all exit 0.
**Completion criteria**: bounded scroll + 50/page + count footer ✅ (all three feeds, live) · no removed flavour string ✅ · AGENTS.md rule ✅ · `validate.sh` green + screenshots in `data/logs/` ✅.
**Documentation**: full README/AGENTS review against code (env table vs `config.py`/`.env*.example`, script flags, Containerfile uid 10001/8090, chart heights 120/110/160, test count, coverage) — one inaccuracy fixed: README said "Every page's event feed" (the overview has none) → now "The three event feeds (`/system`, `/llm`, `/ups`)"; also restored the missing trailing newline in `TODO.md`. Everything else verified consistent.
**Notes**: paging stays client-side (no API change); the task-02 defect fix in `/ups` setup copy (`NUT_HOST`/`NUT_PORT`, `LISTEN 127.0.0.1`, one `instcmds` per command, never `actions = -`) re-verified against README and live bytes. Pre-existing items intentionally left to their own phases: `ups.test.result: "Done and passed"` rated `warn` (operator's `TODO.md` entry) and the dev-DB `database is locked` retention issue.
**Next pending phase**: `.agents/phases/todo/09_disks_smartctl/`
This commit is contained in:
+224
-2
@@ -922,6 +922,132 @@ def test_every_system_chart_names_itself_for_assistive_tech(client) -> None:
|
||||
f"the {slot} chart has no host-scoped accessible name"
|
||||
|
||||
|
||||
# ---- event feeds: a bounded, paginated window ----------------------------- #
|
||||
#
|
||||
# The three `ul.events` feeds are one component (`Dash.eventsPager`) because a pager that
|
||||
# works on only one page is the bug this shape prevents: the `event` table grows for as long
|
||||
# as the fleet runs, and an unbounded feed turns `/system` into a scroll to nowhere
|
||||
# (`TODO.md` L6 — "All the events tables need to be scrollable and paginated"). There is no
|
||||
# JS test runner (a locked anchor), so what is pinned here is the shape every feed must keep:
|
||||
# one shared renderer, a fetch bigger than one page, a footer that counts and labels itself,
|
||||
# and a container that scrolls instead of growing the page.
|
||||
|
||||
# page -> (its script, the `source=` the poller stores that page's events under)
|
||||
EVENT_FEEDS = {
|
||||
"/system": ("page-system.js", "system"),
|
||||
"/llm": ("page-llm.js", "llm"),
|
||||
"/ups": ("page-ups.js", "ups"),
|
||||
}
|
||||
|
||||
|
||||
def _event_feed_path(js: str) -> str:
|
||||
"""Just the `refreshEvents()` half of a page script — the feed's whole code path, comments out."""
|
||||
js = _code(js)
|
||||
return js[js.index("async function refreshEvents("):]
|
||||
|
||||
|
||||
def _feed_fetch_url(js: str) -> str:
|
||||
"""The `/api/events` URL a feed asks for, quoted or templated."""
|
||||
found = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", _event_feed_path(js))
|
||||
assert found, "this page no longer fetches the event feed"
|
||||
return found.group(1)
|
||||
|
||||
|
||||
def test_the_event_feeds_are_one_shared_pager_not_three(client) -> None:
|
||||
"""`Dash.eventsPager()` renders all three feeds, and no page grows its own pager.
|
||||
|
||||
Three copies of a pager is three chances for one page to keep the old behaviour — and
|
||||
`/system` is the one whose feed was already too tall when this was written down.
|
||||
"""
|
||||
app_js = _code(client.get("/static/app.js").text)
|
||||
assert app_js.count("function eventsPager(") == 1
|
||||
exports = client.get("/static/app.js").text.rsplit("return {", 1)[1].split("}", 1)[0]
|
||||
assert "eventsPager" in exports, "Dash.eventsPager is not exported"
|
||||
|
||||
for path, (script, _) in EVENT_FEEDS.items():
|
||||
js = _code(client.get(f"/static/{script}").text)
|
||||
assert "Dash.eventsPager($('#events')" in js, f"{path} does not render its feed through the pager"
|
||||
assert "function eventsPager(" not in js, f"{path} invented its own pager"
|
||||
assert "events.slice(" not in js, f"{path} windows its own events instead of asking the pager"
|
||||
assert "row:" in _event_feed_path(js), f"{path} passes no row renderer"
|
||||
|
||||
|
||||
def test_the_pager_pages_50_events_and_fetches_more_than_one_page(client) -> None:
|
||||
"""The two numbers that make a client-side pager work, and the relation between them.
|
||||
|
||||
`EVENT_PAGE_SIZE` is the 50 the operator asked for; `EVENT_FEED_LIMIT` is what the feed
|
||||
asks `/api/events` for. A limit at or below the page size is a pager that silently never
|
||||
shows — there is never a second page to reach — and the feeds used to fetch 20/25/40.
|
||||
"""
|
||||
app_js = client.get("/static/app.js").text
|
||||
page_size = re.search(r"const EVENT_PAGE_SIZE = (\d+);", app_js)
|
||||
feed_limit = re.search(r"const EVENT_FEED_LIMIT = (\d+);", app_js)
|
||||
assert page_size and int(page_size.group(1)) == 50, "a feed no longer pages 50 events"
|
||||
assert feed_limit, "the feeds' event limit is no longer a named constant"
|
||||
assert int(feed_limit.group(1)) > int(page_size.group(1)), \
|
||||
"a fetch at or below one page means page 2 can never exist"
|
||||
|
||||
for path, (script, source) in EVENT_FEEDS.items():
|
||||
url = _feed_fetch_url(client.get(f"/static/{script}").text)
|
||||
assert "EVENT_FEED_LIMIT" in url, f"{path} hardcodes its own event limit"
|
||||
assert f"source={source}" in url, f"{path} asks for the wrong event source"
|
||||
|
||||
|
||||
def test_the_pager_footer_counts_and_labels_its_controls(client) -> None:
|
||||
"""The footer counts (`1\u201350 of 137`), and prev/next are labelled and disabled at the ends.
|
||||
|
||||
The count is what tells the operator the window is a window; a control that stays live at
|
||||
the last page claims there is more history than there is.
|
||||
"""
|
||||
app_js = _code(client.get("/static/app.js").text)
|
||||
assert "${first}\u2013${last} of ${total}" in app_js, "the footer no longer counts"
|
||||
assert 'data-pager="prev"' in app_js and 'data-pager="next"' in app_js
|
||||
assert "prev.setAttribute('aria-label'" in app_js
|
||||
assert "next.setAttribute('aria-label'" in app_js
|
||||
assert "prev.disabled = pager.page === 0" in app_js
|
||||
assert "next.disabled = pager.page >= pager.pages - 1" in app_js
|
||||
# pagination only applies when there is more than one page of data
|
||||
assert "foot.hidden = !paginating" in app_js
|
||||
|
||||
css = client.get("/static/dash.css").text
|
||||
assert ".events-pager {" in css, "the pager footer has no style"
|
||||
assert "button.ghost:disabled" in css, "a disabled pager control looks live"
|
||||
|
||||
|
||||
def test_the_event_feeds_scroll_instead_of_growing_the_page(client) -> None:
|
||||
"""The `<ul>` is the scrollable element, bounded in `dash.css`; the page stays short.
|
||||
|
||||
Pinned on the served bytes because there is no JS test runner (a locked anchor): a
|
||||
stylesheet edit that drops `max-height` is invisible until the feed is long, and by then
|
||||
the page is massive again.
|
||||
"""
|
||||
css = client.get("/static/dash.css").text
|
||||
block = css[css.index("ul.events {"):css.index("}", css.index("ul.events {"))]
|
||||
assert "max-height" in block and "overflow-y: auto" in block, \
|
||||
"the event feed is unbounded again — the page grows with history"
|
||||
|
||||
for path in EVENT_FEEDS:
|
||||
html = client.get(path).text
|
||||
assert '<ul class="events" id="events"' in html, f"{path} lost its feed's id"
|
||||
|
||||
|
||||
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
|
||||
"""New events reset the window; the same events do not.
|
||||
|
||||
Resetting on every poll would snap `next` back within one interval, so page 2 could never
|
||||
be read; never resetting lands an operator on page 3 of a list that changed underneath
|
||||
them. The page key is the set of events, not the clock.
|
||||
"""
|
||||
app_js = _code(client.get("/static/app.js").text)
|
||||
body = app_js[app_js.index("function eventsPager("):]
|
||||
assert "signature !== pager.signature" in body, "the pager no longer notices new events"
|
||||
reset = body[body.index("signature !== pager.signature"):]
|
||||
assert reset.index("pager.page = 0") < reset.index("paintEventFeed"), \
|
||||
"new events do not reset the page before the feed is drawn"
|
||||
assert "if (pager.markup !== markup)" in app_js, \
|
||||
"every poll rewrites 50 rows and throws away the reader's scroll position"
|
||||
|
||||
|
||||
def test_system_event_feed_queries_the_source_transitions_are_stored_under(client) -> None:
|
||||
"""Host transitions are stored under the *dashboard* name, not the sample source.
|
||||
|
||||
@@ -932,8 +1058,8 @@ def test_system_event_feed_queries_the_source_transitions_are_stored_under(clien
|
||||
the served bytes because there is no JS test runner (a locked anchor).
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
feed = js[js.index("async function refreshEvents("):]
|
||||
url = re.search(r"fetchJSON\('(/api/events[^']*)'\)", feed)
|
||||
feed = _event_feed_path(js)
|
||||
url = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", feed)
|
||||
assert url, "the /system page no longer fetches the event feed"
|
||||
assert "source=system" in url.group(1), \
|
||||
"the /system event feed must ask for the source the poller stores events under"
|
||||
@@ -2198,6 +2324,102 @@ def test_no_page_carries_an_id_nothing_reads(client) -> None:
|
||||
assert quoted, f"{path} has id=\"{element_id}\" that nothing reads"
|
||||
|
||||
|
||||
# ---- the flavour-text audit (phase 08 task 02) ----------------------------
|
||||
#
|
||||
# `TODO.md:7`: "Get rid of most of the flavor text and add a note to AGENTS.md to not add it
|
||||
# unless necessary. It just comes off as LLM generated slop, not helpful information." The
|
||||
# test is one question per string — *would the operator act differently without it?* — and
|
||||
# the answer decided the deletion. Copy that explains how the app builds its numbers is not
|
||||
# helpful information; a provenance tag, a measured Δ window, and the sentence beside an
|
||||
# empty panel are. `AGENTS.md` → *UI conventions* now carries the rule; the pins below are
|
||||
# what stop the deleted sentences crawling back, because a screenshot only shows them once
|
||||
# somebody already knows to look.
|
||||
|
||||
#: Fragments deleted from the served pages. The first two are the lines the operator quoted.
|
||||
DELETED_FLAVOUR = (
|
||||
# the overview's footer
|
||||
"every number is scraped from the box",
|
||||
"Sources the operator never configured are not problems",
|
||||
# /system's "Per host" sub-line and its footer
|
||||
"agent-reported values",
|
||||
"each panel's charts cover only that host",
|
||||
"numbers are measured, not inferred",
|
||||
# /llm's footer, the one half-sentence of it that lived in the script too
|
||||
"each throughput number carries its provenance",
|
||||
"rates over the Δ window in each tag",
|
||||
"a server you do not run",
|
||||
# /ups's footer and its empty state
|
||||
"commands are the only write path in this app",
|
||||
"The dashboard speaks the NUT protocol",
|
||||
# the three event feeds' header captions, all duplicated by an empty-state sentence
|
||||
"unreachable / reachable / reboots",
|
||||
"engine up / down",
|
||||
"state changes, self-test results, commands sent",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fragment", DELETED_FLAVOUR)
|
||||
def test_no_page_narrates_its_own_pipeline(client, fragment: str) -> None:
|
||||
"""The deleted copy is gone from every served byte, markup and script alike.
|
||||
|
||||
Asserted across all four pages and all five scripts rather than the file it came from:
|
||||
the same sentence migrating into another page's card is the same slop, and a pin that
|
||||
only guards its original file would let that through.
|
||||
"""
|
||||
for path in ("/", "/system", "/llm", "/ups", *ASSETS):
|
||||
body = client.get(path).text
|
||||
assert fragment not in body, f"{path} narrates the app again: {fragment!r}"
|
||||
|
||||
|
||||
def test_pages_leave_no_empty_narration_container(client) -> None:
|
||||
"""A deleted sub-line removes its container, not just its words.
|
||||
|
||||
An empty `<span class="sub">` is markup that reserves a line for nothing — the page head
|
||||
renders a gap where the sentence used to be. Elements the scripts fill (`#sub`,
|
||||
`#alerts-count`) carry an `id` and are not in this category.
|
||||
"""
|
||||
for path in ("/", "/system", "/llm", "/ups"):
|
||||
html = _code(client.get(path).text)
|
||||
for cls in ("sub", "faint", "mono"):
|
||||
assert f'<span class="{cls}"></span>' not in html, \
|
||||
f"{path} keeps an empty .{cls} container"
|
||||
|
||||
|
||||
def test_the_copy_the_operator_acts_on_survives_the_audit(client) -> None:
|
||||
"""The other half of the deletion: functional copy stays.
|
||||
|
||||
Stripping flavour must not become stripping information. Every string here answers a
|
||||
question the operator is asking while looking at the page — why is this panel empty,
|
||||
is this number a measurement, is this battery real, is this host's data last-good —
|
||||
and each one is the reason the audit deletes sentences and not labels.
|
||||
"""
|
||||
pages = {path: client.get(path).text for path in ("/", "/system", "/llm", "/ups")}
|
||||
scripts = {name: client.get(f"/static/{name}").text for name in PAGE_SCRIPTS.values()}
|
||||
|
||||
# an empty panel says why, in terms the operator can act on
|
||||
assert "No hosts configured" in pages["/"]
|
||||
assert "HOST_TARGETS" in pages["/system"]
|
||||
assert "No engines configured" in pages["/llm"]
|
||||
assert "No UPS reporting" in pages["/ups"]
|
||||
assert "instcmds" in pages["/ups"], "the UPS setup note stopped naming the grant that matters"
|
||||
assert "3493" in pages["/ups"], "the page stopped warning that upsd's port is not always 3493"
|
||||
# …and the setup copy names the grant NUT actually checks. `README.md` is explicit: a
|
||||
# dashboard user gets `instcmds`, never `actions = -`, so a page that told the operator
|
||||
# to write that was sending them to fix the wrong line in `upsd.users`.
|
||||
assert "actions = -" not in pages["/ups"] + scripts["page-ups.js"] + scripts["page-index.js"]
|
||||
assert "instcmds" in scripts["page-ups.js"] and "instcmds" in scripts["page-index.js"]
|
||||
|
||||
# a Δ label is a measurement, and the rate's provenance travels with the rate
|
||||
assert "windowLabel(" in scripts["page-system.js"]
|
||||
assert "Δ" in scripts["page-llm.js"] and "function provenance(" in scripts["page-llm.js"]
|
||||
assert "at the current load" in scripts["page-ups.js"]
|
||||
|
||||
# modelled data is labelled, and so is a feed that cannot contain what you are hunting
|
||||
assert "SIMULATED" in scripts["page-index.js"] and "SIMULATED" in scripts["page-ups.js"]
|
||||
assert "queued (deferred) requests are not in /slots" in scripts["page-llm.js"]
|
||||
assert "Last good values" in scripts["page-index.js"]
|
||||
|
||||
|
||||
def test_api_responses_are_not_cacheable(client) -> None:
|
||||
for path in ("/api/config", "/api/health", "/api/summary"):
|
||||
assert "no-store" in client.get(path).headers.get("cache-control", "")
|
||||
|
||||
Reference in New Issue
Block a user