feat(admin): add operations summary cards

This commit is contained in:
caoqianming 2026-08-28 16:08:00 +08:00
parent 510033d06f
commit 3bed132c30
3 changed files with 275 additions and 13 deletions

View File

@ -96,6 +96,27 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("永久删除", admin_js) self.assertIn("永久删除", admin_js)
self.assertNotIn("localStorage.setItem", admin_js) self.assertNotIn("localStorage.setItem", admin_js)
def test_admin_has_cross_module_operations_summary(self) -> None:
html = ADMIN_HTML.read_text(encoding="utf-8")
admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8")
self.assertIn('id="ops-summary" class="ops-summary"', admin_js)
self.assertIn(".ops-summary {", html)
for label in (
"运行容量",
"Windows Node",
"存储",
"工具健康",
"用户活跃",
"任务状态",
"成本",
"Token 与缓存",
):
self.assertIn(f'label: "{label}"', admin_js)
self.assertIn('apiGet("/v1/admin/storage/users?page=0&page_size=1")', admin_js)
self.assertIn('target: "s-windows-node"', admin_js)
self.assertIn('target: "s-toolfail"', admin_js)
def test_dev_html_uses_local_markdown_vendor_assets(self) -> None: def test_dev_html_uses_local_markdown_vendor_assets(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8") html = DEV_HTML.read_text(encoding="utf-8")

View File

@ -89,6 +89,34 @@
.page-intro p { position: relative; margin: 3px 0 0; color: var(--muted); font-size: 11px; } .page-intro p { position: relative; margin: 3px 0 0; color: var(--muted); font-size: 11px; }
.anchor { scroll-margin-top: 68px; } .anchor { scroll-margin-top: 68px; }
/* 首屏运营摘要:跨模块聚合关键状态,点击卡片跳到对应明细。 */
.ops-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
.summary-card {
min-width: 0; min-height: 112px; display: flex; flex-direction: column; padding: 10px 12px 9px;
color: inherit; text-decoration: none; background: var(--panel); border: 1px solid var(--border);
border-radius: var(--r-lg); box-shadow: var(--shadow-soft); transition: .15s ease;
}
.summary-card:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); }
.summary-card .label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.summary-card .label { min-width: 0; color: var(--muted); font-size: 11px; font-weight: 600; }
.summary-card .jump { flex: 0 0 auto; color: #a0a6ac; font-size: 12px; }
.summary-card .summary-value { margin-top: 4px; color: var(--text-strong); font: 650 21px/1.2 var(--mono); letter-spacing: -.4px; }
.summary-card .summary-value .unit { color: var(--muted); font-size: 11px; font-weight: 550; letter-spacing: 0; }
.summary-card .summary-sub { margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.summary-card .summary-meta { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 7px; color: var(--muted); font-size: 10px; }
.summary-card .summary-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary-card .summary-meta span:last-child { text-align: right; }
.summary-card .summary-meter { height: 3px; margin-top: 5px; overflow: hidden; border-radius: 999px; background: #e8ebed; }
.summary-card .summary-meter > i { display: block; width: var(--value); height: 100%; border-radius: inherit; background: #89939d; }
.summary-card.warn { border-color: #efd8b7; background: #fffaf2; }
.summary-card.warn .summary-value { color: var(--warn); }
.summary-card.warn .summary-meter > i { background: var(--warn); }
.summary-card.danger { border-color: #edc1bd; background: var(--accent-soft); }
.summary-card.danger .summary-value { color: var(--danger); }
.summary-card.danger .summary-meter > i { background: var(--danger); }
.summary-spark { height: 18px; display: flex; align-items: flex-end; gap: 2px; margin-top: 5px; }
.summary-spark i { flex: 1; min-width: 2px; height: var(--value); border-radius: 2px 2px 0 0; background: rgba(184,58,49,.28); }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; }
.card-head h2 { margin: 0; } .card-head h2 { margin: 0; }
.ctrl { display: flex; gap: 6px; } .ctrl { display: flex; gap: 6px; }
@ -106,17 +134,24 @@
.card h2 { margin: 0 0 8px; font-size: 13px; color: var(--text-strong); font-weight: 650; letter-spacing: .1px; } .card h2 { margin: 0 0 8px; font-size: 13px; color: var(--text-strong); font-weight: 650; letter-spacing: .1px; }
/* 大数字 stat 块 */ /* 大数字 stat 块 */
.stat { position: relative; overflow: hidden; min-height: 78px; background: linear-gradient(145deg,#fff,var(--panel-soft)); border: 1px solid var(--border-soft); border-radius: 7px; padding: 10px 12px; } .stat { position: relative; overflow: hidden; min-height: 102px; display: flex; flex-direction: column; background: linear-gradient(145deg,#fff,var(--panel-soft)); border: 1px solid var(--border-soft); border-radius: 7px; padding: 10px 12px 9px; }
.stat::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: #d9dde1; } .stat::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: #d9dde1; }
.stat .k { color: var(--muted); font-size: 11px; } .stat .k { color: var(--muted); font-size: 11px; }
.stat .v { color: var(--text-strong); font-size: 20px; font-weight: 650; font-family: var(--mono); margin-top: 3px; letter-spacing: -.4px; line-height: 1.25; } .stat .v { color: var(--text-strong); font-size: 20px; font-weight: 650; font-family: var(--mono); margin-top: 3px; letter-spacing: -.4px; line-height: 1.25; }
.stat .v .unit { color: var(--muted); font-size: 12px; font-weight: 550; letter-spacing: 0; }
.stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; } .stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
.stat .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 7px; color: var(--muted); font-size: 10px; }
.stat .foot span:last-child { text-align: right; }
.stat .meter { height: 3px; margin-top: 5px; overflow: hidden; border-radius: 999px; background: #e8ebed; }
.stat .meter > i { display: block; width: var(--value); height: 100%; border-radius: inherit; background: #89939d; }
.stat.warn { border-color: #efd8b7; background: #fffaf2; } .stat.warn { border-color: #efd8b7; background: #fffaf2; }
.stat.warn::before { background: var(--warn); } .stat.warn::before { background: var(--warn); }
.stat.warn .v { color: var(--warn); } .stat.warn .v { color: var(--warn); }
.stat.warn .meter > i { background: var(--warn); }
.stat.danger { border-color: #edc1bd; background: var(--accent-soft); } .stat.danger { border-color: #edc1bd; background: var(--accent-soft); }
.stat.danger::before { background: var(--danger); } .stat.danger::before { background: var(--danger); }
.stat.danger .v { color: var(--danger); } .stat.danger .v { color: var(--danger); }
.stat.danger .meter > i { background: var(--danger); }
/* chips(状态分布) */ /* chips(状态分布) */
.chips { display: flex; flex-wrap: wrap; gap: 5px; } .chips { display: flex; flex-wrap: wrap; gap: 5px; }
@ -302,6 +337,7 @@
@media (min-width: 821px) and (max-width: 1080px) { @media (min-width: 821px) and (max-width: 1080px) {
#layout { grid-template-columns: 148px minmax(0, 1fr); gap: 12px; } #layout { grid-template-columns: 148px minmax(0, 1fr); gap: 12px; }
#toc .nav-index { display: none; } #toc .nav-index { display: none; }
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 820px) { @media (max-width: 820px) {
@ -335,10 +371,15 @@
#toc a.active { border-color: var(--accent); } #toc a.active { border-color: var(--accent); }
.page-intro { padding: 12px 14px; } .page-intro { padding: 12px 14px; }
.page-intro h1 { font-size: 17px; } .page-intro h1 { font-size: 17px; }
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.anchor { scroll-margin-top: 150px; } .anchor { scroll-margin-top: 150px; }
.card { padding: 12px; } .card { padding: 12px; }
} }
@media (max-width: 480px) {
.ops-summary { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
} }

View File

@ -56,6 +56,11 @@ let externalUsers = [];
let externalDefinitionsLoaded = false; let externalDefinitionsLoaded = false;
let externalEditingId = ""; let externalEditingId = "";
let softwareNodes = []; let softwareNodes = [];
let softwareNodesLoaded = false;
let overviewData = null;
let storageSummaryData = null;
let toolFailuresData = null;
let toolWireData = null;
// ───── 格式化 ───── // ───── 格式化 ─────
function fmtCNY(n) { function fmtCNY(n) {
@ -87,22 +92,179 @@ function ctrlHTML(prefix, range, sort) {
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; } function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
// ───── 渲染各 section ───── // ───── 渲染各 section ─────
function statCard(k, v, sub, cls) { function statCard(k, v, sub, cls, foot = [], progress = null) {
const footHTML = foot.length
? `<div class="foot"><span>${escapeHtml(foot[0] || "")}</span><span>${escapeHtml(foot[1] || "")}</span></div>`
: "";
const meterHTML = progress == null ? "" : `<div class="meter" aria-hidden="true"><i style="--value:${Math.max(0, Math.min(100, progress))}%"></i></div>`;
return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>` return `<div class="stat ${cls || ""}"><div class="k">${escapeHtml(k)}</div>`
+ `<div class="v">${v}</div>` + `<div class="v">${v}</div>`
+ (sub ? `<div class="sub">${sub}</div>` : "") + `</div>`; + (sub ? `<div class="sub">${escapeHtml(sub)}</div>` : "")
+ footHTML + meterHTML + `</div>`;
}
function sparkHTML(rows) {
const values = (rows || []).map(row => Number(row.cost_cny) || 0);
if (!values.length) return "";
const max = Math.max(...values, 1);
const bars = values.map(value => {
const height = value > 0 ? Math.max(12, Math.round(value / max * 100)) : 4;
return `<i style="--value:${height}%"></i>`;
}).join("");
return `<div class="summary-spark" aria-hidden="true">${bars}</div>`;
}
function summaryCard({ target, label, value, unit = "", sub = "", meta = [], tone = "", progress = null, visual = "" }) {
const meter = progress == null ? "" : `<div class="summary-meter" aria-hidden="true"><i style="--value:${Math.max(0, Math.min(100, progress))}%"></i></div>`;
const metaHTML = meta.length
? `<div class="summary-meta"><span>${escapeHtml(meta[0] || "")}</span><span>${escapeHtml(meta[1] || "")}</span></div>`
: "";
return `<a class="summary-card ${tone}" href="#${target}">`
+ `<div class="label-row"><span class="label">${escapeHtml(label)}</span><span class="jump" aria-hidden="true">↘</span></div>`
+ `<div class="summary-value">${value}${unit ? ` <span class="unit">${escapeHtml(unit)}</span>` : ""}</div>`
+ (sub ? `<div class="summary-sub" title="${escapeHtml(sub)}">${escapeHtml(sub)}</div>` : "")
+ metaHTML + visual + meter + `</a>`;
}
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 active = Number(runtime.active_runs) || 0;
const maxWorkers = Number(runtime.max_workers) || 0;
const runPct = maxWorkers ? Math.round(active / maxWorkers * 100) : 0;
const runTone = levelClass(maxWorkers ? active / maxWorkers : 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 storage = storageSummaryData;
const topStorage = storage && (storage.rows || [])[0];
const quota = storage && Number(storage.quota_bytes);
const storageRatio = topStorage && quota > 0 ? (Number(topStorage.bytes_used) || 0) / quota : 0;
const storageTone = levelClass(storageRatio);
const storageValue = !storage ? "—" : topStorage
? (quota > 0 ? `${Math.round(storageRatio * 100)}%` : humanSize(topStorage.bytes_used || 0))
: "0";
const storageSub = !storage ? "正在读取存储快照" : topStorage
? `最高占用 · ${userLabelText(topStorage)}`
: "暂无用户存储快照";
const storageMeta = topStorage
? [`${humanSize(topStorage.bytes_used || 0)} · ${topStorage.file_count || 0} 个文件`, `回收站 ${humanSize(topStorage.trash_bytes || 0)}`]
: [storage ? `已扫描 ${storage.total || 0}` : "加载中", ""];
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 userTotal = Number(users.total) || 0;
const activeUsers = Number(users.active_7d) || 0;
const activeRate = userTotal ? Math.round(activeUsers / userTotal * 100) : 0;
const runStatuses = tasks.by_run_status || {};
const runningTasks = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0);
const errorTasks = Number(runStatuses.error) || 0;
const completedTasks = Number(runStatuses.completed) || 0;
const runTaskTotal = Object.values(runStatuses).reduce((sum, value) => sum + (Number(value) || 0), 0);
const completionRate = runTaskTotal ? Math.round(completedTasks / runTaskTotal * 100) : 0;
const events = Number(usage.n_events) || 0;
const cost = Number(usage.cost_cny) || 0;
const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0);
const tokensIn = Number(usage.tokens_in) || 0;
const tokensOut = Number(usage.tokens_out) || 0;
const totalTokens = tokensIn + tokensOut;
const cacheHit = Number(usage.tokens_cache_hit) || 0;
const hitRate = tokensIn ? Math.round(cacheHit / tokensIn * 100) : 0;
root.innerHTML = [
summaryCard({
target: "s-runtime", label: "运行容量", value: maxWorkers ? `${active} / ${maxWorkers}` : String(active),
sub: maxWorkers ? `并发占用 ${runPct}%` : "当前活跃 run", meta: [maxWorkers ? `空闲 ${Math.max(0, maxWorkers - active)}` : "并发上限未设置", `${runtime.sse_subs || 0} 个 SSE`],
tone: runTone, progress: maxWorkers ? runPct : null,
}),
summaryCard({
target: "s-windows-node", label: "Windows Node", value: softwareNodesLoaded ? `${nodeOnline} / ${nodeTotal}` : "—",
sub: softwareNodesLoaded ? `${nodeOnline} 在线 · ${nodeJobs} 个活动任务` : "正在读取节点状态",
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : ["加载中", ""], tone: nodeTone,
}),
summaryCard({
target: "s-storage", label: "存储", value: storageValue,
unit: topStorage && quota > 0 ? "最高配额占用" : "", sub: storageSub, meta: storageMeta,
tone: storageTone, progress: topStorage && quota > 0 ? Math.round(storageRatio * 100) : null,
}),
summaryCard({
target: "s-toolfail", label: "工具健康", value: toolFailuresData ? String(failureCount) : "—", unit: "近 24h 失败",
sub: toolFailuresData ? `系统性聚集 ${systemicCount} · 单任务 ${localCount}` : "正在汇总工具调用状态",
meta: toolFailuresData ? [`链路残余 ${malformed}`, `质量门 ${gateCount}`] : ["加载中", ""], tone: toolTone,
}),
summaryCard({
target: "s-usage", label: "用户活跃", value: String(userTotal), unit: "用户",
sub: `近 7 天活跃 ${activeUsers}`, meta: [`活跃率 ${activeRate}%`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
}),
summaryCard({
target: "s-tasks", label: "任务状态", value: String(runningTasks), unit: "运行中",
sub: `累计任务 ${tasks.total || 0}`, meta: [`错误 ${errorTasks}`, `完成率 ${completionRate}%`],
tone: errorTasks ? "warn" : "", progress: completionRate,
}),
summaryCard({
target: "s-models", label: "成本", value: fmtCNY(cost),
sub: `${events} 次事件 · 均次 ${fmtCNY(events ? cost / events : 0)}`, meta: [`近 7 天 ${fmtCNY(cost7d)}`, "累计口径"], visual: sparkHTML(byDay),
}),
summaryCard({
target: "s-models", label: "Token 与缓存", value: fmtTokens(totalTokens), unit: "总 token",
sub: `输入 ${fmtTokens(tokensIn)} · 输出 ${fmtTokens(tokensOut)}`, meta: [`缓存命中 ${hitRate}%`, `命中 ${fmtTokens(cacheHit)}`], progress: hitRate,
}),
].join("");
} }
function renderRuntime(r) { function renderRuntime(r) {
const active = r.active_runs || 0; const active = r.active_runs || 0;
const max = r.max_workers || 0; const max = r.max_workers || 0;
const ratio = max ? active / max : 0; const ratio = max ? active / max : 0;
const sub = max ? `线程池 ${max}` + (active >= max ? " · 已满,新 run 排队" : "") : ""; const pct = Math.round(ratio * 100);
const available = Math.max(0, max - active);
const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—"; const rss = r.rss_peak_mb != null ? Math.round(r.rss_peak_mb) + " MB" : "—";
return `<div class="card"><h2>实时运行态</h2><div class="grid">` return `<div class="card"><h2>实时运行态</h2><div class="grid">`
+ statCard("活跃 run", active + (max ? ` / ${max}` : ""), sub, levelClass(ratio)) + statCard(
+ statCard("SSE 订阅", r.sse_subs || 0, "当前流式连接") "活跃 run",
+ statCard("内存峰值", rss, "进程 RSS high-water") `${active}${max ? ` <span class="unit">/ ${max}</span>` : ""}`,
max ? "并发工作槽位" : "当前运行任务",
levelClass(ratio),
max ? [`占用 ${pct}%`, `空闲 ${available}`] : [],
max ? pct : null,
)
+ statCard(
"SSE 订阅",
r.sse_subs || 0,
"当前流式连接",
"",
[active ? `关联 ${active} 个活跃 run` : "暂无活跃 run", (r.sse_subs || 0) ? "通道已连接" : "暂无订阅"],
)
+ statCard(
"内存峰值",
rss,
"进程 RSS high-water",
"",
["进程级统计", "重启后重置"],
)
+ `</div></div>`; + `</div></div>`;
} }
@ -126,11 +288,28 @@ function renderTasks(t) {
function renderUsersAndUsage(users, usage) { function renderUsersAndUsage(users, usage) {
const u = usage.total || {}; const u = usage.total || {};
const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0; const hitRate = u.tokens_in ? Math.round(u.tokens_cache_hit / u.tokens_in * 100) : 0;
const activeRate = users.total ? Math.round((users.active_7d || 0) / users.total * 100) : 0;
const idleUsers = Math.max(0, (users.total || 0) - (users.active_7d || 0));
const avgCost = u.n_events ? (Number(u.cost_cny) || 0) / u.n_events : 0;
const totalTokens = (u.tokens_in || 0) + (u.tokens_out || 0);
const outputShare = totalTokens ? Math.round((u.tokens_out || 0) / totalTokens * 100) : 0;
return `<div class="card"><h2>用户与用量总览all-time</h2><div class="grid">` return `<div class="card"><h2>用户与用量总览all-time</h2><div class="grid">`
+ statCard("用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`) + statCard(
+ statCard("总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`) "用户数", users.total || 0, `近 7 天活跃 ${users.active_7d || 0}`, "",
+ statCard("输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`) [`活跃率 ${activeRate}%`, `未活跃 ${idleUsers}`], activeRate,
+ statCard("输出 token", fmtTokens(u.tokens_out), "") )
+ statCard(
"总成本", fmtCNY(u.cost_cny), `${u.n_events || 0} 次事件`, "",
[`均次 ${fmtCNY(avgCost)}`, "累计口径"],
)
+ statCard(
"输入 token", fmtTokens(u.tokens_in), `缓存命中 ${hitRate}%`, "",
[`命中 ${fmtTokens(u.tokens_cache_hit)}`, `未命中 ${fmtTokens(Math.max(0, (u.tokens_in || 0) - (u.tokens_cache_hit || 0)))}`], hitRate,
)
+ statCard(
"输出 token", fmtTokens(u.tokens_out), `总 token ${fmtTokens(totalTokens)}`, "",
[`输出占比 ${outputShare}%`, `输入占比 ${100 - outputShare}%`], outputShare,
)
+ `</div></div>`; + `</div></div>`;
} }
@ -904,9 +1083,11 @@ function ensureSkeleton() {
+ `<nav id="toc" aria-label="管理后台导航"><div class="toc-label">管理模块</div>` + SECTIONS.map(([id, label], index) => + `<nav id="toc" aria-label="管理后台导航"><div class="toc-label">管理模块</div>` + SECTIONS.map(([id, label], index) =>
`<a href="#${id}" data-target="${id}"><span class="nav-index">${String(index + 1).padStart(2, "0")}</span><span>${label}</span></a>`).join("") + `</nav>` `<a href="#${id}" data-target="${id}"><span class="nav-index">${String(index + 1).padStart(2, "0")}</span><span>${label}</span></a>`).join("") + `</nav>`
+ `<div id="content"><section class="page-intro"><h1>系统运行概览</h1>` + `<div id="content"><section class="page-intro"><h1>系统运行概览</h1>`
+ `<p>集中查看服务状态、资源用量与外部能力,数据默认每 10 秒自动更新。</p></section>` + SECTIONS.map(([id]) => + `<p>集中查看服务状态、资源用量与外部能力,数据默认每 10 秒自动更新。</p></section>`
+ `<section id="ops-summary" class="ops-summary" aria-label="运营摘要"></section>` + SECTIONS.map(([id]) =>
`<div id="${id}" class="anchor"></div>`).join("") + `</div>` `<div id="${id}" class="anchor"></div>`).join("") + `</div>`
+ `</div>`; + `</div>`;
renderOpsSummary();
document.querySelectorAll("#toc a").forEach(a => { document.querySelectorAll("#toc a").forEach(a => {
a.onclick = (e) => { a.onclick = (e) => {
e.preventDefault(); e.preventDefault();
@ -932,6 +1113,7 @@ function setupScrollSpy() {
} }
function renderMetrics(d) { function renderMetrics(d) {
overviewData = d;
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : ""; $("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("s-runtime").innerHTML = renderRuntime(d.runtime || {}); $("s-runtime").innerHTML = renderRuntime(d.runtime || {});
$("s-tasks").innerHTML = renderTasks(d.tasks || {}); $("s-tasks").innerHTML = renderTasks(d.tasks || {});
@ -939,6 +1121,7 @@ function renderMetrics(d) {
renderUsersAndUsage(d.users || {}, d.usage || {}) renderUsersAndUsage(d.users || {}, d.usage || {})
+ renderByDay((d.usage || {}).by_day_7d); + renderByDay((d.usage || {}).by_day_7d);
renderWindowsNodes(); renderWindowsNodes();
renderOpsSummary();
} }
function showMsg(html) { function showMsg(html) {
@ -1032,10 +1215,21 @@ async function loadStorage(page) {
try { try {
const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`); const d = await apiGet(`/v1/admin/storage/users?page=${page}&page_size=${PAGE_SIZE}`);
storagePage = d.page || 0; storagePage = d.page || 0;
if (storagePage === 0) {
storageSummaryData = d;
renderOpsSummary();
}
renderStorage(d); renderStorage(d);
} catch (e) { /* 同上 */ } } catch (e) { /* 同上 */ }
} }
async function loadStorageSummary() {
try {
storageSummaryData = await apiGet("/v1/admin/storage/users?page=0&page_size=1");
renderOpsSummary();
} catch (e) { /* 保留最近一次成功的摘要 */ }
}
async function loadToolFailures() { async function loadToolFailures() {
try { try {
const [failures, wire] = await Promise.allSettled([ const [failures, wire] = await Promise.allSettled([
@ -1043,10 +1237,13 @@ async function loadToolFailures() {
apiGet("/v1/admin/tool-wire-health?days=7"), apiGet("/v1/admin/tool-wire-health?days=7"),
]); ]);
if (failures.status !== "fulfilled") throw failures.reason; if (failures.status !== "fulfilled") throw failures.reason;
toolFailuresData = failures.value;
toolWireData = wire.status === "fulfilled" ? wire.value : null;
renderToolFailures( renderToolFailures(
failures.value, failures.value,
wire.status === "fulfilled" ? wire.value : null, toolWireData,
); );
renderOpsSummary();
} catch (e) { /* 同上 */ } } catch (e) { /* 同上 */ }
} }
@ -1068,7 +1265,9 @@ async function loadSoftwareNodes() {
try { try {
const result = await apiGet("/v1/admin/software-nodes"); const result = await apiGet("/v1/admin/software-nodes");
softwareNodes = result.results || []; softwareNodes = result.results || [];
softwareNodesLoaded = true;
renderWindowsNodes(); renderWindowsNodes();
renderOpsSummary();
} catch (e) { /* overview 统一处理鉴权 */ } } catch (e) { /* overview 统一处理鉴权 */ }
} }
@ -1081,6 +1280,7 @@ async function refresh() {
loadModels(); loadModels();
loadUserUsage(userPage); loadUserUsage(userPage);
loadStorage(storagePage); loadStorage(storagePage);
if (storagePage !== 0) loadStorageSummary();
loadSoftwareNodes(); loadSoftwareNodes();
loadExternalDefinitions(); loadExternalDefinitions();
loadToolFailures(); loadToolFailures();