// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,但不挂主应用模块图,
// 自成一页:拉 GET /v1/admin/overview 一次渲染全部 section,默 10s 自动轮询。
// 鉴权失败:401(token 失效)/ 403(非 admin)给出明确提示 + 回控制台链接。
// 后续管理动作(建用户 / 改角色 / 配置)在此页加 tab,各自打对应 /v1/admin/* 端点。
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000;
const PAGE_SIZE = 20;
const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || "";
let timer = null;
let userPage = 0; // 各用户用量表当前页(0-based);独立于 overview 轮询
// ───── 格式化 ─────
function fmtCNY(n) {
n = Number(n) || 0;
if (n < 0.01 && n > 0) return "¥" + n.toFixed(4);
return "¥" + n.toFixed(2);
}
// 相对热力底色:value 占 max 越高,accent 底色越深(占用多 → 有色差)。
function tint(value, max) {
if (!max || max <= 0 || !value || value <= 0) return "";
const a = Math.min(1, value / max) * 0.30;
return `background: rgba(192,57,43,${a.toFixed(3)});`;
}
// 阈值类:ratio>=1 危险,>=0.8 警告。
function levelClass(ratio) {
if (ratio >= 1) return "danger";
if (ratio >= 0.8) return "warn";
return "";
}
// ───── 渲染各 section ─────
function statCard(k, v, sub, cls) {
return `
${escapeHtml(k)}
`
+ `
${v}
`
+ (sub ? `
${sub}
` : "") + `
`;
}
function renderRuntime(r) {
const active = r.active_runs || 0;
const max = r.max_workers || 0;
const ratio = max ? active / max : 0;
const cls = levelClass(ratio);
const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : "";
const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—";
return `实时运行态
`
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls)
+ statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接")
+ statCard("内存峰值", rss, "进程 RSS high-water")
+ `
`;
}
function renderTasks(t) {
const order = ["active", "completed", "abandoned"];
const statusChips = Object.entries(t.by_status || {})
.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
.map(([k, n]) => `${escapeHtml(k)} ${n}`)
.join("") || `无`;
const runChips = Object.entries(t.by_run_status || {})
.map(([k, n]) => {
const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : "";
return `${escapeHtml(k)} ${n}`;
}).join("") || `无`;
return `任务(共 ${t.total || 0})
`
+ `
`
+ `
`
+ `
`;
}
function renderUsersAndUsage(users, usage) {
const u = usage.total || {};
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
return `用户与用量总览(all-time)
`
+ statCard("用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`)
+ statCard("总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`)
+ statCard("输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`)
+ statCard("输出 token", fmtTokens(u.tokens_out), "")
+ `
`;
}
function renderByDay(rows) {
if (!rows || !rows.length) return ``;
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0));
const body = rows.map(r => ``
+ `| ${escapeHtml(r.date)} | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `
`).join("");
return ``;
}
function renderByModel(rows) {
if (!rows || !rows.length) return ``;
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0));
const body = rows.map(r => ``
+ `| ${escapeHtml(r.model_profile || "—")} | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `${r.n_events || 0} | `
+ `
`).join("");
return ``;
}
// 各用户 token 用量(分页)。独立于 overview 轮询:用户翻页时按需拉,overview tick
// 时也顺手刷新当前页保持数字新鲜(userPage 不丢)。
function renderUserUsage(d) {
const c = $("user-usage");
if (!c) return;
const rows = d.rows || [];
const total = d.total_users || 0;
const size = d.page_size || PAGE_SIZE;
const page = d.page || 0;
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
const from = total ? page * size + 1 : 0;
const to = Math.min(total, (page + 1) * size);
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0));
const body = rows.map(r => {
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
return ``
+ `| ${escapeHtml(r.email || r.user_id.slice(0, 8))}`
+ (r.role === "admin" ? ` admin` : "") + ` | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `${hitRate}% | `
+ `${r.n_events || 0} | `
+ `
`;
}).join("") || `| 无数据 |
`;
c.innerHTML = `各用户用量(按成本,all-time)
`
+ `
`
+ `
`;
const prev = $("uu-prev"), next = $("uu-next");
if (prev) prev.onclick = () => loadUserUsage(userPage - 1);
if (next) next.onclick = () => loadUserUsage(userPage + 1);
}
function renderStorage(st) {
const quota = st.quota_bytes;
const rows = st.users || [];
const quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限";
if (!rows.length) return ``;
const maxUsed = Math.max(...rows.map(r => r.bytes_used || 0));
const body = rows.map(r => {
const ratio = quota && quota > 0 ? r.bytes_used / quota : 0;
const cls = levelClass(ratio);
const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—";
// 有配额时按配额占比上色(逼近上限变橙/红);无配额时按相对最大值热力上色
const cellStyle = quota && quota > 0
? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);"
: cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "")
: tint(r.bytes_used, maxUsed);
return ``
+ `| ${escapeHtml(r.email || r.user_id.slice(0, 8))} | `
+ `${humanSize(r.bytes_used)} | `
+ `${pctTxt} | `
+ `${r.file_count || 0} | `
+ `${r.scanned_at ? fmtTime(r.scanned_at) : "—"} | `
+ `
`;
}).join("");
return ``;
}
// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表,
// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。
function ensureSkeleton() {
if ($("metrics")) return;
$("main").innerHTML = ``;
}
function renderMetrics(d) {
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("metrics").innerHTML =
renderRuntime(d.runtime || {})
+ renderTasks(d.tasks || {})
+ renderUsersAndUsage(d.users || {}, d.usage || {})
+ renderByDay((d.usage || {}).by_day_7d)
+ renderByModel((d.usage || {}).by_model)
+ renderStorage(d.storage || {});
}
function showMsg(html) {
$("main").innerHTML = `${html}
`; // 清骨架,错误态独占
}
// 处理鉴权/网络错误:命中返 true(调用方据此中止)。
function handleAuthError(r) {
if (r.status === 401) {
showMsg(`登录已失效。请回 控制台 重新登录。`);
stopAuto(); return true;
}
if (r.status === 403) {
showMsg(`无权限:管理后台仅限管理员(admin)访问。
` +
`返回控制台`);
stopAuto(); return true;
}
return false;
}
// ───── 各用户用量分页 ─────
async function loadUserUsage(page) {
const t = token();
if (!t) return;
page = Math.max(0, page);
try {
const r = await fetch(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}`, {
headers: { Authorization: "Bearer " + t },
});
if (handleAuthError(r)) return;
if (!r.ok) return;
const d = await r.json();
userPage = d.page || 0; // 以服务端回的页码为准(夹紧后)
renderUserUsage(d);
} catch (e) { /* 静默:overview 那边会报总错 */ }
}
// ───── 拉 overview ─────
async function refresh() {
const t = token();
if (!t) {
showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`);
stopAuto();
return;
}
try {
const r = await fetch("/v1/admin/overview", {
headers: { Authorization: "Bearer " + t },
});
if (handleAuthError(r)) return;
if (!r.ok) {
const d = await r.json().catch(() => ({}));
showMsg(`加载失败:${escapeHtml(d.detail || (r.status + ""))}`);
return;
}
ensureSkeleton();
renderMetrics(await r.json());
loadUserUsage(userPage); // 顺手刷当前页,保持数字新鲜(不丢页码)
} catch (e) {
showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
}
}
// ───── 自动刷新 ─────
function startAuto() {
stopAuto();
if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS);
}
function stopAuto() {
if (timer) { clearInterval(timer); timer = null; }
}
$("refresh").onclick = refresh;
$("auto-refresh").onchange = startAuto;
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAuto();
else { refresh(); startAuto(); }
});
refresh();
startAuto();