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:
infosave2007
2026-08-07 17:07:48 +03:00
parent a77beeabab
commit 15de36be3b
32 changed files with 1876 additions and 143 deletions
+60
View File
@@ -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 {
+22
View File
@@ -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) {
+24 -1
View File
@@ -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();
+39
View File
@@ -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.",
};
+39
View File
@@ -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.",
};
+39
View File
@@ -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.",
};
+39
View File
@@ -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.",
};
+39
View File
@@ -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 пока нет.",
};
+39
View File
@@ -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.",
};
+39
View File
@@ -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 聊天模型。",
};
+83
View File
@@ -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" },
+16 -2
View File
@@ -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),
+3 -1
View File
@@ -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"))]));
+64 -1
View File
@@ -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)
);
}
+84
View File
@@ -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;
}
+54 -1
View File
@@ -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;
}
+5 -2
View File
@@ -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 () => {
+172 -46
View File
@@ -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
);
+46 -10
View File
@@ -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
)
)
);