The office-readiness audit found the foundation missing: configured API keys were decorative. Any bearer — or none — passed every model endpoint, per-key rate limits existed only in the form, and all real traffic was recorded as "anonymous", leaving the per-key spend table permanently blank. A middleware on the model surface (/v1/*, /route, /mcp) now enforces keys: - no keys configured → open mode as before (the console already warns); - keys exist → Bearer or x-api-key must match (401 invalid_api_key); - per-key rate_per_min → fixed one-minute window, 429 with Retry-After; - NEW per-key monthly_budget_usd → 429 insufficient_quota once the account's month-to-date spend crosses the ceiling; - the matched account rides the request into stats — the keys table's spend columns finally see real traffic. Month-to-date spend is tracked per account in stats and persisted in the totals sidecar with a watermark (no double counting across restarts). The key dialog gains a budget field; the table shows "budget $N/mo" in red once exhausted. All verified live: 401 wrong/missing key, 200 + attribution with the right key, 429 + Retry-After over the limit, 429 insufficient_quota on the second request past a $0.000001 budget. Also: the dashboard shows the OpenRouter credit balance (GET /api/v1/credits via the stored key, cached 5 min, hidden without a key, red under $5 — live: $16.18), the server drains connections on Ctrl-C instead of dropping them, request bodies are capped at 32 MB, and docs/DEPLOY(.ru) cover office deployment: systemd/launchd/Windows service, nginx/caddy TLS, security checklist, backups. 10 strings ×7 languages.
364 lines
12 KiB
JavaScript
Vendored
364 lines
12 KiB
JavaScript
Vendored
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
|
|
import { h, mount, money, moneyEl, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
|
|
import { t, trTier } from "../i18n.js";
|
|
import { api } from "../api.js";
|
|
import { lineChart, barList } from "../charts.js";
|
|
import { SITE_URL } from "../app.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");
|
|
|
|
// ── host metrics: CPU sparkline + RAM / disk bars, refreshed every 2 s ──
|
|
const sysHost = h("div", {});
|
|
const cpuHistory = []; // last ~60 samples (2 min)
|
|
function fmtG(n) {
|
|
return (n / 1e9).toFixed(1) + " GB";
|
|
}
|
|
function bar(frac, cls) {
|
|
return h("div", { class: "sysbar" }, h("i", { class: cls || "", style: `width:${Math.min(100, frac * 100)}%` }));
|
|
}
|
|
function sparkline(values, w, hgt) {
|
|
const c = document.createElement("canvas");
|
|
c.width = w;
|
|
c.height = hgt;
|
|
const ctx = c.getContext("2d");
|
|
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#5b8def";
|
|
ctx.strokeStyle = accent;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
const n = Math.max(values.length, 2);
|
|
values.forEach((v, i) => {
|
|
const x = (i / (n - 1)) * (w - 2) + 1;
|
|
const y = hgt - 2 - (Math.min(v, 100) / 100) * (hgt - 4);
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
return c;
|
|
}
|
|
async function refreshSystem() {
|
|
let sys;
|
|
try {
|
|
sys = await api.system();
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!sys.ts) return;
|
|
cpuHistory.push(sys.cpu_percent || 0);
|
|
if (cpuHistory.length > 60) cpuHistory.shift();
|
|
const ramFrac = sys.ram_total ? sys.ram_used / sys.ram_total : 0;
|
|
const diskFrac = sys.disk_total ? (sys.disk_total - sys.disk_free) / sys.disk_total : 0;
|
|
mount(
|
|
sysHost,
|
|
h(
|
|
"div",
|
|
{ class: "card" },
|
|
h("div", { class: "card-title" }, t("dash.sys.title")),
|
|
h(
|
|
"div",
|
|
{ class: "sysgrid" },
|
|
h("div", { class: "sysitem" },
|
|
h("div", { class: "flex" },
|
|
h("span", { class: "k grow" }, `CPU · ${sys.cores} ${t("dash.sys.cores")}`),
|
|
h("span", { class: "v mono" }, (sys.cpu_percent || 0).toFixed(0) + "%")),
|
|
sparkline(cpuHistory, 220, 36),
|
|
h("div", { class: "muted small" }, `load ${Number(sys.load1 || 0).toFixed(1)}`)),
|
|
h("div", { class: "sysitem" },
|
|
h("div", { class: "flex" },
|
|
h("span", { class: "k grow" }, "RAM"),
|
|
h("span", { class: "v mono" }, `${fmtG(sys.ram_used)} / ${fmtG(sys.ram_total)}`)),
|
|
bar(ramFrac, ramFrac > 0.9 ? "bad" : ""),
|
|
h("div", { class: "muted small" }, t("dash.sys.gateway") + " " + (sys.process_rss / 1e6).toFixed(0) + " MB")),
|
|
h("div", { class: "sysitem" },
|
|
h("div", { class: "flex" },
|
|
h("span", { class: "k grow" }, t("dash.sys.disk")),
|
|
h("span", { class: "v mono" }, t("dash.sys.free") + " " + fmtG(sys.disk_free)),
|
|
),
|
|
bar(diskFrac, diskFrac > 0.92 ? "bad" : ""),
|
|
h("div", { class: "muted small" }, `${fmtG(sys.disk_total - sys.disk_free)} / ${fmtG(sys.disk_total)}`))
|
|
)
|
|
)
|
|
);
|
|
}
|
|
const sysTimer = setInterval(() => {
|
|
if (!root.isConnected) {
|
|
clearInterval(sysTimer);
|
|
return;
|
|
}
|
|
refreshSystem();
|
|
}, 2000);
|
|
refreshSystem();
|
|
|
|
async function load() {
|
|
const rangeSecs = range;
|
|
let stats, recent, health, balance;
|
|
try {
|
|
[stats, recent, health, balance] = await Promise.all([
|
|
api.stats(rangeSecs, groupby),
|
|
api.requests(40, 0),
|
|
api.health().catch(() => null),
|
|
api.balance().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"))),
|
|
h("div", { class: "flex", style: "gap:8px;align-items:center" },
|
|
seg(RANGES, range, (r) => {
|
|
range = r;
|
|
load();
|
|
}, (r) => t("dash.range." + r)),
|
|
h("button", {
|
|
class: "btn sm danger",
|
|
onClick: async () => {
|
|
if (!(await confirmDialog(t("dash.clearConfirm")))) return;
|
|
try {
|
|
await api.clearStats();
|
|
toast(t("dash.cleared"), "good");
|
|
await load();
|
|
} catch (e) {
|
|
toast(String(e.message || e), "bad");
|
|
}
|
|
},
|
|
}, t("dash.clear")))
|
|
)
|
|
),
|
|
|
|
// KPIs
|
|
h(
|
|
"div",
|
|
{ class: "kpis" },
|
|
kpi(num(tot.requests), t("dash.kpi.requests")),
|
|
kpi(num(tot.total_tokens), t("dash.kpi.tokens")),
|
|
kpi(moneyEl(tot.cost_usd), t("dash.kpi.cost")),
|
|
balance && balance.openrouter_usd != null
|
|
? kpi(
|
|
h("span", { class: balance.openrouter_usd < 5 ? "err" : "" }, "$" + balance.openrouter_usd.toFixed(2)),
|
|
t("dash.kpi.orBalance"))
|
|
: null,
|
|
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"))
|
|
),
|
|
|
|
sysHost,
|
|
|
|
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)
|
|
),
|
|
|
|
// semantic cache
|
|
stats.cache && stats.cache.enabled
|
|
? h(
|
|
"div",
|
|
{ class: "card" },
|
|
h("div", { class: "card-head" }, h("h3", {}, t("dash.cache"))),
|
|
h(
|
|
"div",
|
|
{ class: "kpis" },
|
|
kpi(pct(stats.cache.hit_rate), t("dash.cache.hitRate")),
|
|
kpi(num(stats.cache.hits), t("dash.cache.hits")),
|
|
kpi(money(stats.cache.saved_usd), t("dash.cache.saved"))
|
|
)
|
|
)
|
|
: null,
|
|
|
|
// 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 }, trTier(r.tier)) : "—",
|
|
r.score > 0 ? h("span", { class: "muted small mono", style: "margin-left:6px" }, r.score.toFixed(2)) : null),
|
|
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 keySourceLabel(src) {
|
|
if (src === "store") return t("models.form.secretStored");
|
|
if (src === "env") return t("models.form.secretEnv");
|
|
return t("models.form.secretMissing");
|
|
}
|
|
|
|
function healthCard(hh) {
|
|
const r = hh.router || {};
|
|
const keySrc = r.key_source || "none";
|
|
const err = r.last_error;
|
|
// problems fixed on the site (get / pay for a key) get the top-up link
|
|
const needsSite =
|
|
!r.reachable || keySrc === "missing" || (err && ["auth", "payment", "quota"].includes(err.kind));
|
|
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 " + (r.reachable ? "ok" : "bad"), title: r.url || "" },
|
|
t("status.router") + ": " + (r.reachable ? t("dash.health.reachable") : t("dash.health.unreachable"))
|
|
),
|
|
keySrc !== "none"
|
|
? h(
|
|
"span",
|
|
{ class: "badge " + keyBadge(keySrc), title: r.key_env || "" },
|
|
t("models.col.key") + ": " + keySourceLabel(keySrc)
|
|
)
|
|
: null,
|
|
err ? h("span", { class: "badge bad" }, t("router.probe." + (err.kind || "error"))) : null,
|
|
needsSite
|
|
? h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("dash.health.getKey") + " ↗")
|
|
: null
|
|
),
|
|
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 "";
|
|
}
|