Measured on the deployed console: the page shell and every module land in 39ms, so the 1.4s was entirely /admin/api/ops/overview, fetched twice. Twice because the sidebar and the overview view each called usePoll on the same url, each with its own timer. usePoll now keeps one store per url, so any number of subscribers share a single request and a single interval, and a request already on the wire is joined rather than duplicated. The endpoint itself was dominated by the jobs rollup: a full scan of autonomy_jobs, 521k rows and growing about nine thousand a day. A covering index on (job_type, lane, status, created_at) takes it from 548ms to 110ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
162 lines
6.0 KiB
JavaScript
162 lines
6.0 KiB
JavaScript
// 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';
|
|
}
|