**Phase 09 — final verification pass (all 4 task files already in `complete/`; nothing re-implemented)**
- Verified `GET /api/disks/snapshot` live: 3 hosts / 18 whole-`/sys/block` rows, `smartctl_available` three-way, per-row `severity`, API-summed `fleet`; 404 `{"configured": false}` proven *without dialling* (spies on `httpx.AsyncClient.get` + `collectors.fetch_snapshot`).
- Verified `/api/disks/history` live (`disk.temp_c@dev`, unknown names → empty series) + `test_store.py`'s 300 s rollup/prune buckets; `/api/metrics?source=disks` answers the `disks` family.
- Browser against live data: 18 drive cards (never a partition/mount), collapsed per-host `Charts` with `aria-label`, 3 `.stale` grids + one `.banner.info` why-line and **0 `.fail` classes / green `disks` dot** → criteria 3 and 4 hold; new shots `data/logs/09-verify-disks-live-dimmed.png`, `09-verify-overview-live.png` (13 total for the phase).
- Real-hardware criterion: read 4 driveripper drives through `ops`' `sudo -n smartctl -i -A -H` and ran the live output through `host_agent.parse_smart_output` → `sources/disks.parse_disks_snapshot`: `sda` 35 °C / 30 423 h, `nvme1n1` 58 % wear, `nvme5n1` 13 uncorrectable → `alarm_disks: 1`, temps graded `warn`; consistent with the 16 committed fixtures.
- `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov` → **769 passed, 11 skipped, coverage 98.33%** (floor 94); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene + dependency advisories clean. `uv run pytest -m live --live` → 10 passed, 1 skipped (agents still predate the SMART pass). Dev server healthy: `/api/health` `ok`, 9/9 sources.
- Docs fixed: AGENTS.md test count 698→769 and coverage 98.2→98.33 %; `disks` added to the event dashboard-name list and to the "every feed renders `Dash.eventText`" sentence; README `POLL_MS_*` row now states `/disks`' deliberate fixed 30 s; new Known gap 4 records the dev-DB `database is locked` writes (reported/recovered per invariant 4, ~590 MB DB / ~70 MB WAL, not reproduced by a controlled 25 s external read txn).
- Deviations: **no live-infra change** — granting the agent user the `disk` group and redeploying `host_agent.py` on driveripper/deskwork stay the documented paired operator action (redeploying without the grant yields 16 permission-denied lines; a sudo-spawning agent would break the confirmed "direct read, no sudo" assumption). Store-lock issue left to its own phase rather than patched speculatively.
- Next pending phase: `10_dev_direct_dial`.
645 lines
27 KiB
JavaScript
645 lines
27 KiB
JavaScript
/* app.js — shared behaviour for every page: formatting, polling, theme, status.
|
||
*
|
||
* Two rules the whole UI follows:
|
||
* - `null`/`undefined` renders as `n/a`, never as `0`, `""`, or a leftover number.
|
||
* A dash that changes to another dash silently is how dashboards lie.
|
||
* - a stale value is shown *and labelled*: the dot goes amber/red, `stale 12s` appears,
|
||
* and the card dims. Hiding the failure is worse than the failure.
|
||
*/
|
||
'use strict';
|
||
|
||
const Dash = (() => {
|
||
const $ = (sel, root) => (root || document).querySelector(sel);
|
||
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
|
||
|
||
// ---- formatting ---------------------------------------------------------
|
||
const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||
|
||
function fmtBytes(value, digits) {
|
||
if (value == null || !isFinite(value)) return null;
|
||
let v = Math.abs(value);
|
||
let i = 0;
|
||
while (v >= 1024 && i < BYTE_UNITS.length - 1) { v /= 1024; i += 1; }
|
||
const d = digits != null ? digits : (v >= 100 || i === 0 ? 0 : v >= 10 ? 1 : 2);
|
||
return { text: v.toFixed(d), unit: BYTE_UNITS[i] };
|
||
}
|
||
|
||
function bytes(value, digits) {
|
||
const parts = fmtBytes(value, digits);
|
||
return parts ? `${parts.text} ${parts.unit}` : naText;
|
||
}
|
||
|
||
function rate(value, digits) {
|
||
const parts = fmtBytes(value, digits);
|
||
return parts ? `${parts.text} ${parts.unit}/s` : naText;
|
||
}
|
||
|
||
function pct(value, digits) {
|
||
if (value == null || !isFinite(value)) return naText;
|
||
return `${value.toFixed(digits == null ? 1 : digits)}%`;
|
||
}
|
||
|
||
function num(value, digits) {
|
||
if (value == null || !isFinite(value)) return naText;
|
||
const d = digits == null ? (Math.abs(value) >= 100 ? 0 : Math.abs(value) >= 10 ? 1 : 2) : digits;
|
||
return value.toFixed(d);
|
||
}
|
||
|
||
function duration(seconds) {
|
||
if (seconds == null || !isFinite(seconds)) return naText;
|
||
const s = Math.max(0, seconds);
|
||
if (s < 90) return `${Math.round(s)}s`;
|
||
const m = s / 60;
|
||
if (m < 90) return `${m.toFixed(m < 10 ? 1 : 0)}m`;
|
||
const h = m / 60;
|
||
if (h < 48) return `${h.toFixed(h < 10 ? 1 : 0)}h`;
|
||
const d = h / 24;
|
||
if (d < 60) return `${d.toFixed(d < 10 ? 1 : 0)}d`;
|
||
return `${(d / 365.25).toFixed(1)}y`;
|
||
}
|
||
|
||
function age(ts) {
|
||
if (ts == null) return 'never';
|
||
const seconds = Math.max(0, Date.now() / 1000 - ts);
|
||
if (seconds < 2) return 'now';
|
||
// `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';
|
||
|
||
/**
|
||
* The text of an event-feed row: the subject said **once**.
|
||
*
|
||
* `poller.py` writes messages that name their own subject ("gufo is answering again
|
||
* after 1 failure"), and every feed prints the host as its own chip beside the message,
|
||
* so printing the stored words verbatim reads as two problems — the same reasoning
|
||
* `alerts._clean_error()` applies to the overview's alert list, including the drop of
|
||
* `UpstreamError: ` (this app has one upstream error type, so its name is never a fact).
|
||
* A leading `host:` goes too, but only at the front: the UPS rows used to `replace()`
|
||
* the name anywhere, which mangled "Quick battery test sent to apc: OK". Presentation
|
||
* only — the stored event and `/api/events` keep every word.
|
||
*/
|
||
function eventText(event) {
|
||
const text = String((event && event.message) || '');
|
||
const host = String((event && event.host) || '');
|
||
const out = text.replace(/^UpstreamError:\s+/, '');
|
||
if (!host) return out;
|
||
const esc = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const stripped = out.replace(new RegExp('^' + esc + '\\s*[:\\-]?\\s*'), '');
|
||
return stripped.trim() ? stripped : out;
|
||
}
|
||
|
||
// ---- event feeds --------------------------------------------------------
|
||
/**
|
||
* Rows per page in every event feed. The `event` table keeps transitions for as long as
|
||
* the fleet runs, and a feed that prints all of them is what made `/system` take a
|
||
* scroll to read: a dead host alone writes an event on every outage and recovery.
|
||
*/
|
||
const EVENT_PAGE_SIZE = 50;
|
||
|
||
/**
|
||
* How many events a feed asks `/api/events` for.
|
||
*
|
||
* Paging is page-local — `/api/events` keeps its contract and grows no `page`/`limit`
|
||
* vocabulary for one page's layout — so the fetch has to ask for **more than one page**.
|
||
* A `limit` at or below `EVENT_PAGE_SIZE` is a pager that silently never appears: there
|
||
* is never a second page to reach. `tests/test_api.py` pins the relation.
|
||
*/
|
||
const EVENT_FEED_LIMIT = 200;
|
||
|
||
/** Per-`<ul>` pager state: the page being read, what was last drawn, the footer it owns. */
|
||
const feedPagers = new WeakMap();
|
||
|
||
/**
|
||
* The row a feed draws when its caller passed no `opts.row`.
|
||
*
|
||
* The pager has no default row renderer on purpose: escaping and choosing which fields of
|
||
* an event to print is the page's job. A feed wired up without one must not draw 50 empty
|
||
* rows, and must not draw a message nobody escaped.
|
||
*/
|
||
function unroutedRow() {
|
||
return '<span class="muted">this feed cannot render its rows</span>';
|
||
}
|
||
|
||
/** The footer's count: `1–50 of 137`. */
|
||
function pagerCount(first, last, total) {
|
||
return `${first}–${last} of ${total}`;
|
||
}
|
||
|
||
/**
|
||
* The strip under a feed — prev, `1–50 of N`, next — built once and patched after.
|
||
*
|
||
* It is a sibling of the `<ul>` inside the card body, so the bounded list scrolls while the
|
||
* count and the controls stay put. Clicks are handled on the strip itself (not per button)
|
||
* so nothing is lost when the buttons are re-patched.
|
||
*/
|
||
function ensurePagerFoot(list, pager) {
|
||
if (pager.foot && pager.foot.parentNode) return pager.foot;
|
||
const parent = list.parentNode;
|
||
if (!parent) return null;
|
||
let foot = list.nextElementSibling;
|
||
if (!foot || !foot.classList.contains('events-pager')) {
|
||
foot = document.createElement('div');
|
||
foot.className = 'events-pager';
|
||
foot.innerHTML = '<button class="ghost" type="button" data-pager="prev"></button>'
|
||
+ '<span class="faint mono" data-pager="count"></span>'
|
||
+ '<button class="ghost" type="button" data-pager="next"></button>';
|
||
parent.insertBefore(foot, list.nextSibling);
|
||
}
|
||
pager.foot = foot;
|
||
if (!pager.bound) {
|
||
pager.bound = true;
|
||
foot.addEventListener('click', (event) => {
|
||
const which = event.target && event.target.getAttribute('data-pager');
|
||
if (which !== 'prev' && which !== 'next') return;
|
||
const next = pager.page + (which === 'prev' ? -1 : 1);
|
||
if (next < 0 || next > pager.pages - 1) return;
|
||
pager.page = next;
|
||
paintEventFeed(list, pager);
|
||
});
|
||
}
|
||
return foot;
|
||
}
|
||
|
||
/** Draw the page `pager` is on, then patch its footer. */
|
||
function paintEventFeed(list, pager) {
|
||
const rows = pager.rows;
|
||
pager.pages = Math.max(1, Math.ceil(rows.length / pager.size));
|
||
pager.page = Math.min(Math.max(pager.page, 0), pager.pages - 1);
|
||
const start = pager.page * pager.size;
|
||
const visible = rows.slice(start, start + pager.size);
|
||
// Rows are rebuilt from the payload, as these feeds always did — but only when the
|
||
// markup actually changed. Rewriting 50 rows every poll resets the container's scroll
|
||
// position under the reader and re-announces the whole list to assistive tech for a
|
||
// difference of one row somewhere further down.
|
||
const markup = visible.map((event) => `<li>${pager.row(event)}</li>`).join('')
|
||
|| pager.empty;
|
||
if (pager.markup !== markup) {
|
||
pager.markup = markup;
|
||
list.innerHTML = markup;
|
||
}
|
||
const foot = ensurePagerFoot(list, pager);
|
||
if (!foot) return;
|
||
// No controls for a feed that fits: every row on screen *is* the whole story, and a
|
||
// disabled pair of arrows next to "1–12 of 12" is ink that says nothing.
|
||
const paginating = rows.length > pager.size;
|
||
foot.hidden = !paginating;
|
||
if (!paginating) return;
|
||
setTxt($('[data-pager="count"]', foot), pagerCount(start + 1, start + visible.length, rows.length));
|
||
const prev = foot.querySelector('[data-pager="prev"]');
|
||
const next = foot.querySelector('[data-pager="next"]');
|
||
setTxt(prev, '‹ prev');
|
||
setTxt(next, 'next ›');
|
||
// The feed is newest-first, so "prev" walks towards *now*: say so in the name,
|
||
// because two arrows alone make nobody sure which way history goes.
|
||
prev.setAttribute('aria-label', 'Previous page (newer events)');
|
||
next.setAttribute('aria-label', 'Next page (older events)');
|
||
prev.disabled = pager.page === 0;
|
||
next.disabled = pager.page >= pager.pages - 1;
|
||
}
|
||
|
||
/**
|
||
* Render — or update in place — one page of an event feed.
|
||
*
|
||
* `eventsPager(el, events, opts)`
|
||
* - `el`: the feed's `<ul class="events">` (element or selector). `dash.css` bounds its
|
||
* height, so the **list** scrolls and the page stays short whatever the table holds;
|
||
* - `events`: the list from `/api/events`, newest first; nothing is sliced server-side;
|
||
* - `opts.row(event)` returns one row's *inner* HTML (the pager supplies the `<li>`) —
|
||
* each page keeps its own row markup and its own `eventText()` call, so the
|
||
* subject-said-once rule stays in one place and the `/ups` rows keep their runtime
|
||
* footnote and longer timestamp;
|
||
* - `opts.empty`: the sentence for a feed with no events (kept per page: "no host
|
||
* transitions" and "no power events" are different sentences). It hides the footer;
|
||
* - `opts.pageSize` overrides `EVENT_PAGE_SIZE`; `opts.label` names the scrollable
|
||
* container for assistive tech (a bounded container is a region you can get into).
|
||
*
|
||
* **A refresh carrying a different set of events resets to page 1** — an operator
|
||
* reading page 3 of yesterday's history must not land, silently, on page 3 of a list that
|
||
* just changed underneath them. When the set is unchanged the page is kept: resetting on
|
||
* every poll would snap `next` back within one interval and make page 2 unreadable.
|
||
*/
|
||
function eventsPager(el, events, opts) {
|
||
const list = typeof el === 'string' ? $(el) : el;
|
||
if (!list) return null;
|
||
const options = opts || {};
|
||
let pager = feedPagers.get(list);
|
||
if (!pager) {
|
||
pager = { page: 0, pages: 1, rows: [], size: EVENT_PAGE_SIZE, markup: null, foot: null,
|
||
bound: false, signature: null, row: unroutedRow, empty: '' };
|
||
feedPagers.set(list, pager);
|
||
}
|
||
const rows = Array.isArray(events) ? events : [];
|
||
if (typeof options.row === 'function') pager.row = options.row;
|
||
if (options.empty != null) pager.empty = String(options.empty);
|
||
if (Number.isFinite(options.pageSize) && options.pageSize > 0) {
|
||
pager.size = Math.floor(options.pageSize);
|
||
}
|
||
if (typeof options.label === 'string' && options.label) {
|
||
list.setAttribute('aria-label', options.label);
|
||
list.setAttribute('tabindex', '0');
|
||
}
|
||
// New rows (or rows going away) change the signature; a poll that re-serves the same
|
||
// history does not, and that is the difference between "new events arrived" and "the
|
||
// timer fired".
|
||
const signature = `${rows.length}:${rows.length ? rows[0].ts : ''}`
|
||
+ `:${rows.length ? rows[rows.length - 1].ts : ''}`;
|
||
if (signature !== pager.signature) {
|
||
pager.page = 0;
|
||
pager.signature = signature;
|
||
}
|
||
pager.rows = rows;
|
||
paintEventFeed(list, pager);
|
||
return list;
|
||
}
|
||
|
||
// ---- DOM helpers --------------------------------------------------------
|
||
/** Write text only when it changed: avoids killing transitions and focus. */
|
||
function setTxt(el, value) {
|
||
if (!el) return;
|
||
const text = value == null ? naText : String(value);
|
||
if (el.textContent !== text) el.textContent = text;
|
||
el.classList.toggle('is-null', value == null);
|
||
el.classList.toggle('na', value == null);
|
||
}
|
||
|
||
function setValue(el, value, digits, unit) {
|
||
if (!el) return;
|
||
const parent = el.parentElement;
|
||
if (value == null || !isFinite(value)) {
|
||
setTxt(el, naText);
|
||
if (parent) parent.classList.add('is-null');
|
||
return;
|
||
}
|
||
if (parent) parent.classList.remove('is-null');
|
||
const text = typeof value === 'string' ? value : num(value, digits);
|
||
setTxt(el, text);
|
||
const unitEl = el.nextElementSibling;
|
||
if (unit && unitEl && unitEl.classList.contains('unit')) setTxt(unitEl, unit);
|
||
}
|
||
|
||
function setBar(el, value, kind) {
|
||
if (!el) return;
|
||
const fill = el.tagName === 'I' ? el : $('i', el);
|
||
const pctValue = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||
if (fill) fill.style.width = `${pctValue}%`;
|
||
const threshold = kind || severityFor(pctValue);
|
||
el.classList.remove('ok', 'warn', 'fail');
|
||
el.classList.add(threshold);
|
||
}
|
||
|
||
/**
|
||
* FALLBACK ONLY: a colour class derived from thresholds the *caller* passes.
|
||
*
|
||
* Thresholds live in `src/dashboards/severity.py` and travel in the payload as
|
||
* `severity`; `severityFrom()` below is how a page reads them. **No page script calls
|
||
* this any more** — `/` was converted in phase 02, `/system` in phase 03, `/llm` in
|
||
* phase 04 and `/ups` in phase 05, and `tests/test_api.py` fails if one names it again.
|
||
* It survives as the documented fallback `setBar()` falls back to when a caller names no
|
||
* verdict class. Do not add a new call site: a threshold written in JS is a threshold
|
||
* that can disagree with the other pages — a value worth colouring gets a row in
|
||
* `severity.py` instead.
|
||
*/
|
||
function severityFor(value, warnAt, failAt) {
|
||
const warn = warnAt == null ? 75 : warnAt;
|
||
const fail = failAt == null ? 90 : failAt;
|
||
if (value == null) return '';
|
||
if (value >= fail) return 'fail';
|
||
if (value >= warn) return 'warn';
|
||
return 'ok';
|
||
}
|
||
|
||
// ---- severity & hero numbers -------------------------------------------
|
||
/** The only colour vocabulary in the app — matches `dash.css` and severity.py. */
|
||
const SEVERITY_CLASSES = ['ok', 'warn', 'fail'];
|
||
|
||
/**
|
||
* The colour class for a value, read from the payload instead of a threshold.
|
||
*
|
||
* Accepts a payload cell (`{value, severity}`), the bare `severity` string, or `null`,
|
||
* and returns `ok` / `warn` / `fail` / `''`. Anything the API does not rate — no field,
|
||
* `null`, a word from another vocabulary — returns `''`, which means *no colour*: an
|
||
* unknown number must never look green.
|
||
*/
|
||
function severityFrom(cellOrSeverity) {
|
||
let value = cellOrSeverity;
|
||
if (value && typeof value === 'object') value = value.severity;
|
||
if (typeof value !== 'string') return '';
|
||
const word = value.trim().toLowerCase();
|
||
return SEVERITY_CLASSES.indexOf(word) >= 0 ? word : '';
|
||
}
|
||
|
||
/** First direct child with `className`, or null. */
|
||
function childOf(parent, className) {
|
||
for (const node of Array.from(parent.children)) {
|
||
if (node.classList && node.classList.contains(className)) return node;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** The child with `className`, created (before `before`) on first use. */
|
||
function ensureChild(parent, className, tag, before) {
|
||
const existing = childOf(parent, className);
|
||
if (existing) return existing;
|
||
const node = document.createElement(tag);
|
||
node.className = className;
|
||
parent.insertBefore(node, before || null);
|
||
return node;
|
||
}
|
||
|
||
/** Text for a label or footnote: `null` prints nothing (unlike `setTxt`'s `n/a`). */
|
||
function assignText(el, text) {
|
||
const value = text == null ? '' : String(text);
|
||
if (el.textContent !== value) el.textContent = value;
|
||
}
|
||
|
||
/**
|
||
* Render — or update in place — one big colour-coded number.
|
||
*
|
||
* `hero(el, {label, value, unit, digits, severity, sub, bar})`
|
||
* - `el`: an element or a selector (a missing element is a silent no-op, like the other
|
||
* setters, so a page can call every hero it knows about);
|
||
* - `severity`: taken from the payload (`{value, severity}` cell or the bare string) and
|
||
* resolved by `severityFrom` — this function never compares a number to a threshold;
|
||
* - a `null` / non-finite `value` prints `n/a`, faint, **with no severity class**, so a
|
||
* value nobody measured cannot keep last poll's green;
|
||
* - `bar`: `true` for a 0-100 value, or a number for another max — adds/updates a `.bar`
|
||
* under the number (the `.hero-bar` variant of `dash.css`).
|
||
*
|
||
* The DOM is built once and then patched text-by-text, so a poll does not restart CSS
|
||
* transitions, drop the tabular-numeral layout, or steal focus.
|
||
*/
|
||
function hero(el, options) {
|
||
const node = typeof el === 'string' ? $(el) : el;
|
||
if (!node) return null;
|
||
const opts = options || {};
|
||
node.classList.add('hero');
|
||
assignText(ensureChild(node, 'label', 'div'), opts.label);
|
||
const valueEl = ensureChild(node, 'value', 'div');
|
||
const numberEl = ensureChild(valueEl, 'num', 'span');
|
||
const unitEl = ensureChild(valueEl, 'unit', 'span');
|
||
|
||
const raw = opts.value;
|
||
const measured = raw != null && (typeof raw === 'string' ? raw !== '' : isFinite(raw));
|
||
const sev = measured ? severityFrom(opts.severity) : '';
|
||
SEVERITY_CLASSES.forEach((name) => node.classList.toggle(name, name === sev));
|
||
|
||
if (measured) {
|
||
const text = typeof raw === 'string' ? raw : num(raw, opts.digits);
|
||
if (numberEl.textContent !== text) numberEl.textContent = text;
|
||
numberEl.classList.remove('is-null', 'na');
|
||
valueEl.classList.remove('is-null');
|
||
} else {
|
||
if (numberEl.textContent !== naText) numberEl.textContent = naText;
|
||
numberEl.classList.add('is-null', 'na');
|
||
valueEl.classList.add('is-null');
|
||
}
|
||
assignText(unitEl, measured ? opts.unit : '');
|
||
|
||
if (opts.bar) {
|
||
const bar = ensureChild(node, 'bar', 'div', childOf(node, 'sub'));
|
||
const fill = $('i', bar) || bar.appendChild(document.createElement('i'));
|
||
const max = typeof opts.bar === 'number' && opts.bar > 0 ? opts.bar : 100;
|
||
const width = typeof raw === 'number' ? Math.max(0, Math.min(100, (raw / max) * 100)) : 0;
|
||
fill.style.width = `${width}%`;
|
||
SEVERITY_CLASSES.forEach((name) => bar.classList.toggle(name, name === sev));
|
||
}
|
||
assignText(ensureChild(node, 'sub', 'div'), opts.sub);
|
||
return node;
|
||
}
|
||
|
||
function setDot(el, status) {
|
||
if (!el) return;
|
||
el.classList.remove('ok', 'warn', 'fail', 'idle');
|
||
el.classList.add(status || 'idle');
|
||
}
|
||
|
||
function chip(text, kind, title) {
|
||
const span = document.createElement('span');
|
||
span.className = 'chip' + (kind ? ' ' + kind : '');
|
||
span.textContent = text;
|
||
if (title) span.title = title;
|
||
return span;
|
||
}
|
||
|
||
// ---- networking ---------------------------------------------------------
|
||
async function fetchJSON(url, options) {
|
||
const response = await fetch(url, Object.assign({ headers: { accept: 'application/json' } },
|
||
options || {}));
|
||
let body = null;
|
||
try { body = await response.json(); } catch (err) { body = null; }
|
||
if (!response.ok) {
|
||
const detail = body && body.detail
|
||
? (typeof body.detail === 'string' ? body.detail : body.detail.detail || JSON.stringify(body.detail))
|
||
: `${response.status} ${response.statusText}`;
|
||
const error = new Error(detail);
|
||
error.status = response.status;
|
||
error.body = body;
|
||
throw error;
|
||
}
|
||
return body;
|
||
}
|
||
|
||
/**
|
||
* Poll `fn` every `ms`, skipping overlapping runs and pausing when the tab is hidden
|
||
* or the user hit pause. Errors are reported, never swallowed silently.
|
||
*/
|
||
function poll(fn, ms) {
|
||
let stopped = false;
|
||
let timer = null;
|
||
let inFlight = false;
|
||
const state = {
|
||
get paused() { return state._paused; },
|
||
_paused: false,
|
||
interval: ms,
|
||
setInterval(next) { state.interval = next; if (timer) { clearTimeout(timer); tick(); } },
|
||
setPaused(next) {
|
||
state._paused = next;
|
||
if (next) { if (timer) clearTimeout(timer); timer = null; }
|
||
else if (!timer) tick();
|
||
},
|
||
stop() { stopped = true; if (timer) clearTimeout(timer); },
|
||
runNow() { tick(); },
|
||
};
|
||
async function tick() {
|
||
if (stopped || inFlight || state._paused || document.hidden) { schedule(); return; }
|
||
inFlight = true;
|
||
try { await fn(); } finally { inFlight = false; schedule(); }
|
||
}
|
||
function schedule() {
|
||
if (stopped || state._paused) return;
|
||
timer = setTimeout(tick, state.interval);
|
||
}
|
||
document.addEventListener('visibilitychange', () => { if (!document.hidden && !state._paused) tick(); });
|
||
tick();
|
||
return state;
|
||
}
|
||
|
||
// ---- chrome -------------------------------------------------------------
|
||
function initChrome() {
|
||
const root = document.documentElement;
|
||
const stored = localStorage.getItem('dash-theme');
|
||
const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||
root.dataset.theme = stored || (prefersLight ? 'light' : 'dark');
|
||
|
||
const themeButton = $('#theme-toggle');
|
||
if (themeButton) {
|
||
const paint = () => {
|
||
themeButton.textContent = root.dataset.theme === 'light' ? '☀ Light' : '☾ Dark';
|
||
themeButton.setAttribute('aria-pressed', root.dataset.theme === 'light' ? 'true' : 'false');
|
||
};
|
||
themeButton.addEventListener('click', () => {
|
||
root.dataset.theme = root.dataset.theme === 'light' ? 'dark' : 'light';
|
||
localStorage.setItem('dash-theme', root.dataset.theme);
|
||
paint();
|
||
document.dispatchEvent(new CustomEvent('dash:theme'));
|
||
});
|
||
paint();
|
||
}
|
||
|
||
const clock = $('#clock');
|
||
if (clock) {
|
||
const tickClock = () => {
|
||
clock.textContent = new Date().toLocaleTimeString(undefined, { hour12: false });
|
||
};
|
||
tickClock();
|
||
setInterval(tickClock, 1000);
|
||
}
|
||
|
||
const nav = $('#nav-status');
|
||
if (nav) {
|
||
// One dot per dashboard's own jobs (`hub.states()` keys them by dashboard), so a
|
||
// source nobody configured stays grey instead of red — and the `disks` job gets its
|
||
// own dot rather than hiding inside `hosts`: the same agent can answer the 10-second
|
||
// utilisation poll and have a wedged SMART pass, and those are different sentences.
|
||
const jobs = [
|
||
{ key: 'system', label: 'hosts', href: '/system' },
|
||
{ key: 'disks', label: 'disks', href: '/disks' },
|
||
{ key: 'llm', label: 'engines', href: '/llm' },
|
||
{ key: 'ups', label: 'UPS', href: '/ups' },
|
||
];
|
||
nav.innerHTML = jobs.map((job) => `<span class="chip" title="${job.label}">
|
||
<span class="dot idle" data-dot="${job.key}"></span>${job.label}</span>`).join('');
|
||
const refreshStatus = async () => {
|
||
try {
|
||
const health = await fetchJSON('/api/health');
|
||
for (const job of jobs) {
|
||
const dot = nav.querySelector(`[data-dot="${job.key}"]`);
|
||
const states = (health.sources || []).filter((s) => s.dashboard === job.key && s.configured);
|
||
if (!states.length) { setDot(dot, 'idle'); continue; }
|
||
const ok = states.filter((s) => s.ok).length;
|
||
setDot(dot, ok === states.length ? 'ok' : ok ? 'warn' : 'fail');
|
||
dot.title = `${ok}/${states.length} ${job.label} answering`;
|
||
}
|
||
} catch (err) {
|
||
for (const job of jobs) setDot(nav.querySelector(`[data-dot="${job.key}"]`), 'fail');
|
||
}
|
||
};
|
||
refreshStatus();
|
||
setInterval(refreshStatus, 15000);
|
||
}
|
||
}
|
||
|
||
/** Range buttons (5m … 30d). Calls onChange(seconds, key). */
|
||
function rangeControl(container, current, onChange, choices) {
|
||
const options = choices || [['5m', 300], ['15m', 900], ['1h', 3600], ['6h', 21600],
|
||
['24h', 86400], ['7d', 604800], ['30d', 2592000]];
|
||
let value = current || 3600;
|
||
container.className = 'gap';
|
||
container.innerHTML = '';
|
||
const buttons = options.map(([key, seconds]) => {
|
||
const button = document.createElement('button');
|
||
button.className = 'ghost';
|
||
button.type = 'button';
|
||
button.textContent = key;
|
||
button.dataset.seconds = String(seconds);
|
||
button.setAttribute('aria-pressed', seconds === value ? 'true' : 'false');
|
||
button.addEventListener('click', () => {
|
||
value = seconds;
|
||
buttons.forEach((b) => b.setAttribute('aria-pressed',
|
||
Number(b.dataset.seconds) === value ? 'true' : 'false'));
|
||
onChange(seconds, key);
|
||
});
|
||
container.appendChild(button);
|
||
return button;
|
||
});
|
||
return { get value() { return value; } };
|
||
}
|
||
|
||
let toastTimer = null;
|
||
function toast(message, kind) {
|
||
let host = $('#toasts');
|
||
if (!host) {
|
||
host = document.createElement('div');
|
||
host.id = 'toasts';
|
||
host.style.cssText = 'position:fixed;right:1rem;bottom:1rem;z-index:60;display:grid;gap:.4rem';
|
||
document.body.appendChild(host);
|
||
}
|
||
const div = document.createElement('div');
|
||
div.className = 'banner ' + (kind || 'info');
|
||
div.textContent = message;
|
||
host.appendChild(div);
|
||
setTimeout(() => div.remove(), kind === 'fail' ? 9000 : 4500);
|
||
if (toastTimer) clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { host.remove(); host = null; }, 10000);
|
||
}
|
||
|
||
/** Pause button + Space, wired to a poll handle. */
|
||
function bindPause(poller) {
|
||
const button = $('#pause');
|
||
if (!button || !poller) return;
|
||
const paint = () => {
|
||
button.setAttribute('aria-pressed', poller.paused ? 'true' : 'false');
|
||
button.textContent = poller.paused ? '▶ Paused' : '⏸ Pause';
|
||
};
|
||
button.addEventListener('click', () => { poller.setPaused(!poller.paused); paint(); });
|
||
document.addEventListener('keydown', (event) => {
|
||
const tag = (event.target && event.target.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || event.metaKey || event.ctrlKey) return;
|
||
if (event.code === 'Space') { event.preventDefault(); poller.setPaused(!poller.paused); paint(); }
|
||
if (event.key === 't' || event.key === 'T') {
|
||
const theme = $('#theme-toggle');
|
||
if (theme) theme.click();
|
||
}
|
||
});
|
||
paint();
|
||
}
|
||
|
||
/** The "last poll" line every card header shows. */
|
||
function staleness(el, state) {
|
||
if (!el) return;
|
||
if (!state) { setTxt(el, '—'); return; }
|
||
if (!state.configured) { setTxt(el, 'not configured'); return; }
|
||
if (!state.ok) {
|
||
setTxt(el, state.last_ok ? `stale ${duration(ageSeconds(state.last_ok))} · ${state.error || 'down'}`
|
||
: (state.error || 'never reached'));
|
||
setDot(el.parentElement && el.parentElement.querySelector('.dot'), 'fail');
|
||
return;
|
||
}
|
||
const seconds = ageSeconds(state.last_ok);
|
||
setTxt(el, seconds > 30 ? `stale ${duration(seconds)}` : `updated ${duration(seconds)} ago`);
|
||
setDot(el.parentElement && el.parentElement.querySelector('.dot'),
|
||
seconds > 30 ? 'warn' : 'ok');
|
||
}
|
||
|
||
function ageSeconds(ts) {
|
||
return ts == null ? Infinity : Math.max(0, Date.now() / 1000 - ts);
|
||
}
|
||
|
||
/** Cards whose data is older than the poll interval get dimmed + labelled. */
|
||
function markStale(card, stale) {
|
||
if (!card) return;
|
||
card.classList.toggle('stale', !!stale);
|
||
}
|
||
|
||
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
|
||
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
|
||
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText,
|
||
eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT };
|
||
})();
|
||
|
||
document.addEventListener('DOMContentLoaded', () => Dash.initChrome());
|