Files
dashboards/src/dashboards/web/static/app.js
T
ducoterra fefbeb8227 phase: 09_disks_smartctl
**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`.
2026-10-18 00:45:01 -04:00

645 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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());