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
+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;
}