// 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})

` + `
status
${statusChips}
` + `
run_status
${runChips}
` + `
`; } 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 `

近 7 天用量(按天)

` + `` + `${body}
日期成本输入输出
`; } // 按模型(时间筛选 + 排序)。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)}
` + `
` + `` + `${body}
模型成本输入输出事件
`; $("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() + `
` + `` + `${body}
用户档位成本输入输出缓存命中事件
` + 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})

` + `
` + `` + `${body}
用户已用占配额文件数扫描于
` + pagerHTML("st", page, maxPage, from, to, total) + `
`; wirePager("st", page, maxPage, (p) => loadStorage(p)); } function pagerHTML(prefix, page, maxPage, from, to, total) { return `
` + `` + `${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)` + `` + `
`; } 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 => ``).join("")}${body}
${h}
`; 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();