feat(web): 收敛管理页并分页加载更新日志
This commit is contained in:
parent
605e4eecfe
commit
572da9a365
|
|
@ -72,7 +72,7 @@ class StaticVendorTests(unittest.TestCase):
|
||||||
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="node-enrollment-modal" class="modal"', html)
|
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.assertIn('"/v1/admin/software-node-enrollments"', admin_js)
|
||||||
self.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js)
|
self.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js)
|
||||||
self.assertIn("节点能力由客户端自动发现并上报", html)
|
self.assertIn("节点能力由客户端自动发现并上报", html)
|
||||||
|
|
@ -103,11 +103,11 @@ class StaticVendorTests(unittest.TestCase):
|
||||||
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)
|
||||||
labels = (
|
labels = (
|
||||||
"运行容量",
|
"容器状态",
|
||||||
"任务状态",
|
"任务状态",
|
||||||
"活跃用户",
|
"活跃用户",
|
||||||
"Token 与缓存",
|
"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('apiGet("/v1/admin/storage/users?page=0&page_size=1")', admin_js)
|
||||||
self.assertIn('target: "s-windows-node"', admin_js)
|
self.assertIn('target: "s-windows-node"', admin_js)
|
||||||
self.assertIn('target: "s-toolfail"', 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:
|
def test_dev_html_uses_local_markdown_vendor_assets(self) -> None:
|
||||||
html = DEV_HTML.read_text(encoding="utf-8")
|
html = DEV_HTML.read_text(encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -73,11 +73,17 @@ class PublicEndpointTests(unittest.TestCase):
|
||||||
self.assertIn("brand", body)
|
self.assertIn("brand", body)
|
||||||
|
|
||||||
def test_changelog_public(self):
|
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.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
|
# limit 越界 clamp,不 500
|
||||||
self.assertEqual(_client.get("/v1/changelog?limit=9999").status_code, 200)
|
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):
|
def test_root_redirects_to_dev_spa(self):
|
||||||
r = _client.get("/", follow_redirects=False)
|
r = _client.get("/", follow_redirects=False)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ def register_misc_routes(app, *, require_user) -> None:
|
||||||
"brand": BRAND}
|
"brand": BRAND}
|
||||||
|
|
||||||
@app.get("/v1/changelog", tags=["misc"])
|
@app.get("/v1/changelog", tags=["misc"])
|
||||||
def changelog(limit: int = 20):
|
def changelog(limit: int = 20, offset: int = 0):
|
||||||
"""用户版更新日志(仓库根 CHANGELOG.md 解析,新在前)。
|
"""用户版更新日志(仓库根 CHANGELOG.md 解析,新在前)。
|
||||||
|
|
||||||
公开端点(无鉴权):内容本就是写给用户看的,登录页脚也展示版本号。
|
公开端点(无鉴权):内容本就是写给用户看的,登录页脚也展示版本号。
|
||||||
|
|
@ -61,15 +61,26 @@ def register_misc_routes(app, *, require_user) -> None:
|
||||||
前端:点左栏底部版本号弹层展示,配 localStorage 红点提示新版本。
|
前端:点左栏底部版本号弹层展示,配 localStorage 红点提示新版本。
|
||||||
"""
|
"""
|
||||||
limit = max(1, min(limit, 100))
|
limit = max(1, min(limit, 100))
|
||||||
|
offset = max(0, offset)
|
||||||
try:
|
try:
|
||||||
mtime = _CHANGELOG_PATH.stat().st_mtime
|
mtime = _CHANGELOG_PATH.stat().st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
return {"current": __version__, "entries": []}
|
return {"current": __version__, "entries": [], "total": 0,
|
||||||
|
"has_more": False, "next_offset": offset}
|
||||||
if _changelog_cache["mtime"] != mtime:
|
if _changelog_cache["mtime"] != mtime:
|
||||||
_changelog_cache["entries"] = _parse_changelog(
|
_changelog_cache["entries"] = _parse_changelog(
|
||||||
_CHANGELOG_PATH.read_text(encoding="utf-8"))
|
_CHANGELOG_PATH.read_text(encoding="utf-8"))
|
||||||
_changelog_cache["mtime"] = mtime
|
_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)
|
@app.get("/WW_verify_{token}.txt", include_in_schema=False)
|
||||||
def wecom_domain_verify(token: str):
|
def wecom_domain_verify(token: str):
|
||||||
|
|
|
||||||
|
|
@ -425,7 +425,7 @@
|
||||||
<div id="node-enrollment-modal" class="modal" aria-hidden="true">
|
<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="card" role="dialog" aria-modal="true" aria-labelledby="node-enrollment-title">
|
||||||
<div class="node-modal-head">
|
<div class="node-modal-head">
|
||||||
<h3 id="node-enrollment-title">生成 Windows Node 注册码</h3>
|
<h3 id="node-enrollment-title">生成专业软件节点注册码</h3>
|
||||||
<button id="node-enrollment-close" type="button" aria-label="关闭">×</button>
|
<button id="node-enrollment-close" type="button" aria-label="关闭">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="node-enrollment-form">
|
<form id="node-enrollment-form">
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,14 @@
|
||||||
.cl-md { font-size: 13px; }
|
.cl-md { font-size: 13px; }
|
||||||
.cl-md ul { margin: 4px 0; padding-left: 18px; }
|
.cl-md ul { margin: 4px 0; padding-left: 18px; }
|
||||||
.cl-md li { margin: 2px 0; }
|
.cl-md li { margin: 2px 0; }
|
||||||
|
.cl-more { display: flex; justify-content: center; padding: 14px 0 4px; }
|
||||||
|
.cl-more button {
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; padding: 6px 18px;
|
||||||
|
background: var(--surface); color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.cl-more button:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.cl-more button:disabled { cursor: default; opacity: .55; }
|
||||||
|
.cl-load-error { color: var(--danger); font-size: 12px; text-align: center; padding-top: 10px; }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
#changelog-modal .card { width: 96vw; max-height: 88vh; }
|
#changelog-modal .card { width: 96vw; max-height: 88vh; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
["s-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"],
|
["s-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"],
|
||||||
["s-users", "各用户"], ["s-storage", "存储"],
|
["s-users", "各用户"], ["s-storage", "存储"],
|
||||||
["s-sandbox-capacity", "Sandbox 容量"], ["s-sandbox-packages", "Sandbox 依赖"],
|
["s-sandbox-packages", "容器依赖"],
|
||||||
["s-windows-node", "Windows Node"],
|
["s-windows-node", "专业软件节点"],
|
||||||
["s-external", "外部系统"],
|
["s-external", "外部系统"],
|
||||||
["s-toolfail", "工具失败"],
|
["s-toolfail", "工具失败"],
|
||||||
];
|
];
|
||||||
|
|
@ -60,6 +60,7 @@ let externalEditingId = "";
|
||||||
let softwareNodes = [];
|
let softwareNodes = [];
|
||||||
let softwareNodesLoaded = false;
|
let softwareNodesLoaded = false;
|
||||||
let overviewData = null;
|
let overviewData = null;
|
||||||
|
let sandboxCapacityData = null;
|
||||||
let storageSummaryData = null;
|
let storageSummaryData = null;
|
||||||
let toolFailuresData = null;
|
let toolFailuresData = null;
|
||||||
let toolWireData = null;
|
let toolWireData = null;
|
||||||
|
|
@ -134,6 +135,17 @@ function tokenSummaryHTML(tokens7d, totalTokens) {
|
||||||
+ `</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() {
|
function renderOpsSummary() {
|
||||||
const root = $("ops-summary");
|
const root = $("ops-summary");
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
@ -144,10 +156,17 @@ function renderOpsSummary() {
|
||||||
const usage = (d.usage || {}).total || {};
|
const usage = (d.usage || {}).total || {};
|
||||||
const byDay = (d.usage || {}).by_day_7d || [];
|
const byDay = (d.usage || {}).by_day_7d || [];
|
||||||
|
|
||||||
const active = Number(runtime.active_runs) || 0;
|
const capacity = sandboxCapacityData;
|
||||||
const maxWorkers = Number(runtime.max_workers) || 0;
|
const capacityEnabled = capacity && capacity.enabled !== false;
|
||||||
const runPct = maxWorkers ? Math.round(active / maxWorkers * 100) : 0;
|
const capacityLimits = (capacity && capacity.limits) || {};
|
||||||
const runTone = levelClass(maxWorkers ? active / maxWorkers : 0);
|
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 nodeTotal = softwareNodes.length;
|
||||||
const nodeOnline = softwareNodes.filter(node => node.status === "online").length;
|
const nodeOnline = softwareNodes.filter(node => node.status === "online").length;
|
||||||
|
|
@ -203,14 +222,20 @@ function renderOpsSummary() {
|
||||||
const events = Number(usage.n_events) || 0;
|
const events = Number(usage.n_events) || 0;
|
||||||
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);
|
||||||
const rss = runtime.rss_peak_mb != null ? `${Math.round(runtime.rss_peak_mb)} MB` : "—";
|
|
||||||
|
|
||||||
root.innerHTML = [
|
root.innerHTML = [
|
||||||
summaryCard({
|
summaryCard({
|
||||||
label: "运行容量", value: maxWorkers ? `${active} / ${maxWorkers}` : String(active), unit: maxWorkers ? "活跃 / 上限" : "活跃 run",
|
label: "容器状态",
|
||||||
sub: maxWorkers ? `并发占用 ${runPct}% · SSE ${runtime.sse_subs || 0}` : `SSE ${runtime.sse_subs || 0}`,
|
value: !capacity ? "—" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"),
|
||||||
meta: [maxWorkers ? `空闲 ${Math.max(0, maxWorkers - active)}` : "并发上限未设置", `内存峰值 ${rss}`],
|
unit: capacityEnabled ? "执行中 / 上限" : "",
|
||||||
tone: runTone, progress: maxWorkers ? runPct : null,
|
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({
|
summaryCard({
|
||||||
label: "任务状态", value: `${runningTasks} / ${tasks.total || 0}`, unit: "运行中 / 总数",
|
label: "任务状态", value: `${runningTasks} / ${tasks.total || 0}`, unit: "运行中 / 总数",
|
||||||
|
|
@ -226,7 +251,7 @@ function renderOpsSummary() {
|
||||||
meta: [`缓存命中 ${hitRate}%`, `命中 ${fmtTokens(cacheHit)}`], progress: hitRate, extraClass: "token-summary",
|
meta: [`缓存命中 ${hitRate}%`, `命中 ${fmtTokens(cacheHit)}`], progress: hitRate, extraClass: "token-summary",
|
||||||
}),
|
}),
|
||||||
summaryCard({
|
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}` : "正在读取节点状态",
|
sub: softwareNodesLoaded ? `节点总数 ${nodeTotal}` : "正在读取节点状态",
|
||||||
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : ["加载中", ""], tone: nodeTone,
|
meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : ["加载中", ""], tone: nodeTone,
|
||||||
}),
|
}),
|
||||||
|
|
@ -334,11 +359,11 @@ function renderWindowsNodes() {
|
||||||
+ `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"}</button> `
|
+ `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"}</button> `
|
||||||
+ `<button type="button" data-node-delete class="danger">删除</button></td>`
|
+ `<button type="button" data-node-delete class="danger">删除</button></td>`
|
||||||
+ `</tr>`;
|
+ `</tr>`;
|
||||||
}).join("") || `<tr><td colspan="6" class="empty">尚无已注册的 Windows Node</td></tr>`;
|
}).join("") || `<tr><td colspan="6" class="empty">尚无已注册的专业软件节点</td></tr>`;
|
||||||
|
|
||||||
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
|
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
|
||||||
+ `<div><h2>Windows Node(${softwareNodes.length})</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
|
+ `<div><h2>专业软件节点(${softwareNodes.length})</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
|
||||||
+ `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>`
|
+ `<button id="node-enrollment-open" class="primary" type="button">生成专业软件节点注册码</button>`
|
||||||
+ `</div><div class="scroll-x"><table class="node-table"><thead><tr><th>节点 / Node 版本</th><th>状态</th>`
|
+ `</div><div class="scroll-x"><table class="node-table"><thead><tr><th>节点 / Node 版本</th><th>状态</th>`
|
||||||
+ `<th>可用能力 / 软件版本</th><th>活动任务</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
|
+ `<th>可用能力 / 软件版本</th><th>活动任务</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
|
||||||
$("node-enrollment-open").onclick = openNodeEnrollmentModal;
|
$("node-enrollment-open").onclick = openNodeEnrollmentModal;
|
||||||
|
|
@ -356,7 +381,7 @@ async function toggleSoftwareNode(button) {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const disabling = button.dataset.nodeToggle === "disable";
|
const disabling = button.dataset.nodeToggle === "disable";
|
||||||
const confirmed = await dialogConfirm({
|
const confirmed = await dialogConfirm({
|
||||||
title: disabling ? "禁用 Windows Node" : "重新启用 Windows Node",
|
title: disabling ? "禁用专业软件节点" : "重新启用专业软件节点",
|
||||||
message: disabling
|
message: disabling
|
||||||
? `禁用「${node.name}」?在线连接会立即断开,之后不能接收任务。`
|
? `禁用「${node.name}」?在线连接会立即断开,之后不能接收任务。`
|
||||||
: `重新启用「${node.name}」?启用后需在该电脑托盘菜单点击“立即重连”。`,
|
: `重新启用「${node.name}」?启用后需在该电脑托盘菜单点击“立即重连”。`,
|
||||||
|
|
@ -382,7 +407,7 @@ async function deleteSoftwareNode(button) {
|
||||||
const node = softwareNodes.find(item => item.node_id === row?.dataset.nodeId);
|
const node = softwareNodes.find(item => item.node_id === row?.dataset.nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const confirmed = await dialogConfirm({
|
const confirmed = await dialogConfirm({
|
||||||
title: "删除 Windows Node",
|
title: "删除专业软件节点",
|
||||||
message: `永久删除「${node.name}」?节点会立即断开,本机现有身份失效;如需再次使用,必须清除本机身份并用新注册码重新注册。`,
|
message: `永久删除「${node.name}」?节点会立即断开,本机现有身份失效;如需再次使用,必须清除本机身份并用新注册码重新注册。`,
|
||||||
okText: "永久删除",
|
okText: "永久删除",
|
||||||
danger: true,
|
danger: true,
|
||||||
|
|
@ -1054,28 +1079,6 @@ function renderMetrics(d) {
|
||||||
renderOpsSummary();
|
renderOpsSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSandboxCapacity(d) {
|
|
||||||
if (!d.enabled) {
|
|
||||||
$("s-sandbox-capacity").innerHTML = `<div class="card"><h2>Sandbox 实时容量</h2><p class="muted">Docker Sandbox 未启用。</p></div>`;
|
|
||||||
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 = `<div class="card"><h2>Sandbox 实时容量</h2>`
|
|
||||||
+ `<div class="grid"><div><b>${d.foreground_running || 0}</b><span>前台执行</span></div>`
|
|
||||||
+ `<div><b>${d.foreground_queued || 0}</b><span>前台排队</span></div>`
|
|
||||||
+ `<div><b>${d.background_running || 0}</b><span>后台执行</span></div>`
|
|
||||||
+ `<div><b>${d.background_queued || 0}</b><span>后台排队</span></div>`
|
|
||||||
+ `<div><b>${d.admit_available || 0}</b><span>当前可放行</span></div>`
|
|
||||||
+ `<div><b>${d.active_sandbox_containers || 0}</b><span>活跃普通容器</span></div>`
|
|
||||||
+ `<div><b>${d.idle_reap_candidates || 0}</b><span>空闲待回收</span></div>`
|
|
||||||
+ `<div><b>${d.running_proc_containers || 0}</b><span>运行中 proc</span></div></div>`
|
|
||||||
+ `<p class="muted">硬上限 ${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 ? " · 内存压力暂停放行" : ""}</p><p class="muted">单用户占用:${users}</p></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSandboxPackages(d) {
|
function renderSandboxPackages(d) {
|
||||||
const rows = d.rows || [];
|
const rows = d.rows || [];
|
||||||
const opts = RANGE_OPTS.map(([v,l]) => `<option value="${v}" ${v===packageRange?"selected":""}>${l}</option>`).join("");
|
const opts = RANGE_OPTS.map(([v,l]) => `<option value="${v}" ${v===packageRange?"selected":""}>${l}</option>`).join("");
|
||||||
|
|
@ -1083,7 +1086,7 @@ function renderSandboxPackages(d) {
|
||||||
+ `<td>${r.session_count}</td><td>${r.user_count}</td><td>${r.foreground_sessions}/${r.background_sessions}</td>`
|
+ `<td>${r.session_count}</td><td>${r.user_count}</td><td>${r.foreground_sessions}/${r.background_sessions}</td>`
|
||||||
+ `<td>${r.direct_sessions}</td><td>${humanSize(r.average_installed_bytes)}</td><td>${humanSize(r.total_installed_bytes)}</td>`
|
+ `<td>${r.direct_sessions}</td><td>${humanSize(r.average_installed_bytes)}</td><td>${humanSize(r.total_installed_bytes)}</td>`
|
||||||
+ `<td>${escapeHtml((r.changes || []).join("/"))}<br/><span class="muted">${fmtTimeAgo(r.latest_at)}</span></td></tr>`).join("");
|
+ `<td>${escapeHtml((r.changes || []).join("/"))}<br/><span class="muted">${fmtTimeAgo(r.latest_at)}</span></td></tr>`).join("");
|
||||||
$("s-sandbox-packages").innerHTML = `<div class="card"><div class="card-head"><h2>Sandbox 依赖统计</h2>`
|
$("s-sandbox-packages").innerHTML = `<div class="card"><div class="card-head"><h2>容器依赖统计</h2>`
|
||||||
+ `<select id="sandbox-package-range">${opts}</select></div><p class="muted">只分析临时安装,不自动修改基础镜像;共 ${d.scan_sessions || 0} 个安装会话。</p>`
|
+ `<select id="sandbox-package-range">${opts}</select></div><p class="muted">只分析临时安装,不自动修改基础镜像;共 ${d.scan_sessions || 0} 个安装会话。</p>`
|
||||||
+ `<div class="table-wrap"><table><thead><tr><th>包名</th><th>版本</th><th>安装会话</th><th>独立用户</th><th>前台/后台</th><th>直接安装会话</th><th>平均占用</th><th>累计占用</th><th>基础镜像状态 / 最近</th></tr></thead><tbody>${body || `<tr><td colspan="9">暂无临时依赖记录</td></tr>`}</tbody></table></div></div>`;
|
+ `<div class="table-wrap"><table><thead><tr><th>包名</th><th>版本</th><th>安装会话</th><th>独立用户</th><th>前台/后台</th><th>直接安装会话</th><th>平均占用</th><th>累计占用</th><th>基础镜像状态 / 最近</th></tr></thead><tbody>${body || `<tr><td colspan="9">暂无临时依赖记录</td></tr>`}</tbody></table></div></div>`;
|
||||||
const select = $("sandbox-package-range");
|
const select = $("sandbox-package-range");
|
||||||
|
|
@ -1238,7 +1241,10 @@ async function loadSoftwareNodes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSandboxCapacity() {
|
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() {
|
async function loadSandboxPackages() {
|
||||||
try { renderSandboxPackages(await apiGet(`/v1/admin/sandbox/packages?range=${packageRange}`)); } catch (e) { /* overview 统一处理 */ }
|
try { renderSandboxPackages(await apiGet(`/v1/admin/sandbox/packages?range=${packageRange}`)); } catch (e) { /* overview 统一处理 */ }
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ import { escapeHtml } from "./format.js";
|
||||||
import { renderMd, highlightIn } from "./markdown.js";
|
import { renderMd, highlightIn } from "./markdown.js";
|
||||||
|
|
||||||
const SEEN_KEY = "zcbot_seen_version";
|
const SEEN_KEY = "zcbot_seen_version";
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
let _current = ""; // /healthz 返回的当前版本(main.js loadVersion 灌入)
|
let _current = ""; // /healthz 返回的当前版本(main.js loadVersion 灌入)
|
||||||
|
let _entries = [];
|
||||||
|
let _nextOffset = 0;
|
||||||
|
let _hasMore = false;
|
||||||
|
let _loading = false;
|
||||||
|
let _loadError = "";
|
||||||
|
|
||||||
// main.js loadVersion 拿到版本号后调:决定红点亮不亮(localStorage 不可用时静默不亮)
|
// main.js loadVersion 拿到版本号后调:决定红点亮不亮(localStorage 不可用时静默不亮)
|
||||||
export function markVersion(v) {
|
export function markVersion(v) {
|
||||||
|
|
@ -22,31 +28,21 @@ export function closeChangelogModal() {
|
||||||
$("changelog-modal").classList.remove("show");
|
$("changelog-modal").classList.remove("show");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openChangelogModal() {
|
function renderChangelog() {
|
||||||
const body = $("cl-body");
|
const body = $("cl-body");
|
||||||
$("changelog-modal").classList.add("show");
|
if (!_entries.length && _loading) {
|
||||||
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
|
body.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
|
||||||
// 打开即视为"看过":记住当前版本 + 清红点
|
|
||||||
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 = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$("cl-cur").textContent = d.current ? "当前 v" + d.current : "";
|
if (!_entries.length && _loadError) {
|
||||||
const entries = d.entries || [];
|
body.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(_loadError)}</div>`;
|
||||||
if (!entries.length) {
|
return;
|
||||||
|
}
|
||||||
|
if (!_entries.length) {
|
||||||
body.innerHTML = '<div class="sk-empty">暂无更新说明。</div>';
|
body.innerHTML = '<div class="sk-empty">暂无更新说明。</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = entries.map((e, i) => `
|
body.innerHTML = _entries.map((e, i) => `
|
||||||
<div class="cl-entry">
|
<div class="cl-entry">
|
||||||
<div class="cl-head">
|
<div class="cl-head">
|
||||||
<span class="cl-ver">v${escapeHtml(e.version)}</span>
|
<span class="cl-ver">v${escapeHtml(e.version)}</span>
|
||||||
|
|
@ -54,8 +50,50 @@ async function openChangelogModal() {
|
||||||
${e.date ? `<span class="cl-date">${escapeHtml(e.date)}</span>` : ""}
|
${e.date ? `<span class="cl-date">${escapeHtml(e.date)}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="cl-md">${renderMd(e.body_md)}</div>
|
<div class="cl-md">${renderMd(e.body_md)}</div>
|
||||||
</div>`).join("");
|
</div>`).join("")
|
||||||
|
+ (_loadError ? `<div class="cl-load-error">加载失败: ${escapeHtml(_loadError)}</div>` : "")
|
||||||
|
+ (_hasMore ? `<div class="cl-more"><button id="cl-more" type="button" ${_loading ? "disabled" : ""}>${_loading ? "加载中…" : "加载更多"}</button></div>` : "");
|
||||||
highlightIn(body);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── 顶层绑定 ─────
|
// ───── 顶层绑定 ─────
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue