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
+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;
|
||||
}
|
||||
Reference in New Issue
Block a user