const { useState, useEffect, useMemo } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#a78bfa", "viz": "bars" }/*EDITMODE-END*/; const ACCENT_PRESETS = { '#f59e0b': '#fbbf24', // amber '#a78bfa': '#c4b5fd', // violet '#22d3ee': '#67e8f9', // cyan '#34d399': '#6ee7b7', // emerald '#f5f5f5': '#ffffff', // monochrome '#fb7185': '#fda4af', // rose }; // ─── SPA routing ──────────────────────────────────────────────────────────── // Mirrors the admin pattern: keep one root mount, decide the view from the // URL on every navigation, and use the History API so back/forward work // without a full reload. function viewFromUrl() { const p = window.location.pathname; if (p.startsWith('/incident')) return 'incident'; if (p.startsWith('/maintenance')) return 'maintenance'; if (p.startsWith('/monitor')) return 'monitor'; return 'home'; } function idFromUrl() { return new URLSearchParams(window.location.search).get('id'); } function publicNavigate(view, params = null) { let url; if (view === 'incident' && params?.id != null) url = '/incident/?id=' + params.id; else if (view === 'maintenance' && params?.id != null) url = '/maintenance/?id=' + params.id; else if (view === 'monitor' && params?.id != null) url = '/monitor/?id=' + params.id; else url = '/'; if (window.location.pathname + window.location.search === url) return; history.pushState({ view, id: params?.id ?? null }, '', url); window.dispatchEvent(new CustomEvent('public-navigate')); // Brief top-progress loader during the SPA hand-off. Fixed 400ms window // is long enough to register visually for fast/cached navigations; slower // destination pages render their own "Loading…" text once mounted. document.body.classList.add('is-navigating'); clearTimeout(window.__publicNavTimer); window.__publicNavTimer = setTimeout(() => { document.body.classList.remove('is-navigating'); }, 400); } window.publicNavigate = publicNavigate; function PublicApp() { const [route, setRoute] = useState(() => ({ view: viewFromUrl(), id: idFromUrl() })); useEffect(() => { function sync() { setRoute({ view: viewFromUrl(), id: idFromUrl() }); } window.addEventListener('public-navigate', sync); window.addEventListener('popstate', sync); return () => { window.removeEventListener('public-navigate', sync); window.removeEventListener('popstate', sync); }; }, []); // Scroll to top on view change so a detail page doesn't open mid-page. useEffect(() => { window.scrollTo(0, 0); }, [route.view, route.id]); if (route.view === 'incident') return ; if (route.view === 'maintenance') return ; if (route.view === 'monitor') return ; return ; } // ─── Home view ────────────────────────────────────────────────────────────── function HomeView() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [range, setRange] = useState('1h'); const [now, setNow] = useState(new Date()); const [data, setData] = useState({ services: [], activeIncidents: [], pastIncidents: [], activeMaintenance: [], maintenance: [], pastMaintenance: [], overall: 'operational', }); // Only tracks the *initial* load — interval refreshes don't flip this back to true. const [isLoading, setIsLoading] = useState(true); // Live ticking clock useEffect(() => { const i = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(i); }, []); // Pull rollup from API on mount; refresh every 30s. useEffect(() => { let initial = true; function load() { apiFetch('/api/status.php') .then(d => { setData(d); if (initial) { setIsLoading(false); initial = false; } }) .catch(err => { console.error('Failed to load status:', err); if (initial) { setIsLoading(false); initial = false; } }); } load(); const i = setInterval(load, 30000); return () => clearInterval(i); }, []); // Show the top progress bar only if loading exceeds 200ms — fast loads don't flash. useEffect(() => { if (!isLoading) { document.body.classList.remove('is-loading'); return undefined; } const t = setTimeout(() => document.body.classList.add('is-loading'), 200); return () => clearTimeout(t); }, [isLoading]); // Apply accent useEffect(() => { document.documentElement.style.setProperty('--accent', t.accent); document.documentElement.style.setProperty('--accent-2', ACCENT_PRESETS[t.accent] || t.accent); document.documentElement.style.setProperty('--accent-soft', t.accent + '22'); }, [t.accent]); // Most-recent probe timestamp across all monitors. Changes whenever any // monitor records a fresh check — used by the header to pulse its dot once. const monitorTick = useMemo(() => (data.services || []).reduce((max, s) => { const v = s.lastCheckedAt ? new Date(s.lastCheckedAt).getTime() : 0; return v > max ? v : max; }, 0), [data.services] ); return (
{data.probesStale && ( )} {(data.activeIncidents || []).map(inc => ( ))}
setTweak('accent', v)} /> setTweak('viz', v)} /> ); } ReactDOM.createRoot(document.getElementById('root')).render();