feat(web): optimize admin operations overview
This commit is contained in:
parent
f50bfc1bcf
commit
a1068f0ec1
|
|
@ -6,6 +6,10 @@
|
|||
> 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。
|
||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。
|
||||
|
||||
## 0.70.0 — 2026-09-02
|
||||
|
||||
- 容器长任务会在整机繁忙时安全排队,后台任务可查看或取消排队状态;管理员可在“容器状态”查看实时容量,并通过“容器依赖”分析用户临时安装的 Python 依赖及占用。
|
||||
|
|
|
|||
|
|
@ -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,未连接或迁移生产数据库、未启动真实专业软件。
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue