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