540 lines
25 KiB
JavaScript
540 lines
25 KiB
JavaScript
// 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)
|
||
? ` <span style="color:var(--muted);font-size:.85em;">${escapeHtml(r.user_name)}</span>`
|
||
: "";
|
||
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]) => `<option value="${v}" ${v === cur ? "selected" : ""}>${l}</option>`
|
||
).join("");
|
||
return `<div class="ctrl">`
|
||
+ `<select id="${prefix}-range">${opt(range, RANGE_OPTS)}</select>`
|
||
+ `<select id="${prefix}-sort">${opt(sort, SORT_OPTS)}</select>`
|
||
+ `</div>`;
|
||
}
|
||
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
|
||
|
||
// ───── 渲染各 section ─────
|
||
function statCard(k, v, sub, cls) {
|
||
return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>`
|
||
+ `<div class="v">${v}</div>`
|
||
+ (sub ? `<div class="sub">${sub}</div>` : "") + `</div>`;
|
||
}
|
||
|
||
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 `<div class="card"><h2>实时运行态</h2><div class="grid">`
|
||
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, levelClass(ratio))
|
||
+ statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接")
|
||
+ statCard("内存峰值", rss, "进程 RSS high-water")
|
||
+ `</div></div>`;
|
||
}
|
||
|
||
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]) => `<span class="chip ${k === "completed" ? "ok" : ""}">${escapeHtml(k)} <b>${n}</b></span>`)
|
||
.join("") || `<span class="empty">无</span>`;
|
||
const runChips = Object.entries(t.by_run_status || {})
|
||
.map(([k, n]) => {
|
||
const c = k === "error" ? "err" : (k === "running" || k === "cancelling") ? "run" : "";
|
||
return `<span class="chip ${c}">${escapeHtml(k)} <b>${n}</b></span>`;
|
||
}).join("") || `<span class="empty">无</span>`;
|
||
return `<div class="card"><h2>任务(共 ${t.total || 0})</h2>`
|
||
+ `<div style="margin-bottom:10px;"><div class="sublabel">status</div><div class="chips">${statusChips}</div></div>`
|
||
+ `<div><div class="sublabel">run_status</div><div class="chips">${runChips}</div></div>`
|
||
+ `</div>`;
|
||
}
|
||
|
||
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 `<div class="card"><h2>用户与用量总览(all-time)</h2><div class="grid">`
|
||
+ 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), "")
|
||
+ `</div></div>`;
|
||
}
|
||
|
||
function renderByDay(rows) {
|
||
rows = rows || [];
|
||
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
|
||
const body = rows.map(r => `<tr>`
|
||
+ `<td>${escapeHtml(r.date)}</td>`
|
||
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
|
||
+ `<td class="num">${fmtTokens(r.tokens_in)}</td>`
|
||
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
|
||
+ `</tr>`).join("") || `<tr><td colspan="4" class="empty">无数据</td></tr>`;
|
||
const sum = rows.reduce((a, r) => {
|
||
a.cost_cny += r.cost_cny || 0;
|
||
a.tokens_in += r.tokens_in || 0;
|
||
a.tokens_out += r.tokens_out || 0;
|
||
return a;
|
||
}, { cost_cny: 0, tokens_in: 0, tokens_out: 0 });
|
||
const foot = rows.length ? `<tfoot><tr class="total-row">`
|
||
+ `<td>合计</td>`
|
||
+ `<td class="num">${fmtCNY(sum.cost_cny)}</td>`
|
||
+ `<td class="num">${fmtTokens(sum.tokens_in)}</td>`
|
||
+ `<td class="num">${fmtTokens(sum.tokens_out)}</td>`
|
||
+ `</tr></tfoot>` : "";
|
||
return `<div class="card"><h2>近 7 天用量(按天)</h2><div class="scroll-x"><table>`
|
||
+ `<thead><tr><th>日期</th><th>成本</th><th>输入</th><th>输出</th></tr></thead>`
|
||
+ `<tbody>${body}</tbody>${foot}</table></div></div>`;
|
||
}
|
||
|
||
// 按模型(时间筛选 + 排序)。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 `<tr>`
|
||
+ `<td class="email">${escapeHtml(r.model_profile || "—")}</td>`
|
||
+ `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
|
||
+ `<td class="num bar-cell" style="${byTok ? tint(tok, maxTok) : ""}">${fmtTokens(r.tokens_in)}</td>`
|
||
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
|
||
+ `<td class="num">${r.n_events || 0}</td>`
|
||
+ `</tr>`;
|
||
}).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
|
||
$("s-models").innerHTML = `<div class="card">`
|
||
+ `<div class="card-head"><h2>按模型(${rangeLabel(d.range)})</h2>${ctrlHTML("m", d.range, d.sort)}</div>`
|
||
+ `<div class="scroll-x"><table>`
|
||
+ `<thead><tr><th>模型</th><th>成本</th><th>输入</th><th>输出</th><th>事件</th></tr></thead>`
|
||
+ `<tbody>${body}</tbody></table></div></div>`;
|
||
$("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 `<tr>`
|
||
+ `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}`
|
||
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
|
||
+ `<td>${planSelectHTML(r)}</td>`
|
||
+ `<td class="num bar-cell" style="${byTok ? "" : tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
|
||
+ `<td class="num bar-cell" style="${byTok ? tint(r.tokens_in, maxTin) : ""}">${fmtTokens(r.tokens_in)}</td>`
|
||
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
|
||
+ `<td class="num">${hitRate}%</td>`
|
||
+ `<td class="num">${r.n_events || 0}</td>`
|
||
+ `</tr>`;
|
||
}).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`;
|
||
$("s-users").innerHTML = `<div class="card">`
|
||
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)})</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
|
||
+ tierLegendHTML()
|
||
+ `<div class="scroll-x"><table>`
|
||
+ `<thead><tr><th>用户</th><th>档位</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
|
||
+ `<tbody>${body}</tbody></table></div>`
|
||
+ pagerHTML("uu", page, maxPage, from, to, total)
|
||
+ `</div>`;
|
||
$("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 =>
|
||
`<option value="${escapeHtml(n)}" ${n === cur ? "selected" : ""}>${escapeHtml(n)}${n === def ? "(默认)" : ""}</option>`
|
||
).join("");
|
||
return `<select class="plan-sel" data-uid="${escapeHtml(r.user_id)}">${opts}</select>`;
|
||
}
|
||
|
||
// 档位图例:每档含哪些模型(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 `<div style="margin:2px 0;"><b>${escapeHtml(name)}${name === def ? "(默认)" : ""}</b>:`
|
||
+ `<span style="color:var(--muted);">${members.map(escapeHtml).join("、") || "(空)"}</span></div>`;
|
||
}).join("");
|
||
return `<div class="tier-legend" style="font-size:.85em;margin:0 0 10px;padding:8px 10px;`
|
||
+ `background:var(--bg-soft,#f6f6f6);border-radius:6px;">`
|
||
+ `<div style="color:var(--muted);margin-bottom:4px;">档位说明(改 config/agent.yaml model_tiers;admin 始终全开)</div>`
|
||
+ rows + `</div>`;
|
||
}
|
||
|
||
// 存储用量(分页)。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 `<tr>`
|
||
+ `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}</td>`
|
||
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
|
||
+ `<td class="num">${pctTxt}</td>`
|
||
+ `<td class="num">${r.file_count || 0}</td>`
|
||
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
|
||
+ `</tr>`;
|
||
}).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
|
||
$("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel})</h2>`
|
||
+ `<div class="scroll-x"><table>`
|
||
+ `<thead><tr><th>用户</th><th>已用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>`
|
||
+ `<tbody>${body}</tbody></table></div>`
|
||
+ pagerHTML("st", page, maxPage, from, to, total)
|
||
+ `</div>`;
|
||
wirePager("st", page, maxPage, (p) => loadStorage(p));
|
||
}
|
||
|
||
function pagerHTML(prefix, page, maxPage, from, to, total) {
|
||
return `<div class="pager">`
|
||
+ `<button id="${prefix}-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>`
|
||
+ `<span class="pginfo">${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)</span>`
|
||
+ `<button id="${prefix}-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>`
|
||
+ `</div>`;
|
||
}
|
||
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 = `<div id="layout">`
|
||
+ `<nav id="toc">` + SECTIONS.map(([id, label]) =>
|
||
`<a href="#${id}" data-target="${id}">${label}</a>`).join("") + `</nav>`
|
||
+ `<div id="content">` + SECTIONS.map(([id]) =>
|
||
`<div id="${id}" class="anchor"></div>`).join("") + `</div>`
|
||
+ `</div>`;
|
||
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 = `<div class="msg">${html}</div>`; // 清骨架,错误态独占
|
||
}
|
||
|
||
// ───── 拉数据 ─────
|
||
// 统一 GET:无 token / 401 / 403 → showMsg + stopAuto + 抛(调用方据 e.code 静默)。
|
||
async function apiGet(path) {
|
||
const t = token();
|
||
if (!t) {
|
||
showMsg(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
|
||
stopAuto();
|
||
throw Object.assign(new Error("no token"), { code: "auth" });
|
||
}
|
||
const r = await fetch(path, { headers: { Authorization: "Bearer " + t } });
|
||
if (r.status === 401) {
|
||
showMsg(`登录已失效。请回 <a href="/static/dev.html">控制台</a> 重新登录。`);
|
||
stopAuto();
|
||
throw Object.assign(new Error("401"), { code: "auth" });
|
||
}
|
||
if (r.status === 403) {
|
||
showMsg(`无权限:管理后台仅限管理员(admin)访问。<br/><a href="/static/dev.html">返回控制台</a>`);
|
||
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(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
|
||
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) =>
|
||
`<table class="rpt"><thead><tr>${headers.map(h => `<th>${h}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table>`;
|
||
const dist = (o) => Object.entries(o || {}).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ") || "无";
|
||
|
||
const dayBody = byDay.map(r => `<tr><td>${escapeHtml(r.date)}</td><td>${fmtCNY(r.cost_cny)}</td>`
|
||
+ `<td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td></tr>`).join("")
|
||
|| `<tr><td colspan="4">无数据</td></tr>`;
|
||
const modelBody = (models.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(r.model_profile || "—")}</td>`
|
||
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
|
||
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
|
||
const userBody = (users.rows || []).slice(0, 10).map(r => `<tr><td>${escapeHtml(userLabelText(r))}</td>`
|
||
+ `<td>${fmtCNY(r.cost_cny)}</td><td>${fmtTokens(r.tokens_in)}</td><td>${fmtTokens(r.tokens_out)}</td>`
|
||
+ `<td>${r.n_events || 0}</td></tr>`).join("") || `<tr><td colspan="5">无数据</td></tr>`;
|
||
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 `<tr><td>${escapeHtml(userLabelText(r))}</td><td>${humanSize(r.bytes_used)}</td>`
|
||
+ `<td>${pct}</td><td>${r.file_count || 0}</td></tr>`;
|
||
}).join("") || `<tr><td colspan="4">无数据</td></tr>`;
|
||
|
||
$("print-report").innerHTML =
|
||
`<h1>zcbot 管理后台报告</h1>`
|
||
+ `<div class="rpt-meta">生成时间 ${ov.generated_at ? fmtTime(ov.generated_at) : "—"}`
|
||
+ `${health.version ? " · 版本 v" + escapeHtml(health.version) : ""} · 列表取前 10</div>`
|
||
+ `<h2>实时运行态</h2>`
|
||
+ `<div class="rpt-kv">活跃 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" : "—"}</div>`
|
||
+ `<h2>任务(共 ${tk.total || 0})</h2>`
|
||
+ `<div class="rpt-kv">status:${dist(tk.by_status)}<br/>run_status:${dist(tk.by_run_status)}</div>`
|
||
+ `<h2>用户</h2><div class="rpt-kv">总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}</div>`
|
||
+ `<h2>用量总览(all-time)</h2>`
|
||
+ `<div class="rpt-kv">总成本 ${fmtCNY(u.cost_cny)} | 输入 ${fmtTokens(u.tokens_in)}`
|
||
+ ` | 输出 ${fmtTokens(u.tokens_out)} | 缓存命中 ${hitRate}%`
|
||
+ ` | 事件 ${u.n_events || 0}</div>`
|
||
+ `<h2>近 7 天用量(按天)</h2>` + tbl(["日期", "成本", "输入", "输出"], dayBody)
|
||
+ `<h2>按模型(all-time,Top 10)</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
|
||
+ `<h2>各用户用量(all-time,Top 10)</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
|
||
+ `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10)</h2>`
|
||
+ 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();
|