// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。 // 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。 // 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询; // 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、 // 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。 import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js"; import { dialogPrompt } from "./dialog.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", "存储"], ["s-external", "外部系统"], ["s-toolfail", "工具失败"], ]; 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 = "7d", modelSort = "cost"; let userRange = "7d", userSort = "cost", userPage = 0; let storagePage = 0; let tiersData = null; // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用) let externalDefinitions = []; let externalUsers = []; let externalDefinitionsLoaded = false; let externalEditingId = ""; // ───── 格式化 ───── 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("") || `无数据`; 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 ? `` + `合计` + `${fmtCNY(sum.cost_cny)}` + `${fmtTokens(sum.tokens_in)}` + `${fmtTokens(sum.tokens_out)}` + `` : ""; return `

近 7 天用量(按天)

` + `` + `${body}${foot}
日期成本输入输出
`; } function renderExternalDefinitions() { const rows = externalDefinitions.map(r => { const cfg = r.config || {}; return `` + `${escapeHtml(r.name)} ${escapeHtml(r.provider_title || r.provider)}${r.enabled ? "" : ' 停用'}` + ` ${r.visibility === "organization" ? "全部用户" : `指定 ${((r.selected_user_ids || []).length)} 人`}` + `${escapeHtml(r.host || cfg.base_url || "—")}` + `${cfg.operation_mode === "upstream_managed" ? "上游托管" : `查询(${Object.keys(cfg.operation_policies || {}).length} 个 POST)`}` + ` ` + ``; }).join("") || `尚未配置外部系统`; $("s-external").innerHTML = `

外部系统目录

` + `OpenAPI 与 Streamable HTTP MCP 系统均可配置;用户只提交该系统要求的凭据
` + `
` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `
` + `
` + `
` + `${rows}
系统主机执行模式操作
`; $("ext-admin-form").onsubmit = saveExternalDefinition; $("exa-guidance").value = ""; updateExternalGuidanceSummary(); $("exa-guidance-edit").onclick = editExternalGuidance; $("exa-provider").onchange = applyExternalProviderDefaults; $("exa-auth").onchange = applyExternalAuthDefaults; $("exa-operation-mode").onchange = updateExternalOperationMode; $("exa-access").onchange = () => { $("exa-users-wrap").hidden = $("exa-access").value !== "selected"; }; $("exa-cancel").onclick = () => { externalEditingId = ""; renderExternalDefinitions(); }; updateExternalAuthForm(); updateExternalOperationMode(); $("s-external").onclick = (e) => { const tr = e.target.closest("tr[data-definition-id]"); if (!tr) return; const row = externalDefinitions.find(x => x.definition_id === tr.dataset.definitionId); if (!row) return; if (e.target.closest("[data-ext-edit]")) fillExternalDefinition(row); if (e.target.closest("[data-ext-delete]")) deleteExternalDefinition(row); }; } function updateExternalAuthForm() { $("exa-auth").disabled = false; const login = $("exa-auth").value === "password_jwt"; for (const id of ["exa-login", "exa-token-field", "exa-username-field", "exa-password-field"]) { $(id).closest("label").hidden = !login; } } function updateExternalConnectorForm() { const mcp = $("exa-provider").value === "generic_mcp"; $("exa-spec-wrap").hidden = mcp; $("exa-mcp-wrap").hidden = !mcp; $("exa-mcp-server-wrap").hidden = !mcp; $("exa-operation-mode-wrap").hidden = mcp; $("exa-operations-wrap").hidden = mcp || $("exa-operation-mode").value === "upstream_managed"; $("exa-spec").required = !mcp; $("exa-mcp").required = mcp; } function applyExternalProviderDefaults() { const mcp = $("exa-provider").value === "generic_mcp"; $("exa-auth").value = "password_jwt"; $("exa-operation-mode").value = mcp ? "upstream_managed" : "query"; $("exa-operations").value = ""; $("exa-recommended").value = ""; $("exa-guidance").value = ""; $("exa-name").placeholder = mcp ? "MCP Server" : "ERP / LIMS / 其他系统"; applyExternalAuthDefaults(); updateExternalOperationMode(); updateExternalConnectorForm(); updateExternalGuidanceSummary(); } function updateExternalOperationMode() { const managed = $("exa-operation-mode").value === "upstream_managed"; $("exa-operations-wrap").hidden = managed; $("exa-operation-mode-hint").textContent = managed ? "规格中声明的 POST/PUT/PATCH/DELETE 等操作均可被调用,由上游系统使用当前用户凭据做最终鉴权。" : "GET/HEAD 默认开放;只有这里列出的只读 POST 可以调用。"; updateExternalConnectorForm(); } function applyExternalAuthDefaults() { const auth = $("exa-auth").value; $("exa-auth-header").value = auth === "api_key" ? "X-API-Key" : "Authorization"; $("exa-auth-template").value = auth === "api_key" ? "{token}" : "Bearer {token}"; updateExternalAuthForm(); } function updateExternalGuidanceSummary() { const text = ($("exa-guidance").value || "").trim(); $("exa-guidance-summary").textContent = text ? `${text.length} 字:${text.slice(0, 72)}${text.length > 72 ? "…" : ""}` : "未配置,将使用系统默认提示"; } async function editExternalGuidance() { const value = await dialogPrompt({ title: "编辑外部系统查询规划提示", label: "该提示由管理员维护,用于指导接口选择和查询路线。Ctrl/Command+Enter 保存。", value: $("exa-guidance").value || "", placeholder: "例如:优先使用聚合接口,只有用户明确要求时才查询逐条明细。", multiline: true, maxLength: 4000, okText: "应用", }); if (value === null) return; $("exa-guidance").value = value.trim(); updateExternalGuidanceSummary(); } function fillExternalDefinition(row) { externalEditingId = row.definition_id; const cfg = row.config || {}; $("exa-provider").value = row.provider || "generic_openapi"; $("exa-provider").disabled = true; $("exa-auth").value = cfg.auth_type || "password_jwt"; $("exa-name").value = row.name || ""; $("exa-base").value = cfg.base_url || ""; $("exa-spec").value = cfg.openapi_url || ""; $("exa-mcp").value = cfg.mcp_url || ""; $("exa-mcp-server").value = cfg.expected_server_name || ""; $("exa-login").value = cfg.login_path || "/api/auth/token/"; $("exa-token-field").value = cfg.token_field || "access"; $("exa-username-field").value = cfg.username_field || "username"; $("exa-password-field").value = cfg.password_field || "password"; $("exa-auth-header").value = cfg.auth_header_name || "Authorization"; $("exa-auth-template").value = cfg.auth_header_template || "Bearer {token}"; updateExternalAuthForm(); updateExternalConnectorForm(); $("exa-operation-mode").value = cfg.operation_mode || "query"; updateExternalOperationMode(); $("exa-operations").value = Object.entries(cfg.operation_policies || {}) .filter(([, policy]) => policy === "read" || policy === "export") .map(([operationId]) => operationId).join(", "); $("exa-recommended").value = (cfg.recommended_operation_ids || []).join(", "); $("exa-guidance").value = cfg.query_guidance || ""; updateExternalGuidanceSummary(); $("exa-tls").checked = cfg.verify_tls !== false; $("exa-enabled").checked = row.enabled !== false; $("exa-access").value = row.visibility || "selected"; const selected = new Set(row.selected_user_ids || []); Array.from($("exa-users").options).forEach(o => { o.selected = selected.has(o.value); }); $("exa-users-wrap").hidden = $("exa-access").value !== "selected"; $("exa-cancel").hidden = false; $("exa-name").focus(); } async function saveExternalDefinition(e) { e.preventDefault(); const body = { provider: $("exa-provider").value, name: $("exa-name").value.trim(), base_url: $("exa-base").value.trim(), openapi_url: $("exa-spec").value.trim(), mcp_url: $("exa-mcp").value.trim(), expected_server_name: $("exa-mcp-server").value.trim(), login_path: $("exa-login").value.trim() || "/api/auth/token/", auth_type: $("exa-auth").value, username_field: $("exa-username-field").value.trim() || "username", password_field: $("exa-password-field").value.trim() || "password", token_field: $("exa-token-field").value.trim() || "access", auth_header_name: $("exa-auth-header").value.trim() || "Authorization", auth_header_template: $("exa-auth-template").value || "Bearer {token}", operation_mode: $("exa-operation-mode").value, operation_policies: Object.fromEntries( $("exa-operations").value.split(",").map(x => x.trim()).filter(Boolean) .map(operationId => [operationId, "read"]), ), recommended_operation_ids: $("exa-recommended").value.split(",").map(x => x.trim()).filter(Boolean), query_guidance: $("exa-guidance").value.trim(), verify_tls: $("exa-tls").checked, enabled: $("exa-enabled").checked, visibility: $("exa-access").value, selected_user_ids: Array.from($("exa-users").selectedOptions).map(o => o.value), }; const current = externalDefinitions.find(x => x.definition_id === externalEditingId); body.timeout_seconds = current ? (current.config || {}).timeout_seconds || 15 : 15; body.max_result_bytes = current ? (current.config || {}).max_result_bytes || 65536 : 65536; body.max_total_result_bytes = current ? (current.config || {}).max_total_result_bytes || 262144 : 262144; body.max_response_bytes = current ? (current.config || {}).max_response_bytes || 10485760 : 10485760; try { await apiSend( externalEditingId ? "PUT" : "POST", externalEditingId ? `/v1/admin/external-system-definitions/${externalEditingId}` : "/v1/admin/external-system-definitions", body, ); externalEditingId = ""; await loadExternalDefinitions(true); } catch (err) { alert("保存外部系统失败:" + (err.message || String(err))); } } async function deleteExternalDefinition(row) { if (!confirm(`删除外部系统「${row.name}」?已有用户连接时将拒绝删除,可改为停用。`)) return; try { await apiSend("DELETE", `/v1/admin/external-system-definitions/${row.definition_id}`, {}); externalEditingId = ""; await loadExternalDefinitions(true); } catch (err) { alert("删除外部系统失败:" + (err.message || String(err))); } } // 按模型(时间筛选 + 排序)。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}` + `${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}` + ``; }).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)); } // 工具失败聚集(近7天,低阈值看全量;巡检邮件走 core/toolfail 高阈值)。 // 签名已在后端归一(路径/数字抹平),悬浮 title 给最近一次的原始样例。 // 后端已按活跃度排序(近24h 有发生的在前);近24h=0 的行淡化 —— 修复部署后 // 看对应行是否变灰 + 趋势尾格归零,即知修没修好(旧记录会挂满窗口,行不消失)。 function trendBar(daily) { if (!daily || !daily.length) return ""; const max = Math.max(...daily, 1); const blocks = "▁▂▃▄▅▆▇█"; return daily.map(v => v === 0 ? "·" : blocks[Math.min(blocks.length - 1, Math.ceil(v / max * blocks.length) - 1)]).join(""); } function toolFailureTable(title, rows, emptyText, quiet = false) { const body = rows.map(c => `` + `${escapeHtml(c.tool)}` + `${escapeHtml(c.kind)}` + `${escapeHtml(c.signature)}` + `${c.count}` + `${c.count_24h || 0}` + `${trendBar(c.daily)}` + `${c.task_count}` + `${c.user_count}` + `${c.last_at ? fmtTime(c.last_at) : "—"}` + ``).join("") || `${escapeHtml(emptyText)}`; return `

${escapeHtml(title)}

` + `
` + `` + `${body}
工具类型签名(悬浮看样例)次数近24h趋势任务数用户数最近
`; } function rateText(v) { return v == null ? "—" : `${Number(v).toFixed(1)}%`; } function wireHealthHTML(d) { if (!d || !Array.isArray(d.rows)) { return `

工具调用链路健康

` + `
链路健康数据暂不可用,失败聚集仍可正常查看
`; } const rows = d.rows || []; const total = d.total || {}; const body = rows.map(r => `` + `${escapeHtml(r.model_profile || "?")}` + `${escapeHtml(r.tool || "?")}` + `${r.salvaged_24h || 0}` + `${r.malformed_24h || 0}` + `${rateText(r.recovery_rate_24h)}` + `${r.salvaged || 0}` + `${r.malformed || 0}` + `${rateText(r.recovery_rate)}` + `${r.last_at ? fmtTime(r.last_at) : "—"}` + ``).join("") || `近 ${d.days || 7} 天无工具参数损坏事件`; const summary = `近24h 抢救 ${total.salvaged_24h || 0} / 残余 ${total.malformed_24h || 0}` + ` / 抢救率 ${rateText(total.recovery_rate_24h)};` + `近${d.days || 7}天 抢救 ${total.salvaged || 0} / 残余 ${total.malformed || 0}` + ` / 抢救率 ${rateText(total.recovery_rate)}`; return `

工具调用链路健康(${escapeHtml(summary)})

` + `
` + `` + `` + `${body}
模型档工具24h抢救24h残余24h抢救率窗口抢救窗口残余窗口抢救率最近
`; } function renderToolFailures(d, wire = null) { const rows = d.clusters || []; const active = rows.filter(c => (c.count_24h || 0) > 0); const systemic = active.filter(c => c.category !== "quality_gate" && c.task_count >= 2); const taskLocal = active.filter(c => c.category !== "quality_gate" && c.task_count < 2); const gates = active.filter(c => c.category === "quality_gate"); const quiet = rows.filter(c => !(c.count_24h || 0)); const days = d.days || 7; $("s-toolfail").innerHTML = wireHealthHTML(wire) + toolFailureTable( "当前系统性工具故障(近 24h 活跃、跨 ≥2 个任务)", systemic, "近 24 小时无跨任务系统性工具故障", ) + toolFailureTable( "单任务反复失败(近 24h)", taskLocal, "近 24 小时无单任务反复失败", ) + toolFailureTable( "质量门记录(近 24h,按设计拦截不合规产物)", gates, "近 24 小时无质量门拦截记录", ) + toolFailureTable( `已安静历史(近 ${days} 天窗口,近 24h 为 0)`, quiet, `近 ${days} 天无已安静聚集`, true, ); } 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) { /* 同上 */ } } async function loadToolFailures() { try { const [failures, wire] = await Promise.allSettled([ apiGet("/v1/admin/tool-failures?days=7&min_count=3&min_tasks=1"), apiGet("/v1/admin/tool-wire-health?days=7"), ]); if (failures.status !== "fulfilled") throw failures.reason; renderToolFailures( failures.value, wire.status === "fulfilled" ? wire.value : null, ); } catch (e) { /* 同上 */ } } async function loadExternalDefinitions(force = false) { if (externalDefinitionsLoaded && !force) return; try { const [definitions, users] = await Promise.all([ apiGet("/v1/admin/external-system-definitions"), apiGet("/v1/admin/external-system-users"), ]); externalDefinitions = definitions.results || []; externalUsers = users.results || []; externalDefinitionsLoaded = true; renderExternalDefinitions(); } catch (e) { /* overview 统一处理鉴权 */ } } // overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新四个独立表(保持各自状态) async function refresh() { try { const d = await apiGet("/v1/admin/overview"); ensureSkeleton(); renderMetrics(d); loadModels(); loadUserUsage(userPage); loadStorage(storagePage); loadExternalDefinitions(); loadToolFailures(); } 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();