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:
infosave2007
2026-06-30 10:51:37 +03:00
commit cbc385f6c7
63 changed files with 11339 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
/* ===========================================================================
allaigate Gateway Console — design system (build-free, no framework)
Ported from cortiq-console, extended with light theme, charts, KPIs, modal.
=========================================================================== */
:root,
:root[data-theme="dark"] {
--bg: #0a0e1a;
--bg-2: #0e1424;
--panel: #131a2c;
--card: #151d31;
--card-2: #121a2c;
--border: #243049;
--border-soft: #1d2740;
--text: #e6ecf7;
--muted: #8b97b0;
--faint: #5e6b85;
--accent: #6366f1;
--accent-2: #5eead4;
--gold: #f59e0b;
--good: #34d399;
--warn: #fbbf24;
--bad: #f87171;
--grid: #ffffff14;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
--hover: #ffffff0a;
--bg-grad: radial-gradient(1200px 700px at 80% -10%, #16203a 0%, var(--bg) 55%);
--radius: 14px;
--radius-sm: 9px;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--sans: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
:root[data-theme="light"] {
--bg: #f5f7fb;
--bg-2: #ffffff;
--panel: #ffffff;
--card: #ffffff;
--card-2: #f7f9fc;
--border: #d8e0ee;
--border-soft: #e6ebf4;
--text: #0f172a;
--muted: #5a6781;
--faint: #8a96ad;
--accent: #5457e6;
--accent-2: #0ea5a4;
--gold: #d97706;
--good: #10b981;
--warn: #d97706;
--bad: #ef4444;
--grid: #0f172a14;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
--hover: #0f172a08;
--bg-grad: radial-gradient(1200px 700px at 80% -10%, #e7edf9 0%, var(--bg) 55%);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
background: var(--bg-grad);
color: var(--text);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 { font-weight: 700; letter-spacing: -0.01em; }
h2 { font-size: 20px; margin: 0 0 4px; }
h3 { font-size: 15px; margin: 0 0 10px; }
code, .mono { font-family: var(--mono); }
.boot { padding: 60px; text-align: center; color: var(--faint); }
/* ---- layout -------------------------------------------------------------- */
.app { display: grid; grid-template-columns: 250px 1fr; min-height: 100vh; }
.sidebar {
background: linear-gradient(180deg, var(--bg-2), var(--bg));
border-right: 1px solid var(--border-soft);
padding: 18px 14px;
display: flex; flex-direction: column; gap: 4px;
position: sticky; top: 0; height: 100vh;
}
.main { display: flex; flex-direction: column; min-width: 0; }
.content { padding: 24px 32px 64px; max-width: 1220px; width: 100%; }
/* ---- brand + nav --------------------------------------------------------- */
.brand { display: flex; align-items: center; gap: 10px; padding: 6px 8px 16px; }
.brand img { width: 30px; height: 30px; }
.brand b { font-size: 16px; }
.brand .sub { color: var(--muted); font-weight: 500; font-size: 13px; }
.nav-item {
display: flex; align-items: center; gap: 11px;
padding: 10px 12px; border-radius: var(--radius-sm);
color: var(--muted); cursor: pointer; font-weight: 500;
border: 1px solid transparent; user-select: none;
}
.nav-item:hover { background: var(--hover); color: var(--text); }
.nav-item.active { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--text); border-color: color-mix(in srgb, var(--accent) 35%, transparent); }
.nav-item .ico { width: 18px; text-align: center; opacity: 0.95; font-size: 15px; }
.nav-spacer { flex: 1; }
.nav-foot { font-size: 11px; color: var(--faint); padding: 8px; }
/* ---- topbar -------------------------------------------------------------- */
.topbar {
display: flex; align-items: center; gap: 14px;
padding: 13px 32px; border-bottom: 1px solid var(--border-soft);
position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: blur(8px); z-index: 5;
}
.topbar .title { font-weight: 600; font-size: 15px; }
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.icon-btn {
background: var(--panel); border: 1px solid var(--border); color: var(--muted);
width: 34px; height: 32px; border-radius: 8px; cursor: pointer; font-size: 15px;
display: inline-flex; align-items: center; justify-content: center;
}
.icon-btn:hover { color: var(--text); border-color: var(--accent); }
.lang-select {
background: var(--panel); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 6px 8px; font-size: 12px; font-weight: 600; width: auto;
}
/* ---- status pill --------------------------------------------------------- */
.pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
border: 1px solid var(--border); background: var(--panel);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.dot.ok { background: var(--good); box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 18%, transparent); }
.dot.bad { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 18%, transparent); }
.dot.warn { background: var(--warn); }
/* ---- cards --------------------------------------------------------------- */
.card {
background: linear-gradient(180deg, var(--card), var(--card-2));
border: 1px solid var(--border-soft); border-radius: var(--radius);
padding: 20px; box-shadow: var(--shadow); margin-bottom: 18px;
}
.card .card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.card .card-head .sub { color: var(--muted); font-weight: 400; font-size: 13px; }
.card .card-head .right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.grid { display: grid; gap: 18px; }
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 980px) { .grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; } .app { grid-template-columns: 1fr; } .sidebar { display: none; } }
.page-head { margin-bottom: 20px; }
.page-head .flex { justify-content: space-between; align-items: flex-start; }
.page-head p { color: var(--muted); margin: 6px 0 0; max-width: 74ch; }
/* ---- KPI ----------------------------------------------------------------- */
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; margin-bottom: 18px; }
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 620px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi {
background: linear-gradient(180deg, var(--card), var(--card-2));
border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 14px 16px;
}
.kpi .big { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; }
.kpi .lbl { color: var(--muted); font-size: 12px; margin-top: 3px; }
/* ---- forms --------------------------------------------------------------- */
label.field { display: block; margin-bottom: 14px; }
label.field > span { display: block; color: var(--muted); font-size: 12px; margin-bottom: 6px; font-weight: 600; }
input, select, textarea {
width: 100%; background: var(--bg-2); border: 1px solid var(--border);
color: var(--text); border-radius: var(--radius-sm); padding: 10px 12px;
font-family: inherit; font-size: 14px; outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
textarea { resize: vertical; min-height: 64px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row > * { flex: 1; min-width: 150px; }
.inline { display: flex; align-items: center; gap: 8px; }
.check { display: flex; align-items: center; gap: 8px; cursor: pointer; margin-bottom: 12px; }
.check input { width: auto; }
.hint { color: var(--faint); font-size: 12px; margin-top: 4px; }
/* ---- buttons ------------------------------------------------------------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 10px 16px; cursor: pointer;
font-weight: 600; font-size: 14px; font-family: inherit; transition: 0.12s;
}
.btn:hover { border-color: var(--accent); background: var(--hover); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn.primary { background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 88%, #fff), var(--accent)); border-color: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.gold { background: linear-gradient(180deg, color-mix(in srgb, var(--gold) 85%, #fff), var(--gold)); border-color: var(--gold); color: #1a1206; }
.btn.ghost { background: transparent; }
.btn.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 35%, transparent); }
.btn.danger:hover { background: color-mix(in srgb, var(--bad) 10%, transparent); }
.btn.sm { padding: 6px 11px; font-size: 12.5px; }
/* ---- badges -------------------------------------------------------------- */
.badge {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;
border-radius: 999px; font-size: 11.5px; font-weight: 700; border: 1px solid var(--border);
background: var(--bg-2); color: var(--muted);
}
.badge.tier-low, .badge.cheap, .badge.ok { color: var(--good); border-color: color-mix(in srgb, var(--good) 40%, transparent); background: color-mix(in srgb, var(--good) 8%, transparent); }
.badge.tier-medium, .badge.mid, .badge.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); background: color-mix(in srgb, var(--warn) 8%, transparent); }
.badge.tier-high, .badge.expensive, .badge.bad, .badge.error { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); background: color-mix(in srgb, var(--bad) 8%, transparent); }
.badge.router, .badge.store { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); background: color-mix(in srgb, var(--accent-2) 8%, transparent); }
.badge.env { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
/* ---- tables -------------------------------------------------------------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--border-soft); white-space: nowrap; }
th { color: var(--faint); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; }
tr:last-child td { border-bottom: 0; }
td .mono { font-size: 12.5px; color: var(--muted); }
tbody tr:hover td { background: var(--hover); }
/* ---- chips (routing) ----------------------------------------------------- */
.tierrow { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-soft); }
.tierrow:last-child { border-bottom: 0; }
.tierrow .tname { width: 110px; font-weight: 700; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; align-items: center; }
.chip { display: inline-flex; align-items: center; gap: 7px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 999px; padding: 5px 6px 5px 11px; font-weight: 600; font-size: 13px; }
.chip .ord { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 10px; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; }
.chip .arrow { cursor: pointer; color: var(--faint); padding: 0 1px; }
.chip .arrow:hover { color: var(--text); }
.chip .x { cursor: pointer; color: var(--faint); border-radius: 50%; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; }
.chip .x:hover { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); }
/* ---- toggle list (protocols) --------------------------------------------- */
.togglerow { display: flex; align-items: center; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--border-soft); }
.togglerow:last-child { border-bottom: 0; }
.togglerow .meta { flex: 1; }
.togglerow .meta .name { font-weight: 600; display: flex; gap: 8px; align-items: center; }
.togglerow .meta .desc { color: var(--muted); font-size: 12.5px; margin-top: 3px; }
.switch { position: relative; width: 44px; height: 24px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch .slider { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: 0.15s; cursor: pointer; }
.switch .slider::before { content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: 0.15s; }
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(20px); }
.switch input:disabled + .slider { opacity: 0.5; cursor: not-allowed; }
/* ---- charts -------------------------------------------------------------- */
.chart { width: 100%; height: 180px; display: block; }
.chart .axis { stroke: var(--grid); stroke-width: 1; }
.chart .area { fill: color-mix(in srgb, var(--accent) 16%, transparent); }
.chart .line { fill: none; stroke: var(--accent); stroke-width: 2; }
.chart .bar { fill: var(--accent); }
.barlist { display: flex; flex-direction: column; gap: 10px; }
.barlist .b { display: grid; grid-template-columns: 130px 1fr auto; gap: 10px; align-items: center; font-size: 13px; }
.barlist .b .track { background: var(--bg-2); border-radius: 5px; height: 10px; overflow: hidden; }
.barlist .b .track > i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
.barlist .b .val { color: var(--muted); font-weight: 600; min-width: 56px; text-align: right; }
/* ---- playground ---------------------------------------------------------- */
.pg { display: grid; grid-template-columns: 1fr 340px; gap: 18px; align-items: start; }
@media (max-width: 1024px) { .pg { grid-template-columns: 1fr; } }
.answer { white-space: pre-wrap; word-break: break-word; line-height: 1.55; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 12px; padding: 14px 16px; min-height: 120px; }
.routecard .kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border-soft); }
.routecard .kv:last-child { border-bottom: 0; }
.routecard .kv .k { color: var(--muted); }
.routecard .kv .v { font-weight: 600; text-align: right; }
.scorebar { height: 7px; background: var(--bg-2); border-radius: 4px; overflow: hidden; margin-top: 6px; }
.scorebar > i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
/* ---- modal --------------------------------------------------------------- */
.modal-backdrop { position: fixed; inset: 0; background: rgba(3, 7, 18, 0.6); display: flex; align-items: flex-start; justify-content: center; padding: 48px 16px; z-index: 40; overflow: auto; }
.modal { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); width: 100%; max-width: 560px; padding: 22px; }
.modal h3 { font-size: 17px; margin-bottom: 16px; }
.modal .modal-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
/* ---- login --------------------------------------------------------------- */
.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.login-card { width: 100%; max-width: 400px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 30px; }
.login-card .brand { justify-content: center; padding-top: 0; }
.login-card h2 { text-align: center; }
.login-card p.sub { color: var(--muted); text-align: center; margin: 4px 0 22px; }
/* ---- misc ---------------------------------------------------------------- */
.empty { text-align: center; color: var(--faint); padding: 40px 20px; }
.spinner { width: 16px; height: 16px; border: 2px solid color-mix(in srgb, var(--text) 25%, transparent); border-top-color: var(--text); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.divider { height: 1px; background: var(--border-soft); margin: 18px 0; }
.muted { color: var(--muted); }
.right-align { text-align: right; }
.flex { display: flex; gap: 10px; align-items: center; }
.flex.wrap { flex-wrap: wrap; }
.grow { flex: 1; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.seg button { background: transparent; border: 0; color: var(--muted); padding: 6px 12px; cursor: pointer; font-size: 12.5px; font-weight: 600; }
.seg button.active { background: var(--accent); color: #fff; }
/* ---- toasts -------------------------------------------------------------- */
.toasts { position: fixed; bottom: 22px; right: 22px; display: flex; flex-direction: column; gap: 10px; z-index: 60; }
.toast {
background: var(--panel); border: 1px solid var(--border); border-left: 3px solid var(--accent);
padding: 12px 16px; border-radius: 10px; box-shadow: var(--shadow); min-width: 220px;
animation: slidein 0.18s ease; font-size: 13px;
}
.toast.good { border-left-color: var(--good); }
.toast.bad { border-left-color: var(--bad); }
.toast.warn { border-left-color: var(--warn); }
@keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
/* ---- callout ------------------------------------------------------------- */
.callout { border: 1px solid var(--border); border-left: 3px solid var(--warn); background: color-mix(in srgb, var(--warn) 6%, transparent); border-radius: 10px; padding: 13px 15px; color: var(--muted); margin-bottom: 16px; }
.callout.info { border-left-color: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 6%, transparent); }
.callout b { color: var(--text); }
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b82f6"/>
<stop offset=".5" stop-color="#8b5cf6"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<path d="M9 16h14M17 10l6 6-6 6" stroke="#fff" stroke-width="2.4" fill="none"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>allaigate · Gateway Console</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<link rel="stylesheet" href="/admin/css/styles.css" />
<script>
// apply saved theme early to avoid a flash of the wrong theme
try {
var th = localStorage.getItem("allaigate_theme") || "dark";
document.documentElement.setAttribute("data-theme", th);
} catch (e) {}
</script>
</head>
<body>
<noscript>This console requires JavaScript.</noscript>
<div id="app"><div class="boot">Loading…</div></div>
<div id="toasts" class="toasts"></div>
<script type="module" src="/admin/js/app.js"></script>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
// Thin client over the gateway's /admin/api surface. Adds the Bearer admin token,
// parses JSON, and throws Error(message) on non-2xx so views can try/catch.
// A 401 sets an "unauthorized" flag the app uses to show the login screen.
const BASE = "/admin/api";
const TOKEN_KEY = "allaigate_token";
let onUnauthorized = null;
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || "";
}
export function setToken(tok) {
if (tok) localStorage.setItem(TOKEN_KEY, tok);
else localStorage.removeItem(TOKEN_KEY);
}
async function j(method, path, body) {
const opt = { method, headers: { Authorization: "Bearer " + getToken() } };
if (body !== undefined) {
opt.headers["content-type"] = "application/json";
opt.body = JSON.stringify(body);
}
const r = await fetch(BASE + path, opt);
if (r.status === 401) {
if (onUnauthorized) onUnauthorized();
throw new Error("unauthorized");
}
const text = await r.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!r.ok) {
const msg =
(data && data.error && (data.error.message || data.error)) ||
(data && (data.detail || data.message)) ||
r.statusText;
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
}
return data;
}
export const api = {
// meta / health
meta: () => j("GET", "/meta"),
health: () => j("GET", "/health"),
// config
getConfig: () => j("GET", "/config"),
putConfig: (b) => j("PUT", "/config", b),
// models
listModels: () => j("GET", "/models"),
createModel: (b) => j("POST", "/models", b),
updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b),
deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)),
probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"),
// routing
getRouting: () => j("GET", "/routing"),
putRouting: (b) => j("PUT", "/routing", b),
// protocols
getProtocols: () => j("GET", "/protocols"),
putProtocols: (b) => j("PUT", "/protocols", b),
// settings
getSettings: () => j("GET", "/settings"),
putSettings: (b) => j("PUT", "/settings", b),
// keys
listKeys: () => j("GET", "/keys"),
createKey: (b) => j("POST", "/keys", b),
deleteKey: (k) => j("DELETE", "/keys/" + encodeURIComponent(k)),
// secrets
listSecrets: () => j("GET", "/secrets"),
setSecret: (name, value) => j("PUT", "/secrets", { name, value }),
clearSecret: (name) => j("DELETE", "/secrets?name=" + encodeURIComponent(name)),
// stats
stats: (range, groupby) => j("GET", `/stats?range=${range}&groupby=${groupby}`),
requests: (limit = 50, offset = 0) => j("GET", `/requests?limit=${limit}&offset=${offset}`),
// playground
test: (b) => j("POST", "/test", b),
};
+228
View File
@@ -0,0 +1,228 @@
// App bootstrap: login gate, sidebar, topbar, hash router, language + theme.
import { h, mount, toast } from "./ui.js";
import { t, getLang, setLang, onLangChange, LANGS } from "./i18n.js";
import { api, getToken, setToken, setUnauthorizedHandler } from "./api.js";
import { renderLogin } from "./views/login.js";
import { renderDashboard } from "./views/dashboard.js";
import { renderModels } from "./views/models.js";
import { renderRouting } from "./views/routing.js";
import { renderProtocols } from "./views/protocols.js";
import { renderKeys } from "./views/keys.js";
import { renderPlayground } from "./views/playground.js";
import { renderSettings } from "./views/settings.js";
const ROUTES = [
{ id: "dashboard", icon: "▤", view: renderDashboard },
{ id: "models", icon: "▦", view: renderModels },
{ id: "routing", icon: "⇄", view: renderRouting },
{ id: "protocols", icon: "⇆", view: renderProtocols },
{ id: "keys", icon: "🔑", view: renderKeys },
{ id: "playground", icon: "✦", view: renderPlayground },
{ id: "settings", icon: "⚙", view: renderSettings },
];
export const appState = { meta: null, health: null, version: "" };
function currentRoute() {
const id = location.hash.replace(/^#\/?/, "") || "dashboard";
return ROUTES.find((r) => r.id === id) || ROUTES[0];
}
const FAVICON =
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="%233b82f6"/><stop offset=".5" stop-color="%238b5cf6"/><stop offset="1" stop-color="%2306b6d4"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(%23g)"/><path d="M9 16h14M17 10l6 6-6 6" stroke="white" stroke-width="2.4" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
);
function sidebar(activeId) {
return h(
"aside",
{ class: "sidebar", id: "sidebar" },
h(
"div",
{ class: "brand" },
h("img", { src: FAVICON, alt: "" }),
h("div", {}, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub")))
),
...ROUTES.map((r) =>
h(
"a",
{ class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id },
h("span", { class: "ico" }, r.icon),
t("nav." + r.id)
)
),
h("div", { class: "nav-spacer" }),
h("div", { class: "nav-foot" }, t("nav.foot", { v: appState.version || "—" }))
);
}
function statusPills() {
const h_ = appState.health;
const gwOk = !!appState.meta;
const routerOk = h_?.router?.reachable;
return h(
"div",
{ class: "flex" },
h(
"span",
{ class: "pill" },
h("span", { class: "dot " + (gwOk ? "ok" : "bad") }),
t("status.gateway") + ": " + (gwOk ? t("status.online") : t("status.offline"))
),
h(
"span",
{ class: "pill", title: h_?.router?.url || "" },
h("span", { class: "dot " + (h_ ? (routerOk ? "ok" : "bad") : "warn") }),
t("status.router") + ": " + (h_ ? (routerOk ? t("status.online") : t("status.offline")) : t("status.checking"))
)
);
}
function langSelect() {
const sel = h(
"select",
{
class: "lang-select",
title: "Language",
onChange: (e) => setLang(e.target.value),
},
...LANGS.map((l) => h("option", { value: l.code, selected: l.code === getLang() }, l.label))
);
return sel;
}
function themeToggle() {
const cur = document.documentElement.getAttribute("data-theme") || "dark";
return h(
"button",
{
class: "icon-btn",
title: t("theme.toggle"),
onClick: () => {
const next = (document.documentElement.getAttribute("data-theme") || "dark") === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("allaigate_theme", next);
renderShell();
},
},
cur === "dark" ? "☀" : "☾"
);
}
function topbar(activeId) {
return h(
"header",
{ class: "topbar" },
h("div", { class: "title" }, t("nav." + activeId)),
h(
"div",
{ class: "right" },
statusPills(),
themeToggle(),
langSelect(),
h(
"button",
{
class: "icon-btn",
title: t("logout"),
onClick: () => {
setToken("");
boot();
},
},
"⎋"
)
)
);
}
async function renderView() {
const route = currentRoute();
const view = document.getElementById("view");
if (!view) return;
mount(view, h("div", { class: "empty" }, h("span", { class: "spinner" }), " ", t("common.loading")));
try {
const node = await route.view();
mount(view, node);
} catch (e) {
if (String(e.message) === "unauthorized") return;
mount(view, h("div", { class: "callout" }, String(e.message || e)));
}
}
function renderShell() {
const route = currentRoute();
mount(
document.getElementById("app"),
h(
"div",
{ class: "app" },
sidebar(route.id),
h("main", { class: "main" }, topbar(route.id), h("div", { class: "content", id: "view" }))
)
);
renderView();
}
function showLogin() {
mount(document.getElementById("app"), renderLogin(boot));
}
async function refreshHealth() {
try {
appState.health = await api.health();
} catch {
appState.health = null;
}
// re-render topbar only if shell present
const route = currentRoute();
const main = document.querySelector(".main");
if (main) {
const old = main.querySelector(".topbar");
if (old) old.replaceWith(topbar(route.id));
}
}
export async function boot() {
// convenience: /admin?token=… logs in once, then is stripped from the URL
const urlTok = new URLSearchParams(location.search).get("token");
if (urlTok) {
setToken(urlTok);
history.replaceState({}, "", location.pathname + location.hash);
}
if (!getToken()) {
showLogin();
return;
}
try {
appState.meta = await api.meta();
appState.version = appState.meta.version || "";
} catch (e) {
if (String(e.message) === "unauthorized") {
showLogin();
return;
}
// gateway reachable but error — still show shell
}
if (!location.hash) location.hash = "#/dashboard";
renderShell();
refreshHealth();
}
setUnauthorizedHandler(() => {
toast(t("login.failed"), "bad");
setToken("");
showLogin();
});
onLangChange(() => {
if (document.querySelector(".app")) renderShell();
});
window.addEventListener("hashchange", () => {
if (document.querySelector(".app")) renderShell();
});
setInterval(() => {
if (document.querySelector(".app")) refreshHealth();
}, 20000);
boot();
+56
View File
@@ -0,0 +1,56 @@
// Dependency-free SVG charts. Build-free, theme-aware via CSS classes.
import { h } from "./ui.js";
const SVGNS = "http://www.w3.org/2000/svg";
function s(tag, attrs = {}, ...children) {
const el = document.createElementNS(SVGNS, tag);
for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v);
for (const c of children.flat()) if (c != null) el.appendChild(c);
return el;
}
// Area+line chart from time buckets. `field` is a key on each bucket.
export function lineChart(buckets, field) {
const W = 600, H = 180, padL = 8, padR = 8, padT = 12, padB = 14;
const data = (buckets || []).map((b) => Number(b[field] || 0));
const svg = s("svg", { class: "chart", viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none" });
// baseline + mid gridlines
for (let i = 0; i <= 2; i++) {
const y = padT + ((H - padT - padB) * i) / 2;
svg.appendChild(s("line", { class: "axis", x1: padL, y1: y, x2: W - padR, y2: y }));
}
if (data.length < 2) return svg;
const max = Math.max(1, ...data);
const innerW = W - padL - padR, innerH = H - padT - padB;
const x = (i) => padL + (innerW * i) / (data.length - 1);
const y = (v) => padT + innerH - (innerH * v) / max;
let line = "", area = `M ${x(0)} ${y(0)}`;
data.forEach((v, i) => {
const cmd = i === 0 ? "M" : "L";
line += `${cmd} ${x(i).toFixed(1)} ${y(v).toFixed(1)} `;
area += ` L ${x(i).toFixed(1)} ${y(v).toFixed(1)}`;
});
area += ` L ${x(data.length - 1)} ${y(0)} Z`;
svg.appendChild(s("path", { class: "area", d: area }));
svg.appendChild(s("path", { class: "line", d: line }));
return svg;
}
// Horizontal bar list. items: [{key, value, sub}]
export function barList(items, fmt) {
const max = Math.max(1, ...items.map((i) => i.value));
return h(
"div",
{ class: "barlist" },
items.length === 0 ? h("div", { class: "empty" }, "—") : null,
...items.map((it) =>
h(
"div",
{ class: "b" },
h("div", { class: "mono", title: it.key }, it.key || "—"),
h("div", { class: "track" }, h("i", { style: `width:${(it.value / max) * 100}%` })),
h("div", { class: "val" }, fmt ? fmt(it.value) : it.value)
)
)
);
}
+56
View File
@@ -0,0 +1,56 @@
// i18n engine. English is the source of truth; other languages override per key
// and fall back to English for any missing key.
import en from "./i18n/en.js";
import ru from "./i18n/ru.js";
import de from "./i18n/de.js";
import fr from "./i18n/fr.js";
import es from "./i18n/es.js";
import zh from "./i18n/zh.js";
import tr from "./i18n/tr.js";
const DICTS = { en, ru, de, fr, es, zh, tr };
export const LANGS = [
{ code: "en", label: "English" },
{ code: "ru", label: "Русский" },
{ code: "de", label: "Deutsch" },
{ code: "fr", label: "Français" },
{ code: "es", label: "Español" },
{ code: "zh", label: "中文" },
{ code: "tr", label: "Türkçe" },
];
const listeners = new Set();
function detect() {
const saved = localStorage.getItem("allaigate_lang");
if (saved && DICTS[saved]) return saved;
const url = new URLSearchParams(location.search).get("lang");
if (url && DICTS[url]) return url;
const nav = (navigator.language || "en").slice(0, 2).toLowerCase();
return DICTS[nav] ? nav : "en";
}
let lang = detect();
export function getLang() {
return lang;
}
export function setLang(l) {
lang = DICTS[l] ? l : "en";
localStorage.setItem("allaigate_lang", lang);
document.documentElement.setAttribute("lang", lang);
listeners.forEach((fn) => fn(lang));
}
export function onLangChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
export function t(key, vars) {
const d = DICTS[lang] || en;
let s = d[key] ?? en[key] ?? key;
if (vars) for (const k of Object.keys(vars)) s = s.split("{" + k + "}").join(vars[k]);
return s;
}
+256
View File
@@ -0,0 +1,256 @@
// Deutsch — German translation for allaigate admin console.
export default {
// brand / common
"brand.sub": "Gateway",
"common.loading": "Laden…",
"common.save": "Speichern",
"common.saving": "Wird gespeichert…",
"common.cancel": "Abbrechen",
"common.add": "Hinzufügen",
"common.edit": "Bearbeiten",
"common.delete": "Löschen",
"common.close": "Schließen",
"common.test": "Testen",
"common.copy": "Kopieren",
"common.copied": "Kopiert",
"common.confirm": "Bestätigen",
"common.none": "keine",
"common.saved": "Gespeichert",
"common.error": "Fehler",
"common.ok": "OK",
"common.refresh": "Aktualisieren",
"common.actions": "Aktionen",
"common.optional": "optional",
"common.search": "Suchen",
"common.all": "alle",
// nav
"nav.dashboard": "Dashboard",
"nav.models": "Modelle",
"nav.routing": "Routing",
"nav.protocols": "Protokolle",
"nav.keys": "API-Schlüssel",
"nav.playground": "Playground",
"nav.settings": "Einstellungen",
"nav.foot": "Gateway · v{v}",
// topbar / status
"status.router": "Router",
"status.gateway": "Gateway",
"status.online": "online",
"status.offline": "offline",
"status.checking": "Prüfe…",
"theme.toggle": "Design",
"logout": "Abmelden",
// login
"login.title": "allaigate Gateway",
"login.subtitle": "Admin-Token eingeben, um dieses Gateway zu verwalten.",
"login.token": "Admin-Token",
"login.tokenPlaceholder": "Admin-Token einfügen",
"login.submit": "Konsole öffnen",
"login.hint": "Das Token wird beim Start im Gateway-Log ausgegeben oder über [admin].token_env gesetzt.",
"login.failed": "Ungültiger Admin-Token.",
// dashboard
"dash.title": "Dashboard",
"dash.subtitle": "Live-Traffic, Kosten und Routing-Analyse für Ihren Modell-Pool.",
"dash.range": "Zeitraum",
"dash.range.1h": "1h",
"dash.range.24h": "24h",
"dash.range.7d": "7d",
"dash.kpi.requests": "Anfragen",
"dash.kpi.tokens": "Tokens",
"dash.kpi.cost": "Kosten",
"dash.kpi.latency": "Ø Latenz",
"dash.kpi.success": "Erfolgsrate",
"dash.kpi.failovers": "Failovers",
"dash.series.title": "Anfragen im Zeitverlauf",
"dash.series.cost": "Kosten im Zeitverlauf",
"dash.series.empty": "Kein Traffic in diesem Zeitraum.",
"dash.breakdown.title": "Aufschlüsselung",
"dash.groupby.model": "nach Modell",
"dash.groupby.tier": "nach Tier",
"dash.groupby.label": "nach Aufgabe",
"dash.groupby.account": "nach Account",
"dash.recent.title": "Letzte Anfragen",
"dash.recent.empty": "Noch keine Anfragen aufgezeichnet. Playground ausprobieren.",
"dash.col.time": "Zeit",
"dash.col.model": "Modell",
"dash.col.tier": "Tier",
"dash.col.task": "Aufgabe",
"dash.col.tokens": "Tokens",
"dash.col.cost": "Kosten",
"dash.col.latency": "Latenz",
"dash.col.outcome": "Ergebnis",
"dash.col.requests": "Anfragen",
"dash.col.errors": "Fehler",
"dash.health.title": "Status",
"dash.health.reachable": "erreichbar",
"dash.health.unreachable": "nicht erreichbar",
// models
"models.title": "Modelle",
"models.subtitle": "Ihr Modell-Pool — lokale Modelle und Cloud-APIs kombinieren.",
"models.add": "Modell hinzufügen",
"models.empty": "Noch keine Modelle. Erstes Modell hinzufügen.",
"models.col.id": "ID",
"models.col.provider": "Anbieter",
"models.col.model": "Modell",
"models.col.tier": "Kostenstufe",
"models.col.key": "Schlüssel",
"models.col.caps": "Fähigkeiten",
"models.col.price": "Preis (ein/aus)",
"models.probe": "Prüfen",
"models.probe.ok": "Erreichbar ({ms} ms)",
"models.probe.fail": "Fehlgeschlagen: {err}",
"models.form.addTitle": "Modell hinzufügen",
"models.form.editTitle": "Modell bearbeiten",
"models.form.id": "ID",
"models.form.idHint": "Eindeutige ID für das Routing, z. B. local-qwen",
"models.form.provider": "Anbieter",
"models.form.baseUrl": "Basis-URL",
"models.form.model": "Modellname",
"models.form.kind": "Typ",
"models.form.costTier": "Kostenstufe",
"models.form.priceIn": "Preis ein ($/1M)",
"models.form.priceOut": "Preis aus ($/1M)",
"models.form.apiKeyEnv": "API-Schlüssel Umgebungsvariable",
"models.form.apiKeyEnvHint": "Name der Umgebungsvariable / des gespeicherten Geheimnisses für den Anbieterschlüssel.",
"models.form.caps": "Fähigkeiten",
"models.form.secret": "Geheimwert",
"models.form.secretStored": "gespeichert",
"models.form.secretEnv": "aus Umgebung",
"models.form.secretMissing": "nicht gesetzt",
"models.form.secretPlaceholder": "Schlüssel setzen oder ersetzen (nur Schreiben)",
"models.deleteConfirm": "Modell \"{id}\" löschen?",
// routing
"routing.title": "Routing",
"routing.subtitle": "Komplexitätsstufen einer priorisierten Modellliste zuordnen (Priorität = Fallback).",
"routing.default": "Standardmodell",
"routing.defaultHint": "Wird verwendet, wenn der Router nicht erreichbar ist.",
"routing.tiers": "Stufen",
"routing.tierHint": "Chips ziehen zum Sortieren; erstes bevorzugt, die übrigen sind Fallbacks.",
"routing.addModel": "+ hinzufügen",
"routing.addTier": "Stufe hinzufügen",
"routing.tierName": "Stufenname",
"routing.policy": "Richtlinie",
"routing.policy.mode": "Modus",
"routing.policy.fixed": "Feste Tabelle",
"routing.policy.costAware": "Kostenoptimiert",
"routing.policy.maxCost": "Max. Kosten pro Anfrage ($)",
"routing.policy.minClass": "Mindestklasse je Stufe",
"routing.empty": "Keine Modelle verfügbar — zuerst Modelle hinzufügen.",
// protocols
"protocols.title": "Protokolle",
"protocols.subtitle": "Nur benötigte eingehende API-Oberflächen aktivieren. Änderungen wirken sofort.",
"protocols.implemented": "implementiert",
"protocols.planned": "geplant",
"protocols.openai_chat": "OpenAI Chat Completions",
"protocols.openai_chat.d": "POST /v1/chat/completions — kompatibel mit OpenAI SDK, LangChain, crewAI.",
"protocols.openai_completions": "OpenAI Completions (legacy)",
"protocols.openai_completions.d": "POST /v1/completions — Legacy-Integrationen.",
"protocols.openai_embeddings": "OpenAI Embeddings",
"protocols.openai_embeddings.d": "POST /v1/embeddings — RAG-Pipelines.",
"protocols.openai_models": "OpenAI Models",
"protocols.openai_models.d": "GET /v1/models — automatische Client-Erkennung.",
"protocols.anthropic_messages": "Anthropic Messages",
"protocols.anthropic_messages.d": "POST /v1/messages — Claude-native Agenten.",
"protocols.mcp": "MCP (Model Context Protocol)",
"protocols.mcp.d": "JSON-RPC / SSE — MCP-native Orchestratoren.",
"protocols.native_passthrough": "Nativer Passthrough",
"protocols.native_passthrough.d": "POST /route — direkter Zugriff auf die Routing-Entscheidung.",
// keys
"keys.title": "API-Schlüssel",
"keys.subtitle": "Virtuelle Gateway-Schlüssel für Ihre Agenten. Anbieterschlüssel verlassen das Gateway nicht.",
"keys.add": "Neuer Schlüssel",
"keys.empty": "Keine Schlüssel konfiguriert — Gateway im offenen Modus (keine Authentifizierung).",
"keys.openMode": "Offener Modus: Alle Anfragen werden akzeptiert (kein Schlüssel erforderlich).",
"keys.col.key": "Schlüssel",
"keys.col.account": "Account",
"keys.col.rate": "Rate / Min.",
"keys.col.models": "Erlaubte Modelle",
"keys.form.title": "Neuer API-Schlüssel",
"keys.form.account": "Account",
"keys.form.rate": "Ratenlimit / Min.",
"keys.form.allow": "Erlaubte Modelle",
"keys.form.allowHint": "Leer lassen, um alle Modelle zu erlauben.",
"keys.form.custom": "Eigener Schlüssel (optional)",
"keys.form.customHint": "Leer lassen für automatische Generierung sk-gw-…",
"keys.created": "Schlüssel erstellt — jetzt kopieren, er wird nicht mehr angezeigt:",
"keys.revoke": "Widerrufen",
"keys.revokeConfirm": "Diesen Schlüssel widerrufen?",
"keys.allowAll": "alle",
// playground
"pg.title": "Playground",
"pg.subtitle": "Prompt durch die Live-Pipeline senden und Routing-Entscheidung prüfen.",
"pg.system": "System-Prompt",
"pg.systemPlaceholder": "optionale Systemnachricht",
"pg.prompt": "Prompt",
"pg.promptPlaceholder": "Etwas fragen…",
"pg.mode": "Modus",
"pg.mode.auto": "Auto (Route)",
"pg.mode.pinned": "Fixiertes Modell",
"pg.profile": "Profil",
"pg.model": "Modell",
"pg.temp": "Temperatur",
"pg.maxTokens": "Max. Tokens",
"pg.send": "Senden",
"pg.sending": "Wird gesendet…",
"pg.answer": "Antwort",
"pg.empty": "Antwort und Routing-Entscheidung erscheinen hier.",
"pg.decision": "Routing-Entscheidung",
"pg.task": "Aufgabe",
"pg.tier": "Tier",
"pg.score": "Komplexität",
"pg.selected": "Gewähltes Modell",
"pg.source": "Route-Quelle",
"pg.cost": "Kosten",
"pg.latency": "Latenz",
"pg.tokens": "Tokens (ein/aus)",
"pg.failover": "Failover",
"pg.error": "Anfrage fehlgeschlagen",
// settings
"settings.title": "Einstellungen",
"settings.subtitle": "Gateway-Verbindung, Routing-Eingabe, Zuverlässigkeit und Beobachtbarkeit.",
"settings.router": "Router-Verbindung",
"settings.router.url": "Router-URL",
"settings.router.keyEnv": "Router-Schlüssel Umgebungsvariable",
"settings.router.timeout": "Timeout (ms)",
"settings.router.verifyTls": "TLS-Zertifikat prüfen",
"settings.router.taxonomy": "Taxonomie-ID",
"settings.route": "Routing-Eingabe",
"settings.route.strategy": "Textstrategie",
"settings.route.maxChars": "Max. Zeichen",
"settings.route.cacheTtl": "Cache-TTL",
"settings.route.profile": "Standardprofil",
"settings.breaker": "Circuit Breaker",
"settings.breaker.threshold": "Fehlerschwelle",
"settings.breaker.cooldown": "Cooldown",
"settings.log": "Protokollierung",
"settings.log.level": "Log-Ebene",
"settings.log.bodies": "Nachrichtentexte protokollieren (Datenschutz aus)",
"settings.telemetry": "Telemetrie",
"settings.telemetry.metrics": "Prometheus /metrics bereitstellen",
"settings.cortiq": "Antwort-Metadaten",
"settings.cortiq.echo": "cortiq-Routing-Block in Antworten einbetten",
"settings.stats": "Statistiken",
"settings.stats.enabled": "Anfrage-Statistiken aufzeichnen",
"settings.stats.file": "Statistikdatei (JSONL)",
"settings.stats.retention": "Aufbewahrung",
"settings.listen": "Lausch-Adresse",
"settings.export": "Konfiguration exportieren",
"settings.import": "Konfiguration importieren",
"settings.needsRestart": "Gespeichert. Einige Änderungen (Lausch-Adresse) erfordern einen Neustart.",
// toasts / misc
"toast.saved": "Änderungen gespeichert",
"toast.deleted": "Gelöscht",
"toast.probing": "Prüfe…",
"toast.imported": "Konfiguration importiert",
};
+257
View File
@@ -0,0 +1,257 @@
// English — source of truth. Other languages override per key; missing keys fall
// back to English. Keep keys identical across all language files.
export default {
// brand / common
"brand.sub": "Gateway",
"common.loading": "Loading…",
"common.save": "Save",
"common.saving": "Saving…",
"common.cancel": "Cancel",
"common.add": "Add",
"common.edit": "Edit",
"common.delete": "Delete",
"common.close": "Close",
"common.test": "Test",
"common.copy": "Copy",
"common.copied": "Copied",
"common.confirm": "Confirm",
"common.none": "none",
"common.saved": "Saved",
"common.error": "Error",
"common.ok": "OK",
"common.refresh": "Refresh",
"common.actions": "Actions",
"common.optional": "optional",
"common.search": "Search",
"common.all": "all",
// nav
"nav.dashboard": "Dashboard",
"nav.models": "Models",
"nav.routing": "Routing",
"nav.protocols": "Protocols",
"nav.keys": "API keys",
"nav.playground": "Playground",
"nav.settings": "Settings",
"nav.foot": "Gateway · v{v}",
// topbar / status
"status.router": "Router",
"status.gateway": "Gateway",
"status.online": "online",
"status.offline": "offline",
"status.checking": "checking…",
"theme.toggle": "Theme",
"logout": "Log out",
// login
"login.title": "allaigate Gateway",
"login.subtitle": "Enter the admin token to manage this gateway.",
"login.token": "Admin token",
"login.tokenPlaceholder": "paste admin token",
"login.submit": "Open console",
"login.hint": "The token is printed in the gateway log on startup, or set via [admin].token_env.",
"login.failed": "Invalid admin token.",
// dashboard
"dash.title": "Dashboard",
"dash.subtitle": "Live traffic, cost and routing analytics for your model pool.",
"dash.range": "Range",
"dash.range.1h": "1h",
"dash.range.24h": "24h",
"dash.range.7d": "7d",
"dash.kpi.requests": "Requests",
"dash.kpi.tokens": "Tokens",
"dash.kpi.cost": "Cost",
"dash.kpi.latency": "Avg latency",
"dash.kpi.success": "Success rate",
"dash.kpi.failovers": "Failovers",
"dash.series.title": "Requests over time",
"dash.series.cost": "Cost over time",
"dash.series.empty": "No traffic yet in this range.",
"dash.breakdown.title": "Breakdown",
"dash.groupby.model": "by model",
"dash.groupby.tier": "by tier",
"dash.groupby.label": "by task",
"dash.groupby.account": "by account",
"dash.recent.title": "Recent requests",
"dash.recent.empty": "No requests recorded yet. Try the Playground.",
"dash.col.time": "Time",
"dash.col.model": "Model",
"dash.col.tier": "Tier",
"dash.col.task": "Task",
"dash.col.tokens": "Tokens",
"dash.col.cost": "Cost",
"dash.col.latency": "Latency",
"dash.col.outcome": "Outcome",
"dash.col.requests": "Requests",
"dash.col.errors": "Errors",
"dash.health.title": "Health",
"dash.health.reachable": "reachable",
"dash.health.unreachable": "unreachable",
// models
"models.title": "Models",
"models.subtitle": "Your model pool — mix cheap local models with cloud APIs.",
"models.add": "Add model",
"models.empty": "No models yet. Add your first model to get started.",
"models.col.id": "ID",
"models.col.provider": "Provider",
"models.col.model": "Model",
"models.col.tier": "Cost tier",
"models.col.key": "Key",
"models.col.caps": "Capabilities",
"models.col.price": "Price (in/out)",
"models.probe": "Probe",
"models.probe.ok": "Reachable ({ms} ms)",
"models.probe.fail": "Failed: {err}",
"models.form.addTitle": "Add model",
"models.form.editTitle": "Edit model",
"models.form.id": "ID",
"models.form.idHint": "Unique id used in routing, e.g. local-qwen",
"models.form.provider": "Provider",
"models.form.baseUrl": "Base URL",
"models.form.model": "Model name",
"models.form.kind": "Kind",
"models.form.costTier": "Cost tier",
"models.form.priceIn": "Price in ($/1M)",
"models.form.priceOut": "Price out ($/1M)",
"models.form.apiKeyEnv": "API key env name",
"models.form.apiKeyEnvHint": "Name of the env var / stored secret holding this provider's key.",
"models.form.caps": "Capabilities",
"models.form.secret": "Secret value",
"models.form.secretStored": "stored",
"models.form.secretEnv": "from env",
"models.form.secretMissing": "not set",
"models.form.secretPlaceholder": "set or replace key (write-only)",
"models.deleteConfirm": "Delete model \"{id}\"?",
// routing
"routing.title": "Routing",
"routing.subtitle": "Map complexity tiers to an ordered list of models (priority = fallback).",
"routing.default": "Default model",
"routing.defaultHint": "Used when the router is unreachable.",
"routing.tiers": "Tiers",
"routing.tierHint": "Drag chips to reorder; first is preferred, the rest are fallbacks.",
"routing.addModel": "+ add",
"routing.addTier": "Add tier",
"routing.tierName": "Tier name",
"routing.policy": "Policy",
"routing.policy.mode": "Mode",
"routing.policy.fixed": "Fixed table",
"routing.policy.costAware": "Cost-aware",
"routing.policy.maxCost": "Max cost per request ($)",
"routing.policy.minClass": "Minimum class per tier",
"routing.empty": "No models available — add models first.",
// protocols
"protocols.title": "Protocols",
"protocols.subtitle": "Enable only the inbound API surfaces you need. Toggles apply live.",
"protocols.implemented": "implemented",
"protocols.planned": "planned",
"protocols.openai_chat": "OpenAI Chat Completions",
"protocols.openai_chat.d": "POST /v1/chat/completions — works with OpenAI SDK, LangChain, crewAI.",
"protocols.openai_completions": "OpenAI Completions (legacy)",
"protocols.openai_completions.d": "POST /v1/completions — legacy integrations.",
"protocols.openai_embeddings": "OpenAI Embeddings",
"protocols.openai_embeddings.d": "POST /v1/embeddings — RAG pipelines.",
"protocols.openai_models": "OpenAI Models",
"protocols.openai_models.d": "GET /v1/models — client auto-discovery.",
"protocols.anthropic_messages": "Anthropic Messages",
"protocols.anthropic_messages.d": "POST /v1/messages — Claude-native agents.",
"protocols.mcp": "MCP (Model Context Protocol)",
"protocols.mcp.d": "JSON-RPC / SSE — MCP-native orchestrators.",
"protocols.native_passthrough": "Native passthrough",
"protocols.native_passthrough.d": "POST /route — direct access to the routing decision.",
// keys
"keys.title": "API keys",
"keys.subtitle": "Virtual gateway keys for your agents. Provider keys never leave the gateway.",
"keys.add": "New key",
"keys.empty": "No keys configured — the gateway is in open mode (no auth).",
"keys.openMode": "Open mode: any request is accepted (no key required).",
"keys.col.key": "Key",
"keys.col.account": "Account",
"keys.col.rate": "Rate / min",
"keys.col.models": "Allowed models",
"keys.form.title": "New API key",
"keys.form.account": "Account",
"keys.form.rate": "Rate limit / min",
"keys.form.allow": "Allowed models",
"keys.form.allowHint": "Leave empty to allow all models.",
"keys.form.custom": "Custom key (optional)",
"keys.form.customHint": "Leave empty to auto-generate sk-gw-…",
"keys.created": "Key created — copy it now, it won't be shown again:",
"keys.revoke": "Revoke",
"keys.revokeConfirm": "Revoke this key?",
"keys.allowAll": "all",
// playground
"pg.title": "Playground",
"pg.subtitle": "Send a prompt through the live pipeline and inspect the routing decision.",
"pg.system": "System prompt",
"pg.systemPlaceholder": "optional system message",
"pg.prompt": "Prompt",
"pg.promptPlaceholder": "Ask something…",
"pg.mode": "Mode",
"pg.mode.auto": "Auto (route)",
"pg.mode.pinned": "Pinned model",
"pg.profile": "Profile",
"pg.model": "Model",
"pg.temp": "Temperature",
"pg.maxTokens": "Max tokens",
"pg.send": "Send",
"pg.sending": "Sending…",
"pg.answer": "Answer",
"pg.empty": "The answer and routing decision will appear here.",
"pg.decision": "Routing decision",
"pg.task": "Task",
"pg.tier": "Tier",
"pg.score": "Complexity",
"pg.selected": "Selected model",
"pg.source": "Route source",
"pg.cost": "Cost",
"pg.latency": "Latency",
"pg.tokens": "Tokens (in/out)",
"pg.failover": "Failover",
"pg.error": "Request failed",
// settings
"settings.title": "Settings",
"settings.subtitle": "Gateway connection, routing input, reliability and observability.",
"settings.router": "Router connection",
"settings.router.url": "Router URL",
"settings.router.keyEnv": "Router key env name",
"settings.router.timeout": "Timeout (ms)",
"settings.router.verifyTls": "Verify TLS certificate",
"settings.router.taxonomy": "Taxonomy id",
"settings.route": "Routing input",
"settings.route.strategy": "Text strategy",
"settings.route.maxChars": "Max chars",
"settings.route.cacheTtl": "Cache TTL",
"settings.route.profile": "Default profile",
"settings.breaker": "Circuit breaker",
"settings.breaker.threshold": "Error threshold",
"settings.breaker.cooldown": "Cooldown",
"settings.log": "Logging",
"settings.log.level": "Log level",
"settings.log.bodies": "Log message bodies (privacy off)",
"settings.telemetry": "Telemetry",
"settings.telemetry.metrics": "Expose Prometheus /metrics",
"settings.cortiq": "Response metadata",
"settings.cortiq.echo": "Echo cortiq routing block in responses",
"settings.stats": "Statistics",
"settings.stats.enabled": "Record request statistics",
"settings.stats.file": "Stats file (JSONL)",
"settings.stats.retention": "Retention",
"settings.listen": "Listen address",
"settings.export": "Export config",
"settings.import": "Import config",
"settings.needsRestart": "Saved. Some changes (listen address) require a restart.",
// toasts / misc
"toast.saved": "Changes saved",
"toast.deleted": "Deleted",
"toast.probing": "Probing…",
"toast.imported": "Config imported",
};
+256
View File
@@ -0,0 +1,256 @@
// Spanish — traducciones para la consola de administración de allaigate Gateway.
export default {
// brand / common
"brand.sub": "Gateway",
"common.loading": "Cargando…",
"common.save": "Guardar",
"common.saving": "Guardando…",
"common.cancel": "Cancelar",
"common.add": "Agregar",
"common.edit": "Editar",
"common.delete": "Eliminar",
"common.close": "Cerrar",
"common.test": "Probar",
"common.copy": "Copiar",
"common.copied": "Copiado",
"common.confirm": "Confirmar",
"common.none": "ninguno",
"common.saved": "Guardado",
"common.error": "Error",
"common.ok": "OK",
"common.refresh": "Actualizar",
"common.actions": "Acciones",
"common.optional": "opcional",
"common.search": "Buscar",
"common.all": "todos",
// nav
"nav.dashboard": "Panel",
"nav.models": "Modelos",
"nav.routing": "Enrutamiento",
"nav.protocols": "Protocolos",
"nav.keys": "Claves API",
"nav.playground": "Playground",
"nav.settings": "Configuración",
"nav.foot": "Gateway · v{v}",
// topbar / status
"status.router": "Router",
"status.gateway": "Gateway",
"status.online": "en línea",
"status.offline": "sin conexión",
"status.checking": "verificando…",
"theme.toggle": "Tema",
"logout": "Cerrar sesión",
// login
"login.title": "allaigate Gateway",
"login.subtitle": "Ingresa el token de administrador para gestionar este gateway.",
"login.token": "Token de administrador",
"login.tokenPlaceholder": "pega el token de administrador",
"login.submit": "Abrir consola",
"login.hint": "El token se imprime en el log del gateway al iniciar, o se configura mediante [admin].token_env.",
"login.failed": "Token de administrador inválido.",
// dashboard
"dash.title": "Panel",
"dash.subtitle": "Tráfico en vivo, costos y análisis de enrutamiento de tu pool de modelos.",
"dash.range": "Rango",
"dash.range.1h": "1h",
"dash.range.24h": "24h",
"dash.range.7d": "7d",
"dash.kpi.requests": "Solicitudes",
"dash.kpi.tokens": "Tokens",
"dash.kpi.cost": "Costo",
"dash.kpi.latency": "Latencia promedio",
"dash.kpi.success": "Tasa de éxito",
"dash.kpi.failovers": "Failovers",
"dash.series.title": "Solicitudes en el tiempo",
"dash.series.cost": "Costo en el tiempo",
"dash.series.empty": "Sin tráfico en este rango.",
"dash.breakdown.title": "Desglose",
"dash.groupby.model": "por modelo",
"dash.groupby.tier": "por nivel",
"dash.groupby.label": "por tarea",
"dash.groupby.account": "por cuenta",
"dash.recent.title": "Solicitudes recientes",
"dash.recent.empty": "Sin solicitudes registradas. Prueba el Playground.",
"dash.col.time": "Hora",
"dash.col.model": "Modelo",
"dash.col.tier": "Nivel",
"dash.col.task": "Tarea",
"dash.col.tokens": "Tokens",
"dash.col.cost": "Costo",
"dash.col.latency": "Latencia",
"dash.col.outcome": "Resultado",
"dash.col.requests": "Solicitudes",
"dash.col.errors": "Errores",
"dash.health.title": "Estado",
"dash.health.reachable": "accesible",
"dash.health.unreachable": "inaccesible",
// models
"models.title": "Modelos",
"models.subtitle": "Tu pool de modelos — combina modelos locales económicos con APIs en la nube.",
"models.add": "Agregar modelo",
"models.empty": "Sin modelos aún. Agrega tu primer modelo para comenzar.",
"models.col.id": "ID",
"models.col.provider": "Proveedor",
"models.col.model": "Modelo",
"models.col.tier": "Nivel de costo",
"models.col.key": "Clave",
"models.col.caps": "Capacidades",
"models.col.price": "Precio (entrada/salida)",
"models.probe": "Sondear",
"models.probe.ok": "Accesible ({ms} ms)",
"models.probe.fail": "Error: {err}",
"models.form.addTitle": "Agregar modelo",
"models.form.editTitle": "Editar modelo",
"models.form.id": "ID",
"models.form.idHint": "ID único usado en enrutamiento, ej. local-qwen",
"models.form.provider": "Proveedor",
"models.form.baseUrl": "URL base",
"models.form.model": "Nombre del modelo",
"models.form.kind": "Tipo",
"models.form.costTier": "Nivel de costo",
"models.form.priceIn": "Precio entrada ($/1M)",
"models.form.priceOut": "Precio salida ($/1M)",
"models.form.apiKeyEnv": "Nombre de variable env para clave API",
"models.form.apiKeyEnvHint": "Nombre de la variable de entorno o secreto que contiene la clave del proveedor.",
"models.form.caps": "Capacidades",
"models.form.secret": "Valor secreto",
"models.form.secretStored": "almacenado",
"models.form.secretEnv": "desde env",
"models.form.secretMissing": "no configurado",
"models.form.secretPlaceholder": "establecer o reemplazar clave (solo escritura)",
"models.deleteConfirm": "¿Eliminar modelo \"{id}\"?",
// routing
"routing.title": "Enrutamiento",
"routing.subtitle": "Asigna niveles de complejidad a una lista ordenada de modelos (prioridad = fallback).",
"routing.default": "Modelo predeterminado",
"routing.defaultHint": "Usado cuando el router no está disponible.",
"routing.tiers": "Niveles",
"routing.tierHint": "Arrastra los chips para reordenar; el primero es preferido, los demás son fallbacks.",
"routing.addModel": "+ agregar",
"routing.addTier": "Agregar nivel",
"routing.tierName": "Nombre del nivel",
"routing.policy": "Política",
"routing.policy.mode": "Modo",
"routing.policy.fixed": "Tabla fija",
"routing.policy.costAware": "Consciente del costo",
"routing.policy.maxCost": "Costo máx. por solicitud ($)",
"routing.policy.minClass": "Clase mínima por nivel",
"routing.empty": "Sin modelos disponibles — agrega modelos primero.",
// protocols
"protocols.title": "Protocolos",
"protocols.subtitle": "Activa solo las APIs de entrada que necesitas. Los cambios se aplican en vivo.",
"protocols.implemented": "implementado",
"protocols.planned": "planificado",
"protocols.openai_chat": "OpenAI Chat Completions",
"protocols.openai_chat.d": "POST /v1/chat/completions — compatible con OpenAI SDK, LangChain, crewAI.",
"protocols.openai_completions": "OpenAI Completions (legacy)",
"protocols.openai_completions.d": "POST /v1/completions — integraciones heredadas.",
"protocols.openai_embeddings": "OpenAI Embeddings",
"protocols.openai_embeddings.d": "POST /v1/embeddings — pipelines RAG.",
"protocols.openai_models": "OpenAI Models",
"protocols.openai_models.d": "GET /v1/models — autodescubrimiento de clientes.",
"protocols.anthropic_messages": "Anthropic Messages",
"protocols.anthropic_messages.d": "POST /v1/messages — agentes nativos Claude.",
"protocols.mcp": "MCP (Model Context Protocol)",
"protocols.mcp.d": "JSON-RPC / SSE — orquestadores nativos MCP.",
"protocols.native_passthrough": "Passthrough nativo",
"protocols.native_passthrough.d": "POST /route — acceso directo a la decisión de enrutamiento.",
// keys
"keys.title": "Claves API",
"keys.subtitle": "Claves de gateway virtuales para tus agentes. Las claves del proveedor nunca salen del gateway.",
"keys.add": "Nueva clave",
"keys.empty": "Sin claves configuradas — el gateway está en modo abierto (sin autenticación).",
"keys.openMode": "Modo abierto: cualquier solicitud es aceptada (no se requiere clave).",
"keys.col.key": "Clave",
"keys.col.account": "Cuenta",
"keys.col.rate": "Límite / min",
"keys.col.models": "Modelos permitidos",
"keys.form.title": "Nueva clave API",
"keys.form.account": "Cuenta",
"keys.form.rate": "Límite de tasa / min",
"keys.form.allow": "Modelos permitidos",
"keys.form.allowHint": "Deja vacío para permitir todos los modelos.",
"keys.form.custom": "Clave personalizada (opcional)",
"keys.form.customHint": "Deja vacío para generar automáticamente sk-gw-…",
"keys.created": "Clave creada — cópiala ahora, no se mostrará de nuevo:",
"keys.revoke": "Revocar",
"keys.revokeConfirm": "¿Revocar esta clave?",
"keys.allowAll": "todos",
// playground
"pg.title": "Playground",
"pg.subtitle": "Envía un prompt por el pipeline en vivo e inspecciona la decisión de enrutamiento.",
"pg.system": "Prompt del sistema",
"pg.systemPlaceholder": "mensaje de sistema opcional",
"pg.prompt": "Prompt",
"pg.promptPlaceholder": "Escribe algo…",
"pg.mode": "Modo",
"pg.mode.auto": "Auto (enrutar)",
"pg.mode.pinned": "Modelo fijo",
"pg.profile": "Perfil",
"pg.model": "Modelo",
"pg.temp": "Temperatura",
"pg.maxTokens": "Tokens máx.",
"pg.send": "Enviar",
"pg.sending": "Enviando…",
"pg.answer": "Respuesta",
"pg.empty": "La respuesta y la decisión de enrutamiento aparecerán aquí.",
"pg.decision": "Decisión de enrutamiento",
"pg.task": "Tarea",
"pg.tier": "Nivel",
"pg.score": "Complejidad",
"pg.selected": "Modelo seleccionado",
"pg.source": "Fuente de ruta",
"pg.cost": "Costo",
"pg.latency": "Latencia",
"pg.tokens": "Tokens (entrada/salida)",
"pg.failover": "Failover",
"pg.error": "Solicitud fallida",
// settings
"settings.title": "Configuración",
"settings.subtitle": "Conexión del gateway, entrada de enrutamiento, fiabilidad y observabilidad.",
"settings.router": "Conexión del router",
"settings.router.url": "URL del router",
"settings.router.keyEnv": "Nombre de variable env para clave del router",
"settings.router.timeout": "Tiempo de espera (ms)",
"settings.router.verifyTls": "Verificar certificado TLS",
"settings.router.taxonomy": "ID de taxonomía",
"settings.route": "Entrada de enrutamiento",
"settings.route.strategy": "Estrategia de texto",
"settings.route.maxChars": "Máx. caracteres",
"settings.route.cacheTtl": "TTL de caché",
"settings.route.profile": "Perfil predeterminado",
"settings.breaker": "Circuit breaker",
"settings.breaker.threshold": "Umbral de errores",
"settings.breaker.cooldown": "Cooldown",
"settings.log": "Registro",
"settings.log.level": "Nivel de log",
"settings.log.bodies": "Registrar cuerpos de mensajes (privacidad desactivada)",
"settings.telemetry": "Telemetría",
"settings.telemetry.metrics": "Exponer Prometheus /metrics",
"settings.cortiq": "Metadatos de respuesta",
"settings.cortiq.echo": "Incluir bloque de enrutamiento cortiq en respuestas",
"settings.stats": "Estadísticas",
"settings.stats.enabled": "Registrar estadísticas de solicitudes",
"settings.stats.file": "Archivo de estadísticas (JSONL)",
"settings.stats.retention": "Retención",
"settings.listen": "Dirección de escucha",
"settings.export": "Exportar configuración",
"settings.import": "Importar configuración",
"settings.needsRestart": "Guardado. Algunos cambios (dirección de escucha) requieren reinicio.",
// toasts / misc
"toast.saved": "Cambios guardados",
"toast.deleted": "Eliminado",
"toast.probing": "Sondeando…",
"toast.imported": "Configuración importada",
};
+256
View File
@@ -0,0 +1,256 @@
// French (fr) — same keys as en.js; missing keys fall back to English.
export default {
// brand / common
"brand.sub": "Gateway",
"common.loading": "Chargement…",
"common.save": "Enregistrer",
"common.saving": "Enregistrement…",
"common.cancel": "Annuler",
"common.add": "Ajouter",
"common.edit": "Modifier",
"common.delete": "Supprimer",
"common.close": "Fermer",
"common.test": "Tester",
"common.copy": "Copier",
"common.copied": "Copié",
"common.confirm": "Confirmer",
"common.none": "aucun",
"common.saved": "Enregistré",
"common.error": "Erreur",
"common.ok": "OK",
"common.refresh": "Actualiser",
"common.actions": "Actions",
"common.optional": "optionnel",
"common.search": "Rechercher",
"common.all": "tous",
// nav
"nav.dashboard": "Tableau de bord",
"nav.models": "Modèles",
"nav.routing": "Routage",
"nav.protocols": "Protocoles",
"nav.keys": "Clés API",
"nav.playground": "Playground",
"nav.settings": "Paramètres",
"nav.foot": "Gateway · v{v}",
// topbar / status
"status.router": "Routeur",
"status.gateway": "Gateway",
"status.online": "en ligne",
"status.offline": "hors ligne",
"status.checking": "vérification…",
"theme.toggle": "Thème",
"logout": "Déconnexion",
// login
"login.title": "allaigate Gateway",
"login.subtitle": "Saisissez le jeton administrateur pour gérer cette passerelle.",
"login.token": "Jeton admin",
"login.tokenPlaceholder": "coller le jeton admin",
"login.submit": "Ouvrir la console",
"login.hint": "Le jeton est affiché dans le log de la passerelle au démarrage, ou défini via [admin].token_env.",
"login.failed": "Jeton admin invalide.",
// dashboard
"dash.title": "Tableau de bord",
"dash.subtitle": "Trafic en direct, coûts et analyses de routage pour votre pool de modèles.",
"dash.range": "Période",
"dash.range.1h": "1h",
"dash.range.24h": "24h",
"dash.range.7d": "7j",
"dash.kpi.requests": "Requêtes",
"dash.kpi.tokens": "Tokens",
"dash.kpi.cost": "Coût",
"dash.kpi.latency": "Latence moy.",
"dash.kpi.success": "Taux de succès",
"dash.kpi.failovers": "Basculements",
"dash.series.title": "Requêtes dans le temps",
"dash.series.cost": "Coûts dans le temps",
"dash.series.empty": "Aucun trafic sur cette période.",
"dash.breakdown.title": "Répartition",
"dash.groupby.model": "par modèle",
"dash.groupby.tier": "par niveau",
"dash.groupby.label": "par tâche",
"dash.groupby.account": "par compte",
"dash.recent.title": "Requêtes récentes",
"dash.recent.empty": "Aucune requête enregistrée. Essayez le Playground.",
"dash.col.time": "Heure",
"dash.col.model": "Modèle",
"dash.col.tier": "Niveau",
"dash.col.task": "Tâche",
"dash.col.tokens": "Tokens",
"dash.col.cost": "Coût",
"dash.col.latency": "Latence",
"dash.col.outcome": "Résultat",
"dash.col.requests": "Requêtes",
"dash.col.errors": "Erreurs",
"dash.health.title": "Santé",
"dash.health.reachable": "accessible",
"dash.health.unreachable": "inaccessible",
// models
"models.title": "Modèles",
"models.subtitle": "Votre pool de modèles — combinez modèles locaux économiques et API cloud.",
"models.add": "Ajouter un modèle",
"models.empty": "Aucun modèle. Ajoutez votre premier modèle pour commencer.",
"models.col.id": "ID",
"models.col.provider": "Fournisseur",
"models.col.model": "Modèle",
"models.col.tier": "Niveau de coût",
"models.col.key": "Clé",
"models.col.caps": "Capacités",
"models.col.price": "Prix (entrée/sortie)",
"models.probe": "Sonder",
"models.probe.ok": "Accessible ({ms} ms)",
"models.probe.fail": "Échec : {err}",
"models.form.addTitle": "Ajouter un modèle",
"models.form.editTitle": "Modifier le modèle",
"models.form.id": "ID",
"models.form.idHint": "Identifiant unique utilisé dans le routage, ex. local-qwen",
"models.form.provider": "Fournisseur",
"models.form.baseUrl": "URL de base",
"models.form.model": "Nom du modèle",
"models.form.kind": "Type",
"models.form.costTier": "Niveau de coût",
"models.form.priceIn": "Prix entrée ($/1M)",
"models.form.priceOut": "Prix sortie ($/1M)",
"models.form.apiKeyEnv": "Nom de la var. d'env. pour la clé",
"models.form.apiKeyEnvHint": "Nom de la variable d'env. ou du secret contenant la clé de ce fournisseur.",
"models.form.caps": "Capacités",
"models.form.secret": "Valeur secrète",
"models.form.secretStored": "stocké",
"models.form.secretEnv": "depuis l'env.",
"models.form.secretMissing": "non défini",
"models.form.secretPlaceholder": "définir ou remplacer la clé (écriture seule)",
"models.deleteConfirm": "Supprimer le modèle \"{id}\" ?",
// routing
"routing.title": "Routage",
"routing.subtitle": "Associez les niveaux de complexité à une liste ordonnée de modèles (priorité = repli).",
"routing.default": "Modèle par défaut",
"routing.defaultHint": "Utilisé lorsque le routeur est inaccessible.",
"routing.tiers": "Niveaux",
"routing.tierHint": "Glissez les puces pour réordonner ; le premier est préféré, les autres sont des replis.",
"routing.addModel": "+ ajouter",
"routing.addTier": "Ajouter un niveau",
"routing.tierName": "Nom du niveau",
"routing.policy": "Politique",
"routing.policy.mode": "Mode",
"routing.policy.fixed": "Table fixe",
"routing.policy.costAware": "Sensible au coût",
"routing.policy.maxCost": "Coût max par requête ($)",
"routing.policy.minClass": "Classe minimale par niveau",
"routing.empty": "Aucun modèle disponible — ajoutez des modèles d'abord.",
// protocols
"protocols.title": "Protocoles",
"protocols.subtitle": "Activez uniquement les surfaces API entrantes nécessaires. Les bascules s'appliquent en direct.",
"protocols.implemented": "implémenté",
"protocols.planned": "planifié",
"protocols.openai_chat": "OpenAI Chat Completions",
"protocols.openai_chat.d": "POST /v1/chat/completions — compatible avec OpenAI SDK, LangChain, crewAI.",
"protocols.openai_completions": "OpenAI Completions (héritage)",
"protocols.openai_completions.d": "POST /v1/completions — intégrations héritées.",
"protocols.openai_embeddings": "OpenAI Embeddings",
"protocols.openai_embeddings.d": "POST /v1/embeddings — pipelines RAG.",
"protocols.openai_models": "OpenAI Models",
"protocols.openai_models.d": "GET /v1/models — découverte automatique des clients.",
"protocols.anthropic_messages": "Anthropic Messages",
"protocols.anthropic_messages.d": "POST /v1/messages — agents natifs Claude.",
"protocols.mcp": "MCP (Model Context Protocol)",
"protocols.mcp.d": "JSON-RPC / SSE — orchestrateurs natifs MCP.",
"protocols.native_passthrough": "Passage natif",
"protocols.native_passthrough.d": "POST /route — accès direct à la décision de routage.",
// keys
"keys.title": "Clés API",
"keys.subtitle": "Clés de passerelle virtuelles pour vos agents. Les clés fournisseurs ne quittent jamais la passerelle.",
"keys.add": "Nouvelle clé",
"keys.empty": "Aucune clé configurée — la passerelle est en mode ouvert (sans auth).",
"keys.openMode": "Mode ouvert : toute requête est acceptée (aucune clé requise).",
"keys.col.key": "Clé",
"keys.col.account": "Compte",
"keys.col.rate": "Débit / min",
"keys.col.models": "Modèles autorisés",
"keys.form.title": "Nouvelle clé API",
"keys.form.account": "Compte",
"keys.form.rate": "Limite de débit / min",
"keys.form.allow": "Modèles autorisés",
"keys.form.allowHint": "Laisser vide pour autoriser tous les modèles.",
"keys.form.custom": "Clé personnalisée (optionnel)",
"keys.form.customHint": "Laisser vide pour générer automatiquement sk-gw-…",
"keys.created": "Clé créée — copiez-la maintenant, elle ne sera plus affichée :",
"keys.revoke": "Révoquer",
"keys.revokeConfirm": "Révoquer cette clé ?",
"keys.allowAll": "tous",
// playground
"pg.title": "Playground",
"pg.subtitle": "Envoyez une invite via le pipeline en direct et inspectez la décision de routage.",
"pg.system": "Invite système",
"pg.systemPlaceholder": "message système optionnel",
"pg.prompt": "Invite",
"pg.promptPlaceholder": "Posez une question…",
"pg.mode": "Mode",
"pg.mode.auto": "Auto (routage)",
"pg.mode.pinned": "Modèle épinglé",
"pg.profile": "Profil",
"pg.model": "Modèle",
"pg.temp": "Température",
"pg.maxTokens": "Tokens max",
"pg.send": "Envoyer",
"pg.sending": "Envoi…",
"pg.answer": "Réponse",
"pg.empty": "La réponse et la décision de routage s'afficheront ici.",
"pg.decision": "Décision de routage",
"pg.task": "Tâche",
"pg.tier": "Niveau",
"pg.score": "Complexité",
"pg.selected": "Modèle sélectionné",
"pg.source": "Source du routage",
"pg.cost": "Coût",
"pg.latency": "Latence",
"pg.tokens": "Tokens (entrée/sortie)",
"pg.failover": "Basculement",
"pg.error": "Requête échouée",
// settings
"settings.title": "Paramètres",
"settings.subtitle": "Connexion passerelle, entrée de routage, fiabilité et observabilité.",
"settings.router": "Connexion routeur",
"settings.router.url": "URL du routeur",
"settings.router.keyEnv": "Nom de l'env. pour la clé du routeur",
"settings.router.timeout": "Délai d'expiration (ms)",
"settings.router.verifyTls": "Vérifier le certificat TLS",
"settings.router.taxonomy": "ID de taxonomie",
"settings.route": "Entrée de routage",
"settings.route.strategy": "Stratégie de texte",
"settings.route.maxChars": "Caractères max",
"settings.route.cacheTtl": "TTL du cache",
"settings.route.profile": "Profil par défaut",
"settings.breaker": "Disjoncteur",
"settings.breaker.threshold": "Seuil d'erreur",
"settings.breaker.cooldown": "Temporisation",
"settings.log": "Journalisation",
"settings.log.level": "Niveau de log",
"settings.log.bodies": "Journaliser les corps de messages (confidentialité désactivée)",
"settings.telemetry": "Télémétrie",
"settings.telemetry.metrics": "Exposer Prometheus /metrics",
"settings.cortiq": "Métadonnées de réponse",
"settings.cortiq.echo": "Inclure le bloc de routage cortiq dans les réponses",
"settings.stats": "Statistiques",
"settings.stats.enabled": "Enregistrer les statistiques de requêtes",
"settings.stats.file": "Fichier de stats (JSONL)",
"settings.stats.retention": "Rétention",
"settings.listen": "Adresse d'écoute",
"settings.export": "Exporter la config",
"settings.import": "Importer la config",
"settings.needsRestart": "Enregistré. Certains changements (adresse d'écoute) nécessitent un redémarrage.",
// toasts / misc
"toast.saved": "Modifications enregistrées",
"toast.deleted": "Supprimé",
"toast.probing": "Sondage…",
"toast.imported": "Config importée",
};
+256
View File
@@ -0,0 +1,256 @@
// Russian — ru. Keys must match en.js exactly; missing keys fall back to English.
export default {
// brand / common
"brand.sub": "Шлюз",
"common.loading": "Загрузка…",
"common.save": "Сохранить",
"common.saving": "Сохранение…",
"common.cancel": "Отмена",
"common.add": "Добавить",
"common.edit": "Изменить",
"common.delete": "Удалить",
"common.close": "Закрыть",
"common.test": "Тест",
"common.copy": "Копировать",
"common.copied": "Скопировано",
"common.confirm": "Подтвердить",
"common.none": "нет",
"common.saved": "Сохранено",
"common.error": "Ошибка",
"common.ok": "OK",
"common.refresh": "Обновить",
"common.actions": "Действия",
"common.optional": "необязательно",
"common.search": "Поиск",
"common.all": "все",
// nav
"nav.dashboard": "Дашборд",
"nav.models": "Модели",
"nav.routing": "Роутинг",
"nav.protocols": "Протоколы",
"nav.keys": "API-ключи",
"nav.playground": "Playground",
"nav.settings": "Настройки",
"nav.foot": "Шлюз · v{v}",
// topbar / status
"status.router": "Роутер",
"status.gateway": "Шлюз",
"status.online": "в сети",
"status.offline": "недоступен",
"status.checking": "проверка…",
"theme.toggle": "Тема",
"logout": "Выйти",
// login
"login.title": "allaigate Gateway",
"login.subtitle": "Введите токен администратора для управления этим шлюзом.",
"login.token": "Токен администратора",
"login.tokenPlaceholder": "вставьте токен",
"login.submit": "Войти в консоль",
"login.hint": "Токен печатается в логе шлюза при запуске или задаётся через [admin].token_env.",
"login.failed": "Неверный токен администратора.",
// dashboard
"dash.title": "Дашборд",
"dash.subtitle": "Трафик в реальном времени, расходы и аналитика роутинга по пулу моделей.",
"dash.range": "Период",
"dash.range.1h": "1ч",
"dash.range.24h": "24ч",
"dash.range.7d": "7д",
"dash.kpi.requests": "Запросы",
"dash.kpi.tokens": "Токены",
"dash.kpi.cost": "Стоимость",
"dash.kpi.latency": "Ср. задержка",
"dash.kpi.success": "Успешность",
"dash.kpi.failovers": "Переключения",
"dash.series.title": "Запросы по времени",
"dash.series.cost": "Расходы по времени",
"dash.series.empty": "Трафика за этот период нет.",
"dash.breakdown.title": "Разбивка",
"dash.groupby.model": "по модели",
"dash.groupby.tier": "по уровню",
"dash.groupby.label": "по задаче",
"dash.groupby.account": "по аккаунту",
"dash.recent.title": "Последние запросы",
"dash.recent.empty": "Запросов ещё нет. Попробуйте Playground.",
"dash.col.time": "Время",
"dash.col.model": "Модель",
"dash.col.tier": "Уровень",
"dash.col.task": "Задача",
"dash.col.tokens": "Токены",
"dash.col.cost": "Стоимость",
"dash.col.latency": "Задержка",
"dash.col.outcome": "Результат",
"dash.col.requests": "Запросы",
"dash.col.errors": "Ошибки",
"dash.health.title": "Состояние",
"dash.health.reachable": "доступен",
"dash.health.unreachable": "недоступен",
// models
"models.title": "Модели",
"models.subtitle": "Пул моделей — сочетайте дешёвые локальные модели с облачными API.",
"models.add": "Добавить модель",
"models.empty": "Моделей нет. Добавьте первую модель.",
"models.col.id": "ID",
"models.col.provider": "Провайдер",
"models.col.model": "Модель",
"models.col.tier": "Уровень цены",
"models.col.key": "Ключ",
"models.col.caps": "Возможности",
"models.col.price": "Цена (вх/исх)",
"models.probe": "Проверить",
"models.probe.ok": "Доступна ({ms} мс)",
"models.probe.fail": "Ошибка: {err}",
"models.form.addTitle": "Добавить модель",
"models.form.editTitle": "Редактировать модель",
"models.form.id": "ID",
"models.form.idHint": "Уникальный ID для роутинга, например local-qwen",
"models.form.provider": "Провайдер",
"models.form.baseUrl": "Base URL",
"models.form.model": "Имя модели",
"models.form.kind": "Тип",
"models.form.costTier": "Уровень цены",
"models.form.priceIn": "Цена вх. ($/1M)",
"models.form.priceOut": "Цена исх. ($/1M)",
"models.form.apiKeyEnv": "Имя env-переменной с ключом",
"models.form.apiKeyEnvHint": "Имя переменной окружения или секрета с ключом провайдера.",
"models.form.caps": "Возможности",
"models.form.secret": "Значение секрета",
"models.form.secretStored": "сохранён",
"models.form.secretEnv": "из env",
"models.form.secretMissing": "не задан",
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
"models.deleteConfirm": "Удалить модель \"{id}\"?",
// routing
"routing.title": "Роутинг",
"routing.subtitle": "Сопоставьте уровни сложности с упорядоченным списком моделей (приоритет = fallback).",
"routing.default": "Модель по умолчанию",
"routing.defaultHint": "Используется, если роутер недоступен.",
"routing.tiers": "Уровни",
"routing.tierHint": "Перетащите чипы для изменения порядка; первый — предпочтительный, остальные — резервные.",
"routing.addModel": "+ добавить",
"routing.addTier": "Добавить уровень",
"routing.tierName": "Название уровня",
"routing.policy": "Политика",
"routing.policy.mode": "Режим",
"routing.policy.fixed": "Фиксированная таблица",
"routing.policy.costAware": "С учётом стоимости",
"routing.policy.maxCost": "Макс. стоимость запроса ($)",
"routing.policy.minClass": "Мин. класс на уровень",
"routing.empty": "Моделей нет — сначала добавьте модели.",
// protocols
"protocols.title": "Протоколы",
"protocols.subtitle": "Включайте только нужные входящие API. Переключатели применяются мгновенно.",
"protocols.implemented": "реализован",
"protocols.planned": "планируется",
"protocols.openai_chat": "OpenAI Chat Completions",
"protocols.openai_chat.d": "POST /v1/chat/completions — работает с OpenAI SDK, LangChain, crewAI.",
"protocols.openai_completions": "OpenAI Completions (устаревший)",
"protocols.openai_completions.d": "POST /v1/completions — устаревшие интеграции.",
"protocols.openai_embeddings": "OpenAI Embeddings",
"protocols.openai_embeddings.d": "POST /v1/embeddings — RAG-пайплайны.",
"protocols.openai_models": "OpenAI Models",
"protocols.openai_models.d": "GET /v1/models — автообнаружение клиентами.",
"protocols.anthropic_messages": "Anthropic Messages",
"protocols.anthropic_messages.d": "POST /v1/messages — Claude-нативные агенты.",
"protocols.mcp": "MCP (Model Context Protocol)",
"protocols.mcp.d": "JSON-RPC / SSE — MCP-нативные оркестраторы.",
"protocols.native_passthrough": "Нативный проброс",
"protocols.native_passthrough.d": "POST /route — прямой доступ к решению роутера.",
// keys
"keys.title": "API-ключи",
"keys.subtitle": "Виртуальные ключи шлюза для агентов. Ключи провайдеров не покидают шлюз.",
"keys.add": "Новый ключ",
"keys.empty": "Ключей нет — шлюз в открытом режиме (без аутентификации).",
"keys.openMode": "Открытый режим: любой запрос принимается (ключ не нужен).",
"keys.col.key": "Ключ",
"keys.col.account": "Аккаунт",
"keys.col.rate": "Лимит / мин",
"keys.col.models": "Разрешённые модели",
"keys.form.title": "Новый API-ключ",
"keys.form.account": "Аккаунт",
"keys.form.rate": "Лимит запросов / мин",
"keys.form.allow": "Разрешённые модели",
"keys.form.allowHint": "Оставьте пустым, чтобы разрешить все модели.",
"keys.form.custom": "Произвольный ключ (необязательно)",
"keys.form.customHint": "Оставьте пустым для автогенерации sk-gw-…",
"keys.created": "Ключ создан — скопируйте его сейчас, он больше не отобразится:",
"keys.revoke": "Отозвать",
"keys.revokeConfirm": "Отозвать этот ключ?",
"keys.allowAll": "все",
// playground
"pg.title": "Playground",
"pg.subtitle": "Отправьте запрос через активный пайплайн и изучите решение роутера.",
"pg.system": "Системный промпт",
"pg.systemPlaceholder": "необязательное системное сообщение",
"pg.prompt": "Промпт",
"pg.promptPlaceholder": "Задайте вопрос…",
"pg.mode": "Режим",
"pg.mode.auto": "Авто (роутинг)",
"pg.mode.pinned": "Фиксированная модель",
"pg.profile": "Профиль",
"pg.model": "Модель",
"pg.temp": "Температура",
"pg.maxTokens": "Макс. токены",
"pg.send": "Отправить",
"pg.sending": "Отправка…",
"pg.answer": "Ответ",
"pg.empty": "Ответ и решение роутера появятся здесь.",
"pg.decision": "Решение роутера",
"pg.task": "Задача",
"pg.tier": "Уровень",
"pg.score": "Сложность",
"pg.selected": "Выбранная модель",
"pg.source": "Источник маршрута",
"pg.cost": "Стоимость",
"pg.latency": "Задержка",
"pg.tokens": "Токены (вх/исх)",
"pg.failover": "Failover",
"pg.error": "Ошибка запроса",
// settings
"settings.title": "Настройки",
"settings.subtitle": "Подключение к шлюзу, входные данные роутинга, надёжность и наблюдаемость.",
"settings.router": "Подключение к роутеру",
"settings.router.url": "URL роутера",
"settings.router.keyEnv": "Имя env-переменной с ключом роутера",
"settings.router.timeout": "Таймаут (мс)",
"settings.router.verifyTls": "Проверять TLS-сертификат",
"settings.router.taxonomy": "ID таксономии",
"settings.route": "Входные данные роутинга",
"settings.route.strategy": "Стратегия текста",
"settings.route.maxChars": "Макс. символов",
"settings.route.cacheTtl": "TTL кэша",
"settings.route.profile": "Профиль по умолчанию",
"settings.breaker": "Автоматический выключатель",
"settings.breaker.threshold": "Порог ошибок",
"settings.breaker.cooldown": "Время восстановления",
"settings.log": "Логирование",
"settings.log.level": "Уровень логов",
"settings.log.bodies": "Логировать тела сообщений (приватность отключена)",
"settings.telemetry": "Телеметрия",
"settings.telemetry.metrics": "Включить Prometheus /metrics",
"settings.cortiq": "Метаданные ответа",
"settings.cortiq.echo": "Включать блок роутинга cortiq в ответы",
"settings.stats": "Статистика",
"settings.stats.enabled": "Записывать статистику запросов",
"settings.stats.file": "Файл статистики (JSONL)",
"settings.stats.retention": "Срок хранения",
"settings.listen": "Адрес прослушивания",
"settings.export": "Экспорт конфигурации",
"settings.import": "Импорт конфигурации",
"settings.needsRestart": "Сохранено. Некоторые изменения (адрес прослушивания) требуют перезапуска.",
// toasts / misc
"toast.saved": "Изменения сохранены",
"toast.deleted": "Удалено",
"toast.probing": "Проверка…",
"toast.imported": "Конфигурация импортирована",
};
+256
View File
@@ -0,0 +1,256 @@
// Turkish — tam çeviri; eksik anahtarlar İngilizce'ye geri döner.
export default {
// brand / common
"brand.sub": "Gateway",
"common.loading": "Yükleniyor…",
"common.save": "Kaydet",
"common.saving": "Kaydediliyor…",
"common.cancel": "İptal",
"common.add": "Ekle",
"common.edit": "Düzenle",
"common.delete": "Sil",
"common.close": "Kapat",
"common.test": "Test",
"common.copy": "Kopyala",
"common.copied": "Kopyalandı",
"common.confirm": "Onayla",
"common.none": "yok",
"common.saved": "Kaydedildi",
"common.error": "Hata",
"common.ok": "Tamam",
"common.refresh": "Yenile",
"common.actions": "İşlemler",
"common.optional": "isteğe bağlı",
"common.search": "Ara",
"common.all": "tümü",
// nav
"nav.dashboard": "Pano",
"nav.models": "Modeller",
"nav.routing": "Yönlendirme",
"nav.protocols": "Protokoller",
"nav.keys": "API anahtarları",
"nav.playground": "Oyun alanı",
"nav.settings": "Ayarlar",
"nav.foot": "Gateway · v{v}",
// topbar / status
"status.router": "Yönlendirici",
"status.gateway": "Gateway",
"status.online": "çevrimiçi",
"status.offline": "çevrimdışı",
"status.checking": "kontrol ediliyor…",
"theme.toggle": "Tema",
"logout": "Çıkış yap",
// login
"login.title": "allaigate Gateway",
"login.subtitle": "Bu gateway'i yönetmek için yönetici token'ını girin.",
"login.token": "Yönetici token'ı",
"login.tokenPlaceholder": "yönetici token'ını yapıştırın",
"login.submit": "Konsolu aç",
"login.hint": "Token, başlangıçta gateway günlüğüne yazdırılır veya [admin].token_env ile ayarlanır.",
"login.failed": "Geçersiz yönetici token'ı.",
// dashboard
"dash.title": "Pano",
"dash.subtitle": "Model havuzunuz için canlı trafik, maliyet ve yönlendirme analitiği.",
"dash.range": "Aralık",
"dash.range.1h": "1s",
"dash.range.24h": "24s",
"dash.range.7d": "7g",
"dash.kpi.requests": "İstekler",
"dash.kpi.tokens": "Token'lar",
"dash.kpi.cost": "Maliyet",
"dash.kpi.latency": "Ort. gecikme",
"dash.kpi.success": "Başarı oranı",
"dash.kpi.failovers": "Yedeklemeler",
"dash.series.title": "Zamana göre istekler",
"dash.series.cost": "Zamana göre maliyet",
"dash.series.empty": "Bu aralıkta henüz trafik yok.",
"dash.breakdown.title": "Dağılım",
"dash.groupby.model": "modele göre",
"dash.groupby.tier": "kademeye göre",
"dash.groupby.label": "göreve göre",
"dash.groupby.account": "hesaba göre",
"dash.recent.title": "Son istekler",
"dash.recent.empty": "Henüz kayıtlı istek yok. Oyun alanını deneyin.",
"dash.col.time": "Zaman",
"dash.col.model": "Model",
"dash.col.tier": "Kademe",
"dash.col.task": "Görev",
"dash.col.tokens": "Token'lar",
"dash.col.cost": "Maliyet",
"dash.col.latency": "Gecikme",
"dash.col.outcome": "Sonuç",
"dash.col.requests": "İstekler",
"dash.col.errors": "Hatalar",
"dash.health.title": "Sağlık",
"dash.health.reachable": "erişilebilir",
"dash.health.unreachable": "erişilemiyor",
// models
"models.title": "Modeller",
"models.subtitle": "Model havuzunuz — ucuz yerel modelleri bulut API'leriyle karıştırın.",
"models.add": "Model ekle",
"models.empty": "Henüz model yok. Başlamak için ilk modelinizi ekleyin.",
"models.col.id": "ID",
"models.col.provider": "Sağlayıcı",
"models.col.model": "Model",
"models.col.tier": "Maliyet kademesi",
"models.col.key": "Anahtar",
"models.col.caps": "Yetenekler",
"models.col.price": "Fiyat (giriş/çıkış)",
"models.probe": "Sına",
"models.probe.ok": "Erişilebilir ({ms} ms)",
"models.probe.fail": "Başarısız: {err}",
"models.form.addTitle": "Model ekle",
"models.form.editTitle": "Modeli düzenle",
"models.form.id": "ID",
"models.form.idHint": "Yönlendirmede kullanılan benzersiz id, örn. local-qwen",
"models.form.provider": "Sağlayıcı",
"models.form.baseUrl": "Temel URL",
"models.form.model": "Model adı",
"models.form.kind": "Tür",
"models.form.costTier": "Maliyet kademesi",
"models.form.priceIn": "Giriş fiyatı ($/1M)",
"models.form.priceOut": "Çıkış fiyatı ($/1M)",
"models.form.apiKeyEnv": "API anahtarı ortam değişkeni adı",
"models.form.apiKeyEnvHint": "Bu sağlayıcının anahtarını tutan ortam değişkeni / saklı gizli adı.",
"models.form.caps": "Yetenekler",
"models.form.secret": "Gizli değer",
"models.form.secretStored": "saklandı",
"models.form.secretEnv": "ortamdan",
"models.form.secretMissing": "ayarlanmadı",
"models.form.secretPlaceholder": "anahtarı ayarla veya değiştir (yalnızca yazma)",
"models.deleteConfirm": "\"{id}\" modelini sil?",
// routing
"routing.title": "Yönlendirme",
"routing.subtitle": "Karmaşıklık kademelerini sıralı model listesiyle eşleştirin (öncelik = yedek).",
"routing.default": "Varsayılan model",
"routing.defaultHint": "Yönlendirici erişilemez olduğunda kullanılır.",
"routing.tiers": "Kademeler",
"routing.tierHint": "Sıralamak için çipleri sürükleyin; birincisi tercih edilir, diğerleri yedektir.",
"routing.addModel": "+ ekle",
"routing.addTier": "Kademe ekle",
"routing.tierName": "Kademe adı",
"routing.policy": "İlke",
"routing.policy.mode": "Mod",
"routing.policy.fixed": "Sabit tablo",
"routing.policy.costAware": "Maliyet bilinçli",
"routing.policy.maxCost": "İstek başına maks. maliyet ($)",
"routing.policy.minClass": "Kademe başına minimum sınıf",
"routing.empty": "Kullanılabilir model yok — önce model ekleyin.",
// protocols
"protocols.title": "Protokoller",
"protocols.subtitle": "Yalnızca ihtiyaç duyduğunuz gelen API yüzeylerini etkinleştirin. Geçişler anında uygulanır.",
"protocols.implemented": "uygulandı",
"protocols.planned": "planlandı",
"protocols.openai_chat": "OpenAI Chat Completions",
"protocols.openai_chat.d": "POST /v1/chat/completions — OpenAI SDK, LangChain, crewAI ile çalışır.",
"protocols.openai_completions": "OpenAI Completions (eski)",
"protocols.openai_completions.d": "POST /v1/completions — eski entegrasyonlar.",
"protocols.openai_embeddings": "OpenAI Embeddings",
"protocols.openai_embeddings.d": "POST /v1/embeddings — RAG ardışık düzenleri.",
"protocols.openai_models": "OpenAI Models",
"protocols.openai_models.d": "GET /v1/models — istemci otomatik keşfi.",
"protocols.anthropic_messages": "Anthropic Messages",
"protocols.anthropic_messages.d": "POST /v1/messages — Claude yerel ajanları.",
"protocols.mcp": "MCP (Model Context Protocol)",
"protocols.mcp.d": "JSON-RPC / SSE — MCP yerel orkestratörler.",
"protocols.native_passthrough": "Yerel geçiş",
"protocols.native_passthrough.d": "POST /route — yönlendirme kararına doğrudan erişim.",
// keys
"keys.title": "API anahtarları",
"keys.subtitle": "Ajanlarınız için sanal gateway anahtarları. Sağlayıcı anahtarları gateway'den hiç çıkmaz.",
"keys.add": "Yeni anahtar",
"keys.empty": "Anahtar yapılandırılmadı — gateway açık modda (kimlik doğrulama yok).",
"keys.openMode": "Açık mod: tüm istekler kabul edilir (anahtar gerekmez).",
"keys.col.key": "Anahtar",
"keys.col.account": "Hesap",
"keys.col.rate": "Oran / dak",
"keys.col.models": "İzin verilen modeller",
"keys.form.title": "Yeni API anahtarı",
"keys.form.account": "Hesap",
"keys.form.rate": "Oran sınırı / dak",
"keys.form.allow": "İzin verilen modeller",
"keys.form.allowHint": "Tüm modellere izin vermek için boş bırakın.",
"keys.form.custom": "Özel anahtar (isteğe bağlı)",
"keys.form.customHint": "sk-gw-… otomatik oluşturmak için boş bırakın.",
"keys.created": "Anahtar oluşturuldu — şimdi kopyalayın, bir daha gösterilmeyecek:",
"keys.revoke": "İptal et",
"keys.revokeConfirm": "Bu anahtarı iptal et?",
"keys.allowAll": "tümü",
// playground
"pg.title": "Oyun alanı",
"pg.subtitle": "Canlı ardışık düzen üzerinden istem gönderin ve yönlendirme kararını inceleyin.",
"pg.system": "Sistem istemi",
"pg.systemPlaceholder": "isteğe bağlı sistem mesajı",
"pg.prompt": "İstem",
"pg.promptPlaceholder": "Bir şey sorun…",
"pg.mode": "Mod",
"pg.mode.auto": "Otomatik (yönlendir)",
"pg.mode.pinned": "Sabitlenmiş model",
"pg.profile": "Profil",
"pg.model": "Model",
"pg.temp": "Sıcaklık",
"pg.maxTokens": "Maks. token",
"pg.send": "Gönder",
"pg.sending": "Gönderiliyor…",
"pg.answer": "Yanıt",
"pg.empty": "Yanıt ve yönlendirme kararı burada görünecek.",
"pg.decision": "Yönlendirme kararı",
"pg.task": "Görev",
"pg.tier": "Kademe",
"pg.score": "Karmaşıklık",
"pg.selected": "Seçilen model",
"pg.source": "Rota kaynağı",
"pg.cost": "Maliyet",
"pg.latency": "Gecikme",
"pg.tokens": "Token'lar (giriş/çıkış)",
"pg.failover": "Yedekleme",
"pg.error": "İstek başarısız",
// settings
"settings.title": "Ayarlar",
"settings.subtitle": "Gateway bağlantısı, yönlendirme girişi, güvenilirlik ve gözlemlenebilirlik.",
"settings.router": "Yönlendirici bağlantısı",
"settings.router.url": "Yönlendirici URL'si",
"settings.router.keyEnv": "Yönlendirici anahtarı ortam değişkeni adı",
"settings.router.timeout": "Zaman aşımı (ms)",
"settings.router.verifyTls": "TLS sertifikasını doğrula",
"settings.router.taxonomy": "Taksonomi id",
"settings.route": "Yönlendirme girişi",
"settings.route.strategy": "Metin stratejisi",
"settings.route.maxChars": "Maks. karakter",
"settings.route.cacheTtl": "Önbellek TTL",
"settings.route.profile": "Varsayılan profil",
"settings.breaker": "Devre kesici",
"settings.breaker.threshold": "Hata eşiği",
"settings.breaker.cooldown": "Bekleme süresi",
"settings.log": "Günlükleme",
"settings.log.level": "Günlük seviyesi",
"settings.log.bodies": "Mesaj gövdelerini günlükle (gizlilik kapalı)",
"settings.telemetry": "Telemetri",
"settings.telemetry.metrics": "Prometheus /metrics'i aç",
"settings.cortiq": "Yanıt meta verisi",
"settings.cortiq.echo": "Yanıtlarda cortiq yönlendirme bloğunu yansıt",
"settings.stats": "İstatistikler",
"settings.stats.enabled": "İstek istatistiklerini kaydet",
"settings.stats.file": "İstatistik dosyası (JSONL)",
"settings.stats.retention": "Saklama süresi",
"settings.listen": "Dinleme adresi",
"settings.export": "Yapılandırmayı dışa aktar",
"settings.import": "Yapılandırmayı içe aktar",
"settings.needsRestart": "Kaydedildi. Bazı değişiklikler (dinleme adresi) yeniden başlatma gerektirir.",
// toasts / misc
"toast.saved": "Değişiklikler kaydedildi",
"toast.deleted": "Silindi",
"toast.probing": "Sınanıyor…",
"toast.imported": "Yapılandırma içe aktarıldı",
};
+256
View File
@@ -0,0 +1,256 @@
// 简体中文 — Simplified Chinese translation. Keys must match en.js exactly.
export default {
// brand / common
"brand.sub": "网关",
"common.loading": "加载中…",
"common.save": "保存",
"common.saving": "保存中…",
"common.cancel": "取消",
"common.add": "添加",
"common.edit": "编辑",
"common.delete": "删除",
"common.close": "关闭",
"common.test": "测试",
"common.copy": "复制",
"common.copied": "已复制",
"common.confirm": "确认",
"common.none": "无",
"common.saved": "已保存",
"common.error": "错误",
"common.ok": "确定",
"common.refresh": "刷新",
"common.actions": "操作",
"common.optional": "可选",
"common.search": "搜索",
"common.all": "全部",
// nav
"nav.dashboard": "仪表盘",
"nav.models": "模型",
"nav.routing": "路由",
"nav.protocols": "协议",
"nav.keys": "API 密钥",
"nav.playground": "沙盒",
"nav.settings": "设置",
"nav.foot": "网关 · v{v}",
// topbar / status
"status.router": "路由器",
"status.gateway": "网关",
"status.online": "在线",
"status.offline": "离线",
"status.checking": "检测中…",
"theme.toggle": "主题",
"logout": "退出",
// login
"login.title": "allaigate Gateway",
"login.subtitle": "输入管理员令牌以管理此网关。",
"login.token": "管理员令牌",
"login.tokenPlaceholder": "粘贴管理员令牌",
"login.submit": "进入控制台",
"login.hint": "令牌将在网关启动日志中打印,或通过 [admin].token_env 设置。",
"login.failed": "无效的管理员令牌。",
// dashboard
"dash.title": "仪表盘",
"dash.subtitle": "模型池的实时流量、费用与路由分析。",
"dash.range": "时间范围",
"dash.range.1h": "1小时",
"dash.range.24h": "24小时",
"dash.range.7d": "7天",
"dash.kpi.requests": "请求数",
"dash.kpi.tokens": "Token 数",
"dash.kpi.cost": "费用",
"dash.kpi.latency": "平均延迟",
"dash.kpi.success": "成功率",
"dash.kpi.failovers": "故障转移",
"dash.series.title": "请求趋势",
"dash.series.cost": "费用趋势",
"dash.series.empty": "此时间段内暂无流量。",
"dash.breakdown.title": "明细",
"dash.groupby.model": "按模型",
"dash.groupby.tier": "按层级",
"dash.groupby.label": "按任务",
"dash.groupby.account": "按账号",
"dash.recent.title": "最近请求",
"dash.recent.empty": "暂无请求记录,请前往沙盒测试。",
"dash.col.time": "时间",
"dash.col.model": "模型",
"dash.col.tier": "层级",
"dash.col.task": "任务",
"dash.col.tokens": "Token",
"dash.col.cost": "费用",
"dash.col.latency": "延迟",
"dash.col.outcome": "结果",
"dash.col.requests": "请求数",
"dash.col.errors": "错误数",
"dash.health.title": "健康状态",
"dash.health.reachable": "可达",
"dash.health.unreachable": "不可达",
// models
"models.title": "模型",
"models.subtitle": "模型池——本地低成本模型与云端 API 混合部署。",
"models.add": "添加模型",
"models.empty": "暂无模型,请添加第一个模型。",
"models.col.id": "ID",
"models.col.provider": "提供商",
"models.col.model": "模型",
"models.col.tier": "费用层级",
"models.col.key": "密钥",
"models.col.caps": "能力",
"models.col.price": "价格(输入/输出)",
"models.probe": "探测",
"models.probe.ok": "可达({ms} ms)",
"models.probe.fail": "失败:{err}",
"models.form.addTitle": "添加模型",
"models.form.editTitle": "编辑模型",
"models.form.id": "ID",
"models.form.idHint": "路由中使用的唯一 ID,例如 local-qwen",
"models.form.provider": "提供商",
"models.form.baseUrl": "Base URL",
"models.form.model": "模型名称",
"models.form.kind": "类型",
"models.form.costTier": "费用层级",
"models.form.priceIn": "输入价格($/1M)",
"models.form.priceOut": "输出价格($/1M)",
"models.form.apiKeyEnv": "API 密钥环境变量名",
"models.form.apiKeyEnvHint": "持有该提供商密钥的环境变量或存储机密名称。",
"models.form.caps": "能力",
"models.form.secret": "密钥值",
"models.form.secretStored": "已存储",
"models.form.secretEnv": "来自环境变量",
"models.form.secretMissing": "未设置",
"models.form.secretPlaceholder": "设置或替换密钥(只写)",
"models.deleteConfirm": "删除模型 \"{id}\"?",
// routing
"routing.title": "路由",
"routing.subtitle": "将复杂度层级映射到有序模型列表(优先级 = 回退顺序)。",
"routing.default": "默认模型",
"routing.defaultHint": "路由器不可达时使用。",
"routing.tiers": "层级",
"routing.tierHint": "拖动标签重新排序;第一个为首选,其余为回退。",
"routing.addModel": "+ 添加",
"routing.addTier": "添加层级",
"routing.tierName": "层级名称",
"routing.policy": "策略",
"routing.policy.mode": "模式",
"routing.policy.fixed": "固定表",
"routing.policy.costAware": "成本感知",
"routing.policy.maxCost": "单请求最大费用($)",
"routing.policy.minClass": "每层最低等级",
"routing.empty": "暂无可用模型,请先添加模型。",
// protocols
"protocols.title": "协议",
"protocols.subtitle": "仅启用所需的入站 API 接口,切换立即生效。",
"protocols.implemented": "已实现",
"protocols.planned": "计划中",
"protocols.openai_chat": "OpenAI Chat Completions",
"protocols.openai_chat.d": "POST /v1/chat/completions — 兼容 OpenAI SDK、LangChain、crewAI。",
"protocols.openai_completions": "OpenAI Completions(旧版)",
"protocols.openai_completions.d": "POST /v1/completions — 旧版集成。",
"protocols.openai_embeddings": "OpenAI Embeddings",
"protocols.openai_embeddings.d": "POST /v1/embeddings — RAG 管道。",
"protocols.openai_models": "OpenAI Models",
"protocols.openai_models.d": "GET /v1/models — 客户端自动发现。",
"protocols.anthropic_messages": "Anthropic Messages",
"protocols.anthropic_messages.d": "POST /v1/messages — Claude 原生智能体。",
"protocols.mcp": "MCP(模型上下文协议)",
"protocols.mcp.d": "JSON-RPC / SSE — MCP 原生编排器。",
"protocols.native_passthrough": "原生透传",
"protocols.native_passthrough.d": "POST /route — 直接访问路由决策。",
// keys
"keys.title": "API 密钥",
"keys.subtitle": "为您的智能体提供虚拟网关密钥,提供商密钥不离开网关。",
"keys.add": "新建密钥",
"keys.empty": "未配置密钥——网关处于开放模式(无需认证)。",
"keys.openMode": "开放模式:接受所有请求(无需密钥)。",
"keys.col.key": "密钥",
"keys.col.account": "账号",
"keys.col.rate": "速率/分钟",
"keys.col.models": "允许模型",
"keys.form.title": "新建 API 密钥",
"keys.form.account": "账号",
"keys.form.rate": "速率限制/分钟",
"keys.form.allow": "允许模型",
"keys.form.allowHint": "留空表示允许所有模型。",
"keys.form.custom": "自定义密钥(可选)",
"keys.form.customHint": "留空将自动生成 sk-gw-…",
"keys.created": "密钥已创建——请立即复制,之后将不再显示:",
"keys.revoke": "吊销",
"keys.revokeConfirm": "确认吊销此密钥?",
"keys.allowAll": "全部",
// playground
"pg.title": "沙盒",
"pg.subtitle": "通过实时管道发送提示并查看路由决策。",
"pg.system": "系统提示",
"pg.systemPlaceholder": "可选系统消息",
"pg.prompt": "提示",
"pg.promptPlaceholder": "请输入问题…",
"pg.mode": "模式",
"pg.mode.auto": "自动(路由)",
"pg.mode.pinned": "固定模型",
"pg.profile": "配置文件",
"pg.model": "模型",
"pg.temp": "温度",
"pg.maxTokens": "最大 Token 数",
"pg.send": "发送",
"pg.sending": "发送中…",
"pg.answer": "回答",
"pg.empty": "回答和路由决策将在此显示。",
"pg.decision": "路由决策",
"pg.task": "任务",
"pg.tier": "层级",
"pg.score": "复杂度",
"pg.selected": "已选模型",
"pg.source": "路由来源",
"pg.cost": "费用",
"pg.latency": "延迟",
"pg.tokens": "Token(输入/输出)",
"pg.failover": "故障转移",
"pg.error": "请求失败",
// settings
"settings.title": "设置",
"settings.subtitle": "网关连接、路由输入、可靠性与可观测性。",
"settings.router": "路由器连接",
"settings.router.url": "路由器 URL",
"settings.router.keyEnv": "路由器密钥环境变量名",
"settings.router.timeout": "超时(ms)",
"settings.router.verifyTls": "验证 TLS 证书",
"settings.router.taxonomy": "分类体系 ID",
"settings.route": "路由输入",
"settings.route.strategy": "文本策略",
"settings.route.maxChars": "最大字符数",
"settings.route.cacheTtl": "缓存 TTL",
"settings.route.profile": "默认配置文件",
"settings.breaker": "熔断器",
"settings.breaker.threshold": "错误阈值",
"settings.breaker.cooldown": "冷却时间",
"settings.log": "日志",
"settings.log.level": "日志级别",
"settings.log.bodies": "记录消息体(隐私关闭)",
"settings.telemetry": "遥测",
"settings.telemetry.metrics": "暴露 Prometheus /metrics",
"settings.cortiq": "响应元数据",
"settings.cortiq.echo": "在响应中回显 cortiq 路由块",
"settings.stats": "统计",
"settings.stats.enabled": "记录请求统计",
"settings.stats.file": "统计文件(JSONL)",
"settings.stats.retention": "保留期",
"settings.listen": "监听地址",
"settings.export": "导出配置",
"settings.import": "导入配置",
"settings.needsRestart": "已保存。部分更改(监听地址)需要重启生效。",
// toasts / misc
"toast.saved": "更改已保存",
"toast.deleted": "已删除",
"toast.probing": "探测中…",
"toast.imported": "配置已导入",
};
+103
View File
@@ -0,0 +1,103 @@
// Tiny DOM helpers — no framework. `h` builds elements; `mount` swaps children.
import { t } from "./i18n.js";
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "html") el.innerHTML = v;
else if (k === "dataset") Object.assign(el.dataset, v);
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2).toLowerCase(), v);
else if (v === true) el.setAttribute(k, "");
else el.setAttribute(k, v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
el.appendChild(typeof c === "object" ? c : document.createTextNode(String(c)));
}
return el;
}
export function clear(node) {
while (node && node.firstChild) node.removeChild(node.firstChild);
}
export function mount(node, ...children) {
clear(node);
for (const c of children.flat()) if (c != null && c !== false) node.appendChild(c);
return node;
}
export function toast(msg, kind = "info", ms = 3200) {
const box = document.getElementById("toasts");
if (!box) return;
const el = h("div", { class: `toast ${kind}` }, msg);
box.appendChild(el);
setTimeout(() => {
el.style.opacity = "0";
el.style.transition = "opacity .2s";
setTimeout(() => el.remove(), 220);
}, ms);
}
// Modal dialog. `body` is a DOM node; `onSave` returns a Promise; returns nothing.
export function modal(title, body, onSave, saveLabel) {
const backdrop = h("div", { class: "modal-backdrop" });
const close = () => backdrop.remove();
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
const saveBtn = h("button", { class: "btn primary" }, saveLabel || t("common.save"));
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
try {
const keep = await onSave();
if (keep !== false) close();
} finally {
saveBtn.disabled = false;
}
});
const card = h(
"div",
{ class: "modal" },
h("h3", {}, title),
body,
h(
"div",
{ class: "modal-foot" },
h("button", { class: "btn ghost", onClick: close }, t("common.cancel")),
onSave ? saveBtn : null
)
);
backdrop.appendChild(card);
document.body.appendChild(backdrop);
return { close };
}
export function confirmDialog(message) {
return Promise.resolve(window.confirm(message));
}
// number formatting
export function money(n) {
if (n == null) return "—";
if (n === 0) return "$0";
if (Math.abs(n) < 0.01) return "$" + n.toFixed(6);
return "$" + n.toFixed(4);
}
export function ms(n) {
return n == null ? "—" : `${Math.round(Number(n))} ms`;
}
export function num(n) {
if (n == null) return "—";
return Number(n).toLocaleString();
}
export function pct(n) {
if (n == null) return "—";
return (n * 100).toFixed(1) + "%";
}
export function timeAgo(unixSecs) {
const d = new Date(unixSecs * 1000);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
+218
View File
@@ -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 "";
}
+128
View File
@@ -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;
}
+40
View File
@@ -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"))
)
);
}
+206
View File
@@ -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")
)
)
)
);
}
+137
View File
@@ -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;
}
+67
View File
@@ -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;
}
+158
View File
@@ -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;
}
+191
View File
@@ -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;
}