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
+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" },