phase: 29_gateway_dashboard_page
Phase 29 completion pass is done. Final state:
**Verification (all re-run this pass, no task files touched)**
- `uv run pytest` → **1746 passed, 16 skipped**; `uv run pytest --cov` → **99.05 %** (gate 94 %); `bash .agents/validate.sh` → **rc=0** (ruff clean, pyright `0 errors, 0 warnings`, `node --check` clean); `uv run pytest -m live --live` → **12 passed**.
- Live, read-only: dev server **10/10 sources `ok`** (incl. `gateway`); `data/logs/29-task06-live.cjs` → **49 PASS / 0 FAIL** with per-port inbound counts reconciled against a direct `/proc/net/tcp{,6}` read (divergence 0); `data/logs/verify-29-task06-predates.py` → 0 failed (1.1.0 arm renders `WHY_PREDATES`, no pipes, every tally `n/a`, job still `ok`); phase-27 mobile audit re-run over all nine served pages → **371 PASS / 0 FAIL / 0 FINDING** (`data/logs/29-final-mobile-audit.out`).
- Criteria: (1) unconfigured `GATEWAY_HOST` → `404 {configured:false}` with dial spies + `#gate-empty` sentence ✅; (2) 1.1.0/`--no-proxy` → `supported:false`, no `0`s, never red ✅; (3) live gateway 1.2.0 → pipes animate, totals reconcile ✅; (4) no `severity`/verdict colour anywhere, validate green ✅; (5) docs updated + gap 13 logged ✅; (6) the five existing dashboards, jobs and `/api/summary` unchanged (live control leg) ✅.
**Docs (consistency pass, incl. pre-existing drift)**
- `README.md`: architecture diagram now lists `sources/gateway.py`, the 30 s gateway job and the third `/snapshot` reader; `GATEWAY_HOST` row says *a name, never a URL*; the `POLL_MS_*` row states `/gateway`'s fixed 15 s browser poll; "nine pollers" → per-source cadences; phase-28 "until phase 29" wording → current state; new Testing paragraph for `tests/test_gateway_source.py` (join pinned from the captures, reconciliation identity, `getaddrinfo` spy).
- `AGENTS.md`: phase-28 stop-point wording replaced with the shipped phase-29 state; suite/coverage figures → 1746 / 16 skipped / 99.08 %→99.05 % band and `~99 %`; window-chips paragraph records that `/gateway` has **no** chips (fixed 24 h); new testing-policy bullet for the join; stale page counts fixed (seven → eight tabbed pages / `MARKUP_PAGES`, clock-order and badge-count transcripts annotated); gap 12 now records the phase-29 nine-page audit re-run.
- `tests/test_api.py`: comment/docstring-only fixes for the "seven pages" wording that phase 29 made eight.
**Deviations / notes**
- Nothing implemented: all six task files were already in `complete/`, so this run was the final verification + docs pass; no git index or phase file was touched.
- Coverage wobbles 99.05–99.08 % between runs on a pre-existing timing-dependent line (`collectors.py` `/slots`-alias `except`/`break`), not a phase-29 path (`sources/gateway.py` is 100 %).
- `uv pip list --outdated` still lists upgrades; dependencies did not move in this phase, so they were left alone rather than upgraded mid-verification.
- Historical browser-transcript counts (phase 18/20/25 script runs) were left as recorded history and annotated instead of rewritten.
**Next phase:** none — `.agents/phases/todo/` is empty after 29; further work needs a new phase authored first.
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
# Homelab Dashboards
|
||||
|
||||
Four dashboards and the overview page that points at them — one domain, one process, one SQLite
|
||||
Five dashboards and the overview page that points at them — one domain, one process, one SQLite
|
||||
file:
|
||||
|
||||
| Page | What it answers | Source |
|
||||
| ----------- | ------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `/` | Is anything wrong, and which dashboard do I need? | all four, in one request (`/api/summary`) |
|
||||
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history of all of it | `agent/host_agent.py`, our own stdlib agent per host |
|
||||
| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page |
|
||||
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only |
|
||||
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) |
|
||||
| Page | What it answers | Source |
|
||||
| ------------ | ------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `/` | Is anything wrong, and which dashboard do I need? | all five, in one request (`/api/summary`) |
|
||||
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history of all of it | `agent/host_agent.py`, our own stdlib agent per host |
|
||||
| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page |
|
||||
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only |
|
||||
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) |
|
||||
| `/gateway` | Which domains are coming in on which port, and where the traffic goes — the ingress's listeners, `server_name`s and SNI map joined to its live connections | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
|
||||
|
||||
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
|
||||
runtime; every asset is served by this app.
|
||||
@@ -155,8 +156,8 @@ what the config says: every `frontend` block with its name, each `bind` address:
|
||||
written (`:::22` is the address `::` on port `22`) and the backend names it routes to
|
||||
(`default_backend`, plus every named `use_backend`), and every `backend` block with each `server`'s
|
||||
name, target and port — `port: null` when the line names none, never `0`. It interprets nothing:
|
||||
the routing between the two lists is the **names**, so joining them is the page's job (the gateway
|
||||
page is phase 29) and the agent never evaluates a rule. `available` answers three questions the way `smartctl_available` does —
|
||||
the routing between the two lists is the **names**, so joining them is the gateway page's job
|
||||
(`sources/gateway.py`) and the agent never evaluates a rule. `available` answers three questions the way `smartctl_available` does —
|
||||
`true` read and parsed, `false` no config at that path (this host runs no haproxy), `null` a file
|
||||
is there but could not be read or understood — and each non-`true` answer costs exactly **one**
|
||||
`errors` line for the pass. Like the SMART lines, those lines never make the *system* page amber:
|
||||
@@ -201,10 +202,13 @@ kernel without IPv6 has no `tcp6` file, which costs nothing and no line, while a
|
||||
there and denied costs one. `available` is `true` when either table answered and `null` when
|
||||
neither did, with one line saying why; there is no `false`, because a table that did not answer is
|
||||
"no answer" and not "this host has no sockets". Nothing here is joined to a config: a socket does
|
||||
not know its domain or its backend, so phase 29's gateway page will be what joins these ports to
|
||||
the `haproxy` and `nginx` sections — where a socket no frontend claims stays visible as one. Until
|
||||
that page exists the three sections live only in the agent's own `/snapshot` document: no `/api`
|
||||
payload grows them, no `sample` row is stored for them, and nothing charts them.
|
||||
not know its domain or its backend, so the **gateway page** is what joins these ports to the
|
||||
`haproxy` and `nginx` sections, and a socket no config claims stays visible as one. The join lives
|
||||
in `sources/gateway.py`, never in the agent: `/snapshot` keeps the three sections as the agent read
|
||||
them, `/api/gateway/snapshot` serves the joined topology, and only the counts that join produces are
|
||||
stored — as the `gateway.in_conns@<port>` / `gateway.out_conns@<host>:<port>` gauges the page
|
||||
charts. Nothing in `/api/system/snapshot` grows a proxy or socket field, and no `sample` row is
|
||||
written for a config.
|
||||
|
||||
All three parsers are pinned to captures rather than to descriptions, and
|
||||
`./scripts/capture-gateway-config.sh` re-records them over the `GATEWAY_SSH` alias (default
|
||||
@@ -459,8 +463,9 @@ cannot match against a denylist.
|
||||
One admin, one password, and the server **refuses to start** when it has neither a stored
|
||||
verifier nor `DASH_ADMIN_PASSWORD` — the domain is public and the fleet's hostnames and UPS
|
||||
controls sit behind this login, so there is no way to run it open by accident. There is no
|
||||
second account and no per-user view: the five pages (`/`, `/system`, `/disks`, `/llm`, `/ups`),
|
||||
the two admin pages (`/settings`, `/certs`) and every data route belong to that one operator.
|
||||
second account and no per-user view: the six dashboard pages (`/`, `/system`, `/disks`, `/llm`,
|
||||
`/ups`, `/gateway`), the two admin pages (`/settings`, `/certs`) and every data route belong to
|
||||
that one operator.
|
||||
|
||||
A session is an **HMAC-signed cookie** (`dash_session`), not a row in a table: it carries the
|
||||
signed-in name and its own expiry, is valid for `DASH_SESSION_HOURS` (default `720` = 30 days),
|
||||
@@ -671,10 +676,11 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
|
||||
| `RATE_WINDOW_SECONDS` | the **host's** Δ window (default 10) — bytes/sec and jiffies→CPU %, measured over what a 10 s poll can fill; each value carries the window it actually got, and `/system`'s footnote prints that, not this default. A window the poll loop cannot honour — the ~0.2 s catch-up after a fetch slower than the interval, or a base older than 2× the window — answers one `n/a` instead of a spike, and a rate 8× above the last one is held until a window measured from a **different** sample confirms it (phase 13). The LLM rates do **not** use it: they are measured over `LLM_INTERVAL_SECONDS` (phase 23) |
|
||||
| `LLM_INTERVAL_SECONDS` | the `/llm` poll cadence (default 2 s) **and** the Δ window behind every token rate — one estimator per measuring cadence, so a slot that starts reads steady within two polls instead of ramping over five. Slowing the poll slows the window with it; there is no separate LLM window knob, and a window narrower than the interval would answer `n/a` every poll |
|
||||
| `DASH_HOST` / `DASH_PORT` | bind address for the dashboard itself (default `127.0.0.1:8090`; the container sets `0.0.0.0`) |
|
||||
| `HOST_INTERVAL_SECONDS` / `NUT_INTERVAL_SECONDS` | how often the host agents and upsd are polled (10 / 5 s) |
|
||||
| `HOST_INTERVAL_SECONDS` / `NUT_INTERVAL_SECONDS` | how often the host agents and upsd are polled (10 / 5 s). `GATEWAY_INTERVAL_SECONDS` (30) and `GATEWAY_TIMEOUT_SECONDS` (10) are the gateway source's own pair: a connection count is the shape of half a minute, and the fetch is the plain agent one with headroom for a handler that may be mid-smartctl pass |
|
||||
| `DISKS_INTERVAL_SECONDS` | how often the disk-health source re-reads each agent for SMART (default 300 s — SMART is not a per-second signal). No targets of its own: the `disks:<host>` jobs exist exactly when `HOST_TARGETS` does |
|
||||
| `HOST_TIMEOUT_SECONDS` / `LLM_TIMEOUT_SECONDS` / `NUT_TIMEOUT_SECONDS` | per-source fetch timeout — one slow source never holds up the others |
|
||||
| `DISKS_TIMEOUT_SECONDS` | the disks fetch's own budget (default 30 s), because the agent runs `smartctl` inside its snapshot handler and a wedged SMART pass must not read as a dead host |
|
||||
| `GATEWAY_HOST` | which `HOST_TARGETS` name is the ingress the `/gateway` page watches (default `gateway`). It has no URL of its own — the source reads the same agent `/snapshot` the system page reads, projected onto agent 1.2.0's `haproxy`/`nginx`/`sockets` sections — so a name that is not in `HOST_TARGETS` means "no ingress to watch", and the source is unconfigured rather than red. The page refetches itself every 15 s in the browser and has no `POLL_MS_*` knob, because the payload only moves when that one job re-reads the ingress |
|
||||
| `GUFO_API_KEY` / `VLLM_API_KEY` | only if an engine is behind a bearer token; they never leave the server |
|
||||
| `NUT_HOST` / `NUT_PORT` | upsd address — **whatever port it really listens on**, not always 3493. Reads are unauthenticated in stock NUT |
|
||||
| `NUT_USER` / `NUT_PASSWORD` | only needed for instant commands (battery tests) |
|
||||
@@ -686,7 +692,7 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
|
||||
| `DASH_SESSION_HOURS` | session lifetime (default `720` = 30 days) |
|
||||
| `DASH_COOKIE_SECURE` | `1` marks the session cookie `Secure`; off because dev is `http://127.0.0.1`, and a request that arrived over `X-Forwarded-Proto: https` is treated as secure either way |
|
||||
| `DASH_PBKDF2_ITERATIONS` | verifier cost (default `600000`). The knob exists for the test suite; leave it alone in a deployment. Raising it on a real box does take effect: the next successful sign-in re-hashes a cheaper stored verifier and keeps the signing key |
|
||||
| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents. A backgrounded tab idles its polls and fires one on return; nothing an operator presses stops them (phase 21) |
|
||||
| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` and `/gateway` have no `POLL_MS_*` of their own and poll at a fixed 30 s / 15 s: the first payload only moves when the `disks` job re-reads the agents, the second when the one `gateway` job re-reads the ingress. A backgrounded tab idles its polls and fires one on return; nothing an operator presses stops them (phase 21) |
|
||||
| `DASH_RELOAD` | `1` makes `uv run dashboards` watch the source and restart on a change — development only, and every poll loop restarts with it |
|
||||
| `DEBUGPY` / `DEBUGPY_PORT` | `DEBUGPY=1` opens a debugpy listener (default 5678) without blocking startup |
|
||||
| `RETAIN_RAW_HOURS` / `RETAIN_MINUTE_DAYS` / `RETAIN_HOUR_DAYS` | history rollup and pruning |
|
||||
@@ -700,12 +706,13 @@ installed.
|
||||
```
|
||||
agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
|
||||
│ GET /snapshot │ GET /metrics │ LIST UPS / LIST VAR / LIST CMD
|
||||
│ (twice: system 10s, disks 5m) │ + /slots (gufo) │
|
||||
│ (three readers: system 10s, │ + /slots (gufo) │
|
||||
│ disks 5m, gateway 30s) │ │
|
||||
▼ ▼ ▼
|
||||
src/dashboards/collectors.py ────────────────────────────────────────────┐
|
||||
│ parse + Δcounter/Δt (rates.py) │
|
||||
▼ ▼
|
||||
sources/host.py · disks.py · llm.py · nut.py poller.py (one task per source)
|
||||
sources/host.py · disks.py · gateway.py · llm.py · nut.py poller.py (one task per source)
|
||||
│ samples events │
|
||||
▼ ▼
|
||||
store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event + meta ── api.py → web/
|
||||
@@ -720,9 +727,14 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
`/api/health`, 5-minute cadence, its own fetch timeout) reading the *same* agent
|
||||
`/snapshot` the system page reads — a separate task is what keeps a SMART poll out of the
|
||||
10-second utilisation poll and lets drive history go stale without blinking `/system`, and
|
||||
`alerts.py` is why one dead agent is still one line on the overview rather than two. And it never quietly *stops*: the task is that source's only
|
||||
poller, so a SQLite write that gets refused is reported (`ok: false`, the real reason, the
|
||||
numbers aging, one line in the event log) instead of freezing the source under a green dot.
|
||||
`alerts.py` is why one dead agent is still one line on the overview rather than two. The gateway
|
||||
page's job (phase 29) is the **third** reader of that same `/snapshot`, and one job for the whole
|
||||
deployment rather than one per host — `GATEWAY_HOST` names the single ingress, and a job per host
|
||||
would ask four machines a question only one can answer — on its own 30 s cadence with no
|
||||
`RateEstimator`, because a connection count is not a rate. And it never quietly *stops*: the task
|
||||
is that source's only poller, so a SQLite write that gets refused is reported (`ok: false`, the
|
||||
real reason, the numbers aging, one line in the event log) instead of freezing the source under a
|
||||
green dot.
|
||||
* `store.py` — raw samples roll up to 1-minute and 1-hour averages and the raw rows are
|
||||
pruned, so a year of history is a few hundred MB rather than a disk emergency. Nothing a
|
||||
per-request path asks is answered by a whole-table scan **on every call** (phase 14): the two
|
||||
@@ -730,8 +742,8 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
row counts behind `/api/health` — come from a 60 s / 30 s cache, so the scan is paid once per
|
||||
window (its cost is the honest remainder in the known-gaps list) instead of once per request. `series_names`' is ended only by a write that can
|
||||
change it: a `(source, host, metric)` the store has never written, or any `DELETE`. A poll of an
|
||||
existing series leaves it alone, which is what makes the cache serve at all on a store nine pollers
|
||||
write to (task 04's live watch is the proof: 535 ms per call before, 1.8 ms median after), and the
|
||||
existing series leaves it alone, which is what makes the cache serve at all on a store every
|
||||
poller writes to (task 04's live watch is the proof: 535 ms per call before, 1.8 ms median after), and the
|
||||
counts age on the clock. A chart's resolution comes from one capped compound statement instead of
|
||||
three full `COUNT`s per series. `last_maintain` and `size_bytes` are deliberately never cached —
|
||||
the first is how an operator tells a stalled rollup loop from a healthy one.
|
||||
@@ -801,12 +813,17 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
that stopped answering is `fail`; one that is stale but still serving its last good value
|
||||
is `warn`; an **unconfigured** source never alerts; a dead source is one line rather than
|
||||
one per stale metric; the list dedupes per (source, metric) and is capped at 12 with a
|
||||
marker that says how many it hid.
|
||||
marker that says how many it hid. A job that dials an agent another job already named adds no
|
||||
line either (`AGENT_ECHO_DASHBOARDS`: `disks` and, since phase 29, `gateway` — three jobs read
|
||||
one `/snapshot`, so one dead box is one overview row), while a job failing *while its host
|
||||
answers* keeps its own voice and links to the page that explains it (`/disks` for a wedged SMART
|
||||
pass, `/gateway` for a proxy or socket section that went quiet). Staleness is measured in each
|
||||
source's own interval, so the gateway page goes amber after 90 quiet seconds and not after 30.
|
||||
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
|
||||
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
|
||||
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*` — including
|
||||
`/api/ups/commands`, which answers what may be sent to each UPS right now and why not when it
|
||||
may not), and the certificate authority's own surface (`/api/certs`,
|
||||
dashboard (`/api/system/*`, `/api/disks/*`, `/api/gateway/*`, `/api/llm/*`, `/api/ups/*` —
|
||||
including `/api/ups/commands`, which answers what may be sent to each UPS right now and why not
|
||||
when it may not), and the certificate authority's own surface (`/api/certs`,
|
||||
`/api/certs/<name>/bundle`, `POST …/rotate`, `POST
|
||||
…/revoke`, `POST /api/certs/root/rotate`). GETs everywhere except `POST /api/ups/command`,
|
||||
which stays the **only** route that dials anything: the `/api/certs/*` writes are `meta` row
|
||||
@@ -818,7 +835,7 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
no env config answers `404 {"configured": false}` **without dialling** (so a page says
|
||||
"not configured" instead of going red), and a history query for a series that does not
|
||||
exist answers an empty series instead of an error (so a card asks for every plot it could
|
||||
draw and reads `n/a` for the ones the drive never reported). The four `history` routes take
|
||||
draw and reads `n/a` for the ones the drive never reported). The five `history` routes take
|
||||
one batched door into the store (`api._history_batch` → `store.history_many`), so a page's
|
||||
chart request is one grouped resolution query plus one index seek per series rather than four
|
||||
queries per series — and the JSON it answers with is the same document the per-series loop
|
||||
@@ -842,13 +859,35 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
drive the hardware called `FAILED` without a second call); it is additive, exactly as `alerts`
|
||||
was, and the key paths of both are pinned with and without targets configured. Since phase 22
|
||||
that block lists the **same hosts** `/api/disks/snapshot` does, and `fleet.hosts_total` is
|
||||
that row count, so `/` and `/disks` can never describe two different fleets.
|
||||
* `web/` — five static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the three
|
||||
admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled
|
||||
that row count, so `/` and `/disks` can never describe two different fleets. `/api/summary` grew a
|
||||
`gateway` block the same way in phase 29 (`configured`, `host`, `supported`, `unreachable`,
|
||||
`inbound`, `outbound`, `map_targets`, `unmatched`, `unmatched_listeners`, `partial`, `states`):
|
||||
the ingress's own tallies, `null` and never `0` in every arm that did not measure, `partial: true`
|
||||
when a total had to leave an unattributable pipe out, and no `severity` anywhere — a connection
|
||||
count is not a graded number. `/api/config` carries the matching row, including
|
||||
`gateway.interval_seconds`/`timeout_seconds` and the named target's redacted `url`.
|
||||
`/api/gateway/*` (phase 29 task 03) is that page's pair. `/snapshot` is one host's whole
|
||||
topology — the haproxy frontends and the nginx `listen`/`map` echo the pipes were built from,
|
||||
the inbound pipes (port, its domains, its live connection count, up to 32 client addresses), the
|
||||
outbound pipes (target `host:port`, `by_port` naming which half of the match is a socket fact,
|
||||
the count, the config that named it) each carrying the `metric` it is stored under, the
|
||||
`unmatched` tallies, and `supported`/`sections`/`why` for the degradation arms — and the counts
|
||||
reconcile: inbound + outbound + unmatched == `sockets.established`, so the page's numbers are
|
||||
provable against a direct `/proc/net/tcp` read. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
a `404 {"configured": false}` answered before any client is built (both arms spy-pinned), a host
|
||||
that has never answered is the `unreachable` stub whose every count is `null`, and a 1.1.0 agent
|
||||
is `supported: false` plus its one `why` sentence — three arms, no red among them (invariant 3).
|
||||
`/history` serves the `gateway.in_conns@<port>` and `gateway.out_conns@<host>:<port>` gauges —
|
||||
the port in the outbound name because two services on one host are two facts — with `hosts=`
|
||||
defaulting to the ingress alone, unknown names answering as empty series, and no dial either way
|
||||
because history reads only SQLite.
|
||||
* `web/` — six static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/gateway`) plus
|
||||
the three admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled
|
||||
SVG charts (`charts.js`), and a masthead status dot per dashboard job on **every** signed-in
|
||||
page — the two admin pages included (`disks` has its own:
|
||||
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass).
|
||||
The masthead is **three rows at every width, the two control rows flush right**: the brand and the five dashboard
|
||||
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass, and
|
||||
`gateway` has one because its job is its own poll of one ingress).
|
||||
The masthead is **three rows at every width, the two control rows flush right**: the brand and the six dashboard
|
||||
tabs, then the per-source indicators on their own row, then the `⚷ Certs` / `⚙ Account` /
|
||||
`☾ Dark` / clock controls on a row under those, in that order, so the **time is the farthest-right
|
||||
thing on the bar** (phase 25: the badges and the cluster were re-justified right, then the clock
|
||||
@@ -858,13 +897,13 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
indicators, and so two rows, and a control is never hidden to make a
|
||||
narrower width work. There is no control that stops the polling (phase 21): the bar used to carry
|
||||
a `⏸ Pause` button with a `Space` shortcut, and it left the numbers on screen looking current with
|
||||
no `stale` label on them. Both admin links are on all seven
|
||||
signed-in pages — the five dashboards plus `/settings` and `/certs`, the two they name included —
|
||||
and not on `/login`, the eighth page and the only anonymous one: the cluster is the same wherever
|
||||
no `stale` label on them. Both admin links are on all eight
|
||||
signed-in pages — the six dashboards plus `/settings` and `/certs`, the two they name included —
|
||||
and not on `/login`, the ninth page and the only anonymous one: the cluster is the same wherever
|
||||
you are, and a link to the page you are already on is a harmless no-op rather than chrome that
|
||||
vanishes. The bar is **not sticky**: it scrolls away
|
||||
with the page, so every pixel of height goes to the data (it stays a positioned element, which is
|
||||
what keeps the mobile dropdown anchored under it). At ≤640px the five tab links leave the bar
|
||||
what keeps the mobile dropdown anchored under it). At ≤640px the six tab links leave the bar
|
||||
and come back as a dropdown under the whole of it, opened by the `☰` button (`/login` renders no
|
||||
tabs, so it has no button either), and that menu **slides**: 180 ms of fade plus an 8 px rise on
|
||||
the way out and the same on the way back in, with nothing hidden from a keyboard or a screen
|
||||
@@ -888,6 +927,9 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
list reads as an explicit "all clear", and a live problem moves the panel above the
|
||||
table. A host that is not answering keeps its row — dimmed, every number `n/a` — so the
|
||||
fleet never quietly gets smaller and last good values are never mistaken for live ones.
|
||||
Under that table is the ingress's **one line** (`gateway · 14 inbound · 4 outbound`, linked to
|
||||
`/gateway`): the counts are `/api/summary`'s own sums, a count the source did not measure prints
|
||||
`n/a` rather than `0`, and a deployment with no `GATEWAY_HOST` renders no line at all.
|
||||
A big colour-coded number is `Dash.hero(el, {...})` on the
|
||||
`.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via
|
||||
`Dash.severityFrom()` — the only colour vocabulary is `ok` / `warn` / `fail`, and a value
|
||||
@@ -994,6 +1036,26 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
`No test initiated` or no variable at all — says the same "run a battery test" sentence as
|
||||
`unknown`. Only a word the map has never seen stays a warning, printed verbatim so it can
|
||||
be added.
|
||||
`/gateway` answers the topology question `/system` cannot: which sockets on the ingress face
|
||||
inbound, where each one's traffic goes, and how many connections are live on each. It is four
|
||||
cards keyed on what the **config** said a pipe came from — `haproxy`, `nginx`, the `stream map`,
|
||||
and `unmatched` — each hidden until a poll actually has pipes for it, so a router with no nginx
|
||||
in it spends no panel saying so. An inbound row is a listen port with its `server_name`s and the
|
||||
client addresses on it; an outbound row is the target **exactly as the config wrote it** (the SNI
|
||||
name the stream map matched on in front of it when one key routes there), and a target the join
|
||||
could only match on its socket **port** carries a `by port` chip, because that count belongs to
|
||||
the port and not to that service. A pipe animates while it carries at least one live connection
|
||||
and rests at zero, and **nothing on the page is coloured by a verdict** — a busy ingress is the
|
||||
job, so `severity.py` rates no `gateway.*` metric and no number gets a green/amber/red tint. A
|
||||
count the join did not decide prints `n/a` and never `0`, which is why the fan-out on a port many
|
||||
domains share is `n/a` rather than one number repeated as if it were sixteen. The header's five
|
||||
tallies print the reconciliation instead of asserting it: what the kernel's socket table reported
|
||||
(`listeners`, `established`) beside what the pipes carry (`pipes in`, `pipes out`) and what no
|
||||
config claims (`unmatched` — ports and connections kept as separate cells). Each card's trend is
|
||||
behind a collapsed **Charts** toggle like `/system`'s, one `/api/gateway/history` request per
|
||||
card per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel,
|
||||
and an ingress running an agent older than **1.2.0** says so in the agent's own words with every
|
||||
count `n/a` and nothing coloured.
|
||||
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
|
||||
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
|
||||
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
|
||||
@@ -1171,6 +1233,19 @@ quietly becoming a fallback chain. A busy
|
||||
slot's `prompt` is the user's in-flight request text, so the capture script blanks it and
|
||||
the page never reads it.
|
||||
|
||||
`tests/test_gateway_source.py` is where the ingress join's honesty lives, and it pins that module
|
||||
from both ends. The input is the **captured** gateway — `gateway_haproxy.cfg`, the nginx main config
|
||||
plus its included documents, and the two `/proc/net/tcp{,6}` captures — run through the real
|
||||
`host_agent` parsers, so the test compares the join against the agent's own document rather than
|
||||
against a copy of its field names. The assertions are the join's: the pipes carry exactly the ports,
|
||||
`server_name`s and targets those documents name; `inbound + outbound + unmatched` equals the socket
|
||||
table's own ESTABLISHED count, so no connection is counted twice or dropped; a listening port no
|
||||
config claims stays visible as `unmatched.other`; several names sharing one port produce `null`
|
||||
counts and a `unmatched.groups` line rather than one number repeated on sixteen pipes; and **no join
|
||||
ever asks a resolver** — `socket.getaddrinfo` is spied on and fails the test, because deciding
|
||||
"already an address, or a name?" is the only question that module may ask, and a DNS lookup in the
|
||||
poll loop would be rule 2 with a round trip attached.
|
||||
|
||||
`tests/rate_window_contract.py` is the same device for one number rather than a field table: the Δ
|
||||
window each source measures its rates over — `RATE_WINDOW_SECONDS` for the host agents,
|
||||
`LLM_INTERVAL_SECONDS` for the engines, one estimator per measuring cadence (phase 23). It holds
|
||||
|
||||
Reference in New Issue
Block a user