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.
334 lines
11 KiB
JavaScript
Vendored
334 lines
11 KiB
JavaScript
Vendored
// 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();
|