281 lines
12 KiB
JavaScript
281 lines
12 KiB
JavaScript
// 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 `<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 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 `<div class="card"><h2>实时运行态</h2><div class="grid">`
|
||
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, cls)
|
||
+ 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="k" style="color:var(--muted);font-size:11px;margin-bottom:4px;">status</div><div class="chips">${statusChips}</div></div>`
|
||
+ `<div><div class="k" style="color:var(--muted);font-size:11px;margin-bottom:4px;">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) {
|
||
if (!rows || !rows.length) return `<div class="card"><h2>近 7 天用量</h2><div class="empty">无数据</div></div>`;
|
||
const maxCost = Math.max(...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("");
|
||
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></table></div></div>`;
|
||
}
|
||
|
||
function renderByModel(rows) {
|
||
if (!rows || !rows.length) return `<div class="card"><h2>按模型</h2><div class="empty">无数据</div></div>`;
|
||
const maxCost = Math.max(...rows.map(r => r.cost_cny || 0));
|
||
const body = rows.map(r => `<tr>`
|
||
+ `<td class="email">${escapeHtml(r.model_profile || "—")}</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>`
|
||
+ `<td class="num">${r.n_events || 0}</td>`
|
||
+ `</tr>`).join("");
|
||
return `<div class="card"><h2>按模型(all-time)</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></div>`;
|
||
}
|
||
|
||
// 各用户 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 `<tr>`
|
||
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}`
|
||
+ (r.role === "admin" ? ` <span class="chip ok" style="padding:1px 6px;">admin</span>` : "") + `</td>`
|
||
+ `<td class="num bar-cell" style="${tint(r.cost_cny, maxCost)}">${fmtCNY(r.cost_cny)}</td>`
|
||
+ `<td class="num bar-cell" style="${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="6" class="empty">无数据</td></tr>`;
|
||
c.innerHTML = `<div class="card"><h2>各用户用量(按成本,all-time)</h2>`
|
||
+ `<div class="scroll-x"><table>`
|
||
+ `<thead><tr><th>用户</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
|
||
+ `<tbody>${body}</tbody></table></div>`
|
||
+ `<div class="pager">`
|
||
+ `<button id="uu-prev" ${page <= 0 ? "disabled" : ""}>上一页</button>`
|
||
+ `<span class="pginfo">${from}–${to} / ${total}(第 ${page + 1}/${maxPage + 1} 页)</span>`
|
||
+ `<button id="uu-next" ${page >= maxPage ? "disabled" : ""}>下一页</button>`
|
||
+ `</div></div>`;
|
||
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 `<div class="card"><h2>存储用量(${quotaLabel})</h2><div class="empty">无数据</div></div>`;
|
||
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 `<tr>`
|
||
+ `<td class="email" title="${escapeHtml(r.user_id)}">${escapeHtml(r.email || r.user_id.slice(0, 8))}</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("");
|
||
return `<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></div>`;
|
||
}
|
||
|
||
// #main 拆两块:#metrics(每次 overview tick 整体重渲)与 #user-usage(分页表,
|
||
// 独立 fetch、自管页码)。骨架只建一次,避免翻页态被 overview 重渲冲掉。
|
||
function ensureSkeleton() {
|
||
if ($("metrics")) return;
|
||
$("main").innerHTML = `<div id="metrics"></div><div id="user-usage"></div>`;
|
||
}
|
||
|
||
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 = `<div class="msg">${html}</div>`; // 清骨架,错误态独占
|
||
}
|
||
|
||
// 处理鉴权/网络错误:命中返 true(调用方据此中止)。
|
||
function handleAuthError(r) {
|
||
if (r.status === 401) {
|
||
showMsg(`登录已失效。请回 <a href="/static/dev.html">控制台</a> 重新登录。`);
|
||
stopAuto(); return true;
|
||
}
|
||
if (r.status === 403) {
|
||
showMsg(`无权限:管理后台仅限管理员(admin)访问。<br/>` +
|
||
`<a href="/static/dev.html">返回控制台</a>`);
|
||
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(`未登录。请先在 <a href="/static/dev.html">控制台</a> 登录后再访问管理后台。`);
|
||
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();
|