// Admin view — add / edit / pause / delete endpoints. // Renders a top add-endpoint card plus a table of every configured server. // All mutations go through adminFetch (Basic auth); on 401 the login prompt // is rendered as an overlay. const ADMIN_INPUT = { width: '100%', padding: '5px 9px', background: 'var(--surface)', border: '1px solid var(--border-2)', borderRadius: 2, color: 'var(--text)', fontFamily: 'var(--mono)', fontSize: 12, outline: 'none', }; function AddEndpointCard({ groups, onAdded }) { const [url, setUrl] = React.useState(''); const [group, setGroup] = React.useState(groups[0] || ''); const [customGroup, setCustomGroup] = React.useState(false); const [probePath, setProbePath] = React.useState(''); const [okCodes, setOkCodes] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); React.useEffect(() => { if (!group && groups.length) setGroup(groups[0]); }, [groups]); async function submit(e) { e.preventDefault(); setErr(null); setBusy(true); try { const body = { url: url.trim(), group: group.trim() }; if (probePath.trim()) body.probePath = probePath.trim(); if (okCodes.trim()) { body.okCodes = okCodes.split(/[,\s]+/).filter(Boolean).map(x => { const n = Number(x); if (!Number.isFinite(n)) throw new Error(`okCodes: "${x}" is not a number`); return Math.floor(n); }); } const created = await window.adminFetch('/api/admin/servers', { method: 'POST', body: JSON.stringify(body), }); onAdded(created); setUrl(''); setProbePath(''); setOkCodes(''); setCustomGroup(false); } catch (e2) { setErr(e2.message || 'add failed'); } finally { setBusy(false); } } return (

+ Add endpoint

creates a new node and probes it immediately
setUrl(e.target.value)} />
{customGroup ? ( setGroup(e.target.value)} /> ) : ( )}
setProbePath(e.target.value)} />
setOkCodes(e.target.value)} />
{err &&
{err}
}
); } function AdminRow({ server, runtime, onEdit, onTogglePause, onDelete, onRecaptureShot }) { const [confirming, setConfirming] = React.useState(false); const [shotBusy, setShotBusy] = React.useState(false); React.useEffect(() => { if (!confirming) return; const t = setTimeout(() => setConfirming(false), 4000); return () => clearTimeout(t); }, [confirming]); const paused = !!server.paused; const status = paused ? 'maint' : (runtime?.status || 'unknown'); const shot = runtime?.screenshot || null; async function recap() { setShotBusy(true); try { await onRecaptureShot(server); } finally { setShotBusy(false); } } return ( {server.url} {server.group} {server.probePath || } {server.okCodes && server.okCodes.length ? server.okCodes.join(', ') : } {paused ? 'paused' : status}
{confirming ? ( ) : ( )}
); } function Admin({ runtimeNodes, groups, onNodesChanged, currentUser }) { const auth = window.useAdminAuth(); const [servers, setServers] = React.useState(null); const [editing, setEditing] = React.useState(null); const [loadErr, setLoadErr] = React.useState(null); const [filter, setFilter] = React.useState(''); // Track an in-flight "refresh all screenshots" cycle so the button can // show progress without blocking the UI. We poll /api/nodes (which // already exposes screenshot.capturedAt) and count how many have // flipped past the `startedAt` watermark. const [shotJob, setShotJob] = React.useState(null); // { startedAt, total } async function load() { setLoadErr(null); try { const list = await window.adminFetch('/api/admin/servers'); setServers(list); } catch (e) { if (e.message === 'unauthorized') setServers(null); else setLoadErr(e.message); } } React.useEffect(() => { if (auth.authed) load(); else setServers(null); }, [auth.authed]); // Hooks must run in a stable order on every render, so memos live // above the early-return branches. They tolerate `servers` being null. const runtimeById = React.useMemo(() => { const m = {}; for (const n of runtimeNodes || []) m[n.id] = n; return m; }, [runtimeNodes]); const rows = React.useMemo(() => { if (!servers) return []; const f = filter.trim().toLowerCase(); if (!f) return servers; return servers.filter(s => s.url.toLowerCase().includes(f) || (s.group || '').toLowerCase().includes(f) || (s.probePath || '').toLowerCase().includes(f) ); }, [servers, filter]); // While a refresh job is running, count how many nodes have a // screenshot captured *after* the job kicked off. That's our progress. // Declared before early returns so hook order stays stable. const shotProgress = React.useMemo(() => { if (!shotJob) return null; let done = 0; for (const n of runtimeNodes || []) { const s = n.screenshot; if (s && s.capturedAt >= shotJob.startedAt) done++; } return { done, total: shotJob.total }; }, [shotJob, runtimeNodes]); React.useEffect(() => { if (!shotProgress) return; if (shotProgress.done >= shotProgress.total) { // Small delay so the final "done/total" state is visible briefly. const t = setTimeout(() => setShotJob(null), 1500); return () => clearTimeout(t); } }, [shotProgress?.done, shotProgress?.total]); if (!auth.authed) return
; if (loadErr) return
Failed to load admin: {loadErr}
; if (!servers) return
Loading…
; async function onAdded(_created) { await load(); onNodesChanged && onNodesChanged(); } async function onSaved(updated) { setEditing(null); setServers(cur => cur.map(s => s.id === updated.id ? ({ ...s, ...updated }) : s)); onNodesChanged && onNodesChanged(); } async function onTogglePause(s) { const path = s.paused ? 'resume' : 'pause'; try { await window.adminFetch(`/api/admin/servers/${s.id}/${path}`, { method: 'POST' }); setServers(cur => cur.map(x => x.id === s.id ? { ...x, paused: !s.paused } : x)); onNodesChanged && onNodesChanged(); } catch (e) { setLoadErr(e.message); } } async function onDelete(s) { try { await window.adminFetch(`/api/admin/servers/${s.id}`, { method: 'DELETE' }); setServers(cur => cur.filter(x => x.id !== s.id)); onNodesChanged && onNodesChanged(); } catch (e) { setLoadErr(e.message); } } async function onRecaptureShot(s) { try { await window.adminFetch(`/api/admin/screenshots/${s.id}`, { method: 'POST' }); onNodesChanged && onNodesChanged(); } catch (e) { setLoadErr(e.message); } } async function refreshAllShots() { try { const startedAt = Date.now(); const res = await window.adminFetch('/api/admin/screenshots/refresh', { method: 'POST' }); setShotJob({ startedAt, total: res.queued || servers.length }); } catch (e) { setLoadErr(e.message); } } return (

Endpoints

{servers.length} total {shotProgress && ( shots {shotProgress.done}/{shotProgress.total} )} setFilter(e.target.value)} />
{rows.map(s => ( ))}
url group probe path ok codes status actions
setEditing(null)} onSaved={onSaved} />
); } Object.assign(window, { Admin });