// App bootstrap: login gate, sidebar, topbar, hash router, language + theme. import { h, mount, toast } from "./ui.js"; import { t, getLang, setLang, onLangChange, LANGS } from "./i18n.js"; import { api, getToken, setToken, setUnauthorizedHandler } from "./api.js"; import { renderLogin } from "./views/login.js"; import { renderDashboard } from "./views/dashboard.js"; import { renderModels } from "./views/models.js?v=3"; import { renderImport } from "./views/import.js"; import { renderRouting } from "./views/routing.js"; import { renderProtocols } from "./views/protocols.js"; import { renderKeys } from "./views/keys.js"; import { renderPlayground } from "./views/playground.js"; import { renderSettings } from "./views/settings.js"; import { renderLogs } from "./views/logs.js"; import { renderBenchmark } from "./views/benchmark.js"; import { renderOnboarding } from "./views/onboarding.js"; const ROUTES = [ { id: "dashboard", icon: "▤", view: renderDashboard }, { id: "models", icon: "▦", view: renderModels }, { id: "import", icon: "⬇", view: renderImport }, { id: "routing", icon: "⇄", view: renderRouting }, { id: "protocols", icon: "⇆", view: renderProtocols }, { id: "keys", icon: "🔑", view: renderKeys }, { id: "playground", icon: "✦", view: renderPlayground }, { id: "benchmark", icon: "⏱", view: renderBenchmark }, { id: "logs", icon: "📜", view: renderLogs }, { id: "settings", icon: "⚙", view: renderSettings }, { id: "onboarding", icon: "✦", view: renderOnboarding, hidden: true }, ]; export const appState = { meta: null, health: null, version: "" }; // allaigate site — where router keys are issued and topped up export const SITE_URL = "https://api.allaigate.com/"; let mobileNavOpen = false; function setMobileNav(open) { mobileNavOpen = !!open; const app = document.querySelector(".app"); if (app) app.classList.toggle("nav-open", mobileNavOpen); document.body.classList.toggle("mobile-nav-open", mobileNavOpen); const toggle = document.querySelector(".menu-toggle"); if (toggle) toggle.setAttribute("aria-expanded", String(mobileNavOpen)); } function currentRoute() { const id = location.hash.replace(/^#\/?/, "") || "dashboard"; return ROUTES.find((r) => r.id === id) || ROUTES[0]; } const FAVICON = "data:image/svg+xml;utf8," + encodeURIComponent( '' ); // One-line banners under the topbar: open-admin warning, update available. function shellNotices() { const host = h("div", {}); const m = appState.meta || {}; if (m.admin_open_public && !sessionStorage.getItem("hide_open_admin")) { const close = h("button", { class: "icon-btn", title: "×", onClick: (e) => { sessionStorage.setItem("hide_open_admin", "1"); e.target.closest(".notice").remove(); } }, "×"); host.appendChild( h("div", { class: "notice warn" }, "⚠ " + t("notice.openAdmin"), " ", h("a", { href: "#/settings" }, t("nav.settings") + " →"), close) ); } if (m.update_available) { host.appendChild( h("div", { class: "notice info" }, "⬆ " + t("notice.update", { v: m.update_available }), " ", h("code", { class: "mono" }, "cargo install cortiq-gateway")) ); } return host; } function sidebar(activeId) { const visibleRoutes = ROUTES.filter((r) => !r.hidden); return h( "aside", { class: "sidebar", id: "sidebar", "aria-label": "Main navigation" }, h( "div", { class: "brand" }, h("img", { src: FAVICON, alt: "" }), h("div", { class: "brand-copy" }, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub"))), h("button", { class: "icon-btn nav-close", type: "button", title: "Close menu", "aria-label": "Close menu", onClick: () => setMobileNav(false) }, "×") ), ...visibleRoutes.map((r) => h( "a", { class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id, onClick: () => setMobileNav(false) }, h("span", { class: "ico" }, r.icon), t("nav." + r.id) ) ), h("div", { class: "nav-spacer" }), h( "a", { class: "nav-item", href: SITE_URL, target: "_blank", rel: "noopener" }, h("span", { class: "ico" }, "◈"), t("nav.billing"), h("span", { style: "margin-left:auto;opacity:.55" }, "↗") ), h("div", { class: "nav-foot" }, t("nav.foot", { v: appState.version || "—" })) ); } function statusPills() { const h_ = appState.health; const gwOk = !!appState.meta; const routerOk = h_?.router?.reachable; // reachable but no key resolves → warn: routing silently degrades to default const keyMissing = h_?.router?.key_source === "missing"; const routerDot = h_ ? (routerOk ? (keyMissing ? "warn" : "ok") : "bad") : "warn"; const routerText = h_ ? routerOk ? keyMissing ? t("status.noKey") : t("status.online") : t("status.offline") : t("status.checking"); return h( "div", { class: "flex status-pills" }, h( "span", { class: "pill" }, h("span", { class: "dot " + (gwOk ? "ok" : "bad") }), t("status.gateway") + ": " + (gwOk ? t("status.online") : t("status.offline")) ), h( "span", { class: "pill", title: h_?.router?.url || "" }, h("span", { class: "dot " + routerDot }), t("status.router") + ": " + routerText ) ); } function langSelect() { const sel = h( "select", { class: "lang-select", title: "Language", onChange: (e) => setLang(e.target.value), }, ...LANGS.map((l) => h("option", { value: l.code, selected: l.code === getLang() }, `${l.flag} ${l.label}`)) ); return sel; } function themeToggle() { const cur = document.documentElement.getAttribute("data-theme") || "dark"; return h( "button", { class: "icon-btn", title: t("theme.toggle"), onClick: () => { const next = (document.documentElement.getAttribute("data-theme") || "dark") === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); localStorage.setItem("allaigate_theme", next); renderShell(); }, }, cur === "dark" ? "☀" : "☾" ); } function topbar(activeId) { return h( "header", { class: "topbar" }, h( "button", { class: "icon-btn menu-toggle", type: "button", title: "Menu", "aria-label": "Open menu", "aria-controls": "sidebar", "aria-expanded": String(mobileNavOpen), onClick: () => setMobileNav(!mobileNavOpen), }, h("span", { class: "hamburger", "aria-hidden": "true" }, h("i"), h("i"), h("i")) ), h("div", { class: "title" }, t("nav." + activeId)), h( "div", { class: "right" }, statusPills(), themeToggle(), langSelect(), h( "button", { class: "icon-btn", title: t("logout"), onClick: () => { setToken(""); boot(); }, }, "⎋" ) ) ); } async function renderView() { const route = currentRoute(); const view = document.getElementById("view"); if (!view) return; mount(view, h("div", { class: "empty" }, h("span", { class: "spinner" }), " ", t("common.loading"))); try { const node = await route.view(); mount(view, node); } catch (e) { if (String(e.message) === "unauthorized") return; mount(view, h("div", { class: "callout" }, String(e.message || e))); } } function renderShell() { const route = currentRoute(); mobileNavOpen = false; document.body.classList.remove("mobile-nav-open"); // onboarding — full screen without sidebar/topbar if (route.id === "onboarding") { mount( document.getElementById("app"), h("div", { class: "onb-shell" }, h("div", { class: "onb-content", id: "view" })) ); renderView(); return; } mount( document.getElementById("app"), h( "div", { class: "app" }, sidebar(route.id), h("button", { class: "mobile-nav-backdrop", type: "button", tabindex: "-1", "aria-label": "Close menu", onClick: () => setMobileNav(false) }), h("main", { class: "main" }, topbar(route.id), shellNotices(), h("div", { class: "content", id: "view" })) ) ); renderView(); } function showLogin() { mount(document.getElementById("app"), renderLogin(boot)); } let staleNotified = false; async function checkUiFresh() { // the SPA lives in the tab until reload; if the gateway restarted (dev // rebuild, update, config restart) the embedded UI may have changed — // tell the user instead of letting them stare at stale views try { const m = await api.meta(); if (appState.meta?.started && m.started && m.started !== appState.meta.started && !staleNotified) { staleNotified = true; const bar = h("div", { class: "callout warn", style: "position:fixed; left:50%; transform:translateX(-50%); bottom:18px; z-index:99; box-shadow:0 8px 24px rgba(0,0,0,.35)" }, h("span", {}, "🔄 " + t("shell.uiStale")), " ", h("button", { class: "btn sm primary", onClick: () => location.reload() }, t("shell.reload"))); document.body.append(bar); } } catch {} } async function refreshHealth() { try { appState.health = await api.health(); } catch { appState.health = null; } checkUiFresh(); // re-render topbar only if shell present const route = currentRoute(); const main = document.querySelector(".main"); if (main) { const old = main.querySelector(".topbar"); if (old) old.replaceWith(topbar(route.id)); } } export async function boot() { // convenience: /admin?token=… logs in once, then is stripped from the URL const urlTok = new URLSearchParams(location.search).get("token"); if (urlTok) { setToken(urlTok); history.replaceState({}, "", location.pathname + location.hash); } // try without token first — open mode (no CORTIQ_ADMIN_TOKEN) succeeds, // protected mode returns 401 and we show login try { appState.meta = await api.meta(); appState.version = appState.meta.version || ""; } catch (e) { if (String(e.message) === "unauthorized") { // if we have no token stored, show login; with a stale token also show login showLogin(); return; } // gateway reachable but error — still show shell // if meta failed for other reason but token was present, fall through if (!getToken()) { // open mode but gateway unreachable — still try shell; health will show try { appState.meta = await api.meta(); appState.version = appState.meta.version || ""; } catch {} } } // first-run check — redirect to onboarding if needed (unless already there) try { const st = await api.setupStatus(); if (st.needs_setup && location.hash !== "#/onboarding") { location.hash = "#/onboarding"; } } catch {} if (!location.hash) location.hash = "#/dashboard"; renderShell(); refreshHealth(); } setUnauthorizedHandler(() => { toast(t("login.failed"), "bad"); setToken(""); showLogin(); }); onLangChange(() => { if (document.querySelector(".app")) renderShell(); }); window.addEventListener("hashchange", () => { if (document.querySelector(".app")) renderShell(); }); window.addEventListener("keydown", (e) => { if (e.key === "Escape" && mobileNavOpen) setMobileNav(false); }); window.addEventListener("resize", () => { if (window.innerWidth > 980 && mobileNavOpen) setMobileNav(false); }); setInterval(() => { if (document.querySelector(".app")) refreshHealth(); }, 20000); boot();