feat: universal LLM gateway with embedded multilingual admin console
- OpenAI-compatible gateway with intelligent routing via cortiq-router (allaigate) - Embedded build-free admin SPA at /admin: 7 languages (en/ru/de/fr/es/zh/tr), light/dark themes, hot config reload (no restart) - Admin API, per-request statistics + Prometheus /metrics, secret store, playground - English documentation (primary) with Russian translations (*.ru.md)
This commit is contained in:
Vendored
+218
@@ -0,0 +1,218 @@
|
||||
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
|
||||
import { h, mount, money, num, ms, pct, timeAgo } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { lineChart, barList } from "../charts.js";
|
||||
|
||||
const RANGES = ["1h", "24h", "7d"];
|
||||
const GROUPS = ["model", "tier", "label", "account"];
|
||||
|
||||
function kpi(big, lbl) {
|
||||
return h("div", { class: "kpi" }, h("div", { class: "big" }, big), h("div", { class: "lbl" }, lbl));
|
||||
}
|
||||
|
||||
function seg(options, current, onPick, labelFn) {
|
||||
return h(
|
||||
"div",
|
||||
{ class: "seg" },
|
||||
...options.map((o) =>
|
||||
h("button", { class: o === current ? "active" : "", onClick: () => onPick(o) }, labelFn ? labelFn(o) : o)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderDashboard() {
|
||||
let range = "24h";
|
||||
let groupby = "model";
|
||||
const root = h("div");
|
||||
|
||||
async function load() {
|
||||
const rangeSecs = range;
|
||||
let stats, recent, health;
|
||||
try {
|
||||
[stats, recent, health] = await Promise.all([
|
||||
api.stats(rangeSecs, groupby),
|
||||
api.requests(40, 0),
|
||||
api.health().catch(() => null),
|
||||
]);
|
||||
} catch (e) {
|
||||
mount(root, h("div", { class: "callout" }, String(e.message || e)));
|
||||
return;
|
||||
}
|
||||
const tot = stats.totals || {};
|
||||
const series = stats.series || [];
|
||||
const breakdown = (stats.breakdown || []).map((b) => ({ key: b.key || t("common.none"), value: b.requests }));
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
"div",
|
||||
{ class: "page-head" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex" },
|
||||
h("div", { class: "grow" }, h("h2", {}, t("dash.title")), h("p", {}, t("dash.subtitle"))),
|
||||
seg(RANGES, range, (r) => {
|
||||
range = r;
|
||||
load();
|
||||
}, (r) => t("dash.range." + r))
|
||||
)
|
||||
),
|
||||
|
||||
// KPIs
|
||||
h(
|
||||
"div",
|
||||
{ class: "kpis" },
|
||||
kpi(num(tot.requests), t("dash.kpi.requests")),
|
||||
kpi(num(tot.total_tokens), t("dash.kpi.tokens")),
|
||||
kpi(money(tot.cost_usd), t("dash.kpi.cost")),
|
||||
kpi(ms(tot.avg_latency_ms), t("dash.kpi.latency")),
|
||||
kpi(pct(tot.success_rate), t("dash.kpi.success")),
|
||||
kpi(num(tot.failovers), t("dash.kpi.failovers"))
|
||||
),
|
||||
|
||||
h(
|
||||
"div",
|
||||
{ class: "grid cols-2" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("dash.series.title"))),
|
||||
series.length < 2 ? h("div", { class: "empty" }, t("dash.series.empty")) : lineChart(series, "requests")
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("dash.series.cost"))),
|
||||
series.length < 2 ? h("div", { class: "empty" }, t("dash.series.empty")) : lineChart(series, "cost_usd")
|
||||
)
|
||||
),
|
||||
|
||||
// breakdown
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "card-head" },
|
||||
h("h3", {}, t("dash.breakdown.title")),
|
||||
h(
|
||||
"div",
|
||||
{ class: "right" },
|
||||
seg(GROUPS, groupby, (g) => {
|
||||
groupby = g;
|
||||
load();
|
||||
}, (g) => t("dash.groupby." + g))
|
||||
)
|
||||
),
|
||||
barList(breakdown, num)
|
||||
),
|
||||
|
||||
// recent
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("dash.recent.title"))),
|
||||
recentTable(recent.requests || [])
|
||||
),
|
||||
|
||||
health ? healthCard(health) : null
|
||||
);
|
||||
}
|
||||
|
||||
await load();
|
||||
return root;
|
||||
}
|
||||
|
||||
function recentTable(rows) {
|
||||
if (!rows.length) return h("div", { class: "empty" }, t("dash.recent.empty"));
|
||||
return h(
|
||||
"div",
|
||||
{ class: "table-wrap" },
|
||||
h(
|
||||
"table",
|
||||
{},
|
||||
h(
|
||||
"thead",
|
||||
{},
|
||||
h(
|
||||
"tr",
|
||||
{},
|
||||
h("th", {}, t("dash.col.time")),
|
||||
h("th", {}, t("dash.col.model")),
|
||||
h("th", {}, t("dash.col.tier")),
|
||||
h("th", {}, t("dash.col.task")),
|
||||
h("th", {}, t("dash.col.tokens")),
|
||||
h("th", {}, t("dash.col.cost")),
|
||||
h("th", {}, t("dash.col.latency")),
|
||||
h("th", {}, t("dash.col.outcome"))
|
||||
)
|
||||
),
|
||||
h(
|
||||
"tbody",
|
||||
{},
|
||||
...rows.map((r) =>
|
||||
h(
|
||||
"tr",
|
||||
{},
|
||||
h("td", { class: "mono" }, timeAgo(r.ts)),
|
||||
h("td", {}, r.model_id || "—", r.failover ? h("span", { class: "badge warn", style: "margin-left:6px" }, "fo") : null),
|
||||
h("td", {}, r.tier ? h("span", { class: "badge tier-" + r.tier }, r.tier) : "—"),
|
||||
h("td", {}, r.task_label || "—"),
|
||||
h("td", { class: "mono" }, `${r.prompt_tokens}/${r.completion_tokens}`),
|
||||
h("td", { class: "mono" }, money(r.cost_usd)),
|
||||
h("td", { class: "mono" }, ms(r.latency_ms)),
|
||||
h("td", {}, h("span", { class: "badge " + (r.outcome === "ok" ? "ok" : "error") }, r.outcome))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function healthCard(hh) {
|
||||
return h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("dash.health.title"))),
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex wrap", style: "margin-bottom:12px" },
|
||||
h(
|
||||
"span",
|
||||
{ class: "badge " + (hh.router?.reachable ? "ok" : "bad"), title: hh.router?.url || "" },
|
||||
t("status.router") + ": " + (hh.router?.reachable ? t("dash.health.reachable") : t("dash.health.unreachable"))
|
||||
)
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "table-wrap" },
|
||||
h(
|
||||
"table",
|
||||
{},
|
||||
h("thead", {}, h("tr", {}, h("th", {}, t("models.col.id")), h("th", {}, t("models.col.provider")), h("th", {}, t("models.col.model")), h("th", {}, t("models.col.key")))),
|
||||
h(
|
||||
"tbody",
|
||||
{},
|
||||
...(hh.models || []).map((m) =>
|
||||
h(
|
||||
"tr",
|
||||
{},
|
||||
h("td", { class: "mono" }, m.id),
|
||||
h("td", {}, m.provider),
|
||||
h("td", { class: "mono" }, m.model),
|
||||
h("td", {}, h("span", { class: "badge " + keyBadge(m.key_source) }, m.key_source))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function keyBadge(src) {
|
||||
if (src === "store") return "store";
|
||||
if (src === "env") return "env";
|
||||
if (src === "missing") return "bad";
|
||||
return "";
|
||||
}
|
||||
Vendored
+128
@@ -0,0 +1,128 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
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")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
return root;
|
||||
}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
// Login screen — admin token entry. Shown when there is no token or on 401.
|
||||
import { h } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { setToken } from "../api.js";
|
||||
|
||||
export function renderLogin(onOk) {
|
||||
const input = h("input", {
|
||||
type: "password",
|
||||
placeholder: t("login.tokenPlaceholder"),
|
||||
autofocus: true,
|
||||
});
|
||||
const submit = () => {
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
setToken(val);
|
||||
onOk();
|
||||
};
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
});
|
||||
return h(
|
||||
"div",
|
||||
{ class: "login-wrap" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "login-card" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "brand" },
|
||||
h("b", { style: "font-size:20px" }, "allaigate"),
|
||||
h("span", { class: "sub" }, t("brand.sub"))
|
||||
),
|
||||
h("h2", {}, t("login.title")),
|
||||
h("p", { class: "sub" }, t("login.subtitle")),
|
||||
h("label", { class: "field" }, h("span", {}, t("login.token")), input),
|
||||
h("button", { class: "btn primary", style: "width:100%", onClick: submit }, t("login.submit")),
|
||||
h("p", { class: "hint", style: "margin-top:16px" }, t("login.hint"))
|
||||
)
|
||||
);
|
||||
}
|
||||
Vendored
+206
@@ -0,0 +1,206 @@
|
||||
// Models — pool table with add/edit modal, probe, and per-model secret entry.
|
||||
import { h, mount, modal, toast, confirmDialog, money } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
|
||||
function field(label, control, hint) {
|
||||
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
|
||||
}
|
||||
function opt(v, label, sel) {
|
||||
return h("option", { value: v, selected: sel ? true : null }, label || v);
|
||||
}
|
||||
|
||||
function keyBadge(src) {
|
||||
const map = { store: "store", env: "env", missing: "bad", none: "" };
|
||||
return h("span", { class: "badge " + (map[src] ?? "") }, src);
|
||||
}
|
||||
|
||||
function modelForm(meta, existing) {
|
||||
const m = existing || { provider: "openai", kind: "chat", cost_tier: "cheap", caps: [], price_in: 0, price_out: 0 };
|
||||
const idIn = h("input", { value: m.id || "", placeholder: "local-qwen", disabled: existing ? true : null });
|
||||
const providerSel = h("select", {}, ...(meta.providers || ["openai"]).map((p) => opt(p, p, p === m.provider)));
|
||||
const baseUrlIn = h("input", { value: m.base_url || "", placeholder: "http://localhost:8000/v1" });
|
||||
const modelIn = h("input", { value: m.model || "", placeholder: "qwen2.5-7b-instruct" });
|
||||
const kindSel = h("select", {}, ...(meta.kinds || ["chat"]).map((k) => opt(k, k, k === m.kind)));
|
||||
const tierSel = h("select", {}, ...(meta.cost_tiers || ["cheap"]).map((c) => opt(c, c, c === m.cost_tier)));
|
||||
const priceInIn = h("input", { type: "number", step: "0.01", value: m.price_in ?? 0 });
|
||||
const priceOutIn = h("input", { type: "number", step: "0.01", value: m.price_out ?? 0 });
|
||||
const keyEnvIn = h("input", { value: m.api_key_env || "", placeholder: "OPENAI_API_KEY" });
|
||||
const secretIn = h("input", { type: "password", placeholder: t("models.form.secretPlaceholder") });
|
||||
const capInputs = (meta.caps || ["tools", "vision"]).map((c) => {
|
||||
const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null });
|
||||
return { c, cb };
|
||||
});
|
||||
|
||||
const node = h(
|
||||
"div",
|
||||
{},
|
||||
h("div", { class: "row" }, field(t("models.form.id"), idIn, existing ? null : t("models.form.idHint")), field(t("models.form.provider"), providerSel)),
|
||||
field(t("models.form.baseUrl"), baseUrlIn),
|
||||
field(t("models.form.model"), modelIn),
|
||||
h("div", { class: "row" }, field(t("models.form.kind"), kindSel), field(t("models.form.costTier"), tierSel)),
|
||||
h("div", { class: "row" }, field(t("models.form.priceIn"), priceInIn), field(t("models.form.priceOut"), priceOutIn)),
|
||||
field(t("models.form.caps"), h("div", { class: "flex wrap" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check" }, cb, c)))),
|
||||
field(t("models.form.apiKeyEnv"), keyEnvIn, t("models.form.apiKeyEnvHint")),
|
||||
field(t("models.form.secret"), secretIn)
|
||||
);
|
||||
|
||||
const getValue = () => ({
|
||||
model: {
|
||||
id: existing ? m.id : idIn.value.trim(),
|
||||
provider: providerSel.value,
|
||||
base_url: baseUrlIn.value.trim(),
|
||||
model: modelIn.value.trim(),
|
||||
kind: kindSel.value,
|
||||
cost_tier: tierSel.value,
|
||||
price_in: parseFloat(priceInIn.value) || 0,
|
||||
price_out: parseFloat(priceOutIn.value) || 0,
|
||||
api_key_env: keyEnvIn.value.trim() || null,
|
||||
caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c),
|
||||
},
|
||||
secret: secretIn.value,
|
||||
keyEnv: keyEnvIn.value.trim(),
|
||||
});
|
||||
|
||||
return { node, getValue };
|
||||
}
|
||||
|
||||
async function openModal(meta, existing, reload) {
|
||||
const { node, getValue } = modelForm(meta, existing);
|
||||
modal(existing ? t("models.form.editTitle") : t("models.form.addTitle"), node, async () => {
|
||||
const { model, secret, keyEnv } = getValue();
|
||||
if (!model.id) {
|
||||
toast(t("models.form.id") + "?", "bad");
|
||||
return false;
|
||||
}
|
||||
// strip null api_key_env so serde keeps it optional
|
||||
if (!model.api_key_env) delete model.api_key_env;
|
||||
try {
|
||||
if (existing) await api.updateModel(existing.id, model);
|
||||
else await api.createModel(model);
|
||||
if (secret && keyEnv) await api.setSecret(keyEnv, secret);
|
||||
toast(t("toast.saved"), "good");
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderModels() {
|
||||
const meta = appState.meta || (await api.meta());
|
||||
const root = h("div");
|
||||
|
||||
async function reload() {
|
||||
const data = await api.listModels();
|
||||
const models = data.models || [];
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
"div",
|
||||
{ class: "page-head" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex" },
|
||||
h("div", { class: "grow" }, h("h2", {}, t("models.title")), h("p", {}, t("models.subtitle"))),
|
||||
h("button", { class: "btn primary", onClick: () => openModal(meta, null, reload) }, "+ " + t("models.add"))
|
||||
)
|
||||
),
|
||||
models.length === 0
|
||||
? h("div", { class: "card" }, h("div", { class: "empty" }, t("models.empty")))
|
||||
: h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "table-wrap" },
|
||||
h(
|
||||
"table",
|
||||
{},
|
||||
h(
|
||||
"thead",
|
||||
{},
|
||||
h(
|
||||
"tr",
|
||||
{},
|
||||
h("th", {}, t("models.col.id")),
|
||||
h("th", {}, t("models.col.provider")),
|
||||
h("th", {}, t("models.col.model")),
|
||||
h("th", {}, t("models.col.tier")),
|
||||
h("th", {}, t("models.col.key")),
|
||||
h("th", {}, t("models.col.caps")),
|
||||
h("th", {}, t("models.col.price")),
|
||||
h("th", { class: "right-align" }, t("common.actions"))
|
||||
)
|
||||
),
|
||||
h(
|
||||
"tbody",
|
||||
{},
|
||||
...models.map((m) => row(m, meta, reload))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
return root;
|
||||
}
|
||||
|
||||
function row(m, meta, reload) {
|
||||
const probeBtn = h("button", { class: "btn sm ghost" }, t("models.probe"));
|
||||
probeBtn.addEventListener("click", async () => {
|
||||
probeBtn.disabled = true;
|
||||
probeBtn.textContent = "…";
|
||||
try {
|
||||
const r = await api.probeModel(m.id);
|
||||
if (r.ok) toast(t("models.probe.ok", { ms: r.latency_ms }), "good");
|
||||
else toast(t("models.probe.fail", { err: r.error || "" }), "bad");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
probeBtn.disabled = false;
|
||||
probeBtn.textContent = t("models.probe");
|
||||
});
|
||||
return h(
|
||||
"tr",
|
||||
{},
|
||||
h("td", { class: "mono" }, m.id),
|
||||
h("td", {}, m.provider),
|
||||
h("td", { class: "mono" }, m.model),
|
||||
h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")),
|
||||
h("td", {}, keyBadge(m.key_source || (m.api_key_env ? "missing" : "none"))),
|
||||
h("td", {}, (m.caps || []).length ? h("div", { class: "flex wrap" }, ...m.caps.map((c) => h("span", { class: "badge" }, c))) : "—"),
|
||||
h("td", { class: "mono" }, `${m.price_in || 0}/${m.price_out || 0}`),
|
||||
h(
|
||||
"td",
|
||||
{ class: "right-align" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex", style: "justify-content:flex-end" },
|
||||
probeBtn,
|
||||
h("button", { class: "btn sm ghost", onClick: () => openModal(meta, m, reload) }, t("common.edit")),
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
class: "btn sm danger",
|
||||
onClick: async () => {
|
||||
if (!(await confirmDialog(t("models.deleteConfirm", { id: m.id })))) return;
|
||||
try {
|
||||
await api.deleteModel(m.id);
|
||||
toast(t("toast.deleted"), "good");
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
},
|
||||
},
|
||||
t("common.delete")
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
Vendored
+137
@@ -0,0 +1,137 @@
|
||||
// Playground — send a prompt through the live pipeline, inspect routing decision.
|
||||
import { h, mount, money, ms, num } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
|
||||
export async function renderPlayground() {
|
||||
const meta = appState.meta || (await api.meta());
|
||||
const modelsData = await api.listModels();
|
||||
const ids = (modelsData.models || []).map((m) => m.id);
|
||||
|
||||
let mode = "auto";
|
||||
const root = h("div");
|
||||
|
||||
const sysIn = h("textarea", { placeholder: t("pg.systemPlaceholder"), rows: 2 });
|
||||
const promptIn = h("textarea", { placeholder: t("pg.promptPlaceholder"), rows: 5 });
|
||||
const tempIn = h("input", { type: "number", step: "0.1", value: 0.7 });
|
||||
const maxTokIn = h("input", { type: "number", value: 512 });
|
||||
const profileSel = h("select", {}, ...(meta.profiles || []).map((p) => h("option", { value: p }, p)));
|
||||
const modelSel = h("select", {}, ...ids.map((id) => h("option", { value: id }, id)));
|
||||
|
||||
const modeSeg = h(
|
||||
"div",
|
||||
{ class: "seg" },
|
||||
h("button", { class: "active", onClick: (e) => setMode("auto", e) }, t("pg.mode.auto")),
|
||||
h("button", { onClick: (e) => setMode("pinned", e) }, t("pg.mode.pinned"))
|
||||
);
|
||||
function buildModeExtra() {
|
||||
return h(
|
||||
"label",
|
||||
{ class: "field" },
|
||||
h("span", {}, mode === "auto" ? t("pg.profile") : t("pg.model")),
|
||||
mode === "auto" ? profileSel : modelSel
|
||||
);
|
||||
}
|
||||
let modeExtra = buildModeExtra();
|
||||
function setMode(m, e) {
|
||||
mode = m;
|
||||
modeSeg.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||||
e.target.classList.add("active");
|
||||
const next = buildModeExtra();
|
||||
modeExtra.replaceWith(next);
|
||||
modeExtra = next;
|
||||
}
|
||||
|
||||
const out = h("div", { class: "answer" }, 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" }, "—"));
|
||||
|
||||
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 });
|
||||
let model = "cortiq-auto";
|
||||
if (mode === "auto") {
|
||||
if (profileSel.value) model = "cortiq-auto:" + profileSel.value;
|
||||
} else {
|
||||
model = modelSel.value;
|
||||
}
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = t("pg.sending");
|
||||
mount(out, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.sending")));
|
||||
try {
|
||||
const r = await api.test({
|
||||
model,
|
||||
messages,
|
||||
temperature: parseFloat(tempIn.value),
|
||||
max_tokens: parseInt(maxTokIn.value) || null,
|
||||
});
|
||||
if (r.ok) {
|
||||
mount(out, r.answer || h("span", { class: "muted" }, "—"));
|
||||
renderDecision(r);
|
||||
} else {
|
||||
mount(out, 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)));
|
||||
}
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = t("pg.send");
|
||||
});
|
||||
|
||||
function kv(k, v) {
|
||||
return h("div", { class: "kv" }, h("span", { class: "k" }, k), h("span", { class: "v" }, v));
|
||||
}
|
||||
function renderDecision(r) {
|
||||
const c = r.cortiq || {};
|
||||
const score = c.complexity?.score ?? 0;
|
||||
mount(
|
||||
decision,
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))),
|
||||
kv(t("pg.task"), c.task_label || "—"),
|
||||
kv(t("pg.tier"), c.complexity?.tier ? h("span", { class: "badge tier-" + c.complexity.tier }, c.complexity.tier) : "—"),
|
||||
h(
|
||||
"div",
|
||||
{ class: "kv", style: "display:block" },
|
||||
h("div", { class: "flex" }, h("span", { class: "k grow" }, t("pg.score")), h("span", { class: "v" }, score.toFixed(2))),
|
||||
h("div", { class: "scorebar" }, h("i", { style: `width:${Math.min(100, score * 100)}%` }))
|
||||
),
|
||||
kv(t("pg.selected"), h("span", { class: "mono" }, c.selected_model || "—")),
|
||||
kv(t("pg.source"), c.route_source || "—"),
|
||||
kv(t("pg.tokens"), `${r.usage?.prompt_tokens ?? 0}/${r.usage?.completion_tokens ?? 0}`),
|
||||
kv(t("pg.cost"), money(c.cost_usd)),
|
||||
kv(t("pg.latency"), ms(r.latency_ms)),
|
||||
kv(t("pg.failover"), c.failover ? "yes" : "no")
|
||||
);
|
||||
}
|
||||
|
||||
mount(
|
||||
root,
|
||||
h("div", { class: "page-head" }, h("h2", {}, t("pg.title")), h("p", {}, t("pg.subtitle"))),
|
||||
h(
|
||||
"div",
|
||||
{ class: "pg" },
|
||||
h(
|
||||
"div",
|
||||
{},
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("label", { class: "field" }, h("span", {}, t("pg.system")), sysIn),
|
||||
h("label", { class: "field" }, h("span", {}, t("pg.prompt")), promptIn),
|
||||
h("label", { class: "field" }, h("span", {}, t("pg.mode")), modeSeg),
|
||||
modeExtra,
|
||||
h("div", { class: "row" }, h("label", { class: "field" }, h("span", {}, t("pg.temp")), tempIn), h("label", { class: "field" }, h("span", {}, t("pg.maxTokens")), maxTokIn)),
|
||||
h("div", { class: "flex", style: "justify-content:flex-end" }, sendBtn)
|
||||
),
|
||||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.answer"))), out)
|
||||
),
|
||||
decision
|
||||
)
|
||||
);
|
||||
return root;
|
||||
}
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
// Protocols — live toggles for inbound API surfaces.
|
||||
import { h, mount, toast } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
|
||||
const ORDER = [
|
||||
"openai_chat",
|
||||
"openai_completions",
|
||||
"openai_embeddings",
|
||||
"openai_models",
|
||||
"anthropic_messages",
|
||||
"mcp",
|
||||
"native_passthrough",
|
||||
];
|
||||
|
||||
export async function renderProtocols() {
|
||||
const meta = appState.meta || (await api.meta());
|
||||
const impl = meta.protocols_impl || {};
|
||||
const state = await api.getProtocols();
|
||||
const root = h("div");
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await api.putProtocols(state);
|
||||
toast(t("toast.saved"), "good");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
}
|
||||
|
||||
function row(key) {
|
||||
const implemented = !!impl[key];
|
||||
const input = h("input", {
|
||||
type: "checkbox",
|
||||
checked: state[key] ? true : null,
|
||||
disabled: implemented ? null : true,
|
||||
onChange: (e) => {
|
||||
state[key] = e.target.checked;
|
||||
save();
|
||||
},
|
||||
});
|
||||
return h(
|
||||
"div",
|
||||
{ class: "togglerow" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "meta" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "name" },
|
||||
t("protocols." + key),
|
||||
h("span", { class: "badge " + (implemented ? "ok" : "") }, implemented ? t("protocols.implemented") : t("protocols.planned"))
|
||||
),
|
||||
h("div", { class: "desc" }, t("protocols." + key + ".d"))
|
||||
),
|
||||
h("label", { class: "switch" }, input, h("span", { class: "slider" }))
|
||||
);
|
||||
}
|
||||
|
||||
mount(
|
||||
root,
|
||||
h("div", { class: "page-head" }, h("h2", {}, t("protocols.title")), h("p", {}, t("protocols.subtitle"))),
|
||||
h("div", { class: "card" }, ...ORDER.map(row))
|
||||
);
|
||||
return root;
|
||||
}
|
||||
Vendored
+158
@@ -0,0 +1,158 @@
|
||||
// Routing — tier editor (ordered model chips), default model, and policy.
|
||||
import { h, mount, toast } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
|
||||
export async function renderRouting() {
|
||||
const meta = appState.meta || (await api.meta());
|
||||
const [routing, modelsData] = await Promise.all([api.getRouting(), api.listModels()]);
|
||||
const ids = (modelsData.models || []).map((m) => m.id);
|
||||
|
||||
// local editable state — ensure standard tiers exist for a friendly editor
|
||||
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
|
||||
const st = {
|
||||
tiers,
|
||||
default: routing.default || ids[0] || "",
|
||||
policy: routing.policy || { mode: "fixed_table", min_class: {} },
|
||||
};
|
||||
|
||||
const root = h("div");
|
||||
|
||||
function moveChip(tier, i, dir) {
|
||||
const arr = st.tiers[tier];
|
||||
const ni = i + dir;
|
||||
if (ni < 0 || ni >= arr.length) return;
|
||||
[arr[i], arr[ni]] = [arr[ni], arr[i]];
|
||||
render();
|
||||
}
|
||||
function removeChip(tier, i) {
|
||||
st.tiers[tier].splice(i, 1);
|
||||
render();
|
||||
}
|
||||
function addChip(tier, id) {
|
||||
if (id && !st.tiers[tier].includes(id)) st.tiers[tier].push(id);
|
||||
render();
|
||||
}
|
||||
|
||||
function tierRow(tier) {
|
||||
const arr = st.tiers[tier];
|
||||
const avail = ids.filter((id) => !arr.includes(id));
|
||||
const addSel = h(
|
||||
"select",
|
||||
{
|
||||
style: "width:auto",
|
||||
onChange: (e) => {
|
||||
addChip(tier, e.target.value);
|
||||
},
|
||||
},
|
||||
h("option", { value: "" }, t("routing.addModel")),
|
||||
...avail.map((id) => h("option", { value: id }, id))
|
||||
);
|
||||
return h(
|
||||
"div",
|
||||
{ class: "tierrow" },
|
||||
h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, tier)),
|
||||
h(
|
||||
"div",
|
||||
{ class: "chips" },
|
||||
...arr.map((id, i) =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "chip" },
|
||||
h("span", { class: "ord" }, i + 1),
|
||||
id,
|
||||
h("span", { class: "arrow", title: "←", onClick: () => moveChip(tier, i, -1) }, "◄"),
|
||||
h("span", { class: "arrow", title: "→", onClick: () => moveChip(tier, i, 1) }, "►"),
|
||||
h("span", { class: "x", onClick: () => removeChip(tier, i) }, "×")
|
||||
)
|
||||
),
|
||||
avail.length ? addSel : null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function policyCard() {
|
||||
const modeSel = h(
|
||||
"select",
|
||||
{ onChange: (e) => { st.policy.mode = e.target.value; render(); } },
|
||||
h("option", { value: "fixed_table", selected: st.policy.mode === "fixed_table" }, t("routing.policy.fixed")),
|
||||
h("option", { value: "cost_aware", selected: st.policy.mode === "cost_aware" }, t("routing.policy.costAware"))
|
||||
);
|
||||
const children = [
|
||||
h("label", { class: "field" }, h("span", {}, t("routing.policy.mode")), modeSel),
|
||||
];
|
||||
if (st.policy.mode === "cost_aware") {
|
||||
const maxIn = h("input", {
|
||||
type: "number",
|
||||
step: "0.01",
|
||||
value: st.policy.max_cost_usd_per_request ?? "",
|
||||
placeholder: "0.50",
|
||||
onChange: (e) => { st.policy.max_cost_usd_per_request = e.target.value === "" ? null : parseFloat(e.target.value); },
|
||||
});
|
||||
children.push(h("label", { class: "field" }, h("span", {}, t("routing.policy.maxCost")), maxIn));
|
||||
st.policy.min_class = st.policy.min_class || {};
|
||||
const rows = Object.keys(st.tiers).map((tier) => {
|
||||
const sel = h(
|
||||
"select",
|
||||
{ onChange: (e) => { st.policy.min_class[tier] = e.target.value; } },
|
||||
h("option", { value: "" }, "—"),
|
||||
...(meta.cost_tiers || []).map((c) => h("option", { value: c, selected: st.policy.min_class[tier] === c }, c))
|
||||
);
|
||||
return h("label", { class: "field" }, h("span", {}, t("routing.policy.minClass") + " · " + tier), sel);
|
||||
});
|
||||
children.push(h("div", { class: "row" }, ...rows));
|
||||
}
|
||||
return h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("routing.policy"))), ...children);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const defSel = h(
|
||||
"select",
|
||||
{ onChange: (e) => { st.default = e.target.value; } },
|
||||
...ids.map((id) => h("option", { value: id, selected: id === st.default }, id))
|
||||
);
|
||||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
await api.putRouting({ tiers: st.tiers, default: st.default, policy: st.policy });
|
||||
toast(t("toast.saved"), "good");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
"div",
|
||||
{ class: "page-head" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex" },
|
||||
h("div", { class: "grow" }, h("h2", {}, t("routing.title")), h("p", {}, t("routing.subtitle"))),
|
||||
saveBtn
|
||||
)
|
||||
),
|
||||
ids.length === 0 ? h("div", { class: "callout" }, t("routing.empty")) : null,
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))),
|
||||
...Object.keys(st.tiers).map(tierRow)
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("routing.default"))),
|
||||
h("label", { class: "field" }, h("span", {}, t("routing.defaultHint")), defSel)
|
||||
),
|
||||
policyCard()
|
||||
);
|
||||
}
|
||||
|
||||
render();
|
||||
return root;
|
||||
}
|
||||
Vendored
+191
@@ -0,0 +1,191 @@
|
||||
// Settings — router/route/breaker/log/telemetry/stats + export/import config.
|
||||
import { h, mount, toast } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
|
||||
function field(label, control, hint) {
|
||||
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
|
||||
}
|
||||
function check(label, checked) {
|
||||
const cb = h("input", { type: "checkbox", checked: checked ? true : null });
|
||||
return { node: h("label", { class: "check" }, cb, label), cb };
|
||||
}
|
||||
|
||||
export async function renderSettings() {
|
||||
const meta = appState.meta || (await api.meta());
|
||||
const s = await api.getSettings();
|
||||
const root = h("div");
|
||||
|
||||
const listenIn = h("input", { value: s.listen || "" });
|
||||
// router
|
||||
const rUrl = h("input", { value: s.router?.url || "" });
|
||||
const rKeyEnv = h("input", { value: s.router?.api_key_env || "", placeholder: "CORTIQ_ROUTER_KEY" });
|
||||
const rTimeout = h("input", { type: "number", value: s.router?.timeout_ms ?? 800 });
|
||||
const rTax = h("input", { value: s.router?.taxonomy_id || "" });
|
||||
const rVerify = check(t("settings.router.verifyTls"), s.router?.verify_tls);
|
||||
// route
|
||||
const strategySel = h("select", {}, ...(meta.text_strategies || []).map((x) => h("option", { value: x, selected: x === s.route?.text_strategy }, x)));
|
||||
const maxChars = h("input", { type: "number", value: s.route?.max_chars ?? 4000 });
|
||||
const cacheTtl = h("input", { value: s.route?.cache_ttl || "60s" });
|
||||
const profileSel = h("select", {}, ...(meta.profiles || []).map((x) => h("option", { value: x, selected: x === s.route?.profile }, x)));
|
||||
// breaker
|
||||
const brThresh = h("input", { type: "number", value: s.breaker?.threshold ?? 5 });
|
||||
const brCool = h("input", { value: s.breaker?.cooldown || "30s" });
|
||||
// log
|
||||
const logSel = h("select", {}, ...["error", "warn", "info", "debug", "trace"].map((x) => h("option", { value: x, selected: x === s.log?.level }, x)));
|
||||
const logBodies = check(t("settings.log.bodies"), s.log?.bodies);
|
||||
// telemetry / cortiq
|
||||
const metricsCb = check(t("settings.telemetry.metrics"), s.telemetry?.metrics);
|
||||
const echoCb = check(t("settings.cortiq.echo"), s.cortiq?.echo);
|
||||
// stats
|
||||
const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled);
|
||||
const statsFile = h("input", { value: s.stats?.file || "" });
|
||||
const statsRet = h("input", { value: s.stats?.retention || "7d" });
|
||||
|
||||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
const patch = {
|
||||
listen: listenIn.value.trim(),
|
||||
router: {
|
||||
url: rUrl.value.trim(),
|
||||
api_key_env: rKeyEnv.value.trim() || null,
|
||||
timeout_ms: parseInt(rTimeout.value) || 800,
|
||||
verify_tls: rVerify.cb.checked,
|
||||
taxonomy_id: rTax.value.trim() || null,
|
||||
},
|
||||
route: {
|
||||
text_strategy: strategySel.value,
|
||||
max_chars: parseInt(maxChars.value) || 4000,
|
||||
cache_ttl: cacheTtl.value.trim() || "60s",
|
||||
profile: profileSel.value,
|
||||
},
|
||||
breaker: { threshold: parseInt(brThresh.value) || 5, cooldown: brCool.value.trim() || "30s" },
|
||||
log: { level: logSel.value, bodies: logBodies.cb.checked },
|
||||
telemetry: { metrics: metricsCb.cb.checked, otlp_endpoint_env: s.telemetry?.otlp_endpoint_env || null },
|
||||
cortiq: { echo: echoCb.cb.checked },
|
||||
stats: {
|
||||
enabled: statsEnabled.cb.checked,
|
||||
file: statsFile.value.trim(),
|
||||
retention: statsRet.value.trim() || "7d",
|
||||
ring_size: s.stats?.ring_size ?? 500,
|
||||
},
|
||||
};
|
||||
// serde: drop null optionals
|
||||
if (!patch.router.api_key_env) delete patch.router.api_key_env;
|
||||
if (!patch.router.taxonomy_id) delete patch.router.taxonomy_id;
|
||||
if (!patch.telemetry.otlp_endpoint_env) delete patch.telemetry.otlp_endpoint_env;
|
||||
try {
|
||||
const r = await api.putSettings(patch);
|
||||
toast(r.needs_restart ? t("settings.needsRestart") : t("toast.saved"), r.needs_restart ? "warn" : "good");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
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" });
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
const fileIn = h("input", { type: "file", accept: ".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);
|
||||
toast(t("toast.imported"), "good");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
fileIn.value = "";
|
||||
});
|
||||
|
||||
mount(
|
||||
root,
|
||||
h(
|
||||
"div",
|
||||
{ class: "page-head" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex" },
|
||||
h("div", { class: "grow" }, h("h2", {}, t("settings.title")), h("p", {}, t("settings.subtitle"))),
|
||||
saveBtn
|
||||
)
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "grid cols-2" },
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.router"))),
|
||||
field(t("settings.router.url"), rUrl),
|
||||
field(t("settings.router.keyEnv"), rKeyEnv),
|
||||
h("div", { class: "row" }, field(t("settings.router.timeout"), rTimeout), field(t("settings.router.taxonomy"), rTax)),
|
||||
rVerify.node
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.route"))),
|
||||
field(t("settings.route.strategy"), strategySel),
|
||||
h("div", { class: "row" }, field(t("settings.route.maxChars"), maxChars), field(t("settings.route.cacheTtl"), cacheTtl)),
|
||||
field(t("settings.route.profile"), profileSel)
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.breaker"))),
|
||||
h("div", { class: "row" }, field(t("settings.breaker.threshold"), brThresh), field(t("settings.breaker.cooldown"), brCool))
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.log"))),
|
||||
field(t("settings.log.level"), logSel),
|
||||
logBodies.node
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.telemetry"))),
|
||||
metricsCb.node,
|
||||
h("div", { class: "divider" }),
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.cortiq"))),
|
||||
echoCb.node
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.stats"))),
|
||||
statsEnabled.node,
|
||||
field(t("settings.stats.file"), statsFile),
|
||||
field(t("settings.stats.retention"), statsRet)
|
||||
)
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.listen"))),
|
||||
field(t("settings.listen"), listenIn),
|
||||
h("div", { class: "divider" }),
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex wrap" },
|
||||
h("button", { class: "btn", onClick: exportConfig }, t("settings.export")),
|
||||
h("button", { class: "btn", onClick: () => fileIn.click() }, t("settings.import")),
|
||||
fileIn
|
||||
)
|
||||
)
|
||||
);
|
||||
return root;
|
||||
}
|
||||
Reference in New Issue
Block a user