1162 lines
55 KiB
JavaScript
1162 lines
55 KiB
JavaScript
// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
|
||
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。
|
||
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
|
||
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
|
||
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
|
||
import { humanSize, fmtTime, 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 softwareNodes = [];
|
||
|
||
// ───── 格式化 ─────
|
||
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 = softwareNodes.map(node => {
|
||
const runtime = node.runtime || {};
|
||
const origin = runtime.origin || {};
|
||
const originState = origin.health === "ready" ? "Origin 可用" : "Origin 不可用";
|
||
const originVersion = origin.software_version ? ` ${origin.software_version}` : "";
|
||
const runtimeParts = [
|
||
node.os_version || "",
|
||
runtime.desktop_session === true ? "桌面会话" : "",
|
||
runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "",
|
||
runtime.origin ? `${originState}${originVersion}` : "",
|
||
].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(${softwareNodes.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 = () => toggleSoftwareNode(button);
|
||
});
|
||
$("s-windows-node").querySelectorAll("[data-node-delete]").forEach(button => {
|
||
button.onclick = () => deleteSoftwareNode(button);
|
||
});
|
||
}
|
||
|
||
async function toggleSoftwareNode(button) {
|
||
const row = button.closest("tr[data-node-id]");
|
||
const node = softwareNodes.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/software-nodes/${node.node_id}`, {
|
||
disabled: disabling,
|
||
});
|
||
message(disabling ? "节点已禁用" : "节点已重新启用,请在节点电脑上立即重连", "success", 5000);
|
||
await loadSoftwareNodes();
|
||
} catch (err) {
|
||
if (err.code !== "auth") message("更新节点失败:" + (err.message || String(err)), "error", 5000);
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function deleteSoftwareNode(button) {
|
||
const row = button.closest("tr[data-node-id]");
|
||
const node = softwareNodes.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/software-nodes/${node.node_id}`, {});
|
||
message("节点已删除,本机需重新注册后才能使用", "success", 5000);
|
||
await loadSoftwareNodes();
|
||
} 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/software-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_tiers;admin 始终全开)</div>`
|
||
+ rows + `</div>`;
|
||
}
|
||
|
||
// 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
|
||
function renderStorage(d) {
|
||
const quota = d.quota_bytes;
|
||
const rows = d.rows || [];
|
||
const total = d.total || 0;
|
||
const size = d.page_size || PAGE_SIZE;
|
||
const page = d.page || 0;
|
||
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
|
||
const from = total ? page * size + 1 : 0;
|
||
const to = Math.min(total, (page + 1) * size);
|
||
const quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限";
|
||
const maxUsed = Math.max(0, ...rows.map(r => r.bytes_used || 0));
|
||
const body = rows.map(r => {
|
||
const ratio = quota && quota > 0 ? r.bytes_used / quota : 0;
|
||
const cls = levelClass(ratio);
|
||
const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—";
|
||
const cellStyle = quota && quota > 0
|
||
? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);"
|
||
: cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "")
|
||
: tint(r.bytes_used, maxUsed);
|
||
return `<tr>`
|
||
+ `<td class="email" title="${escapeHtml(userTitle(r))}">${userCellHTML(r)}</td>`
|
||
+ `<td class="num bar-cell" style="${cellStyle}">${humanSize(r.bytes_used)}</td>`
|
||
+ `<td class="num">${pctTxt}</td>`
|
||
+ `<td class="num">${r.file_count || 0}</td>`
|
||
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
|
||
+ `</tr>`;
|
||
}).join("") || `<tr><td colspan="5" class="empty">无数据</td></tr>`;
|
||
$("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel})</h2>`
|
||
+ `<div class="scroll-x"><table>`
|
||
+ `<thead><tr><th>用户</th><th>已用</th><th>占配额</th><th>文件数</th><th>扫描于</th></tr></thead>`
|
||
+ `<tbody>${body}</tbody></table></div>`
|
||
+ pagerHTML("st", page, maxPage, from, to, total)
|
||
+ `</div>`;
|
||
wirePager("st", page, maxPage, (p) => loadStorage(p));
|
||
}
|
||
|
||
// 工具失败聚集(近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 loadSoftwareNodes() {
|
||
try {
|
||
const result = await apiGet("/v1/admin/software-nodes");
|
||
softwareNodes = 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);
|
||
loadSoftwareNodes();
|
||
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 : ""}`
|
||
+ ` | SSE 订阅 ${rt.sse_subs || 0}`
|
||
+ ` | 内存峰值 ${rt.rss_peak_mb != null ? Math.round(rt.rss_peak_mb) + " MB" : "—"}</div>`
|
||
+ `<h2>任务(共 ${tk.total || 0})</h2>`
|
||
+ `<div class="rpt-kv">status:${dist(tk.by_status)}<br/>run_status:${dist(tk.by_run_status)}</div>`
|
||
+ `<h2>用户</h2><div class="rpt-kv">总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}</div>`
|
||
+ `<h2>用量总览(all-time)</h2>`
|
||
+ `<div class="rpt-kv">总成本 ${fmtCNY(u.cost_cny)} | 输入 ${fmtTokens(u.tokens_in)}`
|
||
+ ` | 输出 ${fmtTokens(u.tokens_out)} | 缓存命中 ${hitRate}%`
|
||
+ ` | 事件 ${u.n_events || 0}</div>`
|
||
+ `<h2>近 7 天用量(按天)</h2>` + tbl(["日期", "成本", "输入", "输出"], dayBody)
|
||
+ `<h2>按模型(all-time,Top 10)</h2>` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
|
||
+ `<h2>各用户用量(all-time,Top 10)</h2>` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
|
||
+ `<h2>存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10)</h2>`
|
||
+ tbl(["用户", "已用", "占配额", "文件数"], stBody);
|
||
}
|
||
|
||
// ───── 自动刷新 ─────
|
||
function startAuto() {
|
||
stopAuto();
|
||
if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS);
|
||
}
|
||
function stopAuto() {
|
||
if (timer) { clearInterval(timer); timer = null; }
|
||
}
|
||
|
||
$("refresh").onclick = refresh;
|
||
$("export").onclick = exportPdf;
|
||
$("auto-refresh").onchange = startAuto;
|
||
$("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();
|