// 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-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"],
["s-users", "各用户"], ["s-storage", "存储"],
["s-sandbox-packages", "容器依赖"],
["s-windows-node", "专业软件节点"],
["s-provider-credentials", "API 凭据"],
["s-external", "外部系统"],
["s-toolfail", "工具失败"],
];
const $ = (id) => document.getElementById(id);
const token = () => localStorage.getItem(LS_TOKEN) || "";
// 用户显示名兜底链:name → user_name → email → uid8。监控页各处共用同一规则。
// userCellHTML 给表格单元格:主文本走兜底链;name 与 user_name 都有时,name 后跟一个
// 浅灰 user_name;title 悬浮给完整 name/账号/邮箱/user_id。userLabelText 给概览迷你表(纯文本)。
function userLabelText(r) {
return r.name || r.user_name || r.email || (r.user_id || "").slice(0, 8);
}
function userTitle(r) {
const parts = [];
if (r.name) parts.push(`姓名 ${r.name}`);
if (r.user_name) parts.push(`账号 ${r.user_name}`);
if (r.email) parts.push(`邮箱 ${r.email}`);
parts.push(`ID ${r.user_id}`);
return parts.join("\n");
}
function userCellHTML(r) {
const primary = escapeHtml(userLabelText(r));
// name 与 user_name 同时存在 → 主显 name,后缀浅灰 user_name(满足"name 和 user_name 都显")
const sub = (r.name && r.user_name)
? ` ${escapeHtml(r.user_name)}`
: "";
return `${primary}${sub}`;
}
let timer = null;
// 各表独立状态(不随 overview 轮询重置)
let modelRange = "7d", modelSort = "cost";
let userRange = "7d", userSort = "cost", userPage = 0;
let storagePage = 0;
let packageRange = "30d";
let tiersData = null; // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用)
let externalDefinitions = [];
let providerCredentials = [];
let externalUsers = [];
let externalDefinitionsLoaded = false;
let externalEditingId = "";
let softwareNodes = [];
let softwareNodesLoaded = false;
let overviewData = null;
let sandboxCapacityData = null;
let storageSummaryData = null;
let toolFailuresData = null;
let toolWireData = null;
let capacityDrawerTrigger = null;
// ───── 格式化 ─────
function fmtCNY(n) {
n = Number(n) || 0;
if (n < 0.01 && n > 0) return "¥" + n.toFixed(4);
return "¥" + n.toFixed(2);
}
// 相对热力底色:value 占 max 越高,accent 底色越深(占用多 → 有色差)。
function tint(value, max) {
if (!max || max <= 0 || !value || value <= 0) return "";
const a = Math.min(1, value / max) * 0.30;
return `background: rgba(192,57,43,${a.toFixed(3)});`;
}
function levelClass(ratio) {
if (ratio >= 1) return "danger";
if (ratio >= 0.8) return "warn";
return "";
}
// range/sort 下拉一组(prefix 区分 m=模型 / u=用户);值取当前 state。
function ctrlHTML(prefix, range, sort) {
const opt = (cur, list) => list.map(
([v, l]) => ``
).join("");
return `
`
+ ``
+ ``
+ `
`;
}
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
// ───── 渲染各 section ─────
function summaryCard({ target = "", action = "", label, value = "", unit = "", sub = "", meta = [], badge = null, tone = "", progress = null, extraClass = "" }) {
const meter = progress == null ? "" : `
`;
const metaHTML = meta.length
? `${escapeHtml(meta[0] || "")}${escapeHtml(meta[1] || "")}
`
: "";
const tag = target ? "a" : (action ? "button" : "article");
const attrs = target
? ` href="#${target}"`
: (action ? ` type="button" data-summary-action="${escapeHtml(action)}" aria-controls="capacity-drawer"` : "");
const badgeHTML = badge && badge.text
? `${escapeHtml(badge.text)}`
: "";
const affordance = target ? `›`
: (action ? `查看详情` : "");
return `<${tag} class="summary-card ${tone} ${extraClass}"${attrs}>`
+ `${escapeHtml(label)}${badgeHTML}${affordance}
`
+ (value ? `${value}${unit ? ` ${escapeHtml(unit)}` : ""}
` : "")
+ (sub ? `${escapeHtml(sub)}
` : "")
+ metaHTML + meter + `${tag}>`;
}
function renderOpsSummary() {
const root = $("ops-summary");
if (!root) return;
const d = overviewData || {};
const runtime = d.runtime || {};
const tasks = d.tasks || {};
const users = d.users || {};
const usage = (d.usage || {}).total || {};
const byDay = (d.usage || {}).by_day_7d || [];
const capacity = sandboxCapacityData;
const capacityEnabled = capacity && capacity.enabled !== false;
const capacityLimits = (capacity && capacity.limits) || {};
const activeExecs = capacityEnabled
? (Number(capacity.foreground_running) || 0) + (Number(capacity.background_running) || 0)
: 0;
const activeLimit = Number(capacityLimits.active) || 0;
const foregroundQueued = Number((capacity || {}).foreground_queued) || 0;
const backgroundQueued = Number((capacity || {}).background_queued) || 0;
const queuedExecs = foregroundQueued + backgroundQueued;
const availableExecs = capacityEnabled ? Math.max(0, Number(capacity.admit_available) || 0) : 0;
const maxUserUsage = capacityEnabled
? Math.max(0, ...Object.values(capacity.per_user || {}).map(value => Number(value) || 0))
: 0;
const capacityPct = activeLimit ? Math.round(activeExecs / activeLimit * 100) : 0;
const capacityTone = capacityEnabled && capacity.memory_paused
? "danger"
: levelClass(activeLimit ? activeExecs / activeLimit : 0);
const nodeTotal = softwareNodes.length;
const nodeOnline = softwareNodes.filter(node => node.status === "online").length;
const nodeOffline = softwareNodes.filter(node => node.status === "offline").length;
const nodeDisabled = softwareNodes.filter(node => node.status === "disabled").length;
const nodeJobs = softwareNodes.reduce((sum, node) => sum + (Number(node.active_job_count) || 0), 0);
const nodeTone = softwareNodesLoaded && nodeTotal && !nodeOnline ? "danger" : (nodeOffline || nodeDisabled ? "warn" : "");
const runStatuses = tasks.by_run_status || {};
const runningTasks = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0);
const errorTasks = Number(runStatuses.error) || 0;
const cancellingTasks = Number(runStatuses.cancelling) || 0;
const activeTasks = Number((tasks.by_status || {}).active) || 0;
const userTotal = Number(users.total) || 0;
const activeUsers = Number(users.active_7d) || 0;
const activeRate = userTotal ? Math.round(activeUsers / userTotal * 100) : 0;
const tokensIn = Number(usage.tokens_in) || 0;
const tokensIn7d = byDay.reduce((sum, row) => sum + (Number(row.tokens_in) || 0), 0);
const tokensOut7d = byDay.reduce((sum, row) => sum + (Number(row.tokens_out) || 0), 0);
const tokens7d = tokensIn7d + tokensOut7d;
const cacheHit = Number(usage.tokens_cache_hit) || 0;
const hitRate = tokensIn ? Math.round(cacheHit / tokensIn * 100) : 0;
const storage = storageSummaryData;
const storageTotals = (storage && storage.totals) || {};
const scannedUsers = storage ? Number(storage.total) || 0 : 0;
const storageValue = storage ? humanSize(storageTotals.physical_bytes || 0) : "汇总中";
const storageSub = storage
? `配额内 ${humanSize(storageTotals.bytes_used || 0)} · 回收站 ${humanSize(storageTotals.trash_bytes || 0)}`
: "正在读取存储快照";
const storageMeta = storage
? [`${storageTotals.file_count || 0} 个文件`, `已扫描 ${scannedUsers} 人`]
: ["加载中", ""];
const failureRows = (toolFailuresData && toolFailuresData.clusters) || [];
const activeFailures = failureRows.filter(item => (item.count_24h || 0) > 0);
const operationalFailures = activeFailures.filter(item => item.category !== "quality_gate");
const failureCount = operationalFailures.reduce((sum, item) => sum + (Number(item.count_24h) || 0), 0);
const systemicCount = activeFailures.filter(item => item.category !== "quality_gate" && item.task_count >= 2).length;
const localCount = activeFailures.filter(item => item.category !== "quality_gate" && item.task_count < 2).length;
const gateCount = activeFailures.filter(item => item.category === "quality_gate")
.reduce((sum, item) => sum + (Number(item.count_24h) || 0), 0);
const malformed = Number(((toolWireData || {}).total || {}).malformed_24h) || 0;
const toolTone = systemicCount || malformed ? "danger" : (localCount ? "warn" : "");
const cost = Number(usage.cost_cny) || 0;
const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0);
root.innerHTML = [
summaryCard({
label: "任务运行", value: String(runningTasks), unit: "正在运行",
sub: cancellingTasks ? `取消中 ${cancellingTasks}` : (errorTasks ? "存在执行错误记录" : "当前无执行异常"),
meta: [`活动任务 ${activeTasks}`, `累计 ${tasks.total || 0}`],
badge: errorTasks ? { text: `错误 ${errorTasks}`, tone: "danger" } : { text: "正常", tone: "ok" },
}),
summaryCard({
action: capacityEnabled ? "open-capacity" : "", label: "执行容量",
value: !capacity ? "汇总中" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"),
unit: capacityEnabled ? "使用中 / 上限" : "",
sub: !capacity ? "正在读取容器状态" : (capacityEnabled
? `可用 ${availableExecs} · 排队 ${queuedExecs}`
: "Docker Sandbox 未启用"),
meta: capacityEnabled
? [`后台 ${Number(capacity.background_running) || 0} / ${capacityLimits.background || 0}`, `单用户最高 ${maxUserUsage} / ${capacityLimits.per_user || 0}`]
: ["", ""],
badge: capacityEnabled
? (capacity.memory_paused ? { text: "暂停放行", tone: "danger" } : (queuedExecs ? { text: `排队 ${queuedExecs}`, tone: "warn" } : { text: "正常", tone: "ok" }))
: null,
tone: capacityTone, progress: capacityEnabled && activeLimit ? capacityPct : null,
}),
summaryCard({
target: "s-windows-node", label: "专业软件节点",
value: softwareNodesLoaded ? `${nodeOnline} / ${nodeTotal}` : "汇总中", unit: softwareNodesLoaded ? "在线 / 总数" : "",
sub: softwareNodesLoaded ? `活动任务 ${nodeJobs}` : "正在读取节点状态",
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : [],
badge: softwareNodesLoaded
? (!nodeTotal ? { text: "未配置" } : (nodeTone ? { text: nodeOnline ? "部分异常" : "不可用", tone: nodeTone } : { text: "正常", tone: "ok" }))
: null,
}),
summaryCard({
target: "s-toolfail", label: "工具健康",
value: toolFailuresData ? (failureCount ? `${failureCount} 次失败` : "正常") : "汇总中",
unit: toolFailuresData ? "近 24 小时" : "",
sub: toolFailuresData
? (systemicCount || malformed ? `系统性聚集 ${systemicCount} · 链路残余 ${malformed}` : "未发现系统性工具异常")
: "正在汇总工具调用状态",
meta: toolFailuresData ? [`单任务 ${localCount}`, `质量门 ${gateCount}`] : [],
badge: toolFailuresData
? (toolTone ? { text: systemicCount || malformed ? "需处理" : "有记录", tone: toolTone } : { text: "正常", tone: "ok" })
: null,
}),
summaryCard({
target: "s-users", label: "活跃用户", value: String(activeUsers), unit: "近 7 天活跃",
sub: `活跃率 ${activeRate}% · 总用户 ${userTotal}`,
meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
}),
summaryCard({
target: "s-usage", label: "Token 与缓存", value: fmtTokens(tokens7d), unit: "近 7 天",
sub: `缓存命中 ${hitRate}%`,
meta: [`输入 ${fmtTokens(tokensIn7d)}`, `输出 ${fmtTokens(tokensOut7d)}`], progress: hitRate,
}),
summaryCard({
target: "s-usage", label: "成本", value: fmtCNY(cost7d), unit: "近 7 天",
sub: `累计 ${fmtCNY(cost)}`,
}),
summaryCard({
target: "s-storage", label: "存储", value: storageValue, unit: storage ? "物理总占用" : "",
sub: storageSub, meta: storageMeta,
}),
].join("");
const capacityCard = root.querySelector('[data-summary-action="open-capacity"]');
if (capacityCard) capacityCard.onclick = openCapacityDrawer;
if ($("capacity-drawer").classList.contains("show")) renderCapacityDrawer();
}
function capacityDetailItem(label, value, hint = "", tone = "") {
return ``
+ `${escapeHtml(label)}${escapeHtml(String(value))}`
+ (hint ? `${escapeHtml(hint)}` : "") + `
`;
}
function renderCapacityDrawer() {
const capacity = sandboxCapacityData;
const body = $("capacity-drawer-body");
const status = $("capacity-drawer-status");
if (!capacity || !body || !status) return;
if (capacity.enabled === false) {
status.className = "summary-badge";
status.textContent = "未启用";
body.innerHTML = `Docker Sandbox 未启用,当前没有可展示的执行容量。
`;
return;
}
const limits = capacity.limits || {};
const foregroundRunning = Number(capacity.foreground_running) || 0;
const backgroundRunning = Number(capacity.background_running) || 0;
const foregroundQueued = Number(capacity.foreground_queued) || 0;
const backgroundQueued = Number(capacity.background_queued) || 0;
const active = foregroundRunning + backgroundRunning;
const limit = Number(limits.active) || 0;
const queued = foregroundQueued + backgroundQueued;
const available = Math.max(0, Number(capacity.admit_available) || 0);
const users = Object.entries(capacity.per_user || {}).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0));
const cpuLoad = (capacity.cpu_load || []).map(value => Number(value).toFixed(2)).join(" / ") || "—";
status.className = `summary-badge ${capacity.memory_paused ? "danger" : (queued ? "warn" : "ok")}`;
status.textContent = capacity.memory_paused ? "暂停放行" : (queued ? `排队 ${queued}` : "运行正常");
body.innerHTML = (capacity.memory_paused
? `内存压力已暂停新任务放行运行中的任务不受影响;资源恢复后会继续调度。
`
: "")
+ ``
+ `${active} / ${limit || "—"}使用中 / 上限
`
+ `${available}当前可放行
`
+ `${queued}等待调度
`
+ `执行队列
`
+ capacityDetailItem("前台执行", foregroundRunning, `排队 ${foregroundQueued}`, foregroundQueued ? "warn" : "")
+ capacityDetailItem("后台执行", `${backgroundRunning} / ${Number(limits.background) || 0}`, `排队 ${backgroundQueued}`, backgroundQueued ? "warn" : "")
+ capacityDetailItem("单用户上限", Number(limits.per_user) || 0, "重型任务槽位")
+ `
`
+ `容器生命周期
`
+ capacityDetailItem("活跃容器", Number(capacity.active_sandbox_containers) || 0)
+ capacityDetailItem("待回收", Number(capacity.idle_reap_candidates) || 0, "空闲候选", Number(capacity.idle_reap_candidates) ? "warn" : "")
+ capacityDetailItem("后台 proc", Number(capacity.running_proc_containers) || 0)
+ `
`
+ `单用户占用
`
+ (users.length
? `${users.map(([userId, count]) => `
${escapeHtml(userId.slice(0, 8))}${Number(count) || 0} / ${Number(limits.per_user) || 0}
`).join("")}
`
: `当前没有用户占用执行槽位
`)
+ ``
+ `宿主资源
`
+ `
可用内存${humanSize(capacity.mem_available_bytes || 0)}
`
+ `
CPU Load${escapeHtml(cpuLoad)}
`
+ `
`;
}
function openCapacityDrawer() {
if (!sandboxCapacityData || sandboxCapacityData.enabled === false) return;
capacityDrawerTrigger = document.activeElement;
renderCapacityDrawer();
const drawer = $("capacity-drawer");
drawer.classList.add("show");
drawer.setAttribute("aria-hidden", "false");
document.documentElement.classList.add("drawer-open");
document.body.classList.add("drawer-open");
$("capacity-drawer-close").focus();
}
function closeCapacityDrawer() {
const drawer = $("capacity-drawer");
if (!drawer.classList.contains("show")) return;
drawer.classList.remove("show");
drawer.setAttribute("aria-hidden", "true");
document.documentElement.classList.remove("drawer-open");
document.body.classList.remove("drawer-open");
const nextTrigger = capacityDrawerTrigger && document.contains(capacityDrawerTrigger)
? capacityDrawerTrigger
: document.querySelector('[data-summary-action="open-capacity"]');
if (nextTrigger) nextTrigger.focus();
capacityDrawerTrigger = null;
}
function renderByDay(rows) {
rows = rows || [];
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const body = rows.map(r => ``
+ `| ${escapeHtml(r.date)} | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `
`).join("") || `| 无数据 |
`;
const sum = rows.reduce((a, r) => {
a.cost_cny += r.cost_cny || 0;
a.tokens_in += r.tokens_in || 0;
a.tokens_out += r.tokens_out || 0;
return a;
}, { cost_cny: 0, tokens_in: 0, tokens_out: 0 });
const foot = rows.length ? ``
+ `| 合计 | `
+ `${fmtCNY(sum.cost_cny)} | `
+ `${fmtTokens(sum.tokens_in)} | `
+ `${fmtTokens(sum.tokens_out)} | `
+ `
` : "";
return ``;
}
function nodeStatusHTML(status) {
const labels = { online: "在线", offline: "离线", disabled: "已禁用" };
const value = labels[status] ? status : "offline";
return `${labels[value]}`;
}
function nodeCapabilityRuntimes(runtime) {
const entries = Object.entries(runtime.capability_runtime || {})
.filter(([, value]) => value && typeof value === "object");
if (entries.length) return entries;
return runtime.origin ? [["origin.plot@v2", runtime.origin]] : [];
}
function nodeSoftwareName(capability, item) {
if (item.software === "OriginPro" || capability.startsWith("origin.")) return "Origin";
return item.software || capability;
}
function nodeAvailableCapabilities(node, runtime) {
const reported = new Set(node.capabilities || []);
return nodeCapabilityRuntimes(runtime).filter(([capability, item]) =>
item.health === "ready" && (!reported.size || reported.has(capability))
);
}
function nodeCapabilityVersionHTML(capability, item) {
const name = nodeSoftwareName(capability, item);
const versions = [
item.software_version ? `${name} ${item.software_version}` : name,
item.adapter_version ? `Adapter ${item.adapter_version}` : "",
].filter(Boolean);
return ``
+ `${escapeHtml(capability)}`
+ `${escapeHtml(versions.join(" · "))}`
+ `
`;
}
function renderWindowsNodes() {
const rows = softwareNodes.map(node => {
const runtime = node.runtime || {};
const availableCapabilities = nodeAvailableCapabilities(node, runtime);
const capabilityHTML = availableCapabilities
.map(([capability, item]) => nodeCapabilityVersionHTML(capability, item))
.join("");
const lastSeen = node.last_seen_at
? `${escapeHtml(fmtTimeAgo(node.last_seen_at))}`
: "—";
const disabled = node.status === "disabled";
const activeJobCount = Math.max(0, Number(node.active_job_count) || 0);
return ``
+ `${escapeHtml(node.name || "未命名节点")} `
+ `${escapeHtml(node.node_id)} `
+ `${escapeHtml(node.node_version ? `Node ${node.node_version}` : "Node 版本未上报")} | `
+ `${nodeStatusHTML(node.status)} | `
+ `${capabilityHTML || `暂无可用能力`} | `
+ `${activeJobCount} | `
+ `${lastSeen} | `
+ ` `
+ ` | `
+ `
`;
}).join("") || `| 尚无已注册的专业软件节点 |
`;
$("s-windows-node").innerHTML = ``
+ `
专业软件节点(${softwareNodes.length})
查看节点状态;禁用会立即断开节点并拒绝后续连接。
`
+ `
`
+ `
`;
$("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 ? "禁用专业软件节点" : "重新启用专业软件节点",
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: "删除专业软件节点",
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(),
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 ``
+ `| ${escapeHtml(r.name)} ${escapeHtml(r.provider_title || r.provider)}${r.enabled ? "" : ' 停用'}`
+ ` ${r.visibility === "organization" ? "全部用户" : `指定 ${((r.selected_user_ids || []).length)} 人`} | `
+ `${escapeHtml(r.host || cfg.base_url || "—")} | `
+ `${capability} | `
+ ` | `
+ `
`;
}).join("") || `| 尚未配置外部系统 |
`;
$("s-external").innerHTML = `外部系统目录
`
+ `
OpenAPI 与 Streamable HTTP MCP 系统均可配置;用户只提交该系统要求的凭据 `
+ `
`
+ `
`
+ ``;
$("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 ``
+ `| ${escapeHtml(r.model_profile || "—")} | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `${r.n_events || 0} | `
+ `
`;
}).join("") || `| 无数据 |
`;
$("s-models").innerHTML = ``
+ `
按模型(${rangeLabel(d.range)})
${ctrlHTML("m", d.range, d.sort)}`
+ `
`;
$("m-range").onchange = (e) => { modelRange = e.target.value; loadModels(); };
$("m-sort").onchange = (e) => { modelSort = e.target.value; loadModels(); };
}
// 各用户用量(时间筛选 + 排序 + 分页)。d 含 range/sort/page/page_size/total_users/rows
function renderUserUsage(d) {
const rows = d.rows || [];
const total = d.total_users || 0;
const size = d.page_size || PAGE_SIZE;
const page = d.page || 0;
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
const from = total ? page * size + 1 : 0;
const to = Math.min(total, (page + 1) * size);
const maxCost = Math.max(0, ...rows.map(r => r.cost_cny || 0));
const maxTin = Math.max(0, ...rows.map(r => r.tokens_in || 0));
const byTok = d.sort === "tokens";
const body = rows.map(r => {
const hitRate = r.tokens_in ? Math.round(r.tokens_cache_hit / r.tokens_in * 100) : 0;
return ``
+ `| ${userCellHTML(r)}`
+ (r.role === "admin" ? ` admin` : "") + ` | `
+ `${planSelectHTML(r)} | `
+ `${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | `
+ `${fmtTokens(r.tokens_out)} | `
+ `${hitRate}% | `
+ `${r.n_events || 0} | `
+ `${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"} | `
+ `
`;
}).join("") || `| 无数据 |
`;
$("s-users").innerHTML = ``
+ `
各用户用量(${rangeLabel(d.range)})
${ctrlHTML("u", d.range, d.sort)}`
+ tierLegendHTML()
+ `
`
+ pagerHTML("uu", page, maxPage, from, to, total)
+ `
`;
$("u-range").onchange = (e) => { userRange = e.target.value; userPage = 0; loadUserUsage(0); };
$("u-sort").onchange = (e) => { userSort = e.target.value; userPage = 0; loadUserUsage(0); };
// 档位下拉:选中即 PATCH(admin 看到全部模型,改档不影响 admin 自己的可见性)
$("s-users").querySelectorAll(".plan-sel").forEach(sel => {
sel.onchange = (e) => setUserPlan(e.target.dataset.uid, e.target.value, e.target);
});
wirePager("uu", page, maxPage, (p) => loadUserUsage(p));
}
// 档位下拉(每行一个);plan 为空 → 选中 default 档。tiers 未加载好 → 退化为纯文本。
function planSelectHTML(r) {
const tiers = (tiersData && tiersData.tiers) || {};
const names = Object.keys(tiers);
if (!names.length) return escapeHtml(r.plan || "default");
const def = (tiersData && tiersData.default_tier) || "default";
const cur = r.plan || def;
const opts = names.map(n =>
``
).join("");
return ``;
}
// 档位图例:每档含哪些模型(id → 显示名)。tiersData 未加载 → 空。
function tierLegendHTML() {
if (!tiersData || !tiersData.tiers) return "";
const cat = {};
(tiersData.catalog || []).forEach(m => { cat[m.id] = m.display_name; });
const def = tiersData.default_tier || "default";
const rows = Object.keys(tiersData.tiers).map(name => {
const members = (tiersData.tiers[name] || []).map(id => id === "*" ? "全部模型" : (cat[id] || id));
return `${escapeHtml(name)}${name === def ? "(默认)" : ""}:`
+ `${members.map(escapeHtml).join("、") || "(空)"}
`;
}).join("");
return ``
+ `
档位说明(改 config/agent.yaml model_tiers;admin 始终全开)
`
+ rows + `
`;
}
// 存储用量(分页)。d 含 page/page_size/total/quota_bytes/rows
function renderStorage(d) {
const quota = d.quota_bytes;
const rows = d.rows || [];
const total = d.total || 0;
const size = d.page_size || PAGE_SIZE;
const page = d.page || 0;
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
const from = total ? page * size + 1 : 0;
const to = Math.min(total, (page + 1) * size);
const quotaLabel = quota && quota > 0 ? `配额 ${humanSize(quota)}/人` : "无配额上限";
const maxUsed = Math.max(0, ...rows.map(r => r.bytes_used || 0));
const body = rows.map(r => {
const ratio = quota && quota > 0 ? r.bytes_used / quota : 0;
const cls = levelClass(ratio);
const pctTxt = quota && quota > 0 ? Math.round(ratio * 100) + "%" : "—";
const cellStyle = quota && quota > 0
? (cls === "danger" ? "background:var(--accent-soft);color:var(--danger);"
: cls === "warn" ? "background:#fff8ec;color:var(--warn);" : "")
: tint(r.bytes_used, maxUsed);
return ``
+ `| ${userCellHTML(r)} | `
+ `${humanSize(r.bytes_used)} | `
+ `${pctTxt} | `
+ `${r.file_count || 0} | `
+ `${humanSize(r.trash_bytes || 0)} | `
+ `${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))} | `
+ `${r.scanned_at ? fmtTime(r.scanned_at) : "—"} | `
+ `
`;
}).join("") || `| 无数据 |
`;
$("s-storage").innerHTML = `存储用量(${quotaLabel})
`
+ `
`
+ pagerHTML("st", page, maxPage, from, to, total)
+ `
`;
wirePager("st", page, maxPage, (p) => loadStorage(p));
}
// 工具失败聚集(近7天,低阈值看全量;巡检邮件走 core/toolfail 高阈值)。
// 签名已在后端归一(路径/数字抹平),悬浮 title 给最近一次的原始样例。
// 后端已按活跃度排序(近24h 有发生的在前);近24h=0 的行淡化 —— 修复部署后
// 看对应行是否变灰 + 趋势尾格归零,即知修没修好(旧记录会挂满窗口,行不消失)。
function trendBar(daily) {
if (!daily || !daily.length) return "";
const max = Math.max(...daily, 1);
const blocks = "▁▂▃▄▅▆▇█";
return daily.map(v => v === 0 ? "·"
: blocks[Math.min(blocks.length - 1, Math.ceil(v / max * blocks.length) - 1)]).join("");
}
function toolFailureTable(title, rows, emptyText, quiet = false) {
const body = rows.map(c => ``
+ `| ${escapeHtml(c.tool)} | `
+ `${escapeHtml(c.kind)} | `
+ `${escapeHtml(c.signature)} | `
+ `${c.count} | `
+ `${c.count_24h || 0} | `
+ `${trendBar(c.daily)} | `
+ `${c.task_count} | `
+ `${c.user_count} | `
+ `${c.last_at ? fmtTime(c.last_at) : "—"} | `
+ `
`).join("")
|| `| ${escapeHtml(emptyText)} |
`;
return `${escapeHtml(title)}
`
+ `
`;
}
function rateText(v) {
return v == null ? "—" : `${Number(v).toFixed(1)}%`;
}
function wireHealthHTML(d) {
if (!d || !Array.isArray(d.rows)) {
return `工具调用链路健康
`
+ `
链路健康数据暂不可用,失败聚集仍可正常查看
`;
}
const rows = d.rows || [];
const total = d.total || {};
const body = rows.map(r => ``
+ `| ${escapeHtml(r.model_profile || "?")} | `
+ `${escapeHtml(r.tool || "?")} | `
+ `${r.salvaged_24h || 0} | `
+ `${r.malformed_24h || 0} | `
+ `${rateText(r.recovery_rate_24h)} | `
+ `${r.salvaged || 0} | `
+ `${r.malformed || 0} | `
+ `${rateText(r.recovery_rate)} | `
+ `${r.last_at ? fmtTime(r.last_at) : "—"} | `
+ `
`).join("")
|| `| 近 ${d.days || 7} 天无工具参数损坏事件 |
`;
const summary = `近24h 抢救 ${total.salvaged_24h || 0} / 残余 ${total.malformed_24h || 0}`
+ ` / 抢救率 ${rateText(total.recovery_rate_24h)};`
+ `近${d.days || 7}天 抢救 ${total.salvaged || 0} / 残余 ${total.malformed || 0}`
+ ` / 抢救率 ${rateText(total.recovery_rate)}`;
return `工具调用链路健康(${escapeHtml(summary)})
`
+ `
`;
}
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 ``;
}
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 = ``;
renderOpsSummary();
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) {
overviewData = d;
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("s-usage").innerHTML = renderByDay((d.usage || {}).by_day_7d);
renderWindowsNodes();
renderOpsSummary();
}
function providerStatusLabel(status) {
return ({ normal: "正常", low_balance: "余额偏低", exhausted: "已耗尽",
auth_error: "认证失败", unreachable: "不可达", untested: "未测试" })[status] || status;
}
function renderProviderCredentials() {
const rows = providerCredentials.map(row => {
const fields = (row.fields || []).map(f => `${escapeHtml(f.label)} ${escapeHtml(f.hint || "未配置")}`).join(" · ");
const balance = row.balance
? `${escapeHtml(row.balance.currency || "")} ${escapeHtml(row.balance.amount || "")}` : "—";
const tone = ["low_balance", "exhausted", "auth_error"].includes(row.test_status) ? "err" :
(row.test_status === "normal" ? "ok" : "");
return `${escapeHtml(row.display_name)}`
+ ` ${escapeHtml(row.category)} | `
+ `${escapeHtml(row.source)} ${fields} | `
+ `${escapeHtml(providerStatusLabel(row.test_status))}`
+ ` ${escapeHtml(row.test_detail || "—")} | `
+ `${balance} ${row.last_tested_at ? fmtTimeAgo(row.last_tested_at) : "未测试"} | `
+ ` `
+ ` |
`;
}).join("") || `| 暂无 Provider |
`;
$("s-provider-credentials").innerHTML = `API 凭据
`
+ `数据库密文优先;删除覆盖后回退环境变量。测试候选成功后才原子替换。`
+ `
`;
$("s-provider-credentials").onclick = async event => {
const tr = event.target.closest("tr[data-provider-id]");
if (!tr) return;
const row = providerCredentials.find(item => item.provider_id === tr.dataset.providerId);
if (!row) return;
if (event.target.closest("[data-provider-replace]")) await replaceProviderCredentials(row);
else if (event.target.closest("[data-provider-test]")) await testProviderCredentials(row);
else if (event.target.closest("[data-provider-delete]")) await deleteProviderCredentials(row);
};
}
async function replaceProviderCredentials(row) {
if (row.billable_test && !confirm(`${row.display_name} 没有免费鉴权端点,本次测试会发起一次最小真实调用。继续?`)) return;
const credentials = {};
for (const field of row.fields || []) {
const value = await dialogPrompt({
title: `录入 ${row.display_name}`,
label: `${field.label}(当前 ${field.hint || "未配置"};不会回显原值)`,
placeholder: `输入新的 ${field.label}`,
value: "", maxLength: 4096, okText: "下一步",
});
if (value === null) return;
if (!value.trim()) { message(`${field.label} 不可为空`, "error"); return; }
credentials[field.name] = value.trim();
}
if (!await dialogConfirm({ title: `更换 ${row.display_name} 凭据?`,
message: "系统会先在内存中测试候选凭据;仅测试成功后才替换当前密文。",
okText: "测试并启用", danger: false })) return;
try {
const result = await apiSend("PUT", `/v1/admin/provider-credentials/${row.provider_id}`,
{ credentials, expected_revision: row.revision || 0 });
message(result.test_detail || "凭据已启用", result.test_status === "low_balance" ? "info" : "success");
await loadProviderCredentials();
} catch (err) { message("保存失败:" + (err.message || String(err)), "error", 6000); }
}
async function testProviderCredentials(row) {
if (row.billable_test && !confirm(`${row.display_name} 测试会发起一次最小真实调用。继续?`)) return;
try {
const result = await apiSend("POST", `/v1/admin/provider-credentials/${row.provider_id}/test`, {});
message(result.detail || providerStatusLabel(result.status), result.status === "normal" ? "success" : "info", 5000);
await loadProviderCredentials();
} catch (err) { message("测试失败:" + (err.message || String(err)), "error", 6000); }
}
async function deleteProviderCredentials(row) {
if (!await dialogConfirm({ title: `删除 ${row.display_name} 数据库覆盖?`,
message: "删除后立即回退到部署环境变量;若环境变量也未配置,该能力将不可用。",
okText: "删除覆盖", danger: true })) return;
try {
await apiSend("DELETE", `/v1/admin/provider-credentials/${row.provider_id}`,
{ expected_revision: row.revision });
await loadProviderCredentials();
} catch (err) { message("删除失败:" + (err.message || String(err)), "error", 6000); }
}
function renderSandboxPackages(d) {
const rows = d.rows || [];
const opts = RANGE_OPTS.map(([v,l]) => ``).join("");
const body = rows.map(r => `| ${escapeHtml(r.name)} | ${escapeHtml(r.version)} | `
+ `${r.session_count} | ${r.user_count} | ${r.foreground_sessions}/${r.background_sessions} | `
+ `${r.direct_sessions} | ${humanSize(r.average_installed_bytes)} | ${humanSize(r.total_installed_bytes)} | `
+ `${escapeHtml((r.changes || []).join("/"))} ${fmtTimeAgo(r.latest_at)} |
`).join("");
$("s-sandbox-packages").innerHTML = `容器依赖统计
`
+ `只分析临时安装,不自动修改基础镜像;共 ${d.scan_sessions || 0} 个安装会话。
`
+ `
| 包名 | 版本 | 安装会话 | 独立用户 | 前台/后台 | 直接安装会话 | 平均占用 | 累计占用 | 基础镜像状态 / 最近 |
${body || `| 暂无临时依赖记录 |
`}
`;
const select = $("sandbox-package-range");
if (select) select.onchange = () => { packageRange = select.value; loadSandboxPackages(); };
}
function showMsg(html) {
$("main").innerHTML = `${html}
`; // 清骨架,错误态独占
}
// ───── 拉数据 ─────
// 统一 GET:无 token / 401 / 403 → showMsg + stopAuto + 抛(调用方据 e.code 静默)。
async function apiGet(path) {
const t = token();
if (!t) {
showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`);
stopAuto();
throw Object.assign(new Error("no token"), { code: "auth" });
}
const r = await fetch(path, { headers: { Authorization: "Bearer " + t } });
if (r.status === 401) {
showMsg(`登录已失效。请回 控制台 重新登录。`);
stopAuto();
throw Object.assign(new Error("401"), { code: "auth" });
}
if (r.status === 403) {
showMsg(`无权限:管理后台仅限管理员(admin)访问。
返回控制台`);
stopAuto();
throw Object.assign(new Error("403"), { code: "auth" });
}
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || String(r.status));
}
return r.json();
}
// 写操作(PATCH/POST):带 JSON body;401/403 同 apiGet 提示;其余抛错由调用方 alert。
async function apiSend(method, path, body) {
const t = token();
if (!t) {
showMsg(`未登录。请先在 控制台 登录后再访问管理后台。`);
stopAuto();
throw Object.assign(new Error("no token"), { code: "auth" });
}
const r = await fetch(path, {
method,
headers: { Authorization: "Bearer " + t, "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
});
if (r.status === 401 || r.status === 403) {
throw Object.assign(new Error(String(r.status)), { code: "auth" });
}
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || String(r.status));
}
return r.json();
}
async function loadModels() {
try {
renderModels(await apiGet(`/v1/admin/usage/models?range=${modelRange}&sort=${modelSort}`));
} catch (e) { /* auth 已提示;其它静默,overview 那边兜底 */ }
}
async function loadUserUsage(page) {
page = Math.max(0, page);
try {
await loadTiers(); // 渲染档位下拉 / 图例前确保 tiers 在手(只拉一次)
const d = await apiGet(`/v1/admin/usage/users?page=${page}&page_size=${PAGE_SIZE}&range=${userRange}&sort=${userSort}`);
userPage = d.page || 0;
renderUserUsage(d);
} catch (e) { /* 同上 */ }
}
// 档位定义 + 模型目录:加载一次缓存(管理动作 / 图例用);失败退化为空(下拉降级纯文本)。
async function loadTiers() {
if (tiersData) return tiersData;
try { tiersData = await apiGet("/v1/admin/tiers"); }
catch (e) { tiersData = { tiers: {}, default_tier: "default", catalog: [] }; }
return tiersData;
}
// 设置某用户档位(PATCH);成功后刷新当前页。失败 alert 并复位下拉。
async function setUserPlan(uid, plan, selectEl) {
if (selectEl) selectEl.disabled = true;
try {
await apiSend("PATCH", `/v1/admin/users/${uid}/plan`, { plan });
loadUserUsage(userPage);
} catch (e) {
if (e.code !== "auth") alert("设置档位失败:" + (e.message || String(e)));
if (selectEl) selectEl.disabled = false;
}
}
async function loadStorage(page) {
page = Math.max(0, page);
try {
const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`);
storagePage = d.page || 0;
if (storagePage === 0) {
storageSummaryData = d;
renderOpsSummary();
}
renderStorage(d);
} catch (e) { /* 同上 */ }
}
async function loadStorageSummary() {
try {
storageSummaryData = await apiGet("/v1/admin/storage/users?page=0&page_size=1");
renderOpsSummary();
} 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;
toolFailuresData = failures.value;
toolWireData = wire.status === "fulfilled" ? wire.value : null;
renderToolFailures(
failures.value,
toolWireData,
);
renderOpsSummary();
} 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 || [];
softwareNodesLoaded = true;
renderWindowsNodes();
renderOpsSummary();
} catch (e) { /* overview 统一处理鉴权 */ }
}
async function loadProviderCredentials() {
try {
const result = await apiGet("/v1/admin/provider-credentials");
providerCredentials = result.results || [];
renderProviderCredentials();
} catch (e) { /* overview 统一处理鉴权 */ }
}
async function loadSandboxCapacity() {
try {
sandboxCapacityData = await apiGet("/v1/admin/sandbox/capacity");
renderOpsSummary();
} catch (e) { /* overview 统一处理 */ }
}
async function loadSandboxPackages() {
try { renderSandboxPackages(await apiGet(`/v1/admin/sandbox/packages?range=${packageRange}`)); } catch (e) { /* overview 统一处理 */ }
}
// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新四个独立表(保持各自状态)
async function refresh() {
try {
const d = await apiGet("/v1/admin/overview");
ensureSkeleton();
renderMetrics(d);
loadModels();
loadUserUsage(userPage);
loadStorage(storagePage);
if (storagePage !== 0) loadStorageSummary();
loadSoftwareNodes();
loadProviderCredentials();
loadExternalDefinitions();
loadToolFailures();
loadSandboxCapacity();
loadSandboxPackages();
} catch (e) {
if (e.code !== "auth") showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
}
}
// ───── 导出 PDF(客户端打印;列表取前 10)─────
async function exportPdf() {
const btn = $("export");
btn.disabled = true; const old = btn.textContent; btn.textContent = "生成中…";
try {
const [ov, models, users, storage, health] = await Promise.all([
apiGet("/v1/admin/overview"),
apiGet("/v1/admin/usage/models?range=all&sort=cost"),
apiGet("/v1/admin/usage/users?range=all&sort=cost&page=0&page_size=10"),
apiGet("/v1/admin/storage/users?page=0&page_size=10"),
fetch("/healthz").then(r => r.json()).catch(() => ({})),
]);
buildReport(ov, models, users, storage, health);
window.print();
} catch (e) {
if (e.code !== "auth") alert("导出失败:" + (e.message || String(e)));
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function buildReport(ov, models, users, storage, health) {
const rt = ov.runtime || {}, tk = ov.tasks || {}, us = ov.users || {}, u = (ov.usage || {}).total || {};
const byDay = (ov.usage || {}).by_day_7d || [];
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
const tbl = (headers, body) =>
`${headers.map(h => `| ${h} | `).join("")}
${body}
`;
const dist = (o) => Object.entries(o || {}).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ") || "无";
const dayBody = byDay.map(r => `| ${escapeHtml(r.date)} | ${fmtCNY(r.cost_cny)} | `
+ `${fmtTokens(r.tokens_in)} | ${fmtTokens(r.tokens_out)} |
`).join("")
|| `| 无数据 |
`;
const modelBody = (models.rows || []).slice(0, 10).map(r => `| ${escapeHtml(r.model_profile || "—")} | `
+ `${fmtCNY(r.cost_cny)} | ${fmtTokens(r.tokens_in)} | ${fmtTokens(r.tokens_out)} | `
+ `${r.n_events || 0} |
`).join("") || `| 无数据 |
`;
const userBody = (users.rows || []).slice(0, 10).map(r => `| ${escapeHtml(userLabelText(r))} | `
+ `${fmtCNY(r.cost_cny)} | ${fmtTokens(r.tokens_in)} | ${fmtTokens(r.tokens_out)} | `
+ `${r.n_events || 0} |
`).join("") || `| 无数据 |
`;
const quota = storage.quota_bytes;
const stBody = (storage.rows || []).slice(0, 10).map(r => {
const pct = quota && quota > 0 ? Math.round(r.bytes_used / quota * 100) + "%" : "—";
return `| ${escapeHtml(userLabelText(r))} | ${humanSize(r.bytes_used)} | `
+ `${pct} | ${r.file_count || 0} | ${humanSize(r.trash_bytes || 0)} | `
+ `${humanSize(r.physical_bytes || ((r.bytes_used || 0) + (r.trash_bytes || 0)))} |
`;
}).join("") || `| 无数据 |
`;
$("print-report").innerHTML =
`zcbot 管理后台报告
`
+ `生成时间 ${ov.generated_at ? fmtTime(ov.generated_at) : "—"}`
+ `${health.version ? " · 版本 v" + escapeHtml(health.version) : ""} · 列表取前 10
`
+ `实时运行态
`
+ `活跃 run ${rt.active_runs || 0}${rt.max_workers ? " / " + rt.max_workers : ""}`
+ ` | SSE 订阅 ${rt.sse_subs || 0}`
+ ` | 内存峰值 ${rt.rss_peak_mb != null ? Math.round(rt.rss_peak_mb) + " MB" : "—"}
`
+ `任务(共 ${tk.total || 0})
`
+ `status:${dist(tk.by_status)}
run_status:${dist(tk.by_run_status)}
`
+ `用户
总数 ${us.total || 0} · 近 7 天活跃 ${us.active_7d || 0}
`
+ `用量总览(all-time)
`
+ `总成本 ${fmtCNY(u.cost_cny)} | 输入 ${fmtTokens(u.tokens_in)}`
+ ` | 输出 ${fmtTokens(u.tokens_out)} | 缓存命中 ${hitRate}%`
+ ` | 事件 ${u.n_events || 0}
`
+ `近 7 天用量(按天)
` + tbl(["日期", "成本", "输入", "输出"], dayBody)
+ `按模型(all-time,Top 10)
` + tbl(["模型", "成本", "输入", "输出", "事件"], modelBody)
+ `各用户用量(all-time,Top 10)
` + tbl(["用户", "成本", "输入", "输出", "事件"], userBody)
+ `存储用量(${quota && quota > 0 ? "配额 " + humanSize(quota) + "/人," : ""}Top 10)
`
+ tbl(["用户", "配额占用", "占配额", "文件数", "回收站", "物理合计"], stBody);
}
// ───── 自动刷新 ─────
function startAuto() {
stopAuto();
if ($("auto-refresh").checked) timer = setInterval(refresh, REFRESH_MS);
}
function stopAuto() {
if (timer) { clearInterval(timer); timer = null; }
}
$("refresh").onclick = refresh;
$("export").onclick = exportPdf;
$("auto-refresh").onchange = startAuto;
$("node-enrollment-form").onsubmit = createNodeEnrollment;
$("node-enrollment-close").onclick = closeNodeEnrollmentModal;
$("node-enrollment-cancel").onclick = closeNodeEnrollmentModal;
$("node-enrollment-copy").onclick = copyNodeEnrollmentCode;
$("capacity-drawer-close").onclick = closeCapacityDrawer;
$("capacity-drawer").onclick = (e) => {
if (e.target.id === "capacity-drawer") closeCapacityDrawer();
};
$("node-enrollment-modal").onclick = (e) => {
if (e.target.id === "node-enrollment-modal") closeNodeEnrollmentModal();
};
document.addEventListener("keydown", (e) => {
if ($("capacity-drawer").classList.contains("show") && e.key === "Tab") {
e.preventDefault();
$("capacity-drawer-close").focus();
return;
}
if (e.key === "Escape" && $("capacity-drawer").classList.contains("show")) closeCapacityDrawer();
else if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) closeNodeEnrollmentModal();
});
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAuto();
else { refresh(); startAuto(); }
});
refresh();
startAuto();