/** * T5 (v0.9) — dashboard host list with `●online/offline/draining/revoked` status. * * Renders `api.listHosts()` as cards (subdomain, status dot, last_seen, an "open" action routing to * the terminal view for that host_id) and polls for live `●online` flips. The UI reflects * server-authorized hosts only — there is NO input path to name a foreign host_id (INV1/INV3 * defense-in-depth; the real gate is server-side). A `revoked` host is non-interactive (INV12 UI). * * All dynamic text is set via `textContent` (never `innerHTML`) — XSS discipline. */ import type { ApiClient } from './api-client' import type { HostRecord, HostStatus } from './api-schemas' import { ApiError } from './errors' const DEFAULT_POLL_MS = 5000 export interface Dashboard { refresh(): Promise dispose(): void } export interface DashboardOpts { readonly pollMs?: number /** Routes to the terminal view for a host; injected so the module owns no navigation policy. */ readonly onOpen?: (hostId: string) => void } /** Statuses that must NOT expose an "open" action in the UI (INV12 defense-in-depth). */ const NON_INTERACTIVE: ReadonlySet = new Set(['revoked']) function buildCard(host: HostRecord, opts: DashboardOpts): HTMLElement { const card = document.createElement('div') card.className = 'host-card' card.dataset['hostId'] = host.hostId card.dataset['status'] = host.status const dot = document.createElement('span') dot.className = `status-dot status-${host.status}` dot.setAttribute('aria-label', host.status) dot.textContent = '●' const name = document.createElement('span') name.className = 'host-subdomain' name.textContent = host.subdomain const seen = document.createElement('span') seen.className = 'host-last-seen' seen.textContent = host.lastSeen card.append(dot, name, seen) if (NON_INTERACTIVE.has(host.status)) { const blocked = document.createElement('span') blocked.className = 'host-blocked' blocked.textContent = 'revoked' card.append(blocked) } else { const open = document.createElement('button') open.className = 'host-open' open.type = 'button' open.textContent = 'open' open.addEventListener('click', () => opts.onOpen?.(host.hostId)) card.append(open) } return card } export function mountDashboard(root: HTMLElement, api: ApiClient, opts: DashboardOpts = {}): Dashboard { const pollMs = opts.pollMs ?? DEFAULT_POLL_MS const list = document.createElement('div') list.className = 'host-list' const banner = document.createElement('p') banner.className = 'dashboard-error' banner.setAttribute('role', 'alert') root.append(banner, list) let timer: ReturnType | null = null let disposed = false async function refresh(): Promise { try { const hosts = await api.listHosts() if (disposed) return banner.textContent = '' list.replaceChildren(...hosts.map((h) => buildCard(h, opts))) } catch (err) { if (disposed) return // Zod drift or a rejection surfaces as a banner; polling continues (never renders undefined). banner.textContent = err instanceof ApiError ? `Could not load hosts (${err.kind}).` : 'Could not load hosts.' } } void refresh() timer = setInterval(() => void refresh(), pollMs) return { refresh, dispose(): void { disposed = true if (timer) clearInterval(timer) }, } }