// 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();