Files
cortiq-gateway/web/js/app.js
T
infosave2007 15de36be3b feat: ops & UX batch — jobs survive restarts, logs/system in the console, media queue, gallery, chat, exact stream tokens
Fifteen changes aimed at the first day of real use:

- Import and media jobs persist to <data>/jobs-*.json and reload on start
  (running ones settle as errors); the Register button no longer vanishes
  because the gateway restarted.
- Docker ships the cortiq binary (built in the builder stage) — local .cmf
  chat models and image/video generation now work in containers, where
  auto-install never could (no cargo in the runtime image). libvulkan1 added
  for optional GPU offload.
- Open admin console on 0.0.0.0 without a token now warns in the log and
  shows a dismissible banner — the VPS footgun is at least labeled.
- "Restart gateway" button in Settings: stops managed model servers, spawns
  a fresh copy (bind retried while the port frees), Docker relies on the
  container restart policy instead.
- Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's
  own RSS), free disk at the models dir — sampled by sysinfo every 2 s.
- Featured model cards warn "not enough disk" / "may not fit in RAM" before
  the download, from the same live snapshot.
- Log viewer in the sidebar: a 500-line in-memory ring fed by a second
  ANSI-free tracing layer; filter, level, pause, copy.
- Update check: crates.io polled twice a day, the console shows an
  "update available" notice.
- Media generations queue up (with position shown) instead of being
  rejected; progress gained an honest ETA from measured throughput.
- Generation gallery under the image/video tabs: lazy PPM thumbnails,
  click to reopen with seed/steps, delete.
- Playground chat is a real dialogue now: history is resent, bubbles,
  Clear, Cmd/Ctrl+Enter.
- "Connect your app" snippets (curl / Python / JS) on the keys page, with
  the real key inlined right after creation.
- One-click benchmark on the Models page via `cortiq bench --json`.
- Config export now downloads the actual gateway.toml (comments intact);
  import accepts TOML (validated, hot-reloaded) and the legacy JSON.
- Streaming token counts are exact when the model server provides usage
  (cortiq-server patch, shipped separately); the gateway fallback now
  counts content chunks (1/token on cortiq) instead of chars/4.

Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
2026-08-07 17:07:48 +03:00

334 lines
11 KiB
JavaScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { 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: "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(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%233b82f6"/><stop offset=".5" stop-color="%238b5cf6"/><stop offset="1" stop-color="%2306b6d4"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(%23g)"/><path d="M9 16h14M17 10l6 6-6 6" stroke="white" stroke-width="2.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
);
// 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));
}
async function refreshHealth() {
try {
appState.health = await api.health();
} catch {
appState.health = null;
}
// 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();