// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000;
const PAGE_SIZE = 20;
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
const SECTIONS = [
["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
];
const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || "";
// 用户显示名兜底链:name → user_name → email → uid8。监控页各处共用同一规则。
// userCellHTML 给表格单元格:主文本走兜底链;name 与 user_name 都有时,name 后跟一个
// 浅灰 user_name;title 悬浮给完整 name/账号/邮箱/user_id。userLabelText 给概览迷你表(纯文本)。
function userLabelText(r) {
return r.name || r.user_name || r.email || (r.user_id || "").slice(0, 8);
}
function userTitle(r) {
const parts = [];
if (r.name) parts.push(`姓名 ${r.name}`);
if (r.user_name) parts.push(`账号 ${r.user_name}`);
if (r.email) parts.push(`邮箱 ${r.email}`);
parts.push(`ID ${r.user_id}`);
return parts.join("\n");
}
function userCellHTML(r) {
const primary = escapeHtml(userLabelText(r));
// name 与 user_name 同时存在 → 主显 name,后缀浅灰 user_name(满足"name 和 user_name 都显")
const sub = (r.name && r.user_name)
? ` ${escapeHtml(r.user_name)}`
: "";
return `${primary}${sub}`;
}
let timer = null;
// 各表独立状态(不随 overview 轮询重置)
let modelRange = "all", modelSort = "cost";
let userRange = "all", userSort = "cost", userPage = 0;
let storagePage = 0;
let tiersData = null; // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用)
// ───── 格式化 ─────
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)});`;
}
function levelClass(ratio) {
if (ratio >= 1) return "danger";
if (ratio >= 0.8) return "warn";
return "";
}
// range/sort 下拉一组(prefix 区分 m=模型 / u=用户);值取当前 state。
function ctrlHTML(prefix, range, sort) {
const opt = (cur, list) => list.map(
([v, l]) => ``
).join("");
return `
`
+ ``
+ ``
+ `
`;
}
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
// ───── 渲染各 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 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, levelClass(ratio))
+ 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) {
rows = rows || [];
const maxCost = Math.max(0, ...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 ``;
}
// 按模型(时间筛选 + 排序)。d = {range, sort, rows}
function renderModels(d) {
const rows = d.rows || [];
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTok = Math.max(0, ...rows.map(r => (r.tokens_in || 0) + (r.tokens_out || 0)));
const byTok = d.sort === "tokens";
const body = rows.map(r => {
const tok = (r.tokens_in || 0) + (r.tokens_out || 0);
return ``
+ `| ${escapeHtml(r.model_profile || "—")} | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `${r.n_events || 0} | `
+ `
`;
}).join("") || `| 无数据 |
`;
$("s-models").innerHTML = ``
+ `
按模型(${rangeLabel(d.range)})
${ctrlHTML("m", d.range, d.sort)}`
+ `
`;
$("m-range").onchange = (e) => { modelRange = e.target.value; loadModels(); };
$("m-sort").onchange = (e) => { modelSort = e.target.value; loadModels(); };
}
// 各用户用量(时间筛选 + 排序 + 分页)。d 含 range/sort/page/page_size/total_users/rows
function renderUserUsage(d) {
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 byTok = d.sort === "tokens";
const body = rows.map(r => {
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
return ``
+ `| ${userCellHTML(r)}`
+ (r.role === "admin" ? ` admin` : "") + ` | `
+ `${planSelectHTML(r)} | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `${hitRate}% | `
+ `${r.n_events || 0} | `
+ `
`;
}).join("") || `| 无数据 |
`;
$("s-users").innerHTML = ``
+ `
各用户用量(${rangeLabel(d.range)})
${ctrlHTML("u", d.range, d.sort)}`
+ tierLegendHTML()
+ `
`
+ pagerHTML("uu", page, maxPage, from, to, total)
+ `
`;
$("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); };
$("u-sort").onchange = (e) => { userSort = e.target.value; userPage = 0; loadUserUsage(0); };
// 档位下拉:选中即 PATCH(admin 看到全部模型,改档不影响 admin 自己的可见性)
$("s-users").querySelectorAll(".plan-sel").forEach(sel => {
sel.onchange = (e) => setUserPlan(e.target.dataset.uid, e.target.value, e.target);
});
wirePager("uu", page, maxPage, (p) => loadUserUsage(p));
}
// 档位下拉(每行一个);plan 为空 → 选中 default 档。tiers 未加载好 → 退化为纯文本。
function planSelectHTML(r) {
const tiers = (tiersData && tiersData.tiers) || {};
const names = Object.keys(tiers);
if (!names.length) return escapeHtml(r.plan || "default");
const def = (tiersData && tiersData.default_tier) || "default";
const cur = r.plan || def;
const opts = names.map(n =>
``
).join("");
return ``;
}
// 档位图例:每档含哪些模型(id → 显示名)。tiersData 未加载 → 空。
function tierLegendHTML() {
if (!tiersData || !tiersData.tiers) return "";
const cat = {};
(tiersData.catalog || []).forEach(m => { cat[m.id] = m.display_name; });
const def = tiersData.default_tier || "default";
const rows = Object.keys(tiersData.tiers).map(name => {
const members = (tiersData.tiers[name] || []).map(id => id === "*" ? "全部模型" : (cat[id] || id));
return `${escapeHtml(name)}${name === def ? "(默认)" : ""}:`
+ `${members.map(escapeHtml).join("、") || "(空)"}
`;
}).join("");
return ``
+ `
档位说明(改 config/agent.yaml model_tiers;admin 始终全开)
`
+ rows + `
`;
}
// 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
function renderStorage(d) {
const quota = d.quota_bytes;
const rows = d.rows || [];
const total = d.total || 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 quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限";
const maxUsed = Math.max(0, ...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 ``
+ `| ${userCellHTML(r)} | `
+ `${humanSize(r.bytes_used)} | `
+ `${pctTxt} | `
+ `${r.file_count || 0} | `
+ `${r.scanned_at ? fmtTime(r.scanned_at) : "—"} | `
+ `
`;
}).join("") || `| 无数据 |
`;
$("s-storage").innerHTML = `存储用量(${quotaLabel})
`
+ `
`
+ pagerHTML("st", page, maxPage, from, to, total)
+ `
`;
wirePager("st", page, maxPage, (p) => loadStorage(p));
}
function pagerHTML(prefix, page, maxPage, from, to, total) {
return ``;
}
function wirePager(prefix, page, maxPage, go) {
const prev = $(`${prefix}-prev`), next = $(`${prefix}-next`);
if (prev) prev.onclick = () => go(page - 1);
if (next) next.onclick = () => go(page + 1);
}
// ───── 骨架 + 目录 ─────
function ensureSkeleton() {
if ($("layout")) return;
$("main").innerHTML = ``
+ `
`
+ `
` + SECTIONS.map(([id]) =>
`
`).join("") + `
`
+ `
`;
document.querySelectorAll("#toc a").forEach(a => {
a.onclick = (e) => {
e.preventDefault();
const el = $(a.dataset.target);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
});
setupScrollSpy();
}
// 滚动高亮当前目录项(IntersectionObserver,粗粒度即可)
function setupScrollSpy() {
const links = {};
document.querySelectorAll("#toc a").forEach(a => { links[a.dataset.target] = a; });
const obs = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (!en.isIntersecting) return;
Object.values(links).forEach(l => l.classList.remove("active"));
if (links[en.target.id]) links[en.target.id].classList.add("active");
});
}, { rootMargin: "-70px 0px -65% 0px", threshold: 0 });
SECTIONS.forEach(([id]) => { const el = $(id); if (el) obs.observe(el); });
}
function renderMetrics(d) {
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("s-runtime").innerHTML = renderRuntime(d.runtime || {});
$("s-tasks").innerHTML = renderTasks(d.tasks || {});
$("s-usage").innerHTML =
renderUsersAndUsage(d.users || {}, d.usage || {})
+ renderByDay((d.usage || {}).by_day_7d);
}
function showMsg(html) {
$("main").innerHTML = `${html}
`; // 清骨架,错误态独占
}
// ───── 拉数据 ─────
// 统一 GET:无 token / 401 / 403 → showMsg + stopAuto + 抛(调用方据 e.code 静默)。
async function apiGet(path) {
const t = token();
if (!t) {
showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`);
stopAuto();
throw Object.assign(new Error("no token"), { code: "auth" });
}
const r = await fetch(path, { headers: { Authorization: "Bearer " + t } });
if (r.status === 401) {
showMsg(`登录已失效。请回 控制台 重新登录。`);
stopAuto();
throw Object.assign(new Error("401"), { code: "auth" });
}
if (r.status === 403) {
showMsg(`无权限:管理后台仅限管理员(admin)访问。
返回控制台`);
stopAuto();
throw Object.assign(new Error("403"), { code: "auth" });
}
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || String(r.status));
}
return r.json();
}
// 写操作(PATCH/POST):带 JSON body;401/403 同 apiGet 提示;其余抛错由调用方 alert。
async function apiSend(method, path, body) {
const t = token();
if (!t) {
showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`);
stopAuto();
throw Object.assign(new Error("no token"), { code: "auth" });
}
const r = await fetch(path, {
method,
headers: { Authorization: "Bearer " + t, "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
});
if (r.status === 401 || r.status === 403) {
throw Object.assign(new Error(String(r.status)), { code: "auth" });
}
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || String(r.status));
}
return r.json();
}
async function loadModels() {
try {
renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`));
} catch (e) { /* auth 已提示;其它静默,overview 那边兜底 */ }
}
async function loadUserUsage(page) {
page = Math.max(0, page);
try {
await loadTiers(); // 渲染档位下拉 / 图例前确保 tiers 在手(只拉一次)
const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`);
userPage = d.page || 0;
renderUserUsage(d);
} catch (e) { /* 同上 */ }
}
// 档位定义 + 模型目录:加载一次缓存(管理动作 / 图例用);失败退化为空(下拉降级纯文本)。
async function loadTiers() {
if (tiersData) return tiersData;
try { tiersData = await apiGet("/v1/admin/tiers"); }
catch (e) { tiersData = { tiers: {}, default_tier: "default", catalog: [] }; }
return tiersData;
}
// 设置某用户档位(PATCH);成功后刷新当前页。失败 alert 并复位下拉。
async function setUserPlan(uid, plan, selectEl) {
if (selectEl) selectEl.disabled = true;
try {
await apiSend("PATCH", `/v1/admin/users/${uid}/plan`, { plan });
loadUserUsage(userPage);
} catch (e) {
if (e.code !== "auth") alert("设置档位失败:" + (e.message || String(e)));
if (selectEl) selectEl.disabled = false;
}
}
async function loadStorage(page) {
page = Math.max(0, page);
try {
const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`);
storagePage = d.page || 0;
renderStorage(d);
} catch (e) { /* 同上 */ }
}
// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新三个独立表(保持各自状态)
async function refresh() {
try {
const d = await apiGet("/v1/admin/overview");
ensureSkeleton();
renderMetrics(d);
loadModels();
loadUserUsage(userPage);
loadStorage(storagePage);
} catch (e) {
if (e.code !== "auth") showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
}
}
// ───── 导出 PDF(客户端打印;列表取前 10)─────
async function exportPdf() {
const btn = $("export");
btn.disabled = true; const old = btn.textContent; btn.textContent = "生成中…";
try {
const [ov, models, users, storage, health] = await Promise.all([
apiGet("/v1/admin/overview"),
apiGet("/v1/admin/usage/models?range=all&sort=cost"),
apiGet("/v1/admin/usage/users?range=all&sort=cost&page=0&page_size=10"),
apiGet("/v1/admin/storage/users?page=0&page_size=10"),
fetch("/healthz").then(r => r.json()).catch(() => ({})),
]);
buildReport(ov, models, users, storage, health);
window.print();
} catch (e) {
if (e.code !== "auth") alert("导出失败:" + (e.message || String(e)));
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function buildReport(ov, models, users, storage, health) {
const rt = ov.runtime || {}, tk = ov.tasks || {}, us = ov.users || {}, u = (ov.usage || {}).total || {};
const byDay = (ov.usage || {}).by_day_7d || [];
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
const tbl = (headers, body) =>
`${headers.map(h => `| ${h} | `).join("")}
${body}
`;
const dist = (o) => Object.entries(o || {}).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ") || "无";
const dayBody = byDay.map(r => `| ${escapeHtml(r.date)} | ${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | ${fmtTokens(r.tokens_out)} |
`).join("")
|| `| 无数据 |
`;
const modelBody = (models.rows || []).slice(0, 10).map(r => `| ${escapeHtml(r.model_profile || "—")} | `
+ `${fmtCNY(r.cost_cny)} | ${fmtTokens(r.tokens_in)} | ${fmtTokens(r.tokens_out)} | `
+ `${r.n_events || 0} |
`).join("") || `| 无数据 |
`;
const userBody = (users.rows || []).slice(0, 10).map(r => `| ${escapeHtml(userLabelText(r))} | `
+ `${fmtCNY(r.cost_cny)} | ${fmtTokens(r.tokens_in)} | ${fmtTokens(r.tokens_out)} | `
+ `${r.n_events || 0} |
`).join("") || `| 无数据 |
`;
const quota = storage.quota_bytes;
const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `| ${escapeHtml(userLabelText(r))} | ${humanSize(r.bytes_used)} | `
+ `${pct} | ${r.file_count || 0} |
`;
}).join("") || `| 无数据 |
`;
$("print-report").innerHTML =
`zcbot 管理后台报告
`
+ `生成时间 ${ov.generated_at ? fmtTime(ov.generated_at) : "—"}`
+ `${health.version ? " · 版本 v" + escapeHtml(health.version) : ""} · 列表取前 10
`
+ `实时运行态
`
+ `活跃 run ${rt.active_runs || 0}${rt.max_workers ? " / " + rt.max_workers : ""}`
+ ` | SSE 订阅 ${rt.sse_subs || 0}`
+ ` | 内存峰值 ${rt.rss_peak_mb != null ? Math.round(rt.rss_peak_mb) + " MB" : "—"}
`
+ `任务(共 ${tk.total || 0})
`
+ `status:${dist(tk.by_status)}
run_status:${dist(tk.by_run_status)}
`
+ `用户
总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}
`
+ `用量总览(all-time)
`
+ `总成本 ${fmtCNY(u.cost_cny)} | 输入 ${fmtTokens(u.tokens_in)}`
+ ` | 输出 ${fmtTokens(u.tokens_out)} | 缓存命中 ${hitRate}%`
+ ` | 事件 ${u.n_events || 0}
`
+ `近 7 天用量(按天)
` + tbl(["日期", "成本", "输入", "输出"], dayBody)
+ `按模型(all-time,Top 10)
` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
+ `各用户用量(all-time,Top 10)
` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
+ `存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10)
`
+ tbl(["用户", "已用", "占配额", "文件数"], stBody);
}
// ───── 自动刷新 ─────
function startAuto() {
stopAuto();
if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS);
}
function stopAuto() {
if (timer) { clearInterval(timer); timer = null; }
}
$("refresh").onclick = refresh;
$("export").onclick = exportPdf;
$("auto-refresh").onchange = startAuto;
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAuto();
else { refresh(); startAuto(); }
});
refresh();
startAuto();