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.
This commit is contained in:
Vendored
+60
@@ -585,6 +585,66 @@ body.modal-open { overflow: hidden; }
|
||||
/* Featured gallery: the list grows as new models are published, so give it more
|
||||
room than the search grid before it starts scrolling. */
|
||||
.featured-section .hf-grid { max-height: min(62vh, 760px); }
|
||||
/* Shell notices (open admin warning, update available) */
|
||||
.notice {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 18px; font-size: 13px;
|
||||
border-bottom: 1px solid var(--border-soft, #232833);
|
||||
}
|
||||
.notice.warn { background: color-mix(in srgb, #e5a54b 12%, var(--bg)); }
|
||||
.notice.info { background: color-mix(in srgb, var(--accent) 10%, var(--bg)); }
|
||||
.notice .icon-btn { margin-left: auto; }
|
||||
|
||||
/* Dashboard host metrics */
|
||||
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
|
||||
.sysitem .k { color: var(--muted); font-size: 12px; }
|
||||
.sysitem .v { font-weight: 600; }
|
||||
.sysbar {
|
||||
height: 8px; border-radius: 6px; overflow: hidden; margin: 6px 0;
|
||||
background: var(--bg-2, #12151c); border: 1px solid var(--border, #2a2f3a);
|
||||
}
|
||||
.sysbar i { display: block; height: 100%; background: var(--accent, #5b8def); }
|
||||
.sysbar i.bad { background: #e5534b; }
|
||||
|
||||
/* Log viewer */
|
||||
.logs-view { max-height: 68vh; }
|
||||
|
||||
/* Chat dialogue bubbles */
|
||||
.chat-log { max-height: 56vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.chat-msg { display: flex; }
|
||||
.chat-msg.user { justify-content: flex-end; }
|
||||
.chat-bubble {
|
||||
max-width: 85%; padding: 8px 12px; border-radius: 12px;
|
||||
white-space: pre-wrap; word-break: break-word; font-size: 13.5px;
|
||||
background: var(--bg-2, #12151c); border: 1px solid var(--border-soft, #232833);
|
||||
}
|
||||
.chat-msg.user .chat-bubble {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Generation gallery */
|
||||
.gal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
|
||||
.gal-item {
|
||||
position: relative; cursor: pointer; border: 1px solid var(--border, #2a2f3a);
|
||||
border-radius: 8px; overflow: hidden; background: var(--bg-2, #12151c);
|
||||
}
|
||||
.gal-item:hover { border-color: var(--accent, #5b8def); }
|
||||
.gal-thumb { width: 100%; display: block; min-height: 64px; }
|
||||
.gal-video { display: flex; align-items: center; justify-content: center; font-size: 28px; height: 72px; }
|
||||
.gal-cap { font-size: 10px; color: var(--muted); padding: 3px 6px; }
|
||||
.gal-del {
|
||||
position: absolute; top: 2px; right: 2px; z-index: 2;
|
||||
border: 0; border-radius: 50%; width: 18px; height: 18px; line-height: 16px;
|
||||
background: rgba(0,0,0,.55); color: #fff; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
|
||||
/* Bench + badges */
|
||||
.bench-row { gap: 10px; padding: 6px 0; border-top: 1px solid var(--border-soft, #232833); }
|
||||
.bench-row:first-of-type { border-top: 0; }
|
||||
.badge.warn { background: rgba(229,165,75,.15); color: #e5a54b; }
|
||||
.snippet { max-height: 260px; }
|
||||
|
||||
/* Playground media tabs: generated image / video frame */
|
||||
.media-out { display: flex; flex-direction: column; gap: 8px; }
|
||||
.media-canvas {
|
||||
|
||||
Vendored
+22
@@ -126,6 +126,13 @@ export const api = {
|
||||
mediaCancel: (id) => j("POST", "/media/" + encodeURIComponent(id) + "/cancel"),
|
||||
mediaDelete: (id) => j("DELETE", "/media/" + encodeURIComponent(id)),
|
||||
|
||||
// system / logs / lifecycle
|
||||
system: () => j("GET", "/system"),
|
||||
logs: (limit = 300) => j("GET", "/logs?limit=" + limit),
|
||||
restart: () => j("POST", "/restart"),
|
||||
benchStart: (model) => j("POST", "/bench", { model }),
|
||||
benchList: () => j("GET", "/bench"),
|
||||
|
||||
// first-run onboarding
|
||||
setupStatus: () => j("GET", "/setup/status"),
|
||||
// Connects every finished download as a local model unless register === false.
|
||||
@@ -142,6 +149,21 @@ export const api = {
|
||||
),
|
||||
};
|
||||
|
||||
// Raw-body POST (no JSON encoding) — the config import takes TOML text as-is.
|
||||
export async function postRaw(path, text) {
|
||||
const tok = getToken();
|
||||
const headers = { "content-type": "application/toml" };
|
||||
if (tok) headers["Authorization"] = "Bearer " + tok;
|
||||
const r = await fetch(BASE + path, { method: "POST", headers, body: text });
|
||||
const body = await r.text();
|
||||
if (!r.ok) {
|
||||
let msg = body;
|
||||
try { msg = JSON.parse(body).error?.message || body; } catch { /* raw */ }
|
||||
throw new Error(msg || r.statusText);
|
||||
}
|
||||
return body ? JSON.parse(body) : null;
|
||||
}
|
||||
|
||||
// Streaming playground: POST /test/stream, calls onDelta(text) per content chunk,
|
||||
// returns the routing decision read from X-Cortiq-* response headers.
|
||||
export async function testStream(body, onDelta) {
|
||||
|
||||
Vendored
+24
-1
@@ -11,6 +11,7 @@ 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 = [
|
||||
@@ -21,6 +22,7 @@ const ROUTES = [
|
||||
{ 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 },
|
||||
];
|
||||
@@ -52,6 +54,27 @@ const FAVICON =
|
||||
'<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(
|
||||
@@ -221,7 +244,7 @@ function renderShell() {
|
||||
{ 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), h("div", { class: "content", id: "view" }))
|
||||
h("main", { class: "main" }, topbar(route.id), shellNotices(), h("div", { class: "content", id: "view" }))
|
||||
)
|
||||
);
|
||||
renderView();
|
||||
|
||||
Vendored
+39
@@ -506,4 +506,43 @@ export default {
|
||||
"pg.noMediaModels": "Noch kein lokales {kind}-Modell. Lade eines (z. B. aus der infosave-Sammlung) unter Import herunter:",
|
||||
"pg.tokSpeed": "Geschwindigkeit",
|
||||
"pg.ttft": "Erstes Token",
|
||||
|
||||
// ops: logs, system, notices, gallery, chat, snippets, bench
|
||||
"nav.logs": "Logs",
|
||||
"logs.subtitle": "Jüngstes Gateway-Log — das, was das Terminal zeigen würde.",
|
||||
"logs.filterPlaceholder": "filtern…",
|
||||
"logs.pause": "Pause",
|
||||
"logs.resume": "Fortsetzen",
|
||||
"logs.empty": "Nichts gefunden.",
|
||||
"notice.openAdmin": "Die Konsole ist ohne Token offen und von anderen Rechnern erreichbar. Setze einen Token, wenn diese Maschine nicht privat ist.",
|
||||
"notice.update": "Update verfügbar: v{v}.",
|
||||
"dash.sys.title": "System",
|
||||
"dash.sys.cores": "Kerne",
|
||||
"dash.sys.disk": "Festplatte (Modelle)",
|
||||
"dash.sys.free": "frei",
|
||||
"dash.sys.gateway": "Gateway",
|
||||
"fit.disk": "zu wenig Speicherplatz",
|
||||
"fit.diskTitle": "Der Download ist größer als der freie Platz auf der Modell-Festplatte.",
|
||||
"fit.ram": "passt evtl. nicht in den RAM",
|
||||
"fit.ramTitle": "Das Modell ist nahe an oder größer als der RAM dieser Maschine — es läuft womöglich nicht.",
|
||||
"pg.eta": "übrig",
|
||||
"pg.min": "Min",
|
||||
"pg.queued": "In der Warteschlange — Position {n}. Startet, sobald die aktuelle Generierung fertig ist.",
|
||||
"pg.gallery": "Letzte Generierungen",
|
||||
"pg.galDeleteConfirm": "Diese Generierung samt Dateien löschen?",
|
||||
"pg.clearChat": "Leeren",
|
||||
"keys.snip.title": "Deine App verbinden",
|
||||
"keys.snip.hint": "Füge eines der Beispiele in dein Projekt ein — nur der Schlüssel-Platzhalter muss ersetzt werden.",
|
||||
"keys.snip.withKey": "Die Beispiele unten enthalten bereits den gerade erstellten Schlüssel.",
|
||||
"keys.snip.openMode": "Keine API-Schlüssel konfiguriert — das Gateway akzeptiert jeden Wert.",
|
||||
"keys.snip.keyPh": "dein API-Schlüssel",
|
||||
"settings.restart": "Gateway neu starten",
|
||||
"settings.restartConfirm": "Gateway neu starten? Laufende Anfragen brechen ab; lokale Modellserver starten ebenfalls neu.",
|
||||
"settings.restarting": "Neustart — die Seite lädt neu, sobald das Gateway wieder da ist…",
|
||||
"bench.title": "Geschwindigkeit lokaler Modelle",
|
||||
"bench.hint": "Führt `cortiq bench` auf dieser Maschine aus — echte tok/s, ein Modell nach dem anderen.",
|
||||
"bench.run": "Messen",
|
||||
"bench.running": "misst…",
|
||||
"bench.failed": "fehlgeschlagen",
|
||||
"bench.none": "Noch keine lokalen .cmf-Chat-Modelle.",
|
||||
};
|
||||
|
||||
Vendored
+39
@@ -499,4 +499,43 @@ export default {
|
||||
"pg.noMediaModels": "No local {kind} model yet. Download one (e.g. from the infosave collection) in Import:",
|
||||
"pg.tokSpeed": "Speed",
|
||||
"pg.ttft": "First token",
|
||||
|
||||
// ops: logs, system, notices, gallery, chat, snippets, bench
|
||||
"nav.logs": "Logs",
|
||||
"logs.subtitle": "Recent gateway log — what the terminal would show.",
|
||||
"logs.filterPlaceholder": "filter…",
|
||||
"logs.pause": "Pause",
|
||||
"logs.resume": "Resume",
|
||||
"logs.empty": "Nothing matches.",
|
||||
"notice.openAdmin": "The admin console is open without a token and reachable from other machines. Set a token if this box is not private.",
|
||||
"notice.update": "Update available: v{v}.",
|
||||
"dash.sys.title": "System",
|
||||
"dash.sys.cores": "cores",
|
||||
"dash.sys.disk": "Disk (models)",
|
||||
"dash.sys.free": "free",
|
||||
"dash.sys.gateway": "gateway",
|
||||
"fit.disk": "not enough disk",
|
||||
"fit.diskTitle": "The download is larger than the free space on the models disk.",
|
||||
"fit.ram": "may not fit in RAM",
|
||||
"fit.ramTitle": "The model is close to or larger than this machine's RAM — it may not run.",
|
||||
"pg.eta": "left",
|
||||
"pg.min": "min",
|
||||
"pg.queued": "In queue — position {n}. It starts as soon as the current generation finishes.",
|
||||
"pg.gallery": "Recent generations",
|
||||
"pg.galDeleteConfirm": "Delete this generation and its files?",
|
||||
"pg.clearChat": "Clear",
|
||||
"keys.snip.title": "Connect your app",
|
||||
"keys.snip.hint": "Paste one of these into your project — only the API key placeholder needs replacing.",
|
||||
"keys.snip.withKey": "Snippets below include the key you just created.",
|
||||
"keys.snip.openMode": "No API keys are configured — the gateway accepts any key value.",
|
||||
"keys.snip.keyPh": "your API key",
|
||||
"settings.restart": "Restart gateway",
|
||||
"settings.restartConfirm": "Restart the gateway? Requests in flight will be dropped; local model servers restart too.",
|
||||
"settings.restarting": "Restarting — the page reloads when the gateway is back…",
|
||||
"bench.title": "Local model speed",
|
||||
"bench.hint": "Runs `cortiq bench` on this machine — real tok/s, one model at a time.",
|
||||
"bench.run": "Measure",
|
||||
"bench.running": "measuring…",
|
||||
"bench.failed": "failed",
|
||||
"bench.none": "No local .cmf chat models yet.",
|
||||
};
|
||||
|
||||
Vendored
+39
@@ -506,4 +506,43 @@ export default {
|
||||
"pg.noMediaModels": "Aún no hay un modelo {kind} local. Descarga uno (p. ej. de la colección infosave) en Importar:",
|
||||
"pg.tokSpeed": "Velocidad",
|
||||
"pg.ttft": "Primer token",
|
||||
|
||||
// ops: logs, system, notices, gallery, chat, snippets, bench
|
||||
"nav.logs": "Registros",
|
||||
"logs.subtitle": "Registro reciente de la pasarela — lo que mostraría la terminal.",
|
||||
"logs.filterPlaceholder": "filtrar…",
|
||||
"logs.pause": "Pausa",
|
||||
"logs.resume": "Reanudar",
|
||||
"logs.empty": "Nada coincide.",
|
||||
"notice.openAdmin": "La consola está abierta sin token y accesible desde otras máquinas. Configura un token si esta máquina no es privada.",
|
||||
"notice.update": "Actualización disponible: v{v}.",
|
||||
"dash.sys.title": "Sistema",
|
||||
"dash.sys.cores": "núcleos",
|
||||
"dash.sys.disk": "Disco (modelos)",
|
||||
"dash.sys.free": "libre",
|
||||
"dash.sys.gateway": "pasarela",
|
||||
"fit.disk": "no hay espacio suficiente",
|
||||
"fit.diskTitle": "La descarga supera el espacio libre del disco de modelos.",
|
||||
"fit.ram": "puede no caber en RAM",
|
||||
"fit.ramTitle": "El modelo se acerca o supera la RAM de esta máquina — puede que no funcione.",
|
||||
"pg.eta": "restante",
|
||||
"pg.min": "min",
|
||||
"pg.queued": "En cola — posición {n}. Arranca en cuanto termine la generación actual.",
|
||||
"pg.gallery": "Generaciones recientes",
|
||||
"pg.galDeleteConfirm": "¿Eliminar esta generación y sus archivos?",
|
||||
"pg.clearChat": "Limpiar",
|
||||
"keys.snip.title": "Conecta tu aplicación",
|
||||
"keys.snip.hint": "Pega uno de estos ejemplos en tu proyecto — solo hay que sustituir el marcador de la clave.",
|
||||
"keys.snip.withKey": "Los ejemplos de abajo ya incluyen la clave que acabas de crear.",
|
||||
"keys.snip.openMode": "No hay claves API configuradas — la pasarela acepta cualquier valor.",
|
||||
"keys.snip.keyPh": "tu clave API",
|
||||
"settings.restart": "Reiniciar la pasarela",
|
||||
"settings.restartConfirm": "¿Reiniciar la pasarela? Las peticiones en curso se cortarán; los servidores de modelos locales también se reinician.",
|
||||
"settings.restarting": "Reiniciando — la página se recargará cuando la pasarela vuelva…",
|
||||
"bench.title": "Velocidad de modelos locales",
|
||||
"bench.hint": "Ejecuta `cortiq bench` en esta máquina — tok/s reales, un modelo a la vez.",
|
||||
"bench.run": "Medir",
|
||||
"bench.running": "midiendo…",
|
||||
"bench.failed": "falló",
|
||||
"bench.none": "Aún no hay modelos de chat .cmf locales.",
|
||||
};
|
||||
|
||||
Vendored
+39
@@ -506,4 +506,43 @@ export default {
|
||||
"pg.noMediaModels": "Pas encore de modèle {kind} local. Téléchargez-en un (par ex. de la collection infosave) dans Importer :",
|
||||
"pg.tokSpeed": "Vitesse",
|
||||
"pg.ttft": "Premier token",
|
||||
|
||||
// ops: logs, system, notices, gallery, chat, snippets, bench
|
||||
"nav.logs": "Journaux",
|
||||
"logs.subtitle": "Journal récent de la passerelle — ce que montrerait le terminal.",
|
||||
"logs.filterPlaceholder": "filtrer…",
|
||||
"logs.pause": "Pause",
|
||||
"logs.resume": "Reprendre",
|
||||
"logs.empty": "Rien ne correspond.",
|
||||
"notice.openAdmin": "La console est ouverte sans jeton et accessible depuis d'autres machines. Définissez un jeton si cette machine n'est pas privée.",
|
||||
"notice.update": "Mise à jour disponible : v{v}.",
|
||||
"dash.sys.title": "Système",
|
||||
"dash.sys.cores": "cœurs",
|
||||
"dash.sys.disk": "Disque (modèles)",
|
||||
"dash.sys.free": "libre",
|
||||
"dash.sys.gateway": "passerelle",
|
||||
"fit.disk": "espace disque insuffisant",
|
||||
"fit.diskTitle": "Le téléchargement dépasse l'espace libre du disque des modèles.",
|
||||
"fit.ram": "risque de ne pas tenir en RAM",
|
||||
"fit.ramTitle": "Le modèle approche ou dépasse la RAM de cette machine — il pourrait ne pas tourner.",
|
||||
"pg.eta": "restant",
|
||||
"pg.min": "min",
|
||||
"pg.queued": "En file d'attente — position {n}. Démarre dès la fin de la génération en cours.",
|
||||
"pg.gallery": "Générations récentes",
|
||||
"pg.galDeleteConfirm": "Supprimer cette génération et ses fichiers ?",
|
||||
"pg.clearChat": "Effacer",
|
||||
"keys.snip.title": "Connecter votre application",
|
||||
"keys.snip.hint": "Collez un de ces exemples dans votre projet — seul le espace réservé de la clé est à remplacer.",
|
||||
"keys.snip.withKey": "Les exemples ci-dessous incluent la clé que vous venez de créer.",
|
||||
"keys.snip.openMode": "Aucune clé API configurée — la passerelle accepte n'importe quelle valeur.",
|
||||
"keys.snip.keyPh": "votre clé API",
|
||||
"settings.restart": "Redémarrer la passerelle",
|
||||
"settings.restartConfirm": "Redémarrer la passerelle ? Les requêtes en cours seront coupées ; les serveurs de modèles locaux redémarrent aussi.",
|
||||
"settings.restarting": "Redémarrage — la page se recharge dès que la passerelle revient…",
|
||||
"bench.title": "Vitesse des modèles locaux",
|
||||
"bench.hint": "Exécute `cortiq bench` sur cette machine — de vrais tok/s, un modèle à la fois.",
|
||||
"bench.run": "Mesurer",
|
||||
"bench.running": "mesure…",
|
||||
"bench.failed": "échec",
|
||||
"bench.none": "Pas encore de modèles de chat .cmf locaux.",
|
||||
};
|
||||
|
||||
Vendored
+39
@@ -498,4 +498,43 @@ export default {
|
||||
"pg.noMediaModels": "Локальной модели ({kind}) ещё нет. Скачайте её (например, из коллекции infosave) во вкладке Импорт:",
|
||||
"pg.tokSpeed": "Скорость",
|
||||
"pg.ttft": "Первый токен",
|
||||
|
||||
// ops: logs, system, notices, gallery, chat, snippets, bench
|
||||
"nav.logs": "Логи",
|
||||
"logs.subtitle": "Последние записи журнала шлюза — то, что показал бы терминал.",
|
||||
"logs.filterPlaceholder": "фильтр…",
|
||||
"logs.pause": "Пауза",
|
||||
"logs.resume": "Продолжить",
|
||||
"logs.empty": "Ничего не найдено.",
|
||||
"notice.openAdmin": "Панель управления открыта без токена и доступна с других машин. Задайте токен, если эта машина не приватная.",
|
||||
"notice.update": "Доступно обновление: v{v}.",
|
||||
"dash.sys.title": "Система",
|
||||
"dash.sys.cores": "ядер",
|
||||
"dash.sys.disk": "Диск (модели)",
|
||||
"dash.sys.free": "свободно",
|
||||
"dash.sys.gateway": "шлюз",
|
||||
"fit.disk": "не хватит места",
|
||||
"fit.diskTitle": "Загрузка больше свободного места на диске с моделями.",
|
||||
"fit.ram": "может не влезть в RAM",
|
||||
"fit.ramTitle": "Модель сравнима с объёмом RAM этой машины или больше — может не запуститься.",
|
||||
"pg.eta": "осталось",
|
||||
"pg.min": "мин",
|
||||
"pg.queued": "В очереди — позиция {n}. Стартует, как только закончится текущая генерация.",
|
||||
"pg.gallery": "Последние генерации",
|
||||
"pg.galDeleteConfirm": "Удалить эту генерацию и её файлы?",
|
||||
"pg.clearChat": "Очистить",
|
||||
"keys.snip.title": "Подключите ваше приложение",
|
||||
"keys.snip.hint": "Вставьте один из примеров в свой проект — заменить нужно только плейсхолдер ключа.",
|
||||
"keys.snip.withKey": "В примерах ниже уже подставлен только что созданный ключ.",
|
||||
"keys.snip.openMode": "API-ключи не настроены — шлюз принимает любое значение ключа.",
|
||||
"keys.snip.keyPh": "ваш API-ключ",
|
||||
"settings.restart": "Перезапустить шлюз",
|
||||
"settings.restartConfirm": "Перезапустить шлюз? Текущие запросы оборвутся; серверы локальных моделей тоже перезапустятся.",
|
||||
"settings.restarting": "Перезапуск — страница обновится, когда шлюз поднимется…",
|
||||
"bench.title": "Скорость локальных моделей",
|
||||
"bench.hint": "Запускает `cortiq bench` на этой машине — реальные tok/s, по одной модели за раз.",
|
||||
"bench.run": "Замерить",
|
||||
"bench.running": "замер…",
|
||||
"bench.failed": "ошибка",
|
||||
"bench.none": "Локальных чат-моделей .cmf пока нет.",
|
||||
};
|
||||
|
||||
Vendored
+39
@@ -506,4 +506,43 @@ export default {
|
||||
"pg.noMediaModels": "Henüz yerel bir {kind} modeli yok. İçe aktarma sekmesinden bir tane indirin (ör. infosave koleksiyonundan):",
|
||||
"pg.tokSpeed": "Hız",
|
||||
"pg.ttft": "İlk token",
|
||||
|
||||
// ops: logs, system, notices, gallery, chat, snippets, bench
|
||||
"nav.logs": "Günlükler",
|
||||
"logs.subtitle": "Ağ geçidinin son günlüğü — terminalde görecekleriniz.",
|
||||
"logs.filterPlaceholder": "filtrele…",
|
||||
"logs.pause": "Duraklat",
|
||||
"logs.resume": "Devam et",
|
||||
"logs.empty": "Eşleşen yok.",
|
||||
"notice.openAdmin": "Yönetim konsolu belirteçsiz açık ve diğer makinelerden erişilebilir. Bu makine özel değilse bir belirteç ayarlayın.",
|
||||
"notice.update": "Güncelleme mevcut: v{v}.",
|
||||
"dash.sys.title": "Sistem",
|
||||
"dash.sys.cores": "çekirdek",
|
||||
"dash.sys.disk": "Disk (modeller)",
|
||||
"dash.sys.free": "boş",
|
||||
"dash.sys.gateway": "ağ geçidi",
|
||||
"fit.disk": "disk alanı yetmez",
|
||||
"fit.diskTitle": "İndirme, model diskindeki boş alandan büyük.",
|
||||
"fit.ram": "RAM'e sığmayabilir",
|
||||
"fit.ramTitle": "Model bu makinenin RAM'ine yakın ya da ondan büyük — çalışmayabilir.",
|
||||
"pg.eta": "kaldı",
|
||||
"pg.min": "dk",
|
||||
"pg.queued": "Kuyrukta — {n}. sırada. Mevcut üretim biter bitmez başlar.",
|
||||
"pg.gallery": "Son üretimler",
|
||||
"pg.galDeleteConfirm": "Bu üretim ve dosyaları silinsin mi?",
|
||||
"pg.clearChat": "Temizle",
|
||||
"keys.snip.title": "Uygulamanı bağla",
|
||||
"keys.snip.hint": "Örneklerden birini projene yapıştır — yalnızca anahtar yer tutucusunu değiştirmen gerekir.",
|
||||
"keys.snip.withKey": "Aşağıdaki örnekler az önce oluşturduğun anahtarı içeriyor.",
|
||||
"keys.snip.openMode": "API anahtarı yapılandırılmadı — ağ geçidi her değeri kabul eder.",
|
||||
"keys.snip.keyPh": "API anahtarın",
|
||||
"settings.restart": "Ağ geçidini yeniden başlat",
|
||||
"settings.restartConfirm": "Ağ geçidi yeniden başlatılsın mı? Devam eden istekler kesilir; yerel model sunucuları da yeniden başlar.",
|
||||
"settings.restarting": "Yeniden başlatılıyor — ağ geçidi dönünce sayfa yenilenecek…",
|
||||
"bench.title": "Yerel model hızı",
|
||||
"bench.hint": "Bu makinede `cortiq bench` çalıştırır — gerçek tok/s, aynı anda tek model.",
|
||||
"bench.run": "Ölç",
|
||||
"bench.running": "ölçülüyor…",
|
||||
"bench.failed": "başarısız",
|
||||
"bench.none": "Henüz yerel .cmf sohbet modeli yok.",
|
||||
};
|
||||
|
||||
Vendored
+39
@@ -506,4 +506,43 @@ export default {
|
||||
"pg.noMediaModels": "还没有本地{kind}模型。请在“导入模型”中下载一个(例如 infosave 合集):",
|
||||
"pg.tokSpeed": "速度",
|
||||
"pg.ttft": "首个 token",
|
||||
|
||||
// ops: logs, system, notices, gallery, chat, snippets, bench
|
||||
"nav.logs": "日志",
|
||||
"logs.subtitle": "网关最近的日志 — 终端里会看到的内容。",
|
||||
"logs.filterPlaceholder": "过滤……",
|
||||
"logs.pause": "暂停",
|
||||
"logs.resume": "继续",
|
||||
"logs.empty": "没有匹配内容。",
|
||||
"notice.openAdmin": "管理控制台未设置令牌即可从其他机器访问。若这台机器不是私有的,请设置令牌。",
|
||||
"notice.update": "有可用更新:v{v}。",
|
||||
"dash.sys.title": "系统",
|
||||
"dash.sys.cores": "核",
|
||||
"dash.sys.disk": "磁盘(模型)",
|
||||
"dash.sys.free": "可用",
|
||||
"dash.sys.gateway": "网关",
|
||||
"fit.disk": "磁盘空间不足",
|
||||
"fit.diskTitle": "下载大小超过模型磁盘的可用空间。",
|
||||
"fit.ram": "可能超出内存",
|
||||
"fit.ramTitle": "模型接近或超过本机内存 — 可能无法运行。",
|
||||
"pg.eta": "剩余",
|
||||
"pg.min": "分钟",
|
||||
"pg.queued": "排队中 — 第 {n} 位。当前生成结束后立即开始。",
|
||||
"pg.gallery": "最近的生成",
|
||||
"pg.galDeleteConfirm": "删除这次生成及其文件?",
|
||||
"pg.clearChat": "清空",
|
||||
"keys.snip.title": "接入你的应用",
|
||||
"keys.snip.hint": "把示例粘贴到你的项目里 — 只需替换密钥占位符。",
|
||||
"keys.snip.withKey": "下面的示例已包含你刚创建的密钥。",
|
||||
"keys.snip.openMode": "未配置 API 密钥 — 网关接受任意密钥值。",
|
||||
"keys.snip.keyPh": "你的 API 密钥",
|
||||
"settings.restart": "重启网关",
|
||||
"settings.restartConfirm": "重启网关?进行中的请求会中断;本地模型服务器也会重启。",
|
||||
"settings.restarting": "正在重启 — 网关恢复后页面会自动刷新……",
|
||||
"bench.title": "本地模型速度",
|
||||
"bench.hint": "在本机运行 `cortiq bench` — 真实 tok/s,一次一个模型。",
|
||||
"bench.run": "测速",
|
||||
"bench.running": "测速中……",
|
||||
"bench.failed": "失败",
|
||||
"bench.none": "还没有本地 .cmf 聊天模型。",
|
||||
};
|
||||
|
||||
Vendored
+83
@@ -27,6 +27,87 @@ export async function renderDashboard() {
|
||||
let groupby = "model";
|
||||
const root = h("div");
|
||||
|
||||
// ── host metrics: CPU sparkline + RAM / disk bars, refreshed every 2 s ──
|
||||
const sysHost = h("div", {});
|
||||
const cpuHistory = []; // last ~60 samples (2 min)
|
||||
function fmtG(n) {
|
||||
return (n / 1e9).toFixed(1) + " GB";
|
||||
}
|
||||
function bar(frac, cls) {
|
||||
return h("div", { class: "sysbar" }, h("i", { class: cls || "", style: `width:${Math.min(100, frac * 100)}%` }));
|
||||
}
|
||||
function sparkline(values, w, hgt) {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = w;
|
||||
c.height = hgt;
|
||||
const ctx = c.getContext("2d");
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#5b8def";
|
||||
ctx.strokeStyle = accent;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
const n = Math.max(values.length, 2);
|
||||
values.forEach((v, i) => {
|
||||
const x = (i / (n - 1)) * (w - 2) + 1;
|
||||
const y = hgt - 2 - (Math.min(v, 100) / 100) * (hgt - 4);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
return c;
|
||||
}
|
||||
async function refreshSystem() {
|
||||
let sys;
|
||||
try {
|
||||
sys = await api.system();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!sys.ts) return;
|
||||
cpuHistory.push(sys.cpu_percent || 0);
|
||||
if (cpuHistory.length > 60) cpuHistory.shift();
|
||||
const ramFrac = sys.ram_total ? sys.ram_used / sys.ram_total : 0;
|
||||
const diskFrac = sys.disk_total ? (sys.disk_total - sys.disk_free) / sys.disk_total : 0;
|
||||
mount(
|
||||
sysHost,
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-title" }, t("dash.sys.title")),
|
||||
h(
|
||||
"div",
|
||||
{ class: "sysgrid" },
|
||||
h("div", { class: "sysitem" },
|
||||
h("div", { class: "flex" },
|
||||
h("span", { class: "k grow" }, `CPU · ${sys.cores} ${t("dash.sys.cores")}`),
|
||||
h("span", { class: "v mono" }, (sys.cpu_percent || 0).toFixed(0) + "%")),
|
||||
sparkline(cpuHistory, 220, 36),
|
||||
h("div", { class: "muted small" }, `load ${Number(sys.load1 || 0).toFixed(1)}`)),
|
||||
h("div", { class: "sysitem" },
|
||||
h("div", { class: "flex" },
|
||||
h("span", { class: "k grow" }, "RAM"),
|
||||
h("span", { class: "v mono" }, `${fmtG(sys.ram_used)} / ${fmtG(sys.ram_total)}`)),
|
||||
bar(ramFrac, ramFrac > 0.9 ? "bad" : ""),
|
||||
h("div", { class: "muted small" }, t("dash.sys.gateway") + " " + (sys.process_rss / 1e6).toFixed(0) + " MB")),
|
||||
h("div", { class: "sysitem" },
|
||||
h("div", { class: "flex" },
|
||||
h("span", { class: "k grow" }, t("dash.sys.disk")),
|
||||
h("span", { class: "v mono" }, t("dash.sys.free") + " " + fmtG(sys.disk_free)),
|
||||
),
|
||||
bar(diskFrac, diskFrac > 0.92 ? "bad" : ""),
|
||||
h("div", { class: "muted small" }, `${fmtG(sys.disk_total - sys.disk_free)} / ${fmtG(sys.disk_total)}`))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
const sysTimer = setInterval(() => {
|
||||
if (!root.isConnected) {
|
||||
clearInterval(sysTimer);
|
||||
return;
|
||||
}
|
||||
refreshSystem();
|
||||
}, 2000);
|
||||
refreshSystem();
|
||||
|
||||
async function load() {
|
||||
const rangeSecs = range;
|
||||
let stats, recent, health;
|
||||
@@ -86,6 +167,8 @@ export async function renderDashboard() {
|
||||
kpi(num(tot.failovers), t("dash.kpi.failovers"))
|
||||
),
|
||||
|
||||
sysHost,
|
||||
|
||||
h(
|
||||
"div",
|
||||
{ class: "grid cols-2" },
|
||||
|
||||
Vendored
+16
-2
@@ -78,7 +78,19 @@ function descLine(desc) {
|
||||
return desc && desc.includes(" ") ? h("div", { class: "muted small", style: "margin:6px 0" }, desc) : null;
|
||||
}
|
||||
|
||||
/// One model. `ctx` = { hosts: Map(jobId → element), onDownload(m, v), onDelete(m, v) }.
|
||||
/// Disk/RAM sanity for a model of `bytes` size against the live system
|
||||
/// snapshot (ctx.sys): running a model needs it in RAM; downloading needs disk.
|
||||
function fitWarnings(bytes, sys) {
|
||||
const out = [];
|
||||
if (!bytes || !sys) return out;
|
||||
if (sys.disk_free && bytes > sys.disk_free)
|
||||
out.push(h("span", { class: "badge bad", title: t("fit.diskTitle") }, "⚠ " + t("fit.disk")));
|
||||
else if (sys.ram_total && bytes > sys.ram_total * 0.75)
|
||||
out.push(h("span", { class: "badge warn", title: t("fit.ramTitle") }, "⚠ " + t("fit.ram")));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// One model. `ctx` = { hosts: Map(jobId → element), onDownload(m, v), onDelete(m, v), sys? }.
|
||||
/// Live progress lands in the per-variant host so a poll can repaint it in place,
|
||||
/// without re-rendering the grid under the user's cursor.
|
||||
export function featuredCard(m, ctx) {
|
||||
@@ -101,6 +113,7 @@ export function featuredCard(m, ctx) {
|
||||
h("div", { class: "hf-meta" },
|
||||
h("span", { class: "badge" }, badge),
|
||||
descBadge(desc),
|
||||
...fitWarnings(m.size_bytes, ctx.sys),
|
||||
isDownloaded ? h("span", { class: "badge ok" }, "✓ " + t("onboarding.downloaded")) : null),
|
||||
descLine(desc),
|
||||
h("div", { class: "muted small" }, m.id),
|
||||
@@ -149,7 +162,8 @@ export function featuredCard(m, ctx) {
|
||||
h("div", { class: "hf-meta" },
|
||||
h("span", { class: "badge" }, badge),
|
||||
size0 ? h("span", { class: "badge", style: "background:var(--bg); border-style:dashed" + (done0 ? "; opacity:.7" : "") }, (done0 ? "✓ " : "⬇ ") + size0) : null,
|
||||
descBadge(desc)
|
||||
descBadge(desc),
|
||||
...(done0 ? [] : fitWarnings((v0 && v0.size_bytes) || m.size_bytes, ctx.sys))
|
||||
),
|
||||
descLine(desc),
|
||||
h("div", { class: "muted small" }, m.id),
|
||||
|
||||
Vendored
+3
-1
@@ -424,8 +424,10 @@ export function renderImport() {
|
||||
|
||||
async function renderFeatured(reload = true) {
|
||||
if (reload) featured = await loadFeatured();
|
||||
let sys = null;
|
||||
try { sys = await api.system(); } catch { /* metrics optional */ }
|
||||
featuredHosts = new Map();
|
||||
const ctx = { hosts: featuredHosts, onDownload: startFeatured, onDelete: deleteFeatured };
|
||||
const ctx = { hosts: featuredHosts, onDownload: startFeatured, onDelete: deleteFeatured, sys };
|
||||
mount(featuredGrid, ...(featured.length
|
||||
? featured.map((m) => featuredCard(m, ctx))
|
||||
: [h("div", { class: "muted pad" }, t("import.noResults"))]));
|
||||
|
||||
Vendored
+64
-1
@@ -48,6 +48,68 @@ export async function renderKeys() {
|
||||
const input = h("input", { value: key, readonly: true, class: "mono" });
|
||||
const copyBtn = h("button", { class: "btn", onClick: () => { input.select(); navigator.clipboard?.writeText(key); toast(t("common.copied"), "good"); } }, t("common.copy"));
|
||||
modal(t("common.ok"), h("div", {}, h("p", {}, t("keys.created")), h("div", { class: "flex" }, input, copyBtn)), null);
|
||||
lastCreatedKey = key;
|
||||
reload();
|
||||
}
|
||||
|
||||
let lastCreatedKey = null;
|
||||
|
||||
// "Connect your app" — copy-paste snippets with this gateway's real URL.
|
||||
// Keys are stored hashed-masked in the list, so the key itself is a
|
||||
// placeholder unless one was just created on this page.
|
||||
function snippetsCard(openMode) {
|
||||
const base = location.origin + "/v1";
|
||||
const key = lastCreatedKey || (openMode ? "sk-anything" : "<" + t("keys.snip.keyPh") + ">");
|
||||
const snips = {
|
||||
curl: `curl ${base}/chat/completions \\
|
||||
-H "Authorization: Bearer ${key}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"model":"cortiq-auto","messages":[{"role":"user","content":"Привет!"}]}'`,
|
||||
python: `from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url="${base}", api_key="${key}")
|
||||
resp = client.chat.completions.create(
|
||||
model="cortiq-auto", # авто-роутинг; или точное имя модели
|
||||
messages=[{"role": "user", "content": "Привет!"}],
|
||||
)
|
||||
print(resp.choices[0].message.content)`,
|
||||
javascript: `import OpenAI from "openai";
|
||||
|
||||
const client = new OpenAI({ baseURL: "${base}", apiKey: "${key}" });
|
||||
const resp = await client.chat.completions.create({
|
||||
model: "cortiq-auto",
|
||||
messages: [{ role: "user", content: "Привет!" }],
|
||||
});
|
||||
console.log(resp.choices[0].message.content);`,
|
||||
};
|
||||
let cur = "curl";
|
||||
const pre = h("pre", { class: "conv-log snippet" }, snips[cur]);
|
||||
const seg = h(
|
||||
"div",
|
||||
{ class: "seg" },
|
||||
...Object.keys(snips).map((k, i) =>
|
||||
h("button", { class: i === 0 ? "active" : "", onClick: (e) => {
|
||||
cur = k;
|
||||
pre.textContent = snips[k];
|
||||
seg.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||||
e.target.classList.add("active");
|
||||
} }, k)
|
||||
)
|
||||
);
|
||||
const copy = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy"));
|
||||
copy.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(pre.textContent);
|
||||
toast(t("common.copied"), "ok");
|
||||
});
|
||||
return h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-title" }, "🔌 " + t("keys.snip.title")),
|
||||
h("div", { class: "muted small", style: "margin-bottom:8px" },
|
||||
lastCreatedKey ? t("keys.snip.withKey") : openMode ? t("keys.snip.openMode") : t("keys.snip.hint")),
|
||||
h("div", { class: "flex", style: "gap:8px; margin-bottom:8px" }, seg, h("div", { class: "grow" }), copy),
|
||||
pre
|
||||
);
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
@@ -119,7 +181,8 @@ export async function renderKeys() {
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
snippetsCard(!!data.open_mode)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+84
@@ -0,0 +1,84 @@
|
||||
// Gateway log viewer — the in-memory ring served by /admin/api/logs, for
|
||||
// everyone who never sees the terminal (Docker, autostart, double-clicked exe).
|
||||
import { h, mount, toast } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
|
||||
export function renderLogs() {
|
||||
const root = h("div");
|
||||
let timer = null;
|
||||
let paused = false;
|
||||
let filter = "";
|
||||
let level = "";
|
||||
|
||||
const pre = h("pre", { class: "conv-log logs-view" }, t("common.loading"));
|
||||
const filterIn = h("input", { placeholder: t("logs.filterPlaceholder"), style: "max-width:260px" });
|
||||
const levelSel = h(
|
||||
"select",
|
||||
{ style: "max-width:130px" },
|
||||
h("option", { value: "" }, t("common.all")),
|
||||
...["ERROR", "WARN", "INFO", "DEBUG"].map((l) => h("option", { value: l }, l))
|
||||
);
|
||||
const pauseBtn = h("button", { class: "btn ghost sm" }, "⏸ " + t("logs.pause"));
|
||||
const copyBtn = h("button", { class: "btn ghost sm" }, "⧉ " + t("common.copy"));
|
||||
|
||||
let lines = [];
|
||||
function paint() {
|
||||
let shown = lines;
|
||||
if (level) shown = shown.filter((l) => l.includes(` ${level} `) || l.includes(`${level} `));
|
||||
if (filter) shown = shown.filter((l) => l.toLowerCase().includes(filter));
|
||||
pre.textContent = shown.length ? shown.join("\n") : t("logs.empty");
|
||||
if (!paused) pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
async function refresh() {
|
||||
if (paused) return;
|
||||
try {
|
||||
lines = (await api.logs(500)).lines || [];
|
||||
paint();
|
||||
} catch {
|
||||
/* transient */
|
||||
}
|
||||
}
|
||||
filterIn.addEventListener("input", () => {
|
||||
filter = filterIn.value.trim().toLowerCase();
|
||||
paint();
|
||||
});
|
||||
levelSel.addEventListener("change", () => {
|
||||
level = levelSel.value;
|
||||
paint();
|
||||
});
|
||||
pauseBtn.addEventListener("click", () => {
|
||||
paused = !paused;
|
||||
pauseBtn.textContent = (paused ? "▶ " : "⏸ ") + t(paused ? "logs.resume" : "logs.pause");
|
||||
if (!paused) refresh();
|
||||
});
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(pre.textContent);
|
||||
toast(t("common.copied"), "ok");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "err");
|
||||
}
|
||||
});
|
||||
|
||||
timer = setInterval(() => {
|
||||
if (!root.isConnected) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
}, 2000);
|
||||
refresh();
|
||||
|
||||
mount(
|
||||
root,
|
||||
h("div", { class: "view-head" }, h("h2", {}, t("nav.logs")), h("div", { class: "muted" }, t("logs.subtitle"))),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "flex", style: "gap:8px; flex-wrap:wrap; margin-bottom:10px" }, filterIn, levelSel, pauseBtn, copyBtn),
|
||||
pre
|
||||
)
|
||||
);
|
||||
return root;
|
||||
}
|
||||
Vendored
+54
-1
@@ -315,10 +315,63 @@ export async function renderModels() {
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
benchCard()
|
||||
);
|
||||
}
|
||||
|
||||
// ── one-click benchmark of local .cmf files (cortiq bench --json) ──
|
||||
function benchCard() {
|
||||
const host = h("div", { class: "card" }, h("div", { class: "card-title" }, "⏱ " + t("bench.title")),
|
||||
h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bench.hint")),
|
||||
h("div", { class: "empty" }, t("common.loading")));
|
||||
let timer = null;
|
||||
async function paint() {
|
||||
let files = [];
|
||||
let runs = {};
|
||||
try {
|
||||
files = (await api.mediaModels()).models || [];
|
||||
for (const r of (await api.benchList()).runs || []) runs[r.model] = r;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const chat = files.filter((m) => m.modality === "chat");
|
||||
const anyRunning = Object.values(runs).some((r) => r.state === "running");
|
||||
const rows = chat.map((m) => {
|
||||
const r = runs[m.name];
|
||||
const go = h("button", { class: "btn sm ghost", disabled: anyRunning ? true : null }, "⏱ " + t("bench.run"));
|
||||
go.addEventListener("click", async () => {
|
||||
try {
|
||||
await api.benchStart(m.name);
|
||||
paint();
|
||||
if (!timer) timer = setInterval(() => {
|
||||
if (!host.isConnected) { clearInterval(timer); return; }
|
||||
paint();
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
});
|
||||
let result = h("span", { class: "muted" }, "—");
|
||||
if (r?.state === "running") result = h("span", { class: "flex" }, h("span", { class: "spinner" }), " ", t("bench.running"));
|
||||
else if (r?.state === "done") result = h("b", { class: "mono" }, (r.tok_s != null ? r.tok_s.toFixed(1) : "?") + " tok/s");
|
||||
else if (r?.state === "error") result = h("span", { class: "err", title: (r.log || []).slice(-2).join(" · ") }, t("bench.failed"));
|
||||
return h("div", { class: "flex bench-row" },
|
||||
h("span", { class: "mono grow" }, m.name),
|
||||
h("span", { class: "muted small" }, (m.size_bytes / 1e9).toFixed(2) + " GB"),
|
||||
result,
|
||||
go);
|
||||
});
|
||||
mount(host,
|
||||
h("div", { class: "card-title" }, "⏱ " + t("bench.title")),
|
||||
h("div", { class: "muted small", style: "margin-bottom:8px" }, t("bench.hint")),
|
||||
...(rows.length ? rows : [h("div", { class: "empty" }, t("bench.none"))]));
|
||||
if (!anyRunning && timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
paint();
|
||||
return host;
|
||||
}
|
||||
|
||||
await reload();
|
||||
return root;
|
||||
}
|
||||
|
||||
Vendored
+5
-2
@@ -45,6 +45,7 @@ export function renderOnboarding() {
|
||||
const root = h("div", { class: "onb-wrap" });
|
||||
let jobsPoll = null;
|
||||
let jobs = [];
|
||||
let sysSnapshot = null; // disk/RAM для плашек "не влезет" на карточках
|
||||
let jobStates = new Map(); // job id → last seen state, to spot completions
|
||||
let hosts = new Map(); // job id → card progress host (rebuilt on every render)
|
||||
let jobsHost = h("div", {});
|
||||
@@ -131,6 +132,7 @@ export function renderOnboarding() {
|
||||
|
||||
async function loadFeatured() {
|
||||
featured = await fetchFeatured();
|
||||
try { sysSnapshot = await api.system(); } catch { /* optional */ }
|
||||
featuredLoaded = true;
|
||||
}
|
||||
|
||||
@@ -199,7 +201,7 @@ export function renderOnboarding() {
|
||||
);
|
||||
} else if (step === 1) {
|
||||
const top = topNav(prev, next, h("button", { class: "btn ghost", onClick: () => { step = 3; render(); } }, t("onboarding.skip")));
|
||||
const ctx = { hosts, onDownload: startDownload, onDelete: handleDelete };
|
||||
const ctx = { hosts, onDownload: startDownload, onDelete: handleDelete, sys: sysSnapshot };
|
||||
// the list is fetched from HuggingFace — say so instead of showing a blank grid
|
||||
const grid = featured.length
|
||||
? h("div", { class: "hf-grid" }, ...featured.map((m) => featuredCard(m, ctx)))
|
||||
@@ -290,7 +292,8 @@ export function renderOnboarding() {
|
||||
// let the toast be readable before the reload swaps the page
|
||||
await new Promise((res) => setTimeout(res, 900));
|
||||
}
|
||||
location.hash = "#/models";
|
||||
// land where the reward is: try the model right away
|
||||
location.hash = "#/playground";
|
||||
location.reload();
|
||||
});
|
||||
const skipBtn = h("button", { class: "btn ghost", onClick: async () => {
|
||||
|
||||
Vendored
+172
-46
@@ -91,16 +91,33 @@ export async function renderPlayground() {
|
||||
modeExtra = next;
|
||||
}
|
||||
|
||||
const out = h("div", { class: "answer" }, h("div", { class: "empty" }, t("pg.empty")));
|
||||
const chatLog = []; // the running conversation: {role, content}
|
||||
const out = h("div", { class: "answer chat-log" }, h("div", { class: "empty" }, t("pg.empty")));
|
||||
const decision = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
|
||||
|
||||
function bubble(role, text) {
|
||||
return h("div", { class: "chat-msg " + role }, h("div", { class: "chat-bubble" }, text));
|
||||
}
|
||||
function renderChat(liveText) {
|
||||
const items = chatLog.map((m) => bubble(m.role, m.content));
|
||||
if (liveText != null) items.push(bubble("assistant", liveText || "…"));
|
||||
mount(out, ...(items.length ? items : [h("div", { class: "empty" }, t("pg.empty"))]));
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
const clearBtn = h("button", { class: "btn ghost sm" }, "🧹 " + t("pg.clearChat"));
|
||||
clearBtn.addEventListener("click", () => {
|
||||
chatLog.length = 0;
|
||||
renderChat();
|
||||
mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
|
||||
});
|
||||
|
||||
const sendBtn = h("button", { class: "btn primary" }, t("pg.send"));
|
||||
sendBtn.addEventListener("click", async () => {
|
||||
const prompt = promptIn.value.trim();
|
||||
if (!prompt) return;
|
||||
const messages = [];
|
||||
if (sysIn.value.trim()) messages.push({ role: "system", content: sysIn.value.trim() });
|
||||
messages.push({ role: "user", content: prompt });
|
||||
messages.push(...chatLog, { role: "user", content: prompt });
|
||||
let model = "cortiq-auto";
|
||||
if (mode === "auto") {
|
||||
if (profileSel.value) model = "cortiq-auto:" + profileSel.value;
|
||||
@@ -109,7 +126,9 @@ export async function renderPlayground() {
|
||||
}
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = t("pg.sending");
|
||||
mount(out, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.sending")));
|
||||
chatLog.push({ role: "user", content: prompt });
|
||||
promptIn.value = "";
|
||||
renderChat("");
|
||||
const body = {
|
||||
model,
|
||||
messages,
|
||||
@@ -120,12 +139,12 @@ export async function renderPlayground() {
|
||||
if (streamCb.checked) {
|
||||
const started = performance.now();
|
||||
let acc = "";
|
||||
out.textContent = "";
|
||||
const info = await testStream(body, (d) => {
|
||||
acc += d;
|
||||
out.textContent = acc;
|
||||
renderChat(acc);
|
||||
});
|
||||
out.textContent = acc || "—";
|
||||
chatLog.push({ role: "assistant", content: acc || "—" });
|
||||
renderChat();
|
||||
const latency = Math.round(performance.now() - started);
|
||||
renderDecision({
|
||||
cortiq: {
|
||||
@@ -144,19 +163,33 @@ export async function renderPlayground() {
|
||||
} else {
|
||||
const r = await api.test(body);
|
||||
if (r.ok) {
|
||||
mount(out, r.answer || h("span", { class: "muted" }, "—"));
|
||||
chatLog.push({ role: "assistant", content: r.answer || "—" });
|
||||
renderChat();
|
||||
renderDecision(r);
|
||||
} else {
|
||||
mount(out, h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || ""));
|
||||
chatLog.pop(); // the user turn failed — let them retry it
|
||||
renderChat();
|
||||
promptIn.value = prompt;
|
||||
mount(out, out.firstChild || "", h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || ""));
|
||||
mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
mount(out, h("div", { class: "callout" }, String(e.message || e)));
|
||||
chatLog.pop();
|
||||
renderChat();
|
||||
promptIn.value = prompt;
|
||||
toast(String(e.message || e), "err");
|
||||
}
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = t("pg.send");
|
||||
});
|
||||
// Cmd/Ctrl+Enter sends
|
||||
promptIn.addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
sendBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
function renderDecision(r) {
|
||||
const c = r.cortiq || {};
|
||||
@@ -207,9 +240,9 @@ export async function renderPlayground() {
|
||||
h("div", { class: "row" }, field(t("pg.temp"), tempIn), field(t("pg.maxTokens"), maxTokIn)),
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex", style: "justify-content:space-between" },
|
||||
{ class: "flex", style: "justify-content:space-between; gap:8px" },
|
||||
h("label", { class: "check", style: "margin:0" }, streamCb, t("pg.stream")),
|
||||
sendBtn
|
||||
h("div", { class: "flex", style: "gap:8px" }, clearBtn, sendBtn)
|
||||
)
|
||||
),
|
||||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.answer"))), out)
|
||||
@@ -235,8 +268,24 @@ export async function renderPlayground() {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const secs = ((performance.now() - started) / 1000).toFixed(0);
|
||||
const elapsed = (performance.now() - started) / 1000;
|
||||
const pct = j.progress != null ? Math.round(j.progress * 100) : null;
|
||||
// honest ETA once there is enough signal: total ≈ elapsed / progress
|
||||
let eta = "";
|
||||
if (j.state === "running" && j.progress > 0.03) {
|
||||
const left = elapsed / j.progress - elapsed;
|
||||
if (left > 1) eta = ` · ${t("pg.eta")} ~${left > 90 ? Math.round(left / 60) + " " + t("pg.min") : Math.round(left) + " s"}`;
|
||||
}
|
||||
let statusLine;
|
||||
if (j.state === "queued") {
|
||||
const pos = (j.queue_position ?? 0) + 1;
|
||||
statusLine = t("pg.queued", { n: pos });
|
||||
} else {
|
||||
statusLine =
|
||||
(j.phase || j.state) +
|
||||
(pct != null && j.state === "running" ? ` · ${pct}%` : "") +
|
||||
` · ${elapsed.toFixed(0)}s` + eta;
|
||||
}
|
||||
mount(
|
||||
ui.bar,
|
||||
h("div", { class: "pbar" },
|
||||
@@ -244,13 +293,14 @@ export async function renderPlayground() {
|
||||
? (pct != null
|
||||
? h("div", { class: "pbar-fill det", style: `width:${Math.max(pct, 1)}%` })
|
||||
: h("div", { class: "pbar-fill run" }))
|
||||
: h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" })
|
||||
: j.state === "queued"
|
||||
? h("div", { class: "pbar-fill run" })
|
||||
: h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" })
|
||||
),
|
||||
h("div", { class: "muted small" },
|
||||
(j.phase || j.state) + (pct != null && j.state === "running" ? ` · ${pct}%` : "") + ` · ${secs}s`),
|
||||
(j.log || []).length ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null
|
||||
h("div", { class: "muted small" }, statusLine),
|
||||
(j.log || []).length && j.state === "running" ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null
|
||||
);
|
||||
if (j.state !== "running") {
|
||||
if (j.state !== "running" && j.state !== "queued") {
|
||||
stopMediaPoll();
|
||||
ui.goBtn.disabled = false;
|
||||
ui.cancelBtn.style.display = "none";
|
||||
@@ -286,6 +336,65 @@ export async function renderPlayground() {
|
||||
return b;
|
||||
}
|
||||
|
||||
// ── gallery: past generations of one kind, loaded lazily ──
|
||||
function buildGallery(kind, onOpen) {
|
||||
const host = h("div", {});
|
||||
async function refresh() {
|
||||
let jobs = [];
|
||||
try {
|
||||
jobs = ((await api.mediaJobs()).jobs || []).filter((j) => j.kind === kind && j.state === "done");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!jobs.length) {
|
||||
mount(host);
|
||||
return;
|
||||
}
|
||||
const grid = h("div", { class: "gal-grid" });
|
||||
for (const j of jobs.slice(0, 12)) {
|
||||
const del = h("button", { class: "gal-del", title: t("import.delete") }, "×");
|
||||
del.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm(t("pg.galDeleteConfirm"))) return;
|
||||
try {
|
||||
await api.mediaDelete(j.id);
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast(err.message, "err");
|
||||
}
|
||||
});
|
||||
const cap = h("div", { class: "gal-cap" }, `${j.width}×${j.height} · seed ${j.seed}`);
|
||||
const item = h("div", { class: "gal-item", title: j.prompt }, del, cap);
|
||||
if (kind === "image") {
|
||||
// lazy thumbnail: decode the PPM only when the item scrolls into view
|
||||
const thumb = h("canvas", { class: "gal-thumb" });
|
||||
item.prepend(thumb);
|
||||
const io = new IntersectionObserver(async (entries) => {
|
||||
if (!entries.some((x) => x.isIntersecting)) return;
|
||||
io.disconnect();
|
||||
try {
|
||||
const buf = await mediaFileBytes(j.id, "image");
|
||||
const full = ppmToCanvas(buf);
|
||||
thumb.width = 128;
|
||||
thumb.height = Math.round((128 * full.height) / full.width) || 128;
|
||||
thumb.getContext("2d").drawImage(full, 0, 0, thumb.width, thumb.height);
|
||||
} catch {
|
||||
/* file gone */
|
||||
}
|
||||
});
|
||||
io.observe(item);
|
||||
} else {
|
||||
item.prepend(h("div", { class: "gal-thumb gal-video" }, "🎬"));
|
||||
}
|
||||
item.addEventListener("click", () => onOpen(j));
|
||||
grid.appendChild(item);
|
||||
}
|
||||
mount(host, h("div", { class: "card-title", style: "margin-top:14px" }, t("pg.gallery")), grid);
|
||||
}
|
||||
refresh();
|
||||
return { host, refresh };
|
||||
}
|
||||
|
||||
// ════════════════════════════ image panel ════════════════════════════
|
||||
let imagePanel;
|
||||
if (!imageModels.length) {
|
||||
@@ -328,33 +437,8 @@ export async function renderPlayground() {
|
||||
watchJob(r.job, { bar: imBar, goBtn: imGo, cancelBtn: imCancel }, async (j) => {
|
||||
mount(imBar);
|
||||
const secs = (performance.now() - t0) / 1000;
|
||||
try {
|
||||
const buf = await mediaFileBytes(j.id, "image");
|
||||
const canvas = ppmToCanvas(buf);
|
||||
canvas.className = "media-canvas";
|
||||
const dl = h("button", { class: "btn sm" }, "⬇ PNG");
|
||||
dl.addEventListener("click", () =>
|
||||
canvas.toBlob((b) => {
|
||||
const a = h("a", { href: URL.createObjectURL(b), download: `cortiq-${j.id}.png` });
|
||||
a.click();
|
||||
}, "image/png")
|
||||
);
|
||||
mount(imOut, canvas, h("div", { class: "actions" }, dl));
|
||||
mount(
|
||||
imMetrics,
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))),
|
||||
kv(t("pg.model"), h("span", { class: "mono" }, j.model)),
|
||||
kv(t("pg.resolution"), `${j.width}×${j.height}`),
|
||||
kv(t("pg.steps"), String(j.steps)),
|
||||
kv("CFG", String(params.cfg)),
|
||||
kv(t("pg.seed"), String(j.seed)),
|
||||
kv(t("pg.genTime"), secs.toFixed(1) + " s"),
|
||||
kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s"),
|
||||
kv(t("pg.fileSize"), fmtBytes(j.out_bytes))
|
||||
);
|
||||
} catch (e) {
|
||||
mount(imOut, h("div", { class: "callout" }, String(e.message || e)));
|
||||
}
|
||||
await showImageResult(j, secs);
|
||||
imGallery.refresh();
|
||||
});
|
||||
} catch (e) {
|
||||
imGo.disabled = false;
|
||||
@@ -363,6 +447,45 @@ export async function renderPlayground() {
|
||||
}
|
||||
});
|
||||
|
||||
async function showImageResult(j, secs) {
|
||||
try {
|
||||
const buf = await mediaFileBytes(j.id, "image");
|
||||
const canvas = ppmToCanvas(buf);
|
||||
canvas.className = "media-canvas";
|
||||
const dl = h("button", { class: "btn sm" }, "⬇ PNG");
|
||||
dl.addEventListener("click", () =>
|
||||
canvas.toBlob((b) => {
|
||||
const a = h("a", { href: URL.createObjectURL(b), download: `cortiq-${j.id}.png` });
|
||||
a.click();
|
||||
}, "image/png")
|
||||
);
|
||||
mount(imOut, canvas, h("div", { class: "actions" }, dl));
|
||||
const rows = [
|
||||
kv(t("pg.model"), h("span", { class: "mono" }, j.model)),
|
||||
kv(t("pg.resolution"), `${j.width}×${j.height}`),
|
||||
kv(t("pg.steps"), String(j.steps)),
|
||||
kv(t("pg.seed"), String(j.seed)),
|
||||
kv(t("pg.fileSize"), fmtBytes(j.out_bytes)),
|
||||
];
|
||||
if (secs != null) {
|
||||
rows.push(kv(t("pg.genTime"), secs.toFixed(1) + " s"));
|
||||
rows.push(kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s"));
|
||||
} else if (j.finished && j.started) {
|
||||
const dur = j.finished - j.started;
|
||||
rows.push(kv(t("pg.genTime"), dur + " s"));
|
||||
rows.push(kv(t("pg.timePerStep"), (dur / j.steps).toFixed(2) + " s"));
|
||||
}
|
||||
mount(imMetrics, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), ...rows);
|
||||
} catch (e) {
|
||||
mount(imOut, h("div", { class: "callout" }, String(e.message || e)));
|
||||
}
|
||||
}
|
||||
const imGallery = buildGallery("image", (j) => {
|
||||
imSeed.value = j.seed;
|
||||
imSteps.value = j.steps;
|
||||
showImageResult(j, null);
|
||||
});
|
||||
|
||||
imagePanel = h(
|
||||
"div",
|
||||
{ class: "pg" },
|
||||
@@ -383,7 +506,7 @@ export async function renderPlayground() {
|
||||
h("div", { class: "actions" }, imCancel, imGo),
|
||||
imBar
|
||||
),
|
||||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut)
|
||||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut, imGallery.host)
|
||||
),
|
||||
imMetrics
|
||||
);
|
||||
@@ -436,6 +559,7 @@ export async function renderPlayground() {
|
||||
const t0 = performance.now();
|
||||
watchJob(r.job, { bar: vBar, goBtn: vGo, cancelBtn: vCancel }, async (j) => {
|
||||
mount(vBar);
|
||||
vGallery.refresh();
|
||||
const secs = (performance.now() - t0) / 1000;
|
||||
try {
|
||||
await showVideoResult(j, vOut);
|
||||
@@ -507,6 +631,8 @@ export async function renderPlayground() {
|
||||
mount(host, player.canvas, h("div", { class: "actions" }, playBtn, ...downloads.childNodes ? [downloads] : []));
|
||||
}
|
||||
|
||||
const vGallery = buildGallery("video", (j) => showVideoResult(j, vOut));
|
||||
|
||||
videoPanel = h(
|
||||
"div",
|
||||
{ class: "pg" },
|
||||
@@ -527,7 +653,7 @@ export async function renderPlayground() {
|
||||
h("div", { class: "actions" }, vCancel, vGo),
|
||||
vBar
|
||||
),
|
||||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut)
|
||||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut, vGallery.host)
|
||||
),
|
||||
vMetrics
|
||||
);
|
||||
|
||||
Vendored
+46
-10
@@ -1,7 +1,7 @@
|
||||
// Settings — router/route/breaker/log/telemetry/stats + export/import config.
|
||||
import { h, mount, toast } from "../ui.js";
|
||||
import { h, mount, toast, confirmDialog } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { api, getToken, postRaw } from "../api.js";
|
||||
import { appState, SITE_URL } from "../app.js";
|
||||
|
||||
// A small "?" badge with a hover/focus tooltip explaining a setting.
|
||||
@@ -480,22 +480,33 @@ export async function renderSettings() {
|
||||
});
|
||||
|
||||
async function exportConfig() {
|
||||
const cfg = await api.getConfig();
|
||||
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = h("a", { href: url, download: "gateway-config.json" });
|
||||
// the real gateway.toml from disk — comments and all
|
||||
const tok = getToken();
|
||||
const headers = {};
|
||||
if (tok) headers["Authorization"] = "Bearer " + tok;
|
||||
const r = await fetch("/admin/api/config/export", { headers });
|
||||
if (!r.ok) {
|
||||
toast(await r.text(), "bad");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(await r.blob());
|
||||
const a = h("a", { href: url, download: "gateway.toml" });
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
const fileIn = h("input", { type: "file", accept: ".json,application/json", style: "display:none" });
|
||||
const fileIn = h("input", { type: "file", accept: ".toml,.json,application/json", style: "display:none" });
|
||||
fileIn.addEventListener("change", async () => {
|
||||
const f = fileIn.files[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
const cfg = JSON.parse(await f.text());
|
||||
await api.putConfig(cfg);
|
||||
const text = await f.text();
|
||||
if (f.name.endsWith(".json")) {
|
||||
await api.putConfig(JSON.parse(text)); // legacy JSON export
|
||||
} else {
|
||||
await postRaw("/config/import", text); // gateway.toml — validated + hot-reloaded
|
||||
}
|
||||
toast(t("toast.imported"), "good");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
@@ -503,6 +514,29 @@ export async function renderSettings() {
|
||||
fileIn.value = "";
|
||||
});
|
||||
|
||||
const restartBtn = h("button", { class: "btn danger" }, "⟳ " + t("settings.restart"));
|
||||
restartBtn.addEventListener("click", async () => {
|
||||
if (!(await confirmDialog(t("settings.restartConfirm")))) return;
|
||||
restartBtn.disabled = true;
|
||||
try {
|
||||
await api.restart();
|
||||
toast(t("settings.restarting"), "good");
|
||||
// the gateway comes back on the same port — reload once it answers
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
await api.health();
|
||||
clearInterval(poll);
|
||||
location.reload();
|
||||
} catch {
|
||||
/* still restarting */
|
||||
}
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
restartBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
@@ -623,7 +657,9 @@ export async function renderSettings() {
|
||||
{ class: "flex wrap" },
|
||||
h("button", { class: "btn", onClick: exportConfig }, t("settings.export")),
|
||||
h("button", { class: "btn", onClick: () => fileIn.click() }, t("settings.import")),
|
||||
fileIn
|
||||
fileIn,
|
||||
h("div", { class: "grow" }),
|
||||
restartBtn
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user