// lib.jsx — data + routing foundation for the control panel. // Exposes: window.api, window.ApiError, window.useLive, window.ROUTES, // window.useRoute, window.fmtUptime, window.fmtCount, window.fmtPct, // window.fmtDelta, window.fmtMs, window.timeAgo (() => { const { useState, useEffect, useRef, useCallback } = React; /* ──────────────────────────────────────────── API client ──────────────────────────────────────────── */ class ApiError extends Error { constructor(status, message) { super(message); this.name = "ApiError"; this.status = status; } } const api = { // App registers this; called on any 401 outside /ui/api/auth/*. onAuthExpired: null, async request(path, { method = "GET", body, timeoutMs = 10000, signal } = {}) { const ctrl = new AbortController(); const onExternalAbort = () => ctrl.abort(); if (signal) { if (signal.aborted) ctrl.abort(); else signal.addEventListener("abort", onExternalAbort); } const timer = setTimeout(() => ctrl.abort(), timeoutMs); let res; try { res = await fetch(path, { method, headers: { Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: ctrl.signal, }); } catch (e) { if (e && e.name === "AbortError") { if (signal && signal.aborted) throw new ApiError(0, "Request cancelled"); throw new ApiError(0, `Request timed out after ${Math.round(timeoutMs / 1000)}s`); } throw new ApiError(0, (e && e.message) || "Network error"); } finally { clearTimeout(timer); if (signal) signal.removeEventListener("abort", onExternalAbort); } if (!res.ok) { let message = `HTTP ${res.status}`; const text = await res.text().catch(() => ""); if (text) { try { const parsed = JSON.parse(text); message = parsed.error || parsed.message || text; } catch (e) { message = text; } } if (res.status === 401 && !path.startsWith("/ui/api/auth/")) { if (typeof api.onAuthExpired === "function") api.onAuthExpired(); } throw new ApiError(res.status, message); } if (res.status === 204) return null; const text = await res.text(); if (!text) return null; try { return JSON.parse(text); } catch (e) { return text; } }, get(path, opts) { return api.request(path, opts); }, post(path, body = {}, opts = {}) { return api.request(path, { ...opts, method: "POST", body }); }, }; /* ──────────────────────────────────────────── useLive — resilient polling hook - self-chaining setTimeout (next poll scheduled AFTER previous settles) - keeps last good data on error (stale: true) - reconnecting: true after >= 2 consecutive failures - instant refetch on tab visible / window focus / online - skips polls while document.hidden - aborts in-flight request on unmount; `enabled` gate stops everything ──────────────────────────────────────────── */ function useLive(path, options = {}) { const { interval = 2000, enabled = true, timeoutMs } = options; const [data, setData] = useState(null); const [error, setError] = useState(null); const [stale, setStale] = useState(false); const [reconnecting, setReconnecting] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); const handleRef = useRef(null); useEffect(() => { if (!enabled) return undefined; const st = { alive: true, fetching: false, failures: 0, hasData: false, timer: null, ctrl: null }; const schedule = () => { if (!st.alive) return; st.timer = setTimeout(() => run(false), interval); }; const run = async (force) => { if (!st.alive || st.fetching) return; if (document.hidden && !force) { schedule(); return; } st.fetching = true; st.ctrl = new AbortController(); try { const result = await api.request(path, { timeoutMs, signal: st.ctrl.signal }); if (!st.alive) return; st.failures = 0; st.hasData = true; setData(result); setError(null); setStale(false); setReconnecting(false); setLastUpdated(Date.now()); } catch (e) { if (!st.alive) return; st.failures += 1; setError(e); setStale(st.hasData); // keep last good data, just mark it stale if (st.failures >= 2) setReconnecting(true); } finally { if (st.alive) { st.fetching = false; schedule(); } } }; const instant = () => { if (!st.alive || st.fetching) return; if (st.timer) clearTimeout(st.timer); run(true); }; const onVisibility = () => { if (!document.hidden) instant(); }; handleRef.current = { instant }; run(true); document.addEventListener("visibilitychange", onVisibility); window.addEventListener("focus", instant); window.addEventListener("online", instant); return () => { st.alive = false; if (st.timer) clearTimeout(st.timer); if (st.ctrl) st.ctrl.abort(); handleRef.current = null; document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("focus", instant); window.removeEventListener("online", instant); }; }, [path, interval, enabled, timeoutMs]); const refresh = useCallback(() => { if (handleRef.current) handleRef.current.instant(); }, []); return { data, error, stale, reconnecting, lastUpdated, refresh }; } /* ──────────────────────────────────────────── Routing — single source of truth ──────────────────────────────────────────── */ const ROUTES = [ { id: "dashboard", path: "/dashboard", title: "Dashboard", icon: "dashboard", group: "Overview", component: "Dashboard" }, { id: "analytics", path: "/analytics", title: "Analytics", icon: "activity", group: "Overview", component: "Analytics" }, { id: "config", path: "/config", title: "Configuration", icon: "config", group: "Manage", component: "Configuration" }, { id: "models", path: "/models", title: "Models", icon: "models", group: "Manage", component: "Models" }, { id: "virustotal",path: "/virustotal",title: "VirusTotal", icon: "shield", group: "Manage", component: "VirusTotal" }, { id: "images", path: "/images", title: "Images", icon: "image", group: "Manage", component: "ImageStudio" }, { id: "keys", path: "/keys", title: "API Keys", icon: "key", group: "Manage", component: "ApiKeys" }, { id: "test", path: "/test", title: "Test Request", icon: "test", group: "Operate", component: "TestRequest" }, { id: "logs", path: "/logs", title: "Logs", icon: "logs", group: "Operate", component: "Logs" }, { id: "updates", path: "/updates", title: "Updates", icon: "download", group: "Operate", component: "Updates" }, { id: "browser", path: "/browser", title: "Antigravity Browser", icon: "globe", group: "Integrations", component: "BrowserBridge" }, { id: "cameras", path: "/cameras", title: "Cameras", icon: "globe", group: "Integrations", component: "Cameras" }, { id: "setup", path: "/setup", title: "Claude Code Setup", icon: "terminal", group: "Integrations", component: "Setup" }, ]; function routeFromLocation() { let p = window.location.pathname.replace(/\/+$/, ""); if (p === "") p = "/"; return ROUTES.find((r) => r.path === p) || null; } function useRoute() { const [routeId, setRouteId] = useState(() => { const r = routeFromLocation(); if (!r) { window.history.replaceState(null, "", "/dashboard"); return "dashboard"; } return r.id; }); const routeIdRef = useRef(routeId); routeIdRef.current = routeId; // Back/forward: re-parse only — never push. useEffect(() => { const onPop = () => { const r = routeFromLocation(); if (!r) { window.history.replaceState(null, "", "/dashboard"); setRouteId("dashboard"); } else { setRouteId(r.id); } }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); // Title must keep the "Connect AI Proxy" substring (Chrome window-title allowlist). useEffect(() => { const r = ROUTES.find((x) => x.id === routeId); if (r) document.title = r.title + " · Connect AI Proxy"; }, [routeId]); const navigate = useCallback((id) => { if (id === routeIdRef.current) return; // no-op if already current const r = ROUTES.find((x) => x.id === id); if (!r) return; window.history.pushState(null, "", r.path); setRouteId(id); requestAnimationFrame(() => { const main = document.querySelector(".app-main"); if (main) main.scrollTop = 0; }); }, []); const route = ROUTES.find((r) => r.id === routeId) || ROUTES[0]; return { route, routeId, navigate }; } /* ──────────────────────────────────────────── Formatters ──────────────────────────────────────────── */ function fmtUptime(seconds) { if (seconds == null || isNaN(seconds)) return "—"; const s = Math.max(0, Math.floor(seconds)); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${sec}s`; return `${sec}s`; } function fmtCount(n) { if (n == null || isNaN(n)) return "—"; const abs = Math.abs(n); const r1 = (x) => String(Math.round(x * 10) / 10); if (abs >= 1e9) return r1(n / 1e9) + "B"; if (abs >= 1e6) return r1(n / 1e6) + "M"; if (abs >= 1e3) return r1(n / 1e3) + "k"; return r1(n); } function fmtPct(n, digits = 1) { if (n == null || isNaN(n)) return "—"; const f = Math.pow(10, digits); return String(Math.round(n * f) / f) + "%"; } function fmtDelta(n, suffix = "") { if (n == null || isNaN(n)) return "—"; if (n === 0) return "0" + suffix; const sign = n > 0 ? "+" : "−"; return sign + fmtCount(Math.abs(n)) + suffix; } function fmtMs(ms) { if (ms == null || isNaN(ms)) return "—"; if (ms < 1000) return Math.round(ms) + " ms"; if (ms < 60000) return String(Math.round(ms / 100) / 10) + " s"; return String(Math.round(ms / 6000) / 10) + " min"; } function timeAgo(ts) { if (!ts) return "—"; const time = ts instanceof Date ? ts.getTime() : ts; const s = Math.max(0, Math.floor((Date.now() - time) / 1000)); if (s < 5) return "just now"; if (s < 60) return s + "s ago"; const m = Math.floor(s / 60); if (m < 60) return m + "m ago"; const h = Math.floor(m / 60); if (h < 24) return h + "h ago"; return Math.floor(h / 24) + "d ago"; } /* ──────────────────────────────────────────── */ window.api = api; window.ApiError = ApiError; window.useLive = useLive; window.ROUTES = ROUTES; window.useRoute = useRoute; window.fmtUptime = fmtUptime; window.fmtCount = fmtCount; window.fmtPct = fmtPct; window.fmtDelta = fmtDelta; window.fmtMs = fmtMs; window.timeAgo = timeAgo; // Back-compat for the legacy HTML shell while it still exists. window.usePolling = (path, intervalMs = 2000) => useLive(path, { interval: intervalMs }); })();