feat(web): optimize admin operations overview

This commit is contained in:
caoqianming 2026-09-02 09:56:52 +08:00
parent f50bfc1bcf
commit a1068f0ec1
5 changed files with 274 additions and 110 deletions

View File

@ -6,6 +6,10 @@
> 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## Unreleased
- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。
## 0.70.0 — 2026-09-02
- 容器长任务会在整机繁忙时安全排队,后台任务可查看或取消排队状态;管理员可在“容器状态”查看实时容量,并通过“容器依赖”分析用户临时安装的 Python 依赖及占用。

View File

@ -20,6 +20,8 @@
---
## 已完成关键能力
- **09-02 / Unreleased / 管理后台运行总览分层**Admin 八张摘要卡按当前运行与运营资源重排,主指标统一为近期或实时口径,累计与低频明细降级;执行容量新增可自动刷新的右侧详情抽屉,集中展示前后台队列、容器生命周期、单用户占用与宿主资源,并移除物理占用与逻辑配额口径不一致的存储进度条。无 API、schema、migration 或运行方式变化。
- **09-02 / 0.70.0 / 管理与更新日志界面收敛**Admin 顶部“容器状态”直接承载前后台执行、排队、回收、宿主资源与单用户占用,移除重复的独立容量区块,并统一采用“容器依赖”“专业软件节点”用户文案;用户版更新日志只保留可感知变化,公开接口新增向后兼容的 offset 分页元数据,前端每页加载 5 个版本并按需继续加载。
- **09-01 / 0.70.0 / Sandbox 执行容量与临时依赖观测**:默认单容器资源提升到 4 GiB/2 CPU、pids 1024、shm 512 MiB、tmp 1 GiB普通容器无活动 10 分钟回收;新增跨蓝绿/多 Web 实例共享的整机 6、后台 4、单用户 2 重型槽位,前台可取消排队且排队不计 timeout活动计数防长命令被 reaper 误删内存压力只暂停新放行。Docker 后台 proc 无槽时持久化 queued、约 30 秒恢复调度,运行中仍由 Docker 托管,终态目录继续保留 7 天。镜像构建基础 Python 包清单,容器删除前可信扫描 `/tmp/.local``.dist-info` 并以单会话表幂等记录非空差异Admin 顶部“容器状态”集中展示实时容量,“容器依赖”提供 7/30 天/全部依赖会话统计。新增 0038 migration未连接或迁移生产数据库、未启动真实专业软件。

View File

@ -103,14 +103,14 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('id="ops-summary" class="ops-summary"', admin_js)
self.assertIn(".ops-summary {", html)
labels = (
"容器状态",
"任务状态",
"任务运行",
"执行容量",
"专业软件节点",
"工具健康",
"活跃用户",
"Token 与缓存",
"专业软件节点",
"存储",
"成本",
"工具健康",
"存储",
)
positions = []
for label in labels:
@ -122,8 +122,8 @@ class StaticVendorTests(unittest.TestCase):
self.assertNotIn("function renderUsersAndUsage", admin_js)
self.assertIn('["s-overview", "总览"]', admin_js)
self.assertIn('["s-usage", "用量趋势"]', admin_js)
self.assertIn('unit: "活动任务 / 在线节点"', admin_js)
self.assertIn("tokenSummaryHTML(tokens7d, totalTokens)", admin_js)
self.assertIn('unit: softwareNodesLoaded ? "在线 / 总数"', admin_js)
self.assertIn('value: fmtTokens(tokens7d), unit: "近 7 天"', admin_js)
self.assertIn("storageTotals.physical_bytes", admin_js)
self.assertIn("repeat(auto-fit, minmax(190px, 1fr))", html)
self.assertIn('apiGet("/v1/admin/storage/users?page=0&page_size=1")', admin_js)
@ -133,7 +133,12 @@ class StaticVendorTests(unittest.TestCase):
self.assertNotIn("function renderSandboxCapacity", admin_js)
self.assertIn('["s-sandbox-packages", "容器依赖"]', admin_js)
self.assertIn('sandboxCapacityData = await apiGet("/v1/admin/sandbox/capacity")', admin_js)
self.assertIn("containerStatusHTML(capacity)", admin_js)
self.assertNotIn("containerStatusHTML(capacity)", admin_js)
self.assertIn('data-summary-action="${escapeHtml(action)}"', admin_js)
self.assertIn("function renderCapacityDrawer()", admin_js)
self.assertIn("function openCapacityDrawer()", admin_js)
self.assertIn('id="capacity-drawer" class="capacity-drawer"', html)
self.assertIn(".capacity-drawer-panel {", html)
def test_changelog_loads_incremental_pages(self) -> None:
source = (JS_DIR / "changelog.js").read_text(encoding="utf-8")

View File

@ -94,14 +94,25 @@
/* 首屏运营摘要:第一排业务运行,第二排基础设施与质量。 */
.ops-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
.summary-card {
min-width: 0; min-height: 156px; display: flex; flex-direction: column; padding: 13px 14px 11px;
min-width: 0; min-height: 142px; display: flex; flex-direction: column; padding: 13px 14px 11px;
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;
border-radius: var(--r-lg); box-shadow: var(--shadow-soft); transition: .15s ease; text-align: left;
}
.summary-card[href]:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); }
button.summary-card { width: 100%; appearance: none; font: inherit; cursor: pointer; }
.summary-card[href]:hover, button.summary-card:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); }
.summary-card[href]:focus-visible, button.summary-card:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; border-color: var(--accent); }
.summary-card .label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.summary-card .label { min-width: 0; color: #626b73; font-size: 13px; font-weight: 650; }
.summary-card-actions { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.summary-card .jump { flex: 0 0 auto; color: #929aa1; font-size: 14px; }
.summary-detail { color: #899198; font-size: 10px; white-space: nowrap; }
.summary-badge {
display: inline-flex; align-items: center; min-height: 20px; padding: 2px 7px; border-radius: 999px;
color: #606970; background: #f0f2f3; font-size: 10px; font-weight: 700; white-space: nowrap;
}
.summary-badge.ok { color: var(--ok); background: #e8f5e9; }
.summary-badge.warn { color: var(--warn); background: #fff0d8; }
.summary-badge.danger { color: var(--danger); background: var(--accent-soft); }
.summary-card .summary-value { margin-top: 6px; color: var(--text-strong); font: 680 28px/1.15 var(--mono); letter-spacing: -.7px; }
.summary-card .summary-value .unit { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: 0; white-space: nowrap; }
.summary-card .summary-sub { margin-top: 5px; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@ -116,20 +127,6 @@
.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-dual { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 7px; }
.summary-dual > div { min-width: 0; padding-right: 8px; border-right: 1px solid var(--border-soft); }
.summary-dual > div:last-child { padding-right: 0; border-right: 0; }
.summary-dual span { display: block; color: var(--muted); font-size: 10px; font-weight: 600; }
.summary-dual strong { display: block; overflow: hidden; margin-top: 1px; color: var(--text-strong); font: 680 23px/1.2 var(--mono); letter-spacing: -.6px; text-overflow: ellipsis; white-space: nowrap; }
.summary-task-group { display: grid; grid-template-columns: 27px minmax(0, 1fr); gap: 4px; margin-top: 5px; }
.summary-group-label { padding-top: 2px; color: #8b939a; font-size: 9px; font-weight: 700; }
.summary-task-group > div { display: flex; flex-wrap: wrap; gap: 3px; min-width: 0; }
.summary-status { padding: 1px 5px; border-radius: 999px; background: #f0f2f3; color: #4d555c; font: 10px/1.4 var(--mono); white-space: nowrap; }
.summary-status b { font-weight: 750; }
.summary-status.run { color: #1565c0; background: #e7f3ff; }
.summary-status.err { color: var(--danger); background: var(--accent-soft); }
.summary-status.ok { color: var(--ok); background: #e8f5e9; }
.task-summary .summary-value { font-size: 25px; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; }
.card-head h2 { margin: 0; }
.ctrl { display: flex; gap: 6px; }
@ -232,6 +229,60 @@
#app-dialog button { padding: 5px 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: #fff; cursor: pointer; }
#app-dialog button.primary { color: #fff; border-color: var(--accent); background: var(--accent); }
/* 实时执行容量使用右侧抽屉,避免在摘要卡内堆叠监控明细。 */
html.drawer-open, body.drawer-open { overflow: hidden; }
.capacity-drawer {
display: none; position: fixed; inset: 0; z-index: 140; justify-content: flex-end;
background: rgba(18,24,30,.28); backdrop-filter: blur(1px);
}
.capacity-drawer.show { display: flex; }
.capacity-drawer-panel {
width: min(460px, calc(100vw - 32px)); height: 100%; display: flex; flex-direction: column;
background: var(--panel); border-left: 1px solid var(--border); box-shadow: -12px 0 34px rgba(18,24,30,.14);
}
.capacity-drawer-head {
min-height: 58px; display: flex; align-items: center; gap: 10px; padding: 12px 16px;
border-bottom: 1px solid var(--border); background: rgba(255,255,255,.96);
}
.capacity-drawer-head > div { min-width: 0; flex: 1; }
.capacity-drawer-head h2 { margin: 0; color: var(--text-strong); font-size: 15px; }
.capacity-drawer-head p { margin: 2px 0 0; color: var(--muted); font-size: 10px; }
.capacity-drawer-close {
width: 30px; height: 30px; flex: 0 0 auto; padding: 0; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--muted); background: #fff; font-size: 20px; line-height: 1; cursor: pointer;
}
.capacity-drawer-close:hover { color: var(--text-strong); background: var(--panel-soft); }
.capacity-drawer-body { min-height: 0; padding: 16px; overflow-y: auto; overscroll-behavior: contain; }
.capacity-alert {
display: grid; gap: 3px; margin-bottom: 12px; padding: 10px 12px; border: 1px solid #edc1bd;
border-radius: var(--r-lg); color: var(--danger); background: var(--accent-soft);
}
.capacity-alert span { font-size: 11px; }
.capacity-hero { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 8px; }
.capacity-hero > div { min-width: 0; padding: 11px; border: 1px solid var(--border-soft); border-radius: var(--r-lg); background: var(--panel-soft); }
.capacity-hero strong { display: block; overflow: hidden; color: var(--text-strong); font: 700 21px/1.2 var(--mono); text-overflow: ellipsis; }
.capacity-hero span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
.capacity-detail-section { margin-top: 18px; }
.capacity-detail-section h3 { margin: 0 0 7px; color: #5f6870; font-size: 11px; font-weight: 700; }
.capacity-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.capacity-detail-item { min-width: 0; padding: 9px 10px; border: 1px solid var(--border-soft); border-radius: var(--r-md); }
.capacity-detail-item > span, .capacity-detail-item small { display: block; color: var(--muted); font-size: 10px; }
.capacity-detail-item strong { display: block; margin: 2px 0; color: var(--text-strong); font: 700 18px/1.2 var(--mono); }
.capacity-detail-item.warn { border-color: #efd8b7; background: #fffaf2; }
.capacity-detail-item.warn strong { color: var(--warn); }
.capacity-user-list { overflow: hidden; border: 1px solid var(--border-soft); border-radius: var(--r-md); }
.capacity-user-list > div, .capacity-resource-list > div {
display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px;
padding: 7px 10px; border-bottom: 1px solid var(--border-soft);
}
.capacity-user-list > div:last-child, .capacity-resource-list > div:last-child { border-bottom: 0; }
.capacity-user-list code { overflow: hidden; color: var(--text); text-overflow: ellipsis; }
.capacity-user-list span, .capacity-resource-list span { color: var(--muted); font-size: 11px; }
.capacity-resource-list { border: 1px solid var(--border-soft); border-radius: var(--r-md); }
.capacity-resource-list strong { color: var(--text-strong); font: 650 12px/1.3 var(--mono); text-align: right; }
.capacity-empty { padding: 24px 12px; color: var(--muted); text-align: center; }
.capacity-empty.compact { padding: 14px 10px; border: 1px dashed var(--border); border-radius: var(--r-md); font-size: 11px; }
#s-windows-node button, #node-enrollment-modal button {
font-size: 12px; padding: 5px 11px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; color: var(--text); cursor: pointer;
@ -365,7 +416,7 @@
.page-intro { padding: 12px 14px; }
.page-intro h1 { font-size: 17px; }
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.summary-card { min-height: 150px; }
.summary-card { min-height: 140px; }
.summary-card .summary-value { font-size: 26px; }
.anchor { scroll-margin-top: 150px; }
.card { padding: 12px; }
@ -373,7 +424,13 @@
@media (max-width: 480px) {
.ops-summary { grid-template-columns: 1fr; }
.summary-card { min-height: 146px; }
.summary-card { min-height: 138px; }
.capacity-drawer { align-items: flex-end; }
.capacity-drawer-panel { width: 100%; height: min(90vh, 760px); border: 0; border-radius: 14px 14px 0 0; }
.capacity-hero { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.capacity-hero > div { padding: 9px 8px; }
.capacity-hero strong { font-size: 18px; }
.capacity-detail-grid { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
@ -422,6 +479,19 @@
<!-- 导出 PDF:屏幕隐藏,仅 @media print 显示;exportPdf() 现填充后 window.print() -->
<div id="print-report"></div>
<div id="app-dialog" class="modal"></div>
<div id="capacity-drawer" class="capacity-drawer" aria-hidden="true">
<aside class="capacity-drawer-panel" role="dialog" aria-modal="true" aria-labelledby="capacity-drawer-title">
<div class="capacity-drawer-head">
<div>
<h2 id="capacity-drawer-title">执行容量详情</h2>
<p>自动刷新前后台队列、容器生命周期与宿主资源</p>
</div>
<span id="capacity-drawer-status" class="summary-badge"></span>
<button id="capacity-drawer-close" class="capacity-drawer-close" type="button" aria-label="关闭执行容量详情">×</button>
</div>
<div id="capacity-drawer-body" class="capacity-drawer-body"></div>
</aside>
</div>
<div id="node-enrollment-modal" class="modal" aria-hidden="true">
<div class="card" role="dialog" aria-modal="true" aria-labelledby="node-enrollment-title">
<div class="node-modal-head">

View File

@ -64,6 +64,7 @@ let sandboxCapacityData = null;
let storageSummaryData = null;
let toolFailuresData = null;
let toolWireData = null;
let capacityDrawerTrigger = null;
// ───── 格式化 ─────
function fmtCNY(n) {
@ -95,57 +96,27 @@ function ctrlHTML(prefix, range, sort) {
function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; }
// ───── 渲染各 section ─────
function summaryCard({ target = "", label, value = "", unit = "", sub = "", meta = [], tone = "", progress = null, body = "", extraClass = "" }) {
function summaryCard({ target = "", action = "", label, value = "", unit = "", sub = "", meta = [], badge = null, tone = "", progress = null, extraClass = "" }) {
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>`
: "";
const tag = target ? "a" : "article";
const href = target ? ` href="#${target}"` : "";
return `<${tag} class="summary-card ${tone} ${extraClass}"${href}>`
+ `<div class="label-row"><span class="label">${escapeHtml(label)}</span>${target ? `<span class="jump" aria-hidden="true">↘</span>` : ""}</div>`
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
? `<span class="summary-badge ${escapeHtml(badge.tone || "")}">${escapeHtml(badge.text)}</span>`
: "";
const affordance = target ? `<span class="jump" aria-hidden="true"></span>`
: (action ? `<span class="summary-detail">查看详情</span>` : "");
return `<${tag} class="summary-card ${tone} ${extraClass}"${attrs}>`
+ `<div class="label-row"><span class="label">${escapeHtml(label)}</span><span class="summary-card-actions">${badgeHTML}${affordance}</span></div>`
+ (value ? `<div class="summary-value">${value}${unit ? ` <span class="unit">${escapeHtml(unit)}</span>` : ""}</div>` : "")
+ body
+ (sub ? `<div class="summary-sub" title="${escapeHtml(sub)}">${escapeHtml(sub)}</div>` : "")
+ metaHTML + meter + `</${tag}>`;
}
const TASK_LABELS = {
active: "活动", completed: "完成", abandoned: "废弃",
running: "运行", cancelling: "取消中", error: "错误", idle: "空闲", cancelled: "取消",
};
function taskGroupHTML(label, statuses, preferredOrder) {
const entries = Object.entries(statuses || {}).sort((a, b) => {
const ai = preferredOrder.indexOf(a[0]);
const bi = preferredOrder.indexOf(b[0]);
return (ai < 0 ? preferredOrder.length : ai) - (bi < 0 ? preferredOrder.length : bi);
});
const values = entries.map(([status, count]) => {
const cls = status === "error" ? "err" : (["running", "cancelling"].includes(status) ? "run" : (status === "completed" ? "ok" : ""));
return `<span class="summary-status ${cls}">${escapeHtml(TASK_LABELS[status] || status)} <b>${Number(count) || 0}</b></span>`;
}).join("") || `<span class="summary-status">无</span>`;
return `<div class="summary-task-group"><span class="summary-group-label">${label}</span><div>${values}</div></div>`;
}
function tokenSummaryHTML(tokens7d, totalTokens) {
return `<div class="summary-dual">`
+ `<div><span>近 7 天</span><strong>${fmtTokens(tokens7d)}</strong></div>`
+ `<div><span>累计</span><strong>${fmtTokens(totalTokens)}</strong></div>`
+ `</div>`;
}
function containerStatusHTML(capacity) {
if (!capacity) return "";
const statusGroup = (label, items) => `<div class="summary-task-group"><span class="summary-group-label">${label}</span>`
+ `<div>${items.map(([name, value, cls = ""]) => `<span class="summary-status ${cls}">${name} <b>${Number(value) || 0}</b></span>`).join("")}</div></div>`;
const users = Object.entries(capacity.per_user || {}).sort((a, b) => b[1] - a[1]);
return statusGroup("前台", [["执行", capacity.foreground_running, "run"], ["排队", capacity.foreground_queued]])
+ statusGroup("后台", [["执行", capacity.background_running, "run"], ["排队", capacity.background_queued]])
+ statusGroup("容器", [["活跃", capacity.active_sandbox_containers], ["待回收", capacity.idle_reap_candidates], ["proc", capacity.running_proc_containers]])
+ statusGroup("单用户占用", users.length ? users.map(([userId, count]) => [escapeHtml(userId.slice(0, 8)), count]) : [["无", 0]]);
}
function renderOpsSummary() {
const root = $("ops-summary");
if (!root) return;
@ -163,6 +134,13 @@ function renderOpsSummary() {
? (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"
@ -178,29 +156,24 @@ function renderOpsSummary() {
const runStatuses = tasks.by_run_status || {};
const runningTasks = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0);
const errorTasks = Number(runStatuses.error) || 0;
const taskTone = errorTasks ? "warn" : "";
const taskBody = taskGroupHTML("任务", tasks.by_status, ["active", "completed", "abandoned"])
+ taskGroupHTML("执行", runStatuses, ["running", "cancelling", "error", "idle", "cancelled", "completed"]);
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 tokensOut = Number(usage.tokens_out) || 0;
const totalTokens = tokensIn + tokensOut;
const tokens7d = byDay.reduce((sum, row) => sum + (Number(row.tokens_in) || 0) + (Number(row.tokens_out) || 0), 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 quota = storage && Number(storage.quota_bytes);
const scannedUsers = storage ? Number(storage.total) || 0 : 0;
const aggregateQuota = quota > 0 ? quota * scannedUsers : 0;
const storageRatio = aggregateQuota ? (Number(storageTotals.bytes_used) || 0) / aggregateQuota : 0;
const storageTone = levelClass(storageRatio);
const storageValue = storage ? humanSize(storageTotals.physical_bytes || 0) : "—";
const storageValue = storage ? humanSize(storageTotals.physical_bytes || 0) : "汇总中";
const storageSub = storage
? `配额内 ${humanSize(storageTotals.bytes_used || 0)} · 回收站 ${humanSize(storageTotals.trash_bytes || 0)}`
: "正在读取存储快照";
@ -219,57 +192,159 @@ function renderOpsSummary() {
const malformed = Number(((toolWireData || {}).total || {}).malformed_24h) || 0;
const toolTone = systemicCount || malformed ? "danger" : (localCount ? "warn" : "");
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);
root.innerHTML = [
summaryCard({
label: "容器状态",
value: !capacity ? "—" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"),
unit: capacityEnabled ? "执行中 / 上限" : "",
body: capacityEnabled ? containerStatusHTML(capacity) : "",
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
? `后台上限 ${capacityLimits.background || 0} · 单用户上限 ${capacityLimits.per_user || 0}${capacity.memory_paused ? " · 内存压力暂停放行" : ""}`
? `可用 ${availableExecs} · 排队 ${queuedExecs}`
: "Docker Sandbox 未启用"),
meta: capacityEnabled
? [`当前可放行 ${capacity.admit_available || 0}`, `MemAvailable ${humanSize(capacity.mem_available_bytes || 0)} · CPU ${(capacity.cpu_load || []).join(" / ") || "—"}`]
? [`后台 ${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,
extraClass: "task-summary",
}),
summaryCard({
label: "任务状态", value: `${runningTasks} / ${tasks.total || 0}`, unit: "运行中 / 总数",
body: taskBody, tone: taskTone, extraClass: "task-summary",
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-users", label: "活跃用户", value: `${activeUsers} / ${userTotal}`, unit: "近 7 天 / 总数",
sub: `近 7 天活跃率 ${activeRate}%`, meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
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-usage", label: "Token 与缓存", body: tokenSummaryHTML(tokens7d, totalTokens),
sub: `输入 ${fmtTokens(tokensIn)} · 输出 ${fmtTokens(tokensOut)}`,
meta: [`缓存命中 ${hitRate}%`, `命中 ${fmtTokens(cacheHit)}`], progress: hitRate, extraClass: "token-summary",
target: "s-users", label: "活跃用户", value: String(activeUsers), unit: "近 7 天活跃",
sub: `活跃率 ${activeRate}% · 总用户 ${userTotal}`,
meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
}),
summaryCard({
target: "s-windows-node", label: "专业软件节点", value: softwareNodesLoaded ? `${nodeJobs} / ${nodeOnline}` : "—", unit: "活动任务 / 在线节点",
sub: softwareNodesLoaded ? `节点总数 ${nodeTotal}` : "正在读取节点状态",
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : ["加载中", ""], tone: nodeTone,
target: "s-usage", label: "Token 与缓存", value: fmtTokens(tokens7d), unit: "近 7 天",
sub: `缓存命中 ${hitRate}%`,
meta: [`输入 ${fmtTokens(tokensIn7d)}`, `输出 ${fmtTokens(tokensOut7d)}`], progress: hitRate,
}),
summaryCard({
target: "s-storage", label: "存储", value: storageValue, unit: "物理总占用",
sub: storageSub, meta: storageMeta, tone: storageTone,
progress: aggregateQuota ? Math.round(storageRatio * 100) : null,
target: "s-usage", label: "成本", value: fmtCNY(cost7d), unit: "近 7 天",
sub: `累计 ${fmtCNY(cost)}`,
}),
summaryCard({
target: "s-usage", label: "成本", value: fmtCNY(cost), unit: "累计",
sub: `近 7 天 ${fmtCNY(cost7d)}`, meta: [`${events} 次事件`, `均次 ${fmtCNY(events ? cost / events : 0)}`],
}),
summaryCard({
target: "s-toolfail", label: "工具健康", value: toolFailuresData ? String(failureCount) : "—", unit: "近 24h 失败",
sub: toolFailuresData ? `系统性聚集 ${systemicCount} · 单任务 ${localCount}` : "正在汇总工具调用状态",
meta: toolFailuresData ? [`链路残余 ${malformed}`, `质量门 ${gateCount}`] : ["加载中", ""], tone: toolTone,
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 `<div class="capacity-detail-item ${tone}">`
+ `<span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong>`
+ (hint ? `<small>${escapeHtml(hint)}</small>` : "") + `</div>`;
}
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 = `<div class="capacity-empty">Docker Sandbox 未启用,当前没有可展示的执行容量。</div>`;
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
? `<div class="capacity-alert"><strong>内存压力已暂停新任务放行</strong><span>运行中的任务不受影响;资源恢复后会继续调度。</span></div>`
: "")
+ `<section class="capacity-hero" aria-label="容量摘要">`
+ `<div><strong>${active} / ${limit || "—"}</strong><span>使用中 / 上限</span></div>`
+ `<div><strong>${available}</strong><span>当前可放行</span></div>`
+ `<div><strong>${queued}</strong><span>等待调度</span></div></section>`
+ `<section class="capacity-detail-section"><h3>执行队列</h3><div class="capacity-detail-grid">`
+ capacityDetailItem("前台执行", foregroundRunning, `排队 ${foregroundQueued}`, foregroundQueued ? "warn" : "")
+ capacityDetailItem("后台执行", `${backgroundRunning} / ${Number(limits.background) || 0}`, `排队 ${backgroundQueued}`, backgroundQueued ? "warn" : "")
+ capacityDetailItem("单用户上限", Number(limits.per_user) || 0, "重型任务槽位")
+ `</div></section>`
+ `<section class="capacity-detail-section"><h3>容器生命周期</h3><div class="capacity-detail-grid">`
+ 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)
+ `</div></section>`
+ `<section class="capacity-detail-section"><h3>单用户占用</h3>`
+ (users.length
? `<div class="capacity-user-list">${users.map(([userId, count]) => `<div><code title="${escapeHtml(userId)}">${escapeHtml(userId.slice(0, 8))}</code><span>${Number(count) || 0} / ${Number(limits.per_user) || 0}</span></div>`).join("")}</div>`
: `<div class="capacity-empty compact">当前没有用户占用执行槽位</div>`)
+ `</section>`
+ `<section class="capacity-detail-section"><h3>宿主资源</h3><div class="capacity-resource-list">`
+ `<div><span>可用内存</span><strong>${humanSize(capacity.mem_available_bytes || 0)}</strong></div>`
+ `<div><span>CPU Load</span><strong>${escapeHtml(cpuLoad)}</strong></div>`
+ `</div></section>`;
}
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) {
@ -1354,13 +1429,21 @@ $("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 (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) {
closeNodeEnrollmentModal();
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", () => {