zcbot/web/static/js/admin.js

1158 lines
55 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 工具,不挂主应用模块图。
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js";
import { dialogConfirm, dialogPrompt, message } 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-windows-node", "Windows Node"],
["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)
? ` <span style="color:var(--muted);font-size:.85em;">${escapeHtml(r.user_name)}</span>`
: "";
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 = "";
let computeNodes = [];
// ───── 格式化 ─────
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>`;
}
function nodeStatusHTML(status) {
const labels = { online: "在线", offline: "离线", disabled: "已禁用" };
const value = labels[status] ? status : "offline";
return `<span class="node-status ${value}">${labels[value]}</span>`;
}
function renderWindowsNodes() {
const rows = computeNodes.map(node => {
const runtime = node.runtime || {};
const runtimeParts = [
node.os_version || "",
runtime.desktop_session === true ? "桌面会话" : "",
runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "",
].filter(Boolean);
const lastSeen = node.last_seen_at
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`
: "—";
const disabled = node.status === "disabled";
return `<tr data-node-id="${escapeHtml(node.node_id)}">`
+ `<td><div class="node-name">${escapeHtml(node.name || "未命名节点")}</div>`
+ `<div class="node-id">${escapeHtml(node.node_id)}</div></td>`
+ `<td>${nodeStatusHTML(node.status)}</td>`
+ `<td class="node-runtime">${escapeHtml(runtimeParts.join(" · ") || "—")}</td>`
+ `<td>${escapeHtml(node.node_version || "—")}</td>`
+ `<td>${(node.capabilities || []).map(x => `<span class="chip">${escapeHtml(x)}</span>`).join(" ") || "—"}</td>`
+ `<td>${lastSeen}</td>`
+ `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" `
+ `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"}</button> `
+ `<button type="button" data-node-delete class="danger">删除</button></td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="7" class="empty">尚无已注册的 Windows Node</td></tr>`;
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
+ `<div><h2>Windows Node${computeNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
+ `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>`
+ `</div><div class="scroll-x"><table><thead><tr><th>节点</th><th>状态</th><th>运行环境</th>`
+ `<th>版本</th><th>能力</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
$("node-enrollment-open").onclick = openNodeEnrollmentModal;
$("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => {
button.onclick = () => toggleComputeNode(button);
});
$("s-windows-node").querySelectorAll("[data-node-delete]").forEach(button => {
button.onclick = () => deleteComputeNode(button);
});
}
async function toggleComputeNode(button) {
const row = button.closest("tr[data-node-id]");
const node = computeNodes.find(item => item.node_id === row?.dataset.nodeId);
if (!node) return;
const disabling = button.dataset.nodeToggle === "disable";
const confirmed = await dialogConfirm({
title: disabling ? "禁用 Windows Node" : "重新启用 Windows Node",
message: disabling
? `禁用「${node.name}」?在线连接会立即断开,之后不能接收任务。`
: `重新启用「${node.name}」?启用后需在该电脑托盘菜单点击“立即重连”。`,
okText: disabling ? "禁用" : "重新启用",
danger: disabling,
});
if (!confirmed) return;
button.disabled = true;
try {
await apiSend("PATCH", `/v1/admin/compute-nodes/${node.node_id}`, {
disabled: disabling,
});
message(disabling ? "节点已禁用" : "节点已重新启用,请在节点电脑上立即重连", "success", 5000);
await loadComputeNodes();
} catch (err) {
if (err.code !== "auth") message("更新节点失败:" + (err.message || String(err)), "error", 5000);
button.disabled = false;
}
}
async function deleteComputeNode(button) {
const row = button.closest("tr[data-node-id]");
const node = computeNodes.find(item => item.node_id === row?.dataset.nodeId);
if (!node) return;
const confirmed = await dialogConfirm({
title: "删除 Windows Node",
message: `永久删除「${node.name}」?节点会立即断开,本机现有身份失效;如需再次使用,必须清除本机身份并用新注册码重新注册。`,
okText: "永久删除",
danger: true,
});
if (!confirmed) return;
button.disabled = true;
try {
await apiSend("DELETE", `/v1/admin/compute-nodes/${node.node_id}`, {});
message("节点已删除,本机需重新注册后才能使用", "success", 5000);
await loadComputeNodes();
} catch (err) {
if (err.code !== "auth") message("删除节点失败:" + (err.message || String(err)), "error", 5000);
button.disabled = false;
}
}
function openNodeEnrollmentModal() {
$("node-enrollment-form").reset();
$("node-enrollment-inputs").hidden = false;
$("node-enrollment-result").hidden = true;
$("node-enrollment-code").textContent = "";
$("node-enrollment-expiry").textContent = "";
$("node-enrollment-submit").hidden = false;
$("node-enrollment-cancel").textContent = "取消";
const modal = $("node-enrollment-modal");
modal.classList.add("show");
modal.setAttribute("aria-hidden", "false");
document.documentElement.classList.add("modal-open");
document.body.classList.add("modal-open");
$("node-expected-name").focus();
}
function closeNodeEnrollmentModal() {
const modal = $("node-enrollment-modal");
modal.classList.remove("show");
modal.setAttribute("aria-hidden", "true");
document.documentElement.classList.remove("modal-open");
document.body.classList.remove("modal-open");
}
async function copyNodeEnrollmentCode() {
const value = $("node-enrollment-code").textContent;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const input = document.createElement("textarea");
input.value = value;
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
message("注册码已复制", "success");
} catch (e) {
message("复制失败,请手动选择注册码复制", "error");
}
}
async function createNodeEnrollment(e) {
e.preventDefault();
const submit = $("node-enrollment-submit");
submit.disabled = true;
submit.textContent = "生成中…";
try {
const result = await apiSend("POST", "/v1/admin/compute-node-enrollments", {
expected_name: $("node-expected-name").value.trim(),
capabilities: ["origin.plot@v1"],
ttl_seconds: 600,
});
$("node-enrollment-code").textContent = result.enrollment_code || "";
$("node-enrollment-expiry").textContent = result.expires_at
? `有效期至 ${fmtTime(result.expires_at)}`
: "有效期为 10 分钟";
$("node-enrollment-inputs").hidden = true;
$("node-enrollment-result").hidden = false;
submit.hidden = true;
$("node-enrollment-cancel").textContent = "关闭";
$("node-enrollment-copy").focus();
} catch (err) {
if (err.code !== "auth") message("生成注册码失败:" + (err.message || String(err)), "error", 5000);
} finally {
submit.disabled = false;
submit.textContent = "生成注册码";
}
}
function renderExternalDefinitions() {
const rows = externalDefinitions.map(r => {
const cfg = r.config || {};
const capability = r.provider === "generic_mcp"
? "动态工具tools/list"
: (cfg.operation_mode === "upstream_managed"
? "上游托管"
: `查询(${Object.keys(cfg.operation_policies || {}).length} 个 POST`);
return `<tr data-definition-id="${escapeHtml(r.definition_id)}">`
+ `<td>${escapeHtml(r.name)} <span class="chip">${escapeHtml(r.provider_title || r.provider)}</span>${r.enabled ? "" : ' <span class="chip">停用</span>'}`
+ ` <span class="chip">${r.visibility === "organization" ? "全部用户" : `指定 ${((r.selected_user_ids || []).length)}`}</span></td>`
+ `<td class="email" title="${escapeHtml(cfg.base_url || "")}">${escapeHtml(r.host || cfg.base_url || "—")}</td>`
+ `<td>${capability}</td>`
+ `<td><button data-ext-edit>编辑</button> <button data-ext-delete>删除</button></td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="4" class="empty">尚未配置外部系统</td></tr>`;
$("s-external").innerHTML = `<div class="card"><div class="card-head"><h2>外部系统目录</h2>`
+ `<div><span class="sublabel">OpenAPI 与 Streamable HTTP MCP 系统均可配置;用户只提交该系统要求的凭据</span> `
+ `<button id="exa-add" class="primary" type="button">新增外部系统</button></div></div>`
+ `<div class="scroll-x"><table><thead><tr><th>系统</th><th>主机</th><th>能力模式</th><th>操作</th></tr></thead>`
+ `<tbody>${rows}</tbody></table></div></div>`
+ `<div id="external-definition-modal" class="modal">`
+ `<div class="card" role="dialog" aria-modal="true" aria-labelledby="exa-dialog-title">`
+ `<div class="ext-modal-head"><h3 id="exa-dialog-title">新增外部系统</h3><button id="exa-close" type="button" aria-label="关闭">×</button></div>`
+ `<form id="ext-admin-form">`
+ `<div class="ext-form-body"><div class="ext-form-grid">`
+ `<div class="ext-form-section">连接方式</div>`
+ `<label>系统类型<select id="exa-provider"><option value="generic_openapi">通用 OpenAPI 系统</option><option value="generic_mcp">通用 MCP 系统</option></select></label>`
+ `<label>认证方式<select id="exa-auth"><option value="password_jwt">用户名密码换取 Token</option><option value="api_key">API Key</option><option value="bearer_token">Bearer Token</option></select></label>`
+ `<label>系统名称<input id="exa-name" required placeholder="ERP / LIMS / 其他系统"></label>`
+ `<label id="exa-base-wrap">Base URL<input id="exa-base" placeholder="https://api.example.com"></label>`
+ `<label id="exa-spec-wrap">Swagger / OpenAPI URL<input id="exa-spec" placeholder="https://api.example.com/openapi.json"></label>`
+ `<label id="exa-mcp-wrap">MCP URL<input id="exa-mcp" placeholder="https://api.example.com/mcp"><span class="sublabel">连接后Server 通过 tools/list 暴露的全部工具均可使用。</span></label>`
+ `<label id="exa-mcp-server-wrap">期望的 MCP Server 名称<input id="exa-mcp-server" placeholder="server-name可选"></label>`
+ `<label id="exa-operation-mode-wrap" style="grid-column:1/-1;">接口执行模式<select id="exa-operation-mode"><option value="upstream_managed">上游托管(开放规格中的全部方法)</option><option value="query">查询模式GET/HEAD + 允许的只读 POST</option></select><span id="exa-operation-mode-hint" class="sublabel"></span></label>`
+ `<label id="exa-operations-wrap" style="grid-column:1/-1;">允许的只读 POST operationId逗号分隔GET/HEAD 默认可查询)<input id="exa-operations" placeholder="query_records"></label>`
+ `<div class="ext-form-section">认证参数</div>`
+ `<label>登录路径<input id="exa-login" value="/api/auth/token/"></label>`
+ `<label>Token 字段路径<input id="exa-token-field" value="access" placeholder="data.access_token"></label>`
+ `<label>用户名字段<input id="exa-username-field" value="username"></label>`
+ `<label>密码字段<input id="exa-password-field" value="password"></label>`
+ `<label>认证 Header<input id="exa-auth-header" value="Authorization"></label>`
+ `<label>Header 模板<input id="exa-auth-template" value="Bearer {token}"></label>`
+ `<div class="ext-form-section">查询与授权</div>`
+ `<label style="grid-column:1/-1;">推荐查询入口 operationId逗号分隔<input id="exa-recommended" placeholder="list_datasets, query_dataset"></label>`
+ `<label style="grid-column:1/-1;">查询规划提示`
+ `<input id="exa-guidance" type="hidden">`
+ `<div style="display:flex;align-items:center;gap:8px;">`
+ `<button id="exa-guidance-edit" type="button">编辑提示词</button>`
+ `<span id="exa-guidance-summary" class="sublabel"></span></div></label>`
+ `<label><input id="exa-tls" type="checkbox" checked> 校验 TLS 证书</label>`
+ `<label><input id="exa-enabled" type="checkbox" checked> 启用</label>`
+ `<label>可见范围<select id="exa-access"><option value="selected">指定用户</option><option value="organization">全部用户</option></select></label>`
+ `<label id="exa-users-wrap" style="grid-column:1/-1;">授权用户Ctrl/Command 可多选)<select id="exa-users" multiple size="6">`
+ externalUsers.map(u => `<option value="${escapeHtml(u.user_id)}">${escapeHtml(u.label)}${u.email ? " · " + escapeHtml(u.email) : ""}</option>`).join("")
+ `</select><span class="sublabel">撤销选择会删除该用户为此外部系统保存的密文凭据。</span></label>`
+ `</div></div><div class="ext-form-actions">`
+ `<button id="exa-cancel" type="button">取消</button><button class="primary" type="submit">保存系统定义</button>`
+ `</div></form></div></div>`;
$("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-add").onclick = () => openExternalDefinitionModal();
$("exa-cancel").onclick = closeExternalDefinitionModal;
$("exa-close").onclick = closeExternalDefinitionModal;
$("external-definition-modal").onclick = event => {
if (event.target.id === "external-definition-modal") closeExternalDefinitionModal();
};
$("external-definition-modal").onkeydown = event => {
if (event.key === "Escape") closeExternalDefinitionModal();
};
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]")) openExternalDefinitionModal(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-base-wrap").hidden = 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-base").required = !mcp;
$("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 openExternalDefinitionModal(row = null) {
externalEditingId = row ? row.definition_id : "";
$("exa-dialog-title").textContent = row ? `编辑外部系统:${row.name}` : "新增外部系统";
if (row) {
fillExternalDefinition(row);
} else {
$("ext-admin-form").reset();
$("exa-provider").disabled = false;
$("exa-provider").value = "generic_openapi";
$("exa-access").value = "selected";
$("exa-users-wrap").hidden = false;
applyExternalProviderDefaults();
}
$("external-definition-modal").classList.add("show");
document.documentElement.classList.add("modal-open");
document.body.classList.add("modal-open");
$("exa-name").focus();
}
function closeExternalDefinitionModal() {
externalEditingId = "";
$("external-definition-modal").classList.remove("show");
document.documentElement.classList.remove("modal-open");
document.body.classList.remove("modal-open");
}
function fillExternalDefinition(row) {
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";
}
async function saveExternalDefinition(e) {
e.preventDefault();
const provider = $("exa-provider").value;
const authType = $("exa-auth").value;
const mcp = provider === "generic_mcp";
const current = externalDefinitions.find(x => x.definition_id === externalEditingId);
const currentConfig = (current || {}).config || {};
const body = {
provider,
name: $("exa-name").value.trim(),
auth_type: authType,
auth_header_name: $("exa-auth-header").value.trim() || "Authorization",
auth_header_template: $("exa-auth-template").value || "Bearer {token}",
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),
};
if (authType === "password_jwt") {
Object.assign(body, {
login_path: $("exa-login").value.trim() || "/api/auth/token/",
username_field: $("exa-username-field").value.trim() || "username",
password_field: $("exa-password-field").value.trim() || "password",
token_field: $("exa-token-field").value.trim() || "access",
});
}
if (mcp) {
Object.assign(body, {
// 编辑时保留既有同源认证基址;新建时由后端从 MCP URL 推导 origin。
base_url: currentConfig.base_url || "",
mcp_url: $("exa-mcp").value.trim(),
expected_server_name: $("exa-mcp-server").value.trim(),
max_response_bytes: currentConfig.max_response_bytes || 10485760,
});
} else {
Object.assign(body, {
base_url: $("exa-base").value.trim(),
openapi_url: $("exa-spec").value.trim(),
operation_mode: $("exa-operation-mode").value,
operation_policies: Object.fromEntries(
$("exa-operations").value.split(",").map(x => x.trim()).filter(Boolean)
.map(operationId => [operationId, "read"]),
),
});
}
body.timeout_seconds = currentConfig.timeout_seconds || 15;
body.max_result_bytes = currentConfig.max_result_bytes || 65536;
body.max_total_result_bytes = current
? currentConfig.max_total_result_bytes || 262144
: 262144;
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 `<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>`
+ `<td title="${escapeHtml(fmtTime(r.last_used_at))}">${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}</td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="8" 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><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_tiersadmin 始终全开)</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));
}
// 工具失败聚集(近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 => `<tr${quiet ? ` class="quiet"` : ""}>`
+ `<td>${escapeHtml(c.tool)}</td>`
+ `<td>${escapeHtml(c.kind)}</td>`
+ `<td class="email" title="${escapeHtml(c.sample || "")}">${escapeHtml(c.signature)}</td>`
+ `<td class="num">${c.count}</td>`
+ `<td class="num${c.count_24h ? " hot" : ""}">${c.count_24h || 0}</td>`
+ `<td class="trend" title="每格 24h旧 → 新:${(c.daily || []).join(" / ")}">${trendBar(c.daily)}</td>`
+ `<td class="num">${c.task_count}</td>`
+ `<td class="num">${c.user_count}</td>`
+ `<td>${c.last_at ? fmtTime(c.last_at) : "—"}</td>`
+ `</tr>`).join("")
|| `<tr><td colspan="9" class="empty">${escapeHtml(emptyText)}</td></tr>`;
return `<div class="card"><h2>${escapeHtml(title)}</h2>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>工具</th><th>类型</th><th>签名(悬浮看样例)</th><th>次数</th><th>近24h</th><th>趋势</th><th>任务数</th><th>用户数</th><th>最近</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`;
}
function rateText(v) {
return v == null ? "—" : `${Number(v).toFixed(1)}%`;
}
function wireHealthHTML(d) {
if (!d || !Array.isArray(d.rows)) {
return `<div class="card"><h2>工具调用链路健康</h2>`
+ `<div class="empty">链路健康数据暂不可用,失败聚集仍可正常查看</div></div>`;
}
const rows = d.rows || [];
const total = d.total || {};
const body = rows.map(r => `<tr>`
+ `<td>${escapeHtml(r.model_profile || "?")}</td>`
+ `<td>${escapeHtml(r.tool || "?")}</td>`
+ `<td class="num">${r.salvaged_24h || 0}</td>`
+ `<td class="num${r.malformed_24h ? " hot" : ""}">${r.malformed_24h || 0}</td>`
+ `<td class="num">${rateText(r.recovery_rate_24h)}</td>`
+ `<td class="num">${r.salvaged || 0}</td>`
+ `<td class="num${r.malformed ? " hot" : ""}">${r.malformed || 0}</td>`
+ `<td class="num">${rateText(r.recovery_rate)}</td>`
+ `<td>${r.last_at ? fmtTime(r.last_at) : "—"}</td>`
+ `</tr>`).join("")
|| `<tr><td colspan="9" class="empty">近 ${d.days || 7} 天无工具参数损坏事件</td></tr>`;
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 `<div class="card"><h2>工具调用链路健康(${escapeHtml(summary)}</h2>`
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>模型档</th><th>工具</th><th>24h抢救</th><th>24h残余</th>`
+ `<th>24h抢救率</th><th>窗口抢救</th><th>窗口残余</th><th>窗口抢救率</th><th>最近</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div></div>`;
}
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 `<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);
renderWindowsNodes();
}
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) { /* 同上 */ }
}
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 统一处理鉴权 */ }
}
async function loadComputeNodes() {
try {
const result = await apiGet("/v1/admin/compute-nodes");
computeNodes = result.results || [];
renderWindowsNodes();
} catch (e) { /* overview 统一处理鉴权 */ }
}
// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新四个独立表(保持各自状态)
async function refresh() {
try {
const d = await apiGet("/v1/admin/overview");
ensureSkeleton();
renderMetrics(d);
loadModels();
loadUserUsage(userPage);
loadStorage(storagePage);
loadComputeNodes();
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) =>
`<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 : ""}`
+ ` &nbsp;|&nbsp; SSE 订阅 ${rt.sse_subs || 0}`
+ ` &nbsp;|&nbsp; 内存峰值 ${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)} &nbsp;|&nbsp; 输入 ${fmtTokens(u.tokens_in)}`
+ ` &nbsp;|&nbsp; 输出 ${fmtTokens(u.tokens_out)} &nbsp;|&nbsp; 缓存命中 ${hitRate}%`
+ ` &nbsp;|&nbsp; 事件 ${u.n_events || 0}</div>`
+ `<h2>近 7 天用量(按天)</h2>` + tbl(["日期", "成本", "输入", "输出"], dayBody)
+ `<h2>按模型all-timeTop 10</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
+ `<h2>各用户用量all-timeTop 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;
$("node-enrollment-form").onsubmit = createNodeEnrollment;
$("node-enrollment-close").onclick = closeNodeEnrollmentModal;
$("node-enrollment-cancel").onclick = closeNodeEnrollmentModal;
$("node-enrollment-copy").onclick = copyNodeEnrollmentCode;
$("node-enrollment-modal").onclick = (e) => {
if (e.target.id === "node-enrollment-modal") closeNodeEnrollmentModal();
};
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) {
closeNodeEnrollmentModal();
}
});
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAuto();
else { refresh(); startAuto(); }
});
refresh();
startAuto();