// Shared plumbing: htm binding, hash router, polling fetch, formatters. const { createElement, useState, useEffect, useRef, useCallback } = React; // htm hands props straight through, and React refuses a style string (error #62). // Writing style objects everywhere in tagged templates is miserable, so convert // once here instead and let the views keep using plain css. function cssToObject(css) { const style = {}; for (const declaration of String(css).split(';')) { const split = declaration.indexOf(':'); if (split === -1) continue; const property = declaration.slice(0, split).trim(); const value = declaration.slice(split + 1).trim(); if (!property || !value) continue; // custom properties have to stay verbatim, the rest camelCase style[property.startsWith('--') ? property : property.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value; } return style; } function h(type, props, ...children) { if (props && typeof props.style === 'string') { return createElement(type, { ...props, style: cssToObject(props.style) }, ...children); } return createElement(type, props, ...children); } export const html = htm.bind(h); export { useState, useEffect, useRef, useCallback }; export async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, body: options.body ? JSON.stringify(options.body) : undefined, }); const text = await response.text(); let parsed = null; try { parsed = text ? JSON.parse(text) : null; } catch (error) { // A non JSON body from an API that always speaks JSON means something upstream // failed, so surface the raw text rather than a parse error nobody can act on. console.error('[ops] non-JSON response from', path, error.message); throw new Error(text.slice(0, 200) || `HTTP ${response.status}`); } if (!response.ok) throw new Error((parsed && parsed.error) || `HTTP ${response.status}`); return parsed; } // One store per url, shared by every component asking for it. The sidebar and the // overview both want /ops/overview, and without this they each opened their own // request on their own timer -- the same payload fetched twice, forever. const stores = new Map(); function storeFor(path) { if (stores.has(path)) return stores.get(path); const store = { state: { data: null, error: null, loading: true, at: null }, listeners: new Set(), inflight: null, timer: null, emit() { for (const fn of this.listeners) fn(this.state); }, async load() { // a request already on the wire is shared rather than duplicated if (this.inflight) return this.inflight; this.inflight = api(path) .then((data) => { this.state = { data, error: null, loading: false, at: Date.now() }; }) .catch((error) => { console.error('[ops] poll failed for', path, error.message); this.state = { ...this.state, error: error.message, loading: false }; }) .finally(() => { this.inflight = null; this.emit(); }); return this.inflight; }, }; stores.set(path, store); return store; } export function usePoll(path, intervalMs = 5000) { const store = storeFor(path); const [state, setState] = useState(store.state); useEffect(() => { const listener = (next) => setState(next); store.listeners.add(listener); setState(store.state); store.load(); // the interval belongs to the store, not the component, so N subscribers still // produce exactly one request per tick if (!store.timer) { store.timer = setInterval(() => { if (!document.hidden) store.load(); }, intervalMs); } const onVisible = () => { if (!document.hidden) store.load(); }; document.addEventListener('visibilitychange', onVisible); return () => { store.listeners.delete(listener); document.removeEventListener('visibilitychange', onVisible); if (!store.listeners.size && store.timer) { clearInterval(store.timer); store.timer = null; } }; }, [store, intervalMs]); return { ...state, refresh: () => store.load() }; } export function useHashRoute(fallback) { const read = () => (location.hash || '').replace(/^#\/?/, '') || fallback; const [route, setRoute] = useState(read); useEffect(() => { const onHash = () => setRoute(read()); addEventListener('hashchange', onHash); return () => removeEventListener('hashchange', onHash); }, []); return route; } /* ---------- formatting ---------- */ export const num = (value) => (value === null || value === undefined || Number.isNaN(Number(value)) ? '—' : Number(value).toLocaleString()); export function compact(value) { const n = Number(value); if (!Number.isFinite(n)) return '—'; if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(2) + 'B'; if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'k'; return String(n); } export const pct = (value, digits = 1) => (Number.isFinite(Number(value)) ? `${(Number(value) * 100).toFixed(digits)}%` : '—'); export function ago(minutes) { if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return 'unknown'; const m = Number(minutes); if (m < 1) return 'just now'; if (m < 60) return `${Math.round(m)}m ago`; if (m < 60 * 24) return `${(m / 60).toFixed(1)}h ago`; return `${(m / 1440).toFixed(1)}d ago`; } export function whenDate(value) { if (!value) return '—'; const stamp = String(value).slice(0, 10); const days = Math.round((Date.parse(`${stamp}T00:00:00Z`) - Date.now()) / 86400000); if (!Number.isFinite(days)) return stamp; if (days === 0) return `${stamp} (today)`; return days > 0 ? `${stamp} (in ${days}d)` : `${stamp} (${-days}d ago)`; } export function health(minutes, threshold) { if (minutes === null || minutes === undefined) return 'idle'; if (!Number.isFinite(Number(threshold))) return 'ok'; const m = Number(minutes); if (m <= threshold) return 'ok'; return m <= threshold * 3 ? 'warn' : 'bad'; }