// App root — wires everything together
const { useState, useEffect, useRef } = React;
function App() {
// Auth state FIRST so its hooks run regardless of render branch.
// undefined = still booting (splash)
// null = logged out →
// object = signed-in user; render the app
const [currentUser, setCurrentUser] = useState(undefined);
const [profileOpen, setProfileOpen] = useState(false);
// Dashboard state — declared at top level (never behind a branch) so
// hook order stays stable between login-gated and signed-in renders.
const [nodes, setNodes] = useState(() => window.makeNodes());
const [alerts, setAlerts] = useState(() => window.makeAlerts(window.makeNodes()));
const [logs, setLogs] = useState(() => window.seedLogs(nodes));
const [selected, setSelected] = useState(null);
const [search, setSearch] = useState('');
const [tweaks, setTweaks] = useState(window.__TWEAKS__);
const [tweaksVisible, setTweaksVisible] = useState(false);
// Initial auth check — GET /api/auth/me. If 401 → logged out.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (cancelled) return;
if (r.status === 401) { setCurrentUser(null); return; }
if (!r.ok) { setCurrentUser(null); return; }
setCurrentUser(await r.json());
} catch (_) { if (!cancelled) setCurrentUser(null); }
})();
return () => { cancelled = true; };
}, []);
// Session-lost event (apiFetch emits it on 401, or signOut() on logout).
useEffect(() => {
function onLost() { setCurrentUser(null); }
window.addEventListener('pulse:session-lost', onLost);
return () => window.removeEventListener('pulse:session-lost', onLost);
}, []);
// persist selected view across reloads
useEffect(() => {
const saved = localStorage.getItem('noc-view');
if (saved) setTweaks(t => ({ ...t, view: saved }));
}, []);
useEffect(() => { localStorage.setItem('noc-view', tweaks.view); }, [tweaks.view]);
// apply density to body
useEffect(() => {
document.body.classList.remove('dense-low', 'dense-high');
if (tweaks.density <= 4) document.body.classList.add('dense-low');
else if (tweaks.density >= 8) document.body.classList.add('dense-high');
}, [tweaks.density]);
// Live probe polling — merges backend probe results into local state.
// Real latency/status/httpCode/sslDaysLeft come from the probe agent; we
// preserve client-side history arrays by appending from the new sample.
const [probeMeta, setProbeMeta] = useState({ lastCycleMs: 0, lastCycleAt: 0, live: false });
useEffect(() => {
// Don't start polling the authed API until we know we're signed in.
// Otherwise the initial boot would fire /api/nodes while logged out
// and emit a spurious session-lost event.
if (!currentUser) return;
let cancelled = false;
async function pull() {
try {
const body = await window.fetchProbeNodes();
if (cancelled) return;
// Full-outer merge: backend is source of truth for membership
// (so admin add/delete propagate), local copies keep their
// in-flight ephemeral fields.
setNodes(prev => {
const byLocalId = new Map(prev.map(n => [n.id, n]));
const merged = body.nodes.map(remote => {
const local = byLocalId.get(remote.id);
const latHist = remote.latencyHist && remote.latencyHist.length
? remote.latencyHist
: (local ? [...local.latencyHist.slice(1), remote.latency] : []);
const netHist = remote.netHist && remote.netHist.length
? remote.netHist
: (local ? [...local.netHist.slice(1), (remote.netIn || 0) + (remote.netOut || 0)] : []);
return {
...(local || {}),
...remote,
sslDaysLeft: remote.sslDaysLeft ?? (local ? local.sslDaysLeft : null),
latencyHist: latHist,
netHist,
};
});
// Keep window.GROUPS in sync with live data so existing
// components (threed / topology / overview heatmap) pick up
// newly-added groups without per-file refactors.
if (window.deriveGroups) window.GROUPS = window.deriveGroups(merged);
return merged;
});
setProbeMeta({ ...body.meta, live: true });
// synthesize a log line from the latest probe cycle
setLogs(prev => {
const sample = body.nodes[Math.floor(Math.random() * body.nodes.length)];
if (!sample) return prev;
const lvl = sample.status === 'ok' ? 'I' : sample.status === 'warn' ? 'W' : sample.status === 'crit' ? 'E' : 'W';
const msg = sample.error
? `HEAD ${sample.path} → ${sample.error}`
: `HEAD ${sample.path} → ${sample.httpCode ?? '---'} in ${Math.round(sample.latency)}ms`;
const line = { ts: sample.checkedAt || Date.now(), lvl, src: 'probe', node: sample.id, msg };
return [...prev.slice(-199), line];
});
} catch (e) {
if (!cancelled) setProbeMeta(m => ({ ...m, live: false }));
}
}
pull();
const interval = setInterval(pull, tweaks.liveTick ? 8000 : 30000);
return () => { cancelled = true; clearInterval(interval); };
}, [tweaks.liveTick, currentUser]);
// setup edit-mode postMessage
useEffect(() => {
function onMsg(e) {
if (e.data?.type === '__activate_edit_mode') setTweaksVisible(true);
if (e.data?.type === '__deactivate_edit_mode') setTweaksVisible(false);
}
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
// persist tweaks
useEffect(() => {
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: tweaks }, '*');
}, [tweaks]);
// selected node — always read fresh from nodes array
const selectedNode = selected ? nodes.find(n => n.id === selected) : null;
// filtered nodes by search
const filteredNodes = React.useMemo(() => {
if (!search.trim()) return nodes;
const parts = search.toLowerCase().split(/\s+/);
return nodes.filter(n => parts.every(p => {
if (p.includes(':')) {
const [k, v] = p.split(':');
return String(n[k] || '').toLowerCase().includes(v);
}
return n.hostname.toLowerCase().includes(p) || n.displayName.toLowerCase().includes(p) || n.group.toLowerCase().includes(p) || n.url.toLowerCase().includes(p);
}));
}, [nodes, search]);
// actions
async function rebootNode(n) {
setLogs(prev => [...prev, { ts: Date.now(), lvl: 'I', src: 'probe', node: n.id, msg: `manual recheck requested by operator` }]);
try {
const remote = await window.triggerProbe(n.id);
setNodes(prev => prev.map(nn => nn.id === n.id ? {
...nn,
status: remote.status,
httpCode: remote.httpCode,
latency: remote.latency,
netIn: remote.netIn,
netOut: remote.netOut,
sslDaysLeft: remote.sslDaysLeft ?? nn.sslDaysLeft,
checkedAt: remote.checkedAt,
error: remote.error,
} : nn));
setLogs(prev => [...prev, {
ts: Date.now(),
lvl: remote.status === 'ok' ? 'I' : 'W',
src: 'probe',
node: n.id,
msg: `recheck → ${remote.httpCode ?? remote.error ?? 'err'} in ${Math.round(remote.latency)}ms`,
}]);
} catch (e) {
setLogs(prev => [...prev, { ts: Date.now(), lvl: 'E', src: 'probe', node: n.id, msg: `recheck failed: ${e.message}` }]);
}
}
async function toggleMaint(n) {
// Optimistic flip — backend mirrors within one poll anyway.
const nextPaused = !n.paused;
setNodes(prev => prev.map(nn => nn.id === n.id
? { ...nn, paused: nextPaused, status: nextPaused ? 'maint' : nn.status }
: nn));
try {
await window.adminFetch(
`/api/admin/servers/${n.id}/${nextPaused ? 'pause' : 'resume'}`,
{ method: 'POST' }
);
} catch (e) {
// Revert on auth failure / network error and surface via logs.
setNodes(prev => prev.map(nn => nn.id === n.id ? { ...nn, paused: !nextPaused } : nn));
setLogs(prev => [...prev, { ts: Date.now(), lvl: 'E', src: 'admin', node: n.id, msg: `pause toggle failed: ${e.message}` }]);
}
}
function ackAlert(id) {
setAlerts(prev => prev.map(a => a.id === id ? { ...a, ack: true } : a));
}
// expose incident simulation
useEffect(() => {
window.__simulateIncident = () => {
const pick = nodes[Math.floor(Math.random() * nodes.length)];
setNodes(prev => prev.map(nn => nn.id === pick.id ? { ...nn, status: 'crit', latency: 1500, httpCode: 503 } : nn));
setAlerts(prev => [{
id: 'a-' + Math.random().toString(36).slice(2, 7),
ts: Date.now(),
severity: 'crit',
node: pick.id,
title: 'Simulated latency spike',
body: 'p99 > 400ms for 30s',
ack: false,
}, ...prev]);
};
}, [nodes]);
const viewTitles = {
overview: 'Overview',
threed: '3D fleet',
topology: 'Topology',
nodes: 'Endpoints',
alerts: 'Alerts',
logs: 'Logs',
admin: 'Admin',
notifications: 'Notifications',
users: 'Users',
history: 'History',
};
// Per-user view gate. Admins see everything; non-admins see only what
// their `views` list allows. The Users view is admin-only (it hits
// /api/admin/users which requires isAdmin anyway — exposing it to
// non-admins would just show a useless empty panel).
function isViewAllowed(v) {
if (!currentUser) return false;
if (v === 'users') return !!currentUser.isAdmin;
if (currentUser.isAdmin) return true;
return (currentUser.views || []).includes(v);
}
const allowedViews = Object.keys(viewTitles).filter(isViewAllowed);
const view = isViewAllowed(tweaks.view) ? tweaks.view : (allowedViews[0] || 'overview');
const liveGroups = window.deriveGroups ? window.deriveGroups(nodes) : window.GROUPS;
// Drag-drop from Overview status grid → move a node to another group.
// Optimistic local update + PATCH; on success raises an undo toast
// that reverses the move if clicked within 5 s. On server failure
// the optimistic change is rolled back silently.
async function onGroupMove(nodeId, newGroup, { suppressUndo = false } = {}) {
const cur = nodes.find(n => n.id === nodeId);
if (!cur) return;
const prevGroup = cur.group;
if (prevGroup === newGroup) return;
setNodes(all => all.map(n => n.id === nodeId ? { ...n, group: newGroup } : n));
try {
await window.apiFetch(`/api/admin/servers/${nodeId}`, {
method: 'PATCH', body: JSON.stringify({ group: newGroup }),
});
if (!suppressUndo && window.__pulseShowUndo) {
window.__pulseShowUndo({
label: `moved ${cur.displayName || cur.hostname} → ${newGroup}`,
onUndo: () => onGroupMove(nodeId, prevGroup, { suppressUndo: true }),
});
}
} catch (e) {
setNodes(all => all.map(n => n.id === nodeId ? { ...n, group: prevGroup } : n));
alert(`Move failed: ${e.message || e}`);
}
}
// After an admin-side mutation we trigger an immediate poll so the UI
// reflects the change without waiting for the 8s interval.
async function refetchNodesNow() {
try {
const body = await window.fetchProbeNodes();
setNodes(prev => {
const byLocal = new Map(prev.map(n => [n.id, n]));
const merged = body.nodes.map(remote => {
const local = byLocal.get(remote.id);
return { ...(local || {}), ...remote,
latencyHist: remote.latencyHist || (local ? local.latencyHist : []),
netHist: remote.netHist || (local ? local.netHist : []) };
});
if (window.deriveGroups) window.GROUPS = window.deriveGroups(merged);
return merged;
});
} catch (_) { /* next poll will catch it */ }
}
// Auth render gate — hooks above this point run every render.
if (currentUser === undefined) {
return
starting up…
;
}
if (currentUser === null) {
return ;
}
return (
setTweaks(t => ({ ...t, liveTick: !t.liveTick }))}
currentUser={currentUser}
onOpenProfile={() => setProfileOpen(true)}
/>
setTweaks(t => ({ ...t, view: v }))}
nodes={nodes} alerts={alerts} currentUser={currentUser} isViewAllowed={isViewAllowed} />
noc / {viewTitles[view]}
{search && · filter: "{search}"}
last sync {new Date().toLocaleTimeString()}
{view === 'overview' && isViewAllowed('overview') &&
setSelected(n.id)} currentUser={currentUser} onGroupMove={onGroupMove} />}
{view === 'threed' && isViewAllowed('threed') && setSelected(n.id)} style3d={tweaks.threedStyle} />}
{view === 'topology' && isViewAllowed('topology') && setSelected(n.id)} />}
{view === 'nodes' && isViewAllowed('nodes') && setSelected(n.id)} selectedId={selected} />}
{view === 'alerts' && isViewAllowed('alerts') && setSelected(n.id)} onAck={ackAlert} />}
{view === 'logs' && isViewAllowed('logs') && }
{view === 'notifications' && isViewAllowed('notifications') && }
{view === 'admin' && isViewAllowed('admin') && }
{view === 'users' && isViewAllowed('users') && (
{window.AuditLog && }
)}
{view === 'history' && isViewAllowed('history') && }
setSelected(null)}
onReboot={rebootNode}
onMaint={toggleMaint}
/>
setProfileOpen(false)} />
{window.UndoToastStack && }
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();