diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py
index 595b469..13bf1e4 100644
--- a/tests/test_static_vendor.py
+++ b/tests/test_static_vendor.py
@@ -72,7 +72,7 @@ class StaticVendorTests(unittest.TestCase):
admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8")
self.assertIn('id="node-enrollment-modal" class="modal"', html)
- self.assertIn("生成 Windows Node 注册码", html)
+ self.assertIn("生成专业软件节点注册码", html)
self.assertIn('"/v1/admin/software-node-enrollments"', admin_js)
self.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js)
self.assertIn("节点能力由客户端自动发现并上报", html)
@@ -103,11 +103,11 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('id="ops-summary" class="ops-summary"', admin_js)
self.assertIn(".ops-summary {", html)
labels = (
- "运行容量",
+ "容器状态",
"任务状态",
"活跃用户",
"Token 与缓存",
- "Windows Node",
+ "专业软件节点",
"存储",
"成本",
"工具健康",
@@ -129,6 +129,20 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('apiGet("/v1/admin/storage/users?page=0&page_size=1")', admin_js)
self.assertIn('target: "s-windows-node"', admin_js)
self.assertIn('target: "s-toolfail"', admin_js)
+ 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("containerStatusHTML(capacity)", admin_js)
+
+ def test_changelog_loads_incremental_pages(self) -> None:
+ source = (JS_DIR / "changelog.js").read_text(encoding="utf-8")
+
+ self.assertIn("const PAGE_SIZE = 5", source)
+ self.assertIn("offset=${_nextOffset}", source)
+ self.assertIn("d.has_more", source)
+ self.assertIn("加载更多", source)
+ self.assertNotIn("/v1/changelog?limit=50", source)
def test_dev_html_uses_local_markdown_vendor_assets(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
diff --git a/tests/test_web_routes_nodb.py b/tests/test_web_routes_nodb.py
index 2a4ad85..6be0709 100644
--- a/tests/test_web_routes_nodb.py
+++ b/tests/test_web_routes_nodb.py
@@ -73,11 +73,17 @@ class PublicEndpointTests(unittest.TestCase):
self.assertIn("brand", body)
def test_changelog_public(self):
- r = _client.get("/v1/changelog?limit=2")
+ r = _client.get("/v1/changelog?limit=2&offset=0")
self.assertEqual(r.status_code, 200)
- self.assertIn("entries", r.json())
+ first = r.json()
+ self.assertEqual(len(first["entries"]), 2)
+ self.assertGreaterEqual(first["total"], 2)
+ self.assertEqual(first["next_offset"], 2)
+ second = _client.get("/v1/changelog?limit=2&offset=2").json()
+ self.assertNotEqual(first["entries"][0]["version"], second["entries"][0]["version"])
# limit 越界 clamp,不 500
self.assertEqual(_client.get("/v1/changelog?limit=9999").status_code, 200)
+ self.assertEqual(_client.get("/v1/changelog?offset=-1").status_code, 200)
def test_root_redirects_to_dev_spa(self):
r = _client.get("/", follow_redirects=False)
diff --git a/web/routers/misc.py b/web/routers/misc.py
index 2a5b9bb..176ee6e 100644
--- a/web/routers/misc.py
+++ b/web/routers/misc.py
@@ -53,7 +53,7 @@ def register_misc_routes(app, *, require_user) -> None:
"brand": BRAND}
@app.get("/v1/changelog", tags=["misc"])
- def changelog(limit: int = 20):
+ def changelog(limit: int = 20, offset: int = 0):
"""用户版更新日志(仓库根 CHANGELOG.md 解析,新在前)。
公开端点(无鉴权):内容本就是写给用户看的,登录页脚也展示版本号。
@@ -61,15 +61,26 @@ def register_misc_routes(app, *, require_user) -> None:
前端:点左栏底部版本号弹层展示,配 localStorage 红点提示新版本。
"""
limit = max(1, min(limit, 100))
+ offset = max(0, offset)
try:
mtime = _CHANGELOG_PATH.stat().st_mtime
except OSError:
- return {"current": __version__, "entries": []}
+ return {"current": __version__, "entries": [], "total": 0,
+ "has_more": False, "next_offset": offset}
if _changelog_cache["mtime"] != mtime:
_changelog_cache["entries"] = _parse_changelog(
_CHANGELOG_PATH.read_text(encoding="utf-8"))
_changelog_cache["mtime"] = mtime
- return {"current": __version__, "entries": _changelog_cache["entries"][:limit]}
+ entries = _changelog_cache["entries"]
+ page = entries[offset:offset + limit]
+ next_offset = offset + len(page)
+ return {
+ "current": __version__,
+ "entries": page,
+ "total": len(entries),
+ "has_more": next_offset < len(entries),
+ "next_offset": next_offset,
+ }
@app.get("/WW_verify_{token}.txt", include_in_schema=False)
def wecom_domain_verify(token: str):
diff --git a/web/static/admin.html b/web/static/admin.html
index de393fd..14d837b 100644
--- a/web/static/admin.html
+++ b/web/static/admin.html
@@ -425,7 +425,7 @@
-
生成 Windows Node 注册码
+ 生成专业软件节点注册码
`;
}
+function containerStatusHTML(capacity) {
+ if (!capacity) return "";
+ const statusGroup = (label, items) => `
${label}`
+ + `
${items.map(([name, value, cls = ""]) => `${name} ${Number(value) || 0}`).join("")}
`;
+ 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;
@@ -144,10 +156,17 @@ function renderOpsSummary() {
const usage = (d.usage || {}).total || {};
const byDay = (d.usage || {}).by_day_7d || [];
- const active = Number(runtime.active_runs) || 0;
- const maxWorkers = Number(runtime.max_workers) || 0;
- const runPct = maxWorkers ? Math.round(active / maxWorkers * 100) : 0;
- const runTone = levelClass(maxWorkers ? active / maxWorkers : 0);
+ const capacity = sandboxCapacityData;
+ const capacityEnabled = capacity && capacity.enabled !== false;
+ const capacityLimits = (capacity && capacity.limits) || {};
+ const activeExecs = capacityEnabled
+ ? (Number(capacity.foreground_running) || 0) + (Number(capacity.background_running) || 0)
+ : 0;
+ const activeLimit = Number(capacityLimits.active) || 0;
+ const capacityPct = activeLimit ? Math.round(activeExecs / activeLimit * 100) : 0;
+ const capacityTone = capacityEnabled && capacity.memory_paused
+ ? "danger"
+ : levelClass(activeLimit ? activeExecs / activeLimit : 0);
const nodeTotal = softwareNodes.length;
const nodeOnline = softwareNodes.filter(node => node.status === "online").length;
@@ -203,14 +222,20 @@ function renderOpsSummary() {
const events = Number(usage.n_events) || 0;
const cost = Number(usage.cost_cny) || 0;
const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0);
- const rss = runtime.rss_peak_mb != null ? `${Math.round(runtime.rss_peak_mb)} MB` : "—";
-
root.innerHTML = [
summaryCard({
- label: "运行容量", value: maxWorkers ? `${active} / ${maxWorkers}` : String(active), unit: maxWorkers ? "活跃 / 上限" : "活跃 run",
- sub: maxWorkers ? `并发占用 ${runPct}% · SSE ${runtime.sse_subs || 0}` : `SSE ${runtime.sse_subs || 0}`,
- meta: [maxWorkers ? `空闲 ${Math.max(0, maxWorkers - active)}` : "并发上限未设置", `内存峰值 ${rss}`],
- tone: runTone, progress: maxWorkers ? runPct : null,
+ label: "容器状态",
+ value: !capacity ? "—" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"),
+ unit: capacityEnabled ? "执行中 / 上限" : "",
+ body: capacityEnabled ? containerStatusHTML(capacity) : "",
+ sub: !capacity ? "正在读取容器状态" : (capacityEnabled
+ ? `后台上限 ${capacityLimits.background || 0} · 单用户上限 ${capacityLimits.per_user || 0}${capacity.memory_paused ? " · 内存压力暂停放行" : ""}`
+ : "Docker Sandbox 未启用"),
+ meta: capacityEnabled
+ ? [`当前可放行 ${capacity.admit_available || 0}`, `MemAvailable ${humanSize(capacity.mem_available_bytes || 0)} · CPU ${(capacity.cpu_load || []).join(" / ") || "—"}`]
+ : ["", ""],
+ tone: capacityTone, progress: capacityEnabled && activeLimit ? capacityPct : null,
+ extraClass: "task-summary",
}),
summaryCard({
label: "任务状态", value: `${runningTasks} / ${tasks.total || 0}`, unit: "运行中 / 总数",
@@ -226,7 +251,7 @@ function renderOpsSummary() {
meta: [`缓存命中 ${hitRate}%`, `命中 ${fmtTokens(cacheHit)}`], progress: hitRate, extraClass: "token-summary",
}),
summaryCard({
- target: "s-windows-node", label: "Windows Node", value: softwareNodesLoaded ? `${nodeJobs} / ${nodeOnline}` : "—", unit: "活动任务 / 在线节点",
+ target: "s-windows-node", label: "专业软件节点", value: softwareNodesLoaded ? `${nodeJobs} / ${nodeOnline}` : "—", unit: "活动任务 / 在线节点",
sub: softwareNodesLoaded ? `节点总数 ${nodeTotal}` : "正在读取节点状态",
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : ["加载中", ""], tone: nodeTone,
}),
@@ -334,11 +359,11 @@ function renderWindowsNodes() {
+ `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"} `
+ `
`
+ ``;
- }).join("") || `
| 尚无已注册的 Windows Node |
`;
+ }).join("") || `
| 尚无已注册的专业软件节点 |
`;
$("s-windows-node").innerHTML = `
`
- + `
Windows Node(${softwareNodes.length})
查看节点状态;禁用会立即断开节点并拒绝后续连接。
`
- + `
`
+ + `
专业软件节点(${softwareNodes.length})
查看节点状态;禁用会立即断开节点并拒绝后续连接。
`
+ + `
`
+ `
`;
$("node-enrollment-open").onclick = openNodeEnrollmentModal;
@@ -356,7 +381,7 @@ async function toggleSoftwareNode(button) {
if (!node) return;
const disabling = button.dataset.nodeToggle === "disable";
const confirmed = await dialogConfirm({
- title: disabling ? "禁用 Windows Node" : "重新启用 Windows Node",
+ title: disabling ? "禁用专业软件节点" : "重新启用专业软件节点",
message: disabling
? `禁用「${node.name}」?在线连接会立即断开,之后不能接收任务。`
: `重新启用「${node.name}」?启用后需在该电脑托盘菜单点击“立即重连”。`,
@@ -382,7 +407,7 @@ async function deleteSoftwareNode(button) {
const node = softwareNodes.find(item => item.node_id === row?.dataset.nodeId);
if (!node) return;
const confirmed = await dialogConfirm({
- title: "删除 Windows Node",
+ title: "删除专业软件节点",
message: `永久删除「${node.name}」?节点会立即断开,本机现有身份失效;如需再次使用,必须清除本机身份并用新注册码重新注册。`,
okText: "永久删除",
danger: true,
@@ -1054,28 +1079,6 @@ function renderMetrics(d) {
renderOpsSummary();
}
-function renderSandboxCapacity(d) {
- if (!d.enabled) {
- $("s-sandbox-capacity").innerHTML = `
Sandbox 实时容量
Docker Sandbox 未启用。
`;
- return;
- }
- const lim = d.limits || {};
- const users = Object.entries(d.per_user || {}).sort((a,b) => b[1]-a[1])
- .map(([u,n]) => `${escapeHtml(u.slice(0,8))}: ${n}`).join(" · ") || "无";
- $("s-sandbox-capacity").innerHTML = `
Sandbox 实时容量
`
- + `
${d.foreground_running || 0}前台执行
`
- + `
${d.foreground_queued || 0}前台排队
`
- + `
${d.background_running || 0}后台执行
`
- + `
${d.background_queued || 0}后台排队
`
- + `
${d.admit_available || 0}当前可放行
`
- + `
${d.active_sandbox_containers || 0}活跃普通容器
`
- + `
${d.idle_reap_candidates || 0}空闲待回收
`
- + `
${d.running_proc_containers || 0}运行中 proc
`
- + `
硬上限 ${lim.active || 0} · 后台 ${lim.background || 0} · 单用户 ${lim.per_user || 0}`
- + ` · MemAvailable ${humanSize(d.mem_available_bytes || 0)} · CPU load ${(d.cpu_load || []).join(" / ") || "—"}`
- + `${d.memory_paused ? " · 内存压力暂停放行" : ""}
单用户占用:${users}
`;
-}
-
function renderSandboxPackages(d) {
const rows = d.rows || [];
const opts = RANGE_OPTS.map(([v,l]) => `
`).join("");
@@ -1083,7 +1086,7 @@ function renderSandboxPackages(d) {
+ `
${r.session_count} | ${r.user_count} | ${r.foreground_sessions}/${r.background_sessions} | `
+ `
${r.direct_sessions} | ${humanSize(r.average_installed_bytes)} | ${humanSize(r.total_installed_bytes)} | `
+ `
${escapeHtml((r.changes || []).join("/"))} ${fmtTimeAgo(r.latest_at)} | `).join("");
- $("s-sandbox-packages").innerHTML = `
Sandbox 依赖统计
`
+ $("s-sandbox-packages").innerHTML = `
容器依赖统计
`
+ `只分析临时安装,不自动修改基础镜像;共 ${d.scan_sessions || 0} 个安装会话。
`
+ `
| 包名 | 版本 | 安装会话 | 独立用户 | 前台/后台 | 直接安装会话 | 平均占用 | 累计占用 | 基础镜像状态 / 最近 |
${body || `| 暂无临时依赖记录 |
`}
`;
const select = $("sandbox-package-range");
@@ -1238,7 +1241,10 @@ async function loadSoftwareNodes() {
}
async function loadSandboxCapacity() {
- try { renderSandboxCapacity(await apiGet("/v1/admin/sandbox/capacity")); } catch (e) { /* overview 统一处理 */ }
+ try {
+ sandboxCapacityData = await apiGet("/v1/admin/sandbox/capacity");
+ renderOpsSummary();
+ } catch (e) { /* overview 统一处理 */ }
}
async function loadSandboxPackages() {
try { renderSandboxPackages(await apiGet(`/v1/admin/sandbox/packages?range=${packageRange}`)); } catch (e) { /* overview 统一处理 */ }
diff --git a/web/static/js/changelog.js b/web/static/js/changelog.js
index 337fde7..81bf465 100644
--- a/web/static/js/changelog.js
+++ b/web/static/js/changelog.js
@@ -6,7 +6,13 @@ import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
const SEEN_KEY = "zcbot_seen_version";
+const PAGE_SIZE = 5;
let _current = ""; // /healthz 返回的当前版本(main.js loadVersion 灌入)
+let _entries = [];
+let _nextOffset = 0;
+let _hasMore = false;
+let _loading = false;
+let _loadError = "";
// main.js loadVersion 拿到版本号后调:决定红点亮不亮(localStorage 不可用时静默不亮)
export function markVersion(v) {
@@ -22,31 +28,21 @@ export function closeChangelogModal() {
$("changelog-modal").classList.remove("show");
}
-async function openChangelogModal() {
+function renderChangelog() {
const body = $("cl-body");
- $("changelog-modal").classList.add("show");
- body.innerHTML = '
加载中…
';
- // 打开即视为"看过":记住当前版本 + 清红点
- if (_current) {
- try { localStorage.setItem(SEEN_KEY, _current); } catch (e) {}
- $("app-version").classList.remove("hasnew");
- }
- let d;
- try {
- const r = await fetch("/v1/changelog?limit=50");
- if (!r.ok) throw new Error("HTTP " + r.status);
- d = await r.json();
- } catch (e) {
- body.innerHTML = `
加载失败: ${escapeHtml(e.message)}
`;
+ if (!_entries.length && _loading) {
+ body.innerHTML = '
加载中…
';
return;
}
- $("cl-cur").textContent = d.current ? "当前 v" + d.current : "";
- const entries = d.entries || [];
- if (!entries.length) {
+ if (!_entries.length && _loadError) {
+ body.innerHTML = `
加载失败: ${escapeHtml(_loadError)}
`;
+ return;
+ }
+ if (!_entries.length) {
body.innerHTML = '
暂无更新说明。
';
return;
}
- body.innerHTML = entries.map((e, i) => `
+ body.innerHTML = _entries.map((e, i) => `
v${escapeHtml(e.version)}
@@ -54,8 +50,50 @@ async function openChangelogModal() {
${e.date ? `${escapeHtml(e.date)}` : ""}
${renderMd(e.body_md)}
-
`).join("");
+
`).join("")
+ + (_loadError ? `
加载失败: ${escapeHtml(_loadError)}
` : "")
+ + (_hasMore ? `
` : "");
highlightIn(body);
+ const more = $("cl-more");
+ if (more) more.onclick = () => loadChangelogPage();
+}
+
+async function loadChangelogPage(reset = false) {
+ if (_loading) return;
+ if (reset) {
+ _entries = [];
+ _nextOffset = 0;
+ _hasMore = false;
+ }
+ _loading = true;
+ _loadError = "";
+ renderChangelog();
+ try {
+ const r = await fetch(`/v1/changelog?limit=${PAGE_SIZE}&offset=${_nextOffset}`);
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ const d = await r.json();
+ $("cl-cur").textContent = d.current ? "当前 v" + d.current : "";
+ const page = d.entries || [];
+ _entries = _entries.concat(page);
+ _nextOffset = Number.isInteger(d.next_offset) ? d.next_offset : _nextOffset + page.length;
+ _hasMore = !!d.has_more;
+ } catch (e) {
+ _loadError = e.message || String(e);
+ _hasMore = _entries.length > 0;
+ } finally {
+ _loading = false;
+ renderChangelog();
+ }
+}
+
+function openChangelogModal() {
+ $("changelog-modal").classList.add("show");
+ // 打开即视为"看过":记住当前版本 + 清红点
+ if (_current) {
+ try { localStorage.setItem(SEEN_KEY, _current); } catch (e) {}
+ $("app-version").classList.remove("hasnew");
+ }
+ loadChangelogPage(true);
}
// ───── 顶层绑定 ─────