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.
192 lines
7.3 KiB
JavaScript
Vendored
192 lines
7.3 KiB
JavaScript
Vendored
// API keys — list, create (with copy-once), revoke.
|
|
import { h, mount, modal, toast, confirmDialog } from "../ui.js";
|
|
import { t } from "../i18n.js";
|
|
import { api } from "../api.js";
|
|
|
|
export async function renderKeys() {
|
|
const modelsData = await api.listModels();
|
|
const ids = (modelsData.models || []).map((m) => m.id);
|
|
const root = h("div");
|
|
|
|
function openCreate(reload) {
|
|
const accountIn = h("input", { placeholder: "team-a" });
|
|
const rateIn = h("input", { type: "number", value: 600 });
|
|
const customIn = h("input", { placeholder: "sk-gw-…" });
|
|
const allowInputs = ids.map((id) => ({ id, cb: h("input", { type: "checkbox" }) }));
|
|
const body = h(
|
|
"div",
|
|
{},
|
|
h("label", { class: "field" }, h("span", {}, t("keys.form.account")), accountIn),
|
|
h("label", { class: "field" }, h("span", {}, t("keys.form.rate")), rateIn),
|
|
h(
|
|
"label",
|
|
{ class: "field" },
|
|
h("span", {}, t("keys.form.allow")),
|
|
h("div", { class: "flex wrap" }, ...allowInputs.map(({ id, cb }) => h("label", { class: "check" }, cb, id))),
|
|
h("div", { class: "hint" }, t("keys.form.allowHint"))
|
|
),
|
|
h("label", { class: "field" }, h("span", {}, t("keys.form.custom")), customIn, h("div", { class: "hint" }, t("keys.form.customHint")))
|
|
);
|
|
modal(t("keys.form.title"), body, async () => {
|
|
try {
|
|
const r = await api.createKey({
|
|
key: customIn.value.trim(),
|
|
account: accountIn.value.trim() || "default",
|
|
rate_per_min: parseInt(rateIn.value) || 0,
|
|
allow_models: allowInputs.filter(({ cb }) => cb.checked).map(({ id }) => id),
|
|
});
|
|
reload();
|
|
showCreated(r.key);
|
|
} catch (e) {
|
|
toast(String(e.message || e), "bad");
|
|
return false;
|
|
}
|
|
}, t("common.add"));
|
|
}
|
|
|
|
function showCreated(key) {
|
|
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() {
|
|
const data = await api.listKeys();
|
|
const keys = data.keys || [];
|
|
mount(
|
|
root,
|
|
h(
|
|
"div",
|
|
{ class: "page-head" },
|
|
h(
|
|
"div",
|
|
{ class: "flex" },
|
|
h("div", { class: "grow" }, h("h2", {}, t("keys.title")), h("p", {}, t("keys.subtitle"))),
|
|
h("button", { class: "btn primary", onClick: () => openCreate(reload) }, "+ " + t("keys.add"))
|
|
)
|
|
),
|
|
data.open_mode ? h("div", { class: "callout info" }, t("keys.openMode")) : null,
|
|
keys.length === 0
|
|
? h("div", { class: "card" }, h("div", { class: "empty" }, t("keys.empty")))
|
|
: h(
|
|
"div",
|
|
{ class: "card" },
|
|
h(
|
|
"div",
|
|
{ class: "table-wrap" },
|
|
h(
|
|
"table",
|
|
{},
|
|
h(
|
|
"thead",
|
|
{},
|
|
h("tr", {}, h("th", {}, t("keys.col.key")), h("th", {}, t("keys.col.account")), h("th", {}, t("keys.col.rate")), h("th", {}, t("keys.col.models")), h("th", { class: "right-align" }, t("common.actions")))
|
|
),
|
|
h(
|
|
"tbody",
|
|
{},
|
|
...keys.map((k) =>
|
|
h(
|
|
"tr",
|
|
{},
|
|
h("td", { class: "mono" }, k.key_masked),
|
|
h("td", {}, k.account),
|
|
h("td", { class: "mono" }, k.rate_per_min || "—"),
|
|
h("td", {}, (k.allow_models && k.allow_models.length) ? k.allow_models.join(", ") : h("span", { class: "muted" }, t("keys.allowAll"))),
|
|
h(
|
|
"td",
|
|
{ class: "right-align" },
|
|
h(
|
|
"button",
|
|
{
|
|
class: "btn sm danger",
|
|
onClick: async () => {
|
|
if (!(await confirmDialog(t("keys.revokeConfirm")))) return;
|
|
try {
|
|
await api.deleteKey(k.key);
|
|
toast(t("toast.deleted"), "good");
|
|
reload();
|
|
} catch (e) {
|
|
toast(String(e.message || e), "bad");
|
|
}
|
|
},
|
|
},
|
|
t("keys.revoke")
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
),
|
|
snippetsCard(!!data.open_mode)
|
|
);
|
|
}
|
|
|
|
await reload();
|
|
return root;
|
|
}
|