zcbot/web/static/js/admin.js

281 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();