);
}
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