feat(admin): 重构总览与分组工作区

This commit is contained in:
caoqianming 2026-09-03 08:54:59 +08:00
parent e82b6842c7
commit 2ff648afe6
6 changed files with 361 additions and 148 deletions

View File

@ -16,7 +16,7 @@
- Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。 - Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。
- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别 - 管理后台改为常驻的紧凑总览与“运行、用量、用户、资源、集成、质量”分组工作区;总览卡可直接定位到同口径详情,执行容量仍可从侧栏快速查看,桌面端和移动端都能更快进入具体管理内容
## 0.70.0 — 2026-09-02 ## 0.70.0 — 2026-09-02

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-09-020.70.0 正式发布:容器共享容量、专业软件节点、模型与交互更新 最后更新:2026-09-03Unreleased管理后台紧凑总览与分组工作区
--- ---
@ -20,6 +20,8 @@
--- ---
## 已完成关键能力 ## 已完成关键能力
- **09-03 / Unreleased / 管理后台紧凑总览与分组工作区**Admin 由纵向长页改为顶部常驻的两级总览和“运行、用量、用户、资源、集成、质量”六个页签四项运行状态使用主卡、四项运营指标收进紧凑指标栏点击总览可切换页签并精确定位同口径详情任务、容量、用户活跃、Token/成本和存储均补齐详情指标。页签 hash 支持刷新与前进后退桌面端页签吸顶移动端顶栏操作收进菜单、运营指标与页签可横向滑动1440px/390px 浏览器渲染、六页签与深链交互检查通过,无 API、schema、migration 或运行方式变化。
- **09-02 / Unreleased / 单用户重型执行容量与排队提示**:共享执行容量保持整机 6、后台 4单用户上限由 2 提升到 3前台重型任务首次等待时通过 SSE 告知单用户、整机、内存压力或队列顺序原因,放行后恢复正常执行提示,排队仍不计入命令 timeout 且可取消。无 schema、migration 或 API 变化。 - **09-02 / Unreleased / 单用户重型执行容量与排队提示**:共享执行容量保持整机 6、后台 4单用户上限由 2 提升到 3前台重型任务首次等待时通过 SSE 告知单用户、整机、内存压力或队列顺序原因,放行后恢复正常执行提示,排队仍不计入命令 timeout 且可取消。无 schema、migration 或 API 变化。
- **09-02 / Unreleased / Admin Provider API 凭据管理与余额提醒**:新增代码内静态可信 Provider 注册表和单行当前凭据控制面0039 以 AES-GCM 字段级 AAD 密文保存Admin 可查看 DB/env 来源与尾号、先测后原子替换、手动测试和删除覆盖LLM/媒体/平台来源/语音均按新请求热解析。DeepSeek 每 30 分钟检查官方 CNY 余额,低于 ¥30 及明确 402/认证失败进入统一状态与邮件提醒,蓝绿实例以 PostgreSQL advisory lock 单轮选主;持续异常每日最多一次、恢复重置。未连接生产数据库、未调用真实第三方 API。 - **09-02 / Unreleased / Admin Provider API 凭据管理与余额提醒**:新增代码内静态可信 Provider 注册表和单行当前凭据控制面0039 以 AES-GCM 字段级 AAD 密文保存Admin 可查看 DB/env 来源与尾号、先测后原子替换、手动测试和删除覆盖LLM/媒体/平台来源/语音均按新请求热解析。DeepSeek 每 30 分钟检查官方 CNY 余额,低于 ¥30 及明确 402/认证失败进入统一状态与邮件提醒,蓝绿实例以 PostgreSQL advisory lock 单轮选主;持续异常每日最多一次、恢复重置。未连接生产数据库、未调用真实第三方 API。

2
RUN.md
View File

@ -177,7 +177,7 @@
容器留着复用即可(alpine 版很轻);schema 升级后重跑一次 `db upgrade head` 容器留着复用即可(alpine 版很轻);schema 升级后重跑一次 `db upgrade head`
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 - **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。
- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面:左侧目录(点击滚到对应区)+ 运行态/任务/用户用量/按模型/各用户用量/存储;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。 - **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面顶部常驻紧凑总览,下方按“运行/用量/用户/资源/集成/质量”页签切换;点击总览卡会进入对应页签并定位同口径详情。执行容量也可从详情侧栏查看;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。
--- ---

View File

@ -96,12 +96,14 @@ 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_consolidates_overview_into_eight_summary_cards(self) -> None: def test_admin_uses_compact_overview_and_grouped_workspace_tabs(self) -> None:
html = ADMIN_HTML.read_text(encoding="utf-8") html = ADMIN_HTML.read_text(encoding="utf-8")
admin_js = (JS_DIR / "admin.js").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('id="ops-summary" class="ops-summary"', admin_js)
self.assertIn(".ops-summary {", html) self.assertIn(".ops-summary {", html)
self.assertIn('class="summary-primary"', admin_js)
self.assertIn('class="summary-secondary"', admin_js)
labels = ( labels = (
"任务运行", "任务运行",
"执行容量", "执行容量",
@ -117,24 +119,30 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn(f'label: "{label}"', admin_js) self.assertIn(f'label: "{label}"', admin_js)
positions.append(admin_js.index(f'label: "{label}"')) positions.append(admin_js.index(f'label: "{label}"'))
self.assertEqual(positions, sorted(positions)) self.assertEqual(positions, sorted(positions))
self.assertNotIn("function renderRuntime", admin_js) self.assertIn("function renderRuntimeDetails()", admin_js)
self.assertNotIn("function renderTasks", admin_js) self.assertIn("function renderCapacitySection()", admin_js)
self.assertNotIn("function renderUsersAndUsage", admin_js) self.assertIn("function renderUsageSection(d)", admin_js)
self.assertIn('["s-overview", "总览"]', admin_js) for tab in ("运行", "用量", "用户", "资源", "集成", "质量"):
self.assertIn('["s-usage", "用量趋势"]', admin_js) self.assertIn(f'"{tab}"', admin_js)
self.assertIn('class="workspace-tabs"', admin_js)
self.assertIn('role="tabpanel"', admin_js)
self.assertIn('tab: "run", target: "s-runtime"', admin_js)
self.assertIn('tab: "run", target: "s-sandbox-capacity"', admin_js)
self.assertIn('tab: "resources", target: "s-windows-node"', admin_js)
self.assertIn('tab: "quality", target: "s-toolfail"', admin_js)
self.assertIn('tab: "users", target: "s-users"', admin_js)
self.assertIn('tab: "usage", target: "s-usage"', admin_js)
self.assertIn('tab: "resources", target: "s-storage"', admin_js)
self.assertIn('unit: softwareNodesLoaded ? "在线 / 总数"', admin_js) self.assertIn('unit: softwareNodesLoaded ? "在线 / 总数"', admin_js)
self.assertIn('value: fmtTokens(tokens7d), unit: "近 7 天"', admin_js) self.assertIn('value: fmtTokens(tokens7d), unit: "近 7 天"', admin_js)
self.assertIn("storageTotals.physical_bytes", admin_js) self.assertIn("storageTotals.physical_bytes", admin_js)
self.assertIn("repeat(auto-fit, minmax(190px, 1fr))", html) self.assertIn("repeat(auto-fit, minmax(190px, 1fr))", html)
self.assertIn('apiGet("/v1/admin/storage/users?page=0&page_size=1")', 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('id="s-sandbox-capacity"', admin_js)
self.assertIn('target: "s-toolfail"', admin_js) self.assertIn('id="s-sandbox-packages"', admin_js)
self.assertNotIn('"s-sandbox-capacity"', admin_js)
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('sandboxCapacityData = await apiGet("/v1/admin/sandbox/capacity")', admin_js)
self.assertNotIn("containerStatusHTML(capacity)", admin_js) self.assertNotIn("containerStatusHTML(capacity)", admin_js)
self.assertIn('data-summary-action="${escapeHtml(action)}"', admin_js) self.assertIn('data-summary-tab="${escapeHtml(tab)}"', admin_js)
self.assertIn("function renderCapacityDrawer()", admin_js) self.assertIn("function renderCapacityDrawer()", admin_js)
self.assertIn("function openCapacityDrawer()", admin_js) self.assertIn("function openCapacityDrawer()", admin_js)
self.assertIn('id="capacity-drawer" class="capacity-drawer"', html) self.assertIn('id="capacity-drawer" class="capacity-drawer"', html)

View File

@ -43,6 +43,9 @@
header .spacer { flex: 1; } header .spacer { flex: 1; }
header .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); } header .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); }
header .actions { display: flex; align-items: center; gap: 6px; } header .actions { display: flex; align-items: center; gap: 6px; }
.action-menu { display: contents; }
.action-menu > summary { display: none; }
.action-menu-body { display: flex; align-items: center; gap: 6px; }
header a, header button { header a, header button {
min-height: 30px; display: inline-flex; align-items: center; justify-content: center; min-height: 30px; display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 550; color: var(--text); text-decoration: none; font-size: 12px; font-weight: 550; color: var(--text); text-decoration: none;
@ -58,28 +61,9 @@
.msg { padding: 28px 14px; text-align: center; color: var(--muted); } .msg { padding: 28px 14px; text-align: center; color: var(--muted); }
.msg a { color: var(--accent); } .msg a { color: var(--accent); }
/* 目录 + 内容两栏;目录 sticky 跟随滚动 */ #layout, #content { min-width: 0; }
#layout { display: grid; grid-template-columns: 158px minmax(0, 1fr); gap: 14px; align-items: start; }
#toc {
position: sticky; top: 70px; display: flex; flex-direction: column; gap: 3px;
padding: 8px; background: rgba(255,255,255,.88); border: 1px solid var(--border);
border-radius: var(--r-lg); box-shadow: var(--shadow-soft);
}
#toc .toc-label { padding: 4px 9px 7px; color: #8a9299; font-size: 10px; font-weight: 700; letter-spacing: .8px; }
#toc a {
position: relative; display: flex; align-items: center; gap: 8px; min-height: 34px;
color: var(--muted); text-decoration: none; font-size: 12.5px; padding: 6px 8px;
border-radius: 6px; border: 1px solid transparent; transition: .15s ease;
}
#toc .nav-index { width: 20px; flex: 0 0 auto; color: #9aa2a9; font: 10px/1 var(--mono); }
#toc a > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#toc a:hover { background: var(--panel-soft); color: var(--text-strong); }
#toc a.active { color: var(--accent); border-color: #f0d5d2; background: var(--accent-soft); font-weight: 650; }
#toc a.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
#toc a.active .nav-index { color: var(--accent); }
#content { min-width: 0; }
.page-intro { .page-intro {
position: relative; overflow: hidden; margin-bottom: 10px; padding: 13px 16px; position: relative; overflow: hidden; padding: 12px 16px 10px;
border: 1px solid #e6d9d7; border-radius: var(--r-lg); border: 1px solid #e6d9d7; border-radius: var(--r-lg);
background: linear-gradient(120deg, #fff 0%, #fff 54%, #fdf4f2 100%); box-shadow: var(--shadow-soft); background: linear-gradient(120deg, #fff 0%, #fff 54%, #fdf4f2 100%); box-shadow: var(--shadow-soft);
} }
@ -87,22 +71,29 @@
content: ""; position: absolute; right: -55px; top: -75px; width: 170px; height: 170px; content: ""; position: absolute; right: -55px; top: -75px; width: 170px; height: 170px;
border: 32px solid rgba(184,58,49,.045); border-radius: 50%; pointer-events: none; border: 32px solid rgba(184,58,49,.045); border-radius: 50%; pointer-events: none;
} }
.page-intro h1 { position: relative; margin: 0; color: var(--text-strong); font-size: 17px; line-height: 1.3; } .overview-heading { position: relative; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.page-intro p { position: relative; margin: 3px 0 0; color: var(--muted); font-size: 11px; } .page-intro h1 { margin: 0; color: var(--text-strong); font-size: 17px; line-height: 1.3; }
.anchor { scroll-margin-top: 68px; } .page-intro p { margin: 2px 0 0; color: var(--muted); font-size: 11px; }
.overview-health { position: relative; flex: 0 0 auto; }
.anchor { scroll-margin-top: 116px; }
/* 首屏运营摘要:第一排业务运行,第二排基础设施与质量。 */ /* 总览分两级:运行状态使用主卡,运营指标使用紧凑指标栏。 */
.ops-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; } .ops-summary { position: relative; margin-top: 10px; }
.summary-primary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.summary-secondary {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 8px;
overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--panel);
}
.summary-card { .summary-card {
min-width: 0; min-height: 142px; display: flex; flex-direction: column; padding: 13px 14px 11px; min-width: 0; min-height: 82px; display: flex; flex-direction: column; padding: 10px 12px;
color: inherit; text-decoration: none; background: var(--panel); border: 1px solid var(--border); 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; text-align: left; border-radius: var(--r-lg); box-shadow: var(--shadow-soft); transition: .15s ease; text-align: left;
} }
button.summary-card { width: 100%; appearance: none; font: inherit; cursor: pointer; } 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); } 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); } 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-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 .label { min-width: 0; color: #626b73; font-size: 12px; font-weight: 650; }
.summary-card-actions { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; } .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-card .jump { flex: 0 0 auto; color: #929aa1; font-size: 14px; }
.summary-detail { color: #899198; font-size: 10px; white-space: nowrap; } .summary-detail { color: #899198; font-size: 10px; white-space: nowrap; }
@ -113,10 +104,10 @@
.summary-badge.ok { color: var(--ok); background: #e8f5e9; } .summary-badge.ok { color: var(--ok); background: #e8f5e9; }
.summary-badge.warn { color: var(--warn); background: #fff0d8; } .summary-badge.warn { color: var(--warn); background: #fff0d8; }
.summary-badge.danger { color: var(--danger); background: var(--accent-soft); } .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 { margin-top: 3px; color: var(--text-strong); font: 680 22px/1.12 var(--mono); letter-spacing: -.5px; }
.summary-card .summary-value .unit { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: 0; white-space: nowrap; } .summary-card .summary-value .unit { color: var(--muted); font-size: 10px; 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; } .summary-card .summary-sub { margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 11px; 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: 8px; color: var(--muted); font-size: 11px; } .summary-card .summary-meta { display: none; }
.summary-card .summary-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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-meta span:last-child { text-align: right; }
.summary-card .summary-meter { height: 4px; margin-top: 6px; overflow: hidden; border-radius: 999px; background: #e8ebed; } .summary-card .summary-meter { height: 4px; margin-top: 6px; overflow: hidden; border-radius: 999px; background: #e8ebed; }
@ -127,6 +118,47 @@
.summary-card.danger { border-color: #edc1bd; background: var(--accent-soft); } .summary-card.danger { border-color: #edc1bd; background: var(--accent-soft); }
.summary-card.danger .summary-value { color: var(--danger); } .summary-card.danger .summary-value { color: var(--danger); }
.summary-card.danger .summary-meter > i { background: var(--danger); } .summary-card.danger .summary-meter > i { background: var(--danger); }
.summary-secondary .summary-card {
min-height: 58px; padding: 8px 12px; border: 0; border-right: 1px solid var(--border-soft);
border-radius: 0; box-shadow: none;
}
.summary-secondary .summary-card:last-child { border-right: 0; }
.summary-secondary .summary-card .summary-value { margin-top: 2px; font-size: 18px; }
.summary-secondary .summary-card .summary-sub,
.summary-secondary .summary-card .summary-badge { display: none; }
.summary-secondary .summary-card:hover { transform: none; box-shadow: none; background: var(--panel-soft); }
/* 总览下方是唯一工作区tab 保持可见panel 只显示当前分组。 */
.workspace { margin-top: 10px; }
.workspace-tabs {
position: sticky; top: 54px; z-index: 6; display: flex; gap: 3px; padding: 5px;
overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-lg);
background: rgba(255,255,255,.94); backdrop-filter: blur(10px); box-shadow: var(--shadow-soft);
scrollbar-width: none;
}
.workspace-tabs::-webkit-scrollbar { display: none; }
.workspace-tab {
min-width: 76px; min-height: 34px; flex: 1 0 auto; padding: 5px 13px; border: 1px solid transparent;
border-radius: 6px; color: var(--muted); background: transparent; font-size: 12px; font-weight: 600; cursor: pointer;
}
.workspace-tab:hover { color: var(--text-strong); background: var(--panel-soft); }
.workspace-tab[aria-selected="true"] { color: var(--accent); border-color: #f0d5d2; background: var(--accent-soft); }
.tab-panel { padding-top: 10px; }
.tab-panel[hidden] { display: none; }
.tab-intro { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 2px 9px; }
.tab-intro h2 { margin: 0; color: var(--text-strong); font-size: 15px; }
.tab-intro p { margin: 0; color: var(--muted); font-size: 11px; text-align: right; }
.detail-anchor { scroll-margin-top: 108px; }
.detail-anchor.focus-pulse { animation: focus-pulse 1.2s ease; }
@keyframes focus-pulse { 0%, 100% { opacity: 1; } 35% { opacity: .62; } }
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.detail-stat { min-width: 0; padding: 11px 12px; border: 1px solid var(--border-soft); border-radius: var(--r-lg); background: var(--panel-soft); }
.detail-stat span { display: block; color: var(--muted); font-size: 10px; }
.detail-stat strong { display: block; margin-top: 2px; color: var(--text-strong); font: 700 20px/1.2 var(--mono); }
.detail-stat.warn { border-color: #efd8b7; background: #fffaf2; }
.detail-stat.danger { border-color: #edc1bd; background: var(--accent-soft); }
.detail-stat.warn strong { color: var(--warn); }
.detail-stat.danger strong { color: var(--danger); }
.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; }
@ -239,6 +271,11 @@
font-size: 12px; padding: 4px 8px; border: 1px solid var(--border); font-size: 12px; padding: 4px 8px; border: 1px solid var(--border);
border-radius: var(--r-md); background: #fff; cursor: pointer; border-radius: var(--r-md); background: #fff; cursor: pointer;
} }
#s-sandbox-capacity button {
min-height: 30px; padding: 4px 9px; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--text); background: #fff; cursor: pointer;
}
#s-sandbox-capacity button:disabled { opacity: .45; cursor: default; }
#s-provider-credentials button:disabled { opacity: .45; cursor: default; } #s-provider-credentials button:disabled { opacity: .45; cursor: default; }
.capacity-drawer.show { display: flex; } .capacity-drawer.show { display: flex; }
.capacity-drawer-panel { .capacity-drawer-panel {
@ -384,23 +421,35 @@
} }
select:focus, input:focus, textarea:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-ring); outline: none; } select:focus, input:focus, textarea:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-ring); outline: none; }
@media (min-width: 821px) and (max-width: 1240px) { @media (min-width: 821px) and (max-width: 1100px) {
#layout { grid-template-columns: 148px minmax(0, 1fr); gap: 12px; } .summary-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#toc .nav-index { display: none; } .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 820px) { @media (max-width: 820px) {
main { padding: 10px; } main { padding: 10px; }
.grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } .grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
/* header 紧凑化:缩 padding/gap、字号,gen-at 时间戳截断不撑宽 */ /* header 紧凑化:缩 padding/gap、字号,gen-at 时间戳截断不撑宽 */
header { padding: 8px 10px; gap: 8px; flex-wrap: wrap; } header { min-height: 52px; padding: 8px 10px; gap: 8px; flex-wrap: nowrap; }
header .title { font-size: 14px; } header .title { font-size: 14px; }
header .eyebrow { display: none; } header .eyebrow { display: none; }
header .meta { font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } header .meta { font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header a, header button { padding: 4px 8px; } header a, header button { padding: 4px 8px; }
header .actions { order: 3; width: 100%; overflow-x: auto; padding-bottom: 1px; } header .actions { position: relative; flex: 0 0 auto; }
header .actions > * { flex: 0 0 auto; } .action-menu { position: relative; display: block; }
.action-menu > summary {
min-height: 30px; display: inline-flex; align-items: center; padding: 4px 8px;
border: 1px solid var(--border); border-radius: var(--r-md); background: #fff;
color: var(--text); font-size: 12px; font-weight: 550; cursor: pointer; list-style: none;
}
.action-menu > summary::-webkit-details-marker { display: none; }
.action-menu-body {
position: absolute; top: calc(100% + 7px); right: 0; width: 170px; display: none;
padding: 7px; border: 1px solid var(--border); border-radius: var(--r-lg); background: #fff;
box-shadow: 0 10px 26px rgba(18,24,30,.14);
}
.action-menu[open] .action-menu-body { display: grid; gap: 5px; }
.action-menu-body > * { width: 100%; justify-content: flex-start; }
/* card-head 允许换行,标题长 + 下拉不再撑出横向溢出 */ /* card-head 允许换行,标题长 + 下拉不再撑出横向溢出 */
.card-head { flex-wrap: wrap; } .card-head { flex-wrap: wrap; }
.ctrl { flex-wrap: wrap; } .ctrl { flex-wrap: wrap; }
@ -408,28 +457,31 @@
.ext-form-grid { grid-template-columns: 1fr; } .ext-form-grid { grid-template-columns: 1fr; }
.ext-form-grid > label, .ext-form-section { grid-column: 1 !important; } .ext-form-grid > label, .ext-form-section { grid-column: 1 !important; }
.node-code-wrap { flex-direction: column; } .node-code-wrap { flex-direction: column; }
/* 窄屏:目录变顶部横向 chip 条 */
#layout { grid-template-columns: 1fr; gap: 10px; }
#toc {
flex-direction: row; overflow-x: auto; gap: 6px; top: 109px;
background: rgba(244,246,248,.94); backdrop-filter: blur(8px); padding: 6px 0; z-index: 5;
border: 0; border-radius: 0; box-shadow: none;
}
#toc .toc-label, #toc .nav-index { display: none; }
#toc a { min-height: 32px; white-space: nowrap; border: 1px solid var(--border); background: #fff; }
#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)); } .summary-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.summary-card { min-height: 140px; } .summary-card { min-height: 76px; padding: 9px 10px; }
.summary-card .summary-value { font-size: 26px; } .summary-card .summary-value { font-size: 20px; }
.anchor { scroll-margin-top: 150px; } .summary-secondary { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none; }
.summary-secondary::-webkit-scrollbar { display: none; }
.summary-secondary .summary-card { min-width: 145px; flex: 1 0 42%; scroll-snap-align: start; }
.workspace-tabs { top: 52px; margin-inline: -10px; padding-inline: 10px; border-inline: 0; border-radius: 0; }
.workspace-tab { min-width: 68px; }
.tab-intro { display: block; }
.tab-intro p { margin-top: 2px; text-align: left; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.anchor, .detail-anchor { scroll-margin-top: 100px; }
.card { padding: 12px; } .card { padding: 12px; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.ops-summary { grid-template-columns: 1fr; } header .meta { display: none; }
.summary-card { min-height: 138px; } .overview-heading p { max-width: 250px; }
.summary-card .summary-badge { padding-inline: 5px; }
.summary-detail { display: none; }
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-stat { padding: 9px 10px; }
.detail-stat strong { font-size: 18px; }
.capacity-drawer { align-items: flex-end; } .capacity-drawer { align-items: flex-end; }
.capacity-drawer-panel { width: 100%; height: min(90vh, 760px); border: 0; border-radius: 14px 14px 0 0; } .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 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@ -472,10 +524,15 @@
<span class="meta" id="gen-at"></span> <span class="meta" id="gen-at"></span>
<div class="spacer"></div> <div class="spacer"></div>
<div class="actions"> <div class="actions">
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
<button id="refresh">刷新数据</button> <button id="refresh">刷新数据</button>
<button id="export">导出 PDF</button> <details id="action-menu" class="action-menu">
<a href="/static/dev.html">返回控制台</a> <summary>更多</summary>
<div class="action-menu-body">
<label class="auto"><input type="checkbox" id="auto-refresh" checked /> 自动刷新</label>
<button id="export">导出 PDF</button>
<a href="/static/dev.html">返回控制台</a>
</div>
</details>
</div> </div>
</header> </header>
<main id="main"> <main id="main">

View File

@ -1,6 +1,6 @@
// zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。 // zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。
// 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。 // 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询; // 结构:紧凑总览常驻页面顶部 + 六个分组页签。overview(固定指标)10s 轮询;
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、 // 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。 // 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js"; import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js";
@ -11,14 +11,13 @@ const REFRESH_MS = 10000;
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]]; const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]]; const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
const SECTIONS = [ const TABS = [
["s-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"], ["run", "运行", "任务状态、执行容量与实时队列"],
["s-users", "各用户"], ["s-storage", "存储"], ["usage", "用量", "Token、缓存、成本和模型分布"],
["s-sandbox-packages", "容器依赖"], ["users", "用户", "活跃情况、用户用量与模型档位"],
["s-windows-node", "专业软件节点"], ["resources", "资源", "存储、容器依赖与专业软件节点"],
["s-provider-credentials", "API 凭据"], ["integrations", "集成", "平台 API 凭据与外部系统"],
["s-external", "外部系统"], ["quality", "质量", "工具失败、链路健康与质量门"],
["s-toolfail", "工具失败"],
]; ];
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@ -98,20 +97,19 @@ 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 summaryCard({ target = "", action = "", label, value = "", unit = "", sub = "", meta = [], badge = null, tone = "", progress = null, extraClass = "" }) { function summaryCard({ tab, target, 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 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 const metaHTML = meta.length
? `<div class="summary-meta"><span>${escapeHtml(meta[0] || "")}</span><span>${escapeHtml(meta[1] || "")}</span></div>` ? `<div class="summary-meta"><span>${escapeHtml(meta[0] || "")}</span><span>${escapeHtml(meta[1] || "")}</span></div>`
: ""; : "";
const tag = target ? "a" : (action ? "button" : "article"); const tag = tab ? "button" : "article";
const attrs = target const attrs = tab
? ` href="#${target}"` ? ` type="button" data-summary-tab="${escapeHtml(tab)}" data-summary-target="${escapeHtml(target || "")}"`
: (action ? ` type="button" data-summary-action="${escapeHtml(action)}" aria-controls="capacity-drawer"` : ""); : "";
const badgeHTML = badge && badge.text const badgeHTML = badge && badge.text
? `<span class="summary-badge ${escapeHtml(badge.tone || "")}">${escapeHtml(badge.text)}</span>` ? `<span class="summary-badge ${escapeHtml(badge.tone || "")}">${escapeHtml(badge.text)}</span>`
: ""; : "";
const affordance = target ? `<span class="jump" aria-hidden="true"></span>` const affordance = tab ? `<span class="jump" aria-hidden="true"></span>` : "";
: (action ? `<span class="summary-detail">查看详情</span>` : "");
return `<${tag} class="summary-card ${tone} ${extraClass}"${attrs}>` 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>` + `<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>` : "") + (value ? `<div class="summary-value">${value}${unit ? ` <span class="unit">${escapeHtml(unit)}</span>` : ""}</div>` : "")
@ -196,15 +194,16 @@ function renderOpsSummary() {
const cost = Number(usage.cost_cny) || 0; const cost = Number(usage.cost_cny) || 0;
const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0); const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0);
root.innerHTML = [ const primaryCards = [
summaryCard({ summaryCard({
tab: "run", target: "s-runtime",
label: "任务运行", value: String(runningTasks), unit: "正在运行", label: "任务运行", value: String(runningTasks), unit: "正在运行",
sub: cancellingTasks ? `取消中 ${cancellingTasks}` : (errorTasks ? "存在执行错误记录" : "当前无执行异常"), sub: cancellingTasks ? `取消中 ${cancellingTasks}` : (errorTasks ? "存在执行错误记录" : "当前无执行异常"),
meta: [`活动任务 ${activeTasks}`, `累计 ${tasks.total || 0}`], meta: [`活动任务 ${activeTasks}`, `累计 ${tasks.total || 0}`],
badge: errorTasks ? { text: `错误 ${errorTasks}`, tone: "danger" } : { text: "正常", tone: "ok" }, badge: errorTasks ? { text: `错误 ${errorTasks}`, tone: "danger" } : { text: "正常", tone: "ok" },
}), }),
summaryCard({ summaryCard({
action: capacityEnabled ? "open-capacity" : "", label: "执行容量", tab: "run", target: "s-sandbox-capacity", label: "执行容量",
value: !capacity ? "汇总中" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"), value: !capacity ? "汇总中" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"),
unit: capacityEnabled ? "使用中 / 上限" : "", unit: capacityEnabled ? "使用中 / 上限" : "",
sub: !capacity ? "正在读取容器状态" : (capacityEnabled sub: !capacity ? "正在读取容器状态" : (capacityEnabled
@ -219,7 +218,7 @@ function renderOpsSummary() {
tone: capacityTone, progress: capacityEnabled && activeLimit ? capacityPct : null, tone: capacityTone, progress: capacityEnabled && activeLimit ? capacityPct : null,
}), }),
summaryCard({ summaryCard({
target: "s-windows-node", label: "专业软件节点", tab: "resources", target: "s-windows-node", label: "专业软件节点",
value: softwareNodesLoaded ? `${nodeOnline} / ${nodeTotal}` : "汇总中", unit: softwareNodesLoaded ? "在线 / 总数" : "", value: softwareNodesLoaded ? `${nodeOnline} / ${nodeTotal}` : "汇总中", unit: softwareNodesLoaded ? "在线 / 总数" : "",
sub: softwareNodesLoaded ? `活动任务 ${nodeJobs}` : "正在读取节点状态", sub: softwareNodesLoaded ? `活动任务 ${nodeJobs}` : "正在读取节点状态",
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : [], meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : [],
@ -228,7 +227,7 @@ function renderOpsSummary() {
: null, : null,
}), }),
summaryCard({ summaryCard({
target: "s-toolfail", label: "工具健康", tab: "quality", target: "s-toolfail", label: "工具健康",
value: toolFailuresData ? (failureCount ? `${failureCount} 次失败` : "正常") : "汇总中", value: toolFailuresData ? (failureCount ? `${failureCount} 次失败` : "正常") : "汇总中",
unit: toolFailuresData ? "近 24 小时" : "", unit: toolFailuresData ? "近 24 小时" : "",
sub: toolFailuresData sub: toolFailuresData
@ -239,27 +238,43 @@ function renderOpsSummary() {
? (toolTone ? { text: systemicCount || malformed ? "需处理" : "有记录", tone: toolTone } : { text: "正常", tone: "ok" }) ? (toolTone ? { text: systemicCount || malformed ? "需处理" : "有记录", tone: toolTone } : { text: "正常", tone: "ok" })
: null, : null,
}), }),
];
const secondaryCards = [
summaryCard({ summaryCard({
target: "s-users", label: "活跃用户", value: String(activeUsers), unit: "近 7 天活跃", tab: "users", target: "s-users", label: "活跃用户", value: String(activeUsers), unit: "近 7 天活跃",
sub: `活跃率 ${activeRate}% · 总用户 ${userTotal}`, sub: `活跃率 ${activeRate}% · 总用户 ${userTotal}`,
meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate, meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate,
}), }),
summaryCard({ summaryCard({
target: "s-usage", label: "Token 与缓存", value: fmtTokens(tokens7d), unit: "近 7 天", tab: "usage", target: "s-usage", label: "Token 与缓存", value: fmtTokens(tokens7d), unit: "近 7 天",
sub: `缓存命中 ${hitRate}%`, sub: `缓存命中 ${hitRate}%`,
meta: [`输入 ${fmtTokens(tokensIn7d)}`, `输出 ${fmtTokens(tokensOut7d)}`], progress: hitRate, meta: [`输入 ${fmtTokens(tokensIn7d)}`, `输出 ${fmtTokens(tokensOut7d)}`], progress: hitRate,
}), }),
summaryCard({ summaryCard({
target: "s-usage", label: "成本", value: fmtCNY(cost7d), unit: "近 7 天", tab: "usage", target: "s-usage", label: "成本", value: fmtCNY(cost7d), unit: "近 7 天",
sub: `累计 ${fmtCNY(cost)}`, sub: `累计 ${fmtCNY(cost)}`,
}), }),
summaryCard({ summaryCard({
target: "s-storage", label: "存储", value: storageValue, unit: storage ? "物理总占用" : "", tab: "resources", target: "s-storage", label: "存储", value: storageValue, unit: storage ? "物理总占用" : "",
sub: storageSub, meta: storageMeta, sub: storageSub, meta: storageMeta,
}), }),
].join(""); ];
const capacityCard = root.querySelector('[data-summary-action="open-capacity"]'); root.innerHTML = `<div class="summary-primary">${primaryCards.join("")}</div>`
if (capacityCard) capacityCard.onclick = openCapacityDrawer; + `<div class="summary-secondary" aria-label="运营指标">${secondaryCards.join("")}</div>`;
root.querySelectorAll("[data-summary-tab]").forEach(card => {
card.onclick = () => activateTab(card.dataset.summaryTab, card.dataset.summaryTarget);
});
const attentionCount = Number(Boolean(errorTasks))
+ Number(Boolean(capacityEnabled && (capacity.memory_paused || queuedExecs)))
+ Number(Boolean(nodeTone)) + Number(Boolean(toolTone));
const health = $("overview-health");
if (health) {
health.className = `summary-badge overview-health ${attentionCount ? "warn" : "ok"}`;
health.textContent = attentionCount ? `${attentionCount} 项需关注` : "全部正常";
}
renderRuntimeDetails();
renderCapacitySection();
if ($("capacity-drawer").classList.contains("show")) renderCapacityDrawer(); if ($("capacity-drawer").classList.contains("show")) renderCapacityDrawer();
} }
@ -269,18 +284,37 @@ function capacityDetailItem(label, value, hint = "", tone = "") {
+ (hint ? `<small>${escapeHtml(hint)}</small>` : "") + `</div>`; + (hint ? `<small>${escapeHtml(hint)}</small>` : "") + `</div>`;
} }
function renderCapacityDrawer() { function detailStat(label, value, tone = "") {
const capacity = sandboxCapacityData; return `<div class="detail-stat ${tone}"><span>${escapeHtml(label)}</span>`
const body = $("capacity-drawer-body"); + `<strong>${escapeHtml(String(value))}</strong></div>`;
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;
}
function renderRuntimeDetails() {
const root = $("s-runtime");
if (!root || !overviewData) return;
const runtime = overviewData.runtime || {};
const tasks = overviewData.tasks || {};
const runStatuses = tasks.by_run_status || {};
const taskStatuses = tasks.by_status || {};
const running = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0);
const errors = Number(runStatuses.error) || 0;
root.innerHTML = `<div class="card"><div class="card-head"><div><h2>任务运行</h2>`
+ `<div class="sublabel">与顶部总览使用同一份实时数据</div></div></div>`
+ `<div class="detail-grid">`
+ detailStat("正在运行", running, errors ? "warn" : "")
+ detailStat("取消中", Number(runStatuses.cancelling) || 0)
+ detailStat("错误记录", errors, errors ? "danger" : "")
+ detailStat("活动任务", Number(taskStatuses.active) || 0)
+ detailStat("累计任务", Number(tasks.total) || 0)
+ detailStat("执行线程", `${Number(runtime.active_runs) || 0} / ${Number(runtime.max_workers) || "—"}`)
+ detailStat("SSE 订阅", Number(runtime.sse_subs) || 0)
+ detailStat("内存峰值", runtime.rss_peak_mb == null ? "—" : `${Math.round(runtime.rss_peak_mb)} MB`)
+ `</div></div>`;
}
function capacityContentHTML(capacity) {
if (!capacity) return `<div class="capacity-empty">正在读取执行容量…</div>`;
if (capacity.enabled === false) return `<div class="capacity-empty">Docker Sandbox 未启用,当前没有可展示的执行容量。</div>`;
const limits = capacity.limits || {}; const limits = capacity.limits || {};
const foregroundRunning = Number(capacity.foreground_running) || 0; const foregroundRunning = Number(capacity.foreground_running) || 0;
const backgroundRunning = Number(capacity.background_running) || 0; const backgroundRunning = Number(capacity.background_running) || 0;
@ -292,10 +326,7 @@ function renderCapacityDrawer() {
const available = Math.max(0, Number(capacity.admit_available) || 0); 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 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(" / ") || "—"; const cpuLoad = (capacity.cpu_load || []).map(value => Number(value).toFixed(2)).join(" / ") || "—";
return (capacity.memory_paused
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>` ? `<div class="capacity-alert"><strong>内存压力已暂停新任务放行</strong><span>运行中的任务不受影响;资源恢复后会继续调度。</span></div>`
: "") : "")
+ `<section class="capacity-hero" aria-label="容量摘要">` + `<section class="capacity-hero" aria-label="容量摘要">`
@ -323,6 +354,29 @@ function renderCapacityDrawer() {
+ `</div></section>`; + `</div></section>`;
} }
function renderCapacitySection() {
const root = $("s-sandbox-capacity");
if (!root) return;
const enabled = sandboxCapacityData && sandboxCapacityData.enabled !== false;
root.innerHTML = `<div class="card"><div class="card-head"><div><h2>执行容量</h2>`
+ `<div class="sublabel">队列、容器生命周期、单用户占用与宿主资源</div></div>`
+ `<button id="capacity-inline-open" type="button" ${enabled ? "" : "disabled"}>在侧栏查看</button></div>`
+ capacityContentHTML(sandboxCapacityData) + `</div>`;
if ($("capacity-inline-open")) $("capacity-inline-open").onclick = openCapacityDrawer;
}
function renderCapacityDrawer() {
const capacity = sandboxCapacityData;
const body = $("capacity-drawer-body");
const status = $("capacity-drawer-status");
if (!capacity || !body || !status) return;
const queued = (Number(capacity.foreground_queued) || 0) + (Number(capacity.background_queued) || 0);
status.className = `summary-badge ${capacity.memory_paused ? "danger" : (queued ? "warn" : "ok")}`;
status.textContent = capacity.enabled === false ? "未启用"
: (capacity.memory_paused ? "暂停放行" : (queued ? `排队 ${queued}` : "运行正常"));
body.innerHTML = capacityContentHTML(capacity);
}
function openCapacityDrawer() { function openCapacityDrawer() {
if (!sandboxCapacityData || sandboxCapacityData.enabled === false) return; if (!sandboxCapacityData || sandboxCapacityData.enabled === false) return;
capacityDrawerTrigger = document.activeElement; capacityDrawerTrigger = document.activeElement;
@ -344,7 +398,7 @@ function closeCapacityDrawer() {
document.body.classList.remove("drawer-open"); document.body.classList.remove("drawer-open");
const nextTrigger = capacityDrawerTrigger && document.contains(capacityDrawerTrigger) const nextTrigger = capacityDrawerTrigger && document.contains(capacityDrawerTrigger)
? capacityDrawerTrigger ? capacityDrawerTrigger
: document.querySelector('[data-summary-action="open-capacity"]'); : $("capacity-inline-open");
if (nextTrigger) nextTrigger.focus(); if (nextTrigger) nextTrigger.focus();
capacityDrawerTrigger = null; capacityDrawerTrigger = null;
} }
@ -381,6 +435,26 @@ function nodeStatusHTML(status) {
return `<span class="node-status ${value}">${labels[value]}</span>`; return `<span class="node-status ${value}">${labels[value]}</span>`;
} }
function renderUsageSection(d) {
const usage = (d.usage || {}).total || {};
const rows = (d.usage || {}).by_day_7d || [];
const input7d = rows.reduce((sum, row) => sum + (Number(row.tokens_in) || 0), 0);
const output7d = rows.reduce((sum, row) => sum + (Number(row.tokens_out) || 0), 0);
const cost7d = rows.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0);
const hitRate = Number(usage.tokens_in) ? Math.round((Number(usage.tokens_cache_hit) || 0) / Number(usage.tokens_in) * 100) : 0;
return `<div class="card"><div class="card-head"><div><h2>Token、缓存与成本</h2>`
+ `<div class="sublabel">顶部总览的完整指标口径</div></div></div><div class="detail-grid">`
+ detailStat("近 7 天 Token", fmtTokens(input7d + output7d))
+ detailStat("近 7 天输入", fmtTokens(input7d))
+ detailStat("近 7 天输出", fmtTokens(output7d))
+ detailStat("缓存命中率", `${hitRate}%`)
+ detailStat("近 7 天成本", fmtCNY(cost7d))
+ detailStat("累计成本", fmtCNY(usage.cost_cny))
+ detailStat("累计输入", fmtTokens(usage.tokens_in))
+ detailStat("累计输出", fmtTokens(usage.tokens_out))
+ `</div></div>` + renderByDay(rows);
}
function nodeCapabilityRuntimes(runtime) { function nodeCapabilityRuntimes(runtime) {
const entries = Object.entries(runtime.capability_runtime || {}) const entries = Object.entries(runtime.capability_runtime || {})
.filter(([, value]) => value && typeof value === "object"); .filter(([, value]) => value && typeof value === "object");
@ -920,7 +994,17 @@ function renderUserUsage(d) {
+ `<td title="${escapeHtml(fmtTime(r.last_used_at))}">${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}</td>` + `<td title="${escapeHtml(fmtTime(r.last_used_at))}">${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}</td>`
+ `</tr>`; + `</tr>`;
}).join("") || `<tr><td colspan="8" class="empty">无数据</td></tr>`; }).join("") || `<tr><td colspan="8" class="empty">无数据</td></tr>`;
$("s-users").innerHTML = `<div class="card">` const userOverview = (overviewData && overviewData.users) || {};
const userTotal = Number(userOverview.total) || 0;
const activeUsers = Number(userOverview.active_7d) || 0;
const activity = `<div class="card"><div class="card-head"><div><h2>用户活跃</h2>`
+ `<div class="sublabel">近 7 天口径,与顶部总览一致</div></div></div><div class="detail-grid">`
+ detailStat("近 7 天活跃", activeUsers)
+ detailStat("总用户", userTotal)
+ detailStat("活跃率", `${userTotal ? Math.round(activeUsers / userTotal * 100) : 0}%`)
+ detailStat("近 7 天未活跃", Math.max(0, userTotal - activeUsers))
+ `</div></div>`;
$("s-users").innerHTML = activity + `<div class="card">`
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>` + `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
+ tierLegendHTML() + tierLegendHTML()
+ `<div class="scroll-x"><table>` + `<div class="scroll-x"><table>`
@ -997,7 +1081,17 @@ function renderStorage(d) {
+ `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>` + `<td>${r.scanned_at ? fmtTime(r.scanned_at) : "—"}</td>`
+ `</tr>`; + `</tr>`;
}).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`; }).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`;
$("s-storage").innerHTML = `<div class="card"><h2>存储用量(${quotaLabel}</h2>` const totals = d.totals || {};
const overview = `<div class="card"><div class="card-head"><div><h2>存储概况</h2>`
+ `<div class="sublabel">物理占用包含配额内文件与产物回收站</div></div></div><div class="detail-grid">`
+ detailStat("物理总占用", humanSize(totals.physical_bytes || 0))
+ detailStat("配额内占用", humanSize(totals.bytes_used || 0))
+ detailStat("回收站", humanSize(totals.trash_bytes || 0))
+ detailStat("文件数", Number(totals.file_count) || 0)
+ detailStat("已扫描用户", Number(d.total) || 0)
+ detailStat("人均配额", quota && quota > 0 ? humanSize(quota) : "无限制")
+ `</div></div>`;
$("s-storage").innerHTML = overview + `<div class="card"><h2>存储用量(${quotaLabel}</h2>`
+ `<div class="scroll-x"><table>` + `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>配额占用</th><th>占配额</th><th>文件数</th><th>回收站</th><th>物理合计</th><th>扫描于</th></tr></thead>` + `<thead><tr><th>用户</th><th>配额占用</th><th>占配额</th><th>文件数</th><th>回收站</th><th>物理合计</th><th>扫描于</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>` + `<tbody>${body}</tbody></table></div>`
@ -1112,46 +1206,75 @@ function wirePager(prefix, page, maxPage, go) {
if (next) next.onclick = () => go(page + 1); if (next) next.onclick = () => go(page + 1);
} }
// ───── 骨架 + 目录 ───── // ───── 骨架 + 分组页签 ─────
function ensureSkeleton() { function ensureSkeleton() {
if ($("layout")) return; if ($("layout")) return;
$("main").innerHTML = `<div id="layout">` $("main").innerHTML = `<div id="layout">`
+ `<nav id="toc" aria-label="管理后台导航"><div class="toc-label">页面导航</div>` + SECTIONS.map(([id, label], index) => + `<div id="content"><section id="s-overview" class="page-intro anchor">`
`<a href="#${id}" data-target="${id}"><span class="nav-index">${String(index + 1).padStart(2, "0")}</span><span>${label}</span></a>`).join("") + `</nav>` + `<div class="overview-heading"><div><h1>系统总览</h1>`
+ `<div id="content"><section id="s-overview" class="page-intro anchor"><h1>系统运行概览</h1>` + `<p>运行状态与关键运营指标,每 10 秒自动更新</p></div>`
+ `<p>集中查看服务状态、资源用量与外部能力,数据默认每 10 秒自动更新。</p></section>` + `<span id="overview-health" class="summary-badge overview-health">加载中</span></div>`
+ `<section id="ops-summary" class="ops-summary" aria-label="系统运营摘要"></section>` + SECTIONS.slice(1).map(([id]) => + `<section id="ops-summary" class="ops-summary" aria-label="系统运营摘要"></section></section>`
`<div id="${id}" class="anchor"></div>`).join("") + `</div>` + `<div class="workspace"><nav id="workspace-tabs" class="workspace-tabs" role="tablist" aria-label="管理工作区">`
+ TABS.map(([id, label]) => `<button id="tab-${id}" class="workspace-tab" type="button" role="tab" `
+ `aria-controls="panel-${id}" aria-selected="false" data-tab="${id}">${label}</button>`).join("")
+ `</nav><div id="tab-panels">`
+ tabPanel("run", `<div id="s-runtime" class="detail-anchor"></div><div id="s-sandbox-capacity" class="detail-anchor"></div>`)
+ tabPanel("usage", `<div id="s-usage" class="detail-anchor"></div><div id="s-models" class="detail-anchor"></div>`)
+ tabPanel("users", `<div id="s-users" class="detail-anchor"></div>`)
+ tabPanel("resources", `<div id="s-storage" class="detail-anchor"></div><div id="s-sandbox-packages" class="detail-anchor"></div><div id="s-windows-node" class="detail-anchor"></div>`)
+ tabPanel("integrations", `<div id="s-provider-credentials" class="detail-anchor"></div><div id="s-external" class="detail-anchor"></div>`)
+ tabPanel("quality", `<div id="s-toolfail" class="detail-anchor"></div>`)
+ `</div></div></div>`
+ `</div>`; + `</div>`;
renderOpsSummary(); document.querySelectorAll(".workspace-tab").forEach(button => {
document.querySelectorAll("#toc a").forEach(a => { button.onclick = () => activateTab(button.dataset.tab);
a.onclick = (e) => {
e.preventDefault();
const el = $(a.dataset.target);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
}); });
setupScrollSpy(); const initial = parseTabHash();
activateTab(initial.tab, initial.target, { updateHash: false, scroll: false });
renderOpsSummary();
} }
// 滚动高亮当前目录项(IntersectionObserver,粗粒度即可) function tabPanel(id, content) {
function setupScrollSpy() { const tab = TABS.find(item => item[0] === id);
const links = {}; return `<section id="panel-${id}" class="tab-panel" role="tabpanel" aria-labelledby="tab-${id}" hidden>`
document.querySelectorAll("#toc a").forEach(a => { links[a.dataset.target] = a; }); + `<div class="tab-intro"><h2>${escapeHtml(tab[1])}</h2><p>${escapeHtml(tab[2])}</p></div>`
const obs = new IntersectionObserver((entries) => { + content + `</section>`;
entries.forEach(en => { }
if (!en.isIntersecting) return;
Object.values(links).forEach(l => l.classList.remove("active")); function parseTabHash() {
if (links[en.target.id]) links[en.target.id].classList.add("active"); const raw = decodeURIComponent(location.hash.replace(/^#/, ""));
}); const [tab, target = ""] = raw.split("/");
}, { rootMargin: "-70px 0px -65% 0px", threshold: 0 }); return TABS.some(item => item[0] === tab) ? { tab, target } : { tab: "run", target: "" };
SECTIONS.forEach(([id]) => { const el = $(id); if (el) obs.observe(el); }); }
function activateTab(tab, target = "", options = {}) {
if (!TABS.some(item => item[0] === tab)) tab = "run";
document.querySelectorAll(".workspace-tab").forEach(button => {
const selected = button.dataset.tab === tab;
button.setAttribute("aria-selected", selected ? "true" : "false");
button.tabIndex = selected ? 0 : -1;
});
document.querySelectorAll(".tab-panel").forEach(panel => {
panel.hidden = panel.id !== `panel-${tab}`;
});
const selectedButton = $(`tab-${tab}`);
if (selectedButton) selectedButton.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
const nextHash = `#${tab}${target ? `/${target}` : ""}`;
if (options.updateHash !== false && location.hash !== nextHash) history.pushState(null, "", nextHash);
if (target && options.scroll !== false) requestAnimationFrame(() => {
const el = $(target);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.remove("focus-pulse");
requestAnimationFrame(() => el.classList.add("focus-pulse"));
});
} }
function renderMetrics(d) { function renderMetrics(d) {
overviewData = d; overviewData = d;
$("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : ""; $("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : "";
$("s-usage").innerHTML = renderByDay((d.usage || {}).by_day_7d); $("s-usage").innerHTML = renderUsageSection(d);
renderWindowsNodes(); renderWindowsNodes();
renderOpsSummary(); renderOpsSummary();
} }
@ -1537,6 +1660,29 @@ document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("capacity-drawer").classList.contains("show")) closeCapacityDrawer(); if (e.key === "Escape" && $("capacity-drawer").classList.contains("show")) closeCapacityDrawer();
else if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) closeNodeEnrollmentModal(); else if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) closeNodeEnrollmentModal();
}); });
document.addEventListener("keydown", (e) => {
const current = e.target.closest && e.target.closest(".workspace-tab");
if (!current || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
e.preventDefault();
const buttons = [...document.querySelectorAll(".workspace-tab")];
let index = buttons.indexOf(current);
if (e.key === "Home") index = 0;
else if (e.key === "End") index = buttons.length - 1;
else index = (index + (e.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length;
buttons[index].focus();
activateTab(buttons[index].dataset.tab);
});
window.addEventListener("popstate", () => {
if (!$("workspace-tabs")) return;
const state = parseTabHash();
activateTab(state.tab, state.target, { updateHash: false });
});
const compactHeader = window.matchMedia("(max-width: 820px)");
function syncActionMenu() {
$("action-menu").open = !compactHeader.matches;
}
compactHeader.addEventListener("change", syncActionMenu);
syncActionMenu();
// 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求) // 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求)
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.hidden) stopAuto(); if (document.hidden) stopAuto();