From dd9d325f1c95f1585ae750b7c9b4118622c90e28 Mon Sep 17 00:00:00 2001 From: Niutopia <120653980+Niutopia@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:41:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(cluster):=20=E6=89=A9=E5=B1=95=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=98=BE=E7=A4=BA=20CPU=20/=20=E5=86=85=E5=AD=98=20/?= =?UTF-8?q?=20=E5=AE=B9=E5=99=A8=E5=86=85=E5=AD=98=EF=BC=9B=E4=BF=AE?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=20CPU=20=E4=B8=8A=E9=99=90=2010%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + src/lib/admin/panel-api.mjs | 2 +- src/lib/cluster/cluster-manager.mjs | 5 ++ src/lib/cluster/node-host.mjs | 62 ++++++++++++++++++++++++ test/unit/cluster-node-host.test.mjs | 45 +++++++++++++++++ web/src/features/cluster/remote-list.tsx | 50 +++++++++++++++++-- web/src/types/panel-cluster.ts | 15 ++++++ 7 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/lib/cluster/node-host.mjs create mode 100644 test/unit/cluster-node-host.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 816b17b9..16d2a149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 未发布 - 面板:侧栏「GitHub / 文档」两枚链接不再折行,字体偏宽时「文档」不会逐字竖排,空间不够先缩箭头再省略文字;VM 列表搜索框关闭浏览器自动填充,不再被回填成登录邮箱。 +- 集群页「扩展节点」新增 CPU / 内存 / 容器三列:健康检查(每 30 秒)经同一条 SSH 连接读节点 `/proc` 和 cgroup,口径同本机宿主列;容器 = 节点上全部 Docker 容器内存合计(扣除 inactive_file,同 `docker stats`)。修复本机宿主列 CPU 最高只显示 10%(`Math.min(100, …×1000)/10` 写错上限)。链路列不再折行。 - 集群槽无需重建 VM 即可更新 Go 槽位服务(kin-worker / kin-egress):持久化 `slot-bin` 挂载推送二进制,fleet `action=telemetry` 热更新遥测与 egress。(#600) - Codex 入站在平台探测前支持 CPR 式 `routing.codex.model_mappings` 精确模型映射;默认 `codex-auto-review` → `gpt-6-luna`(清空该键时回退 `gpt-6.1-sol`)。日志保留 `requested_model` / `upstream_model`;非法或非 GPT 目标 400,不 hop Anthropic。(#526) - 保留路径不再给没有可用签名的 thinking 补 `signature: ""`:缺失、空、短于 24、dummy 的删掉;签名可用的留,空正文也留。这一支里的 `redacted_thinking` 不看 data 和签名,原样留;不走保留路径时仍按原规则删。5.5 的非保留路径仍接受短的非 dummy 签名。没有按账号归属过滤签名。 diff --git a/src/lib/admin/panel-api.mjs b/src/lib/admin/panel-api.mjs index 1351ee25..fa2e7720 100644 --- a/src/lib/admin/panel-api.mjs +++ b/src/lib/admin/panel-api.mjs @@ -1507,7 +1507,7 @@ function hostStats() { load1, load5, load15, - cpu_pct: Math.round(Math.min(100, (load1 / n) * 1000) / 10), + cpu_pct: Math.round(Math.min(1000, (load1 / n) * 1000)) / 10, mem_total: total, mem_free: free, mem_used: used, diff --git a/src/lib/cluster/cluster-manager.mjs b/src/lib/cluster/cluster-manager.mjs index e67a6662..51484aa9 100644 --- a/src/lib/cluster/cluster-manager.mjs +++ b/src/lib/cluster/cluster-manager.mjs @@ -18,6 +18,7 @@ import { validateNodeEditPatch, verifyNodeRuntimes, } from './node-edit.mjs' +import { NODE_HOST_COMMAND, NODE_HOST_TIMEOUT_MS, parseNodeHost } from './node-host.mjs' import { shellQuote } from './remote-fs.mjs' import { SocketRelay } from './socket-relay.mjs' import { configuredIpv6Enabled } from '../vm/proxy-policy.mjs' @@ -224,6 +225,7 @@ export class ClusterManager { } catch (err) { if (err.code === 'streamlocal_failed') latencyMs = performance.now() - t0 } + const hostRead = execCollect(client, NODE_HOST_COMMAND, { timeoutMs: NODE_HOST_TIMEOUT_MS }).catch(() => null) let docker let containers = null try { @@ -233,6 +235,8 @@ export class ClusterManager { } catch (err) { docker = { ok: false, error: err.message } } + const hostOut = await hostRead + const host = hostOut?.code === 0 ? parseNodeHost(hostOut.stdout) : null if (link.client !== client) return if (docker.ok && !configuredIpv6Enabled()) { const exits = await syncIpv6ProxyEgress(null, null, { nodeId: id, vms: this.vmsOnNode(id) }) @@ -244,6 +248,7 @@ export class ClusterManager { latency_ms: latencyMs == null ? null : Math.round(latencyMs), docker, containers, + host, checked_at: new Date().toISOString(), }) } diff --git a/src/lib/cluster/node-host.mjs b/src/lib/cluster/node-host.mjs new file mode 100644 index 00000000..c16b6c69 --- /dev/null +++ b/src/lib/cluster/node-host.mjs @@ -0,0 +1,62 @@ +/** + * Extension-node host stats read over the existing SSH link, on the same scale + * as the local panel host column: CPU = load1 / cores, memory = MemAvailable. + * Remote nodes run no control-plane process, so the third figure is the sum of + * Docker container memory (cgroup v2, minus inactive_file like `docker stats`). + */ + +import { shellQuote } from './remote-fs.mjs' + +export const NODE_HOST_TIMEOUT_MS = 5_000 + +const NODE_HOST_SCRIPT = [ + 'echo "ncpu $(nproc)"', + 'echo "load $(cat /proc/loadavg)"', + "grep -E '^(MemTotal|MemAvailable):' /proc/meminfo", + 'for d in /sys/fs/cgroup/system.slice/docker-*.scope /sys/fs/cgroup/docker/*; do', + ' [ -r "$d/memory.current" ] || continue', + ` echo "cg $(cat "$d/memory.current") $(awk '$1=="inactive_file"{print $2}' "$d/memory.stat" 2>/dev/null)"`, + 'done', +].join('\n') + +export const NODE_HOST_COMMAND = `sh -c ${shellQuote(NODE_HOST_SCRIPT)}` + +const num = (s) => { + const n = Number(s) + return Number.isFinite(n) ? n : null +} + +/** Returns null when the essentials (cores, load, memory) are missing. */ +export function parseNodeHost(stdout) { + let cpuCount = null + let load = [] + let memTotal = null + let memAvailable = null + let containersMem = null + for (const line of String(stdout || '').split('\n')) { + const parts = line.trim().split(/\s+/) + if (parts[0] === 'ncpu') cpuCount = num(parts[1]) + else if (parts[0] === 'load') load = parts.slice(1, 4).map(num) + else if (parts[0] === 'MemTotal:') memTotal = num(parts[1]) * 1024 + else if (parts[0] === 'MemAvailable:') memAvailable = num(parts[1]) * 1024 + else if (parts[0] === 'cg') { + const current = num(parts[1]) + if (current == null) continue + containersMem = (containersMem || 0) + Math.max(0, current - (num(parts[2]) || 0)) + } + } + const [load1, load5, load15] = load + if (!cpuCount || load1 == null || !memTotal || memAvailable == null) return null + const memUsed = Math.max(0, memTotal - memAvailable) + return { + cpu_count: cpuCount, + load1, + load5, + load15, + cpu_pct: Math.round(Math.min(1000, (load1 / cpuCount) * 1000)) / 10, + mem_total: memTotal, + mem_used: memUsed, + mem_pct: Math.round((memUsed / memTotal) * 1000) / 10, + containers_mem: containersMem, + } +} diff --git a/test/unit/cluster-node-host.test.mjs b/test/unit/cluster-node-host.test.mjs new file mode 100644 index 00000000..53bfa300 --- /dev/null +++ b/test/unit/cluster-node-host.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { NODE_HOST_COMMAND, parseNodeHost } from '../../src/lib/cluster/node-host.mjs' + +const SAMPLE = [ + 'ncpu 2', + 'load 0.50 0.40 0.30 1/234 5678', + 'MemTotal: 2000000 kB', + 'MemAvailable: 500000 kB', + 'cg 300000000 100000000', + 'cg 50000000 ', + '', +].join('\n') + +test('parseNodeHost uses the local host column scale', () => { + assert.deepEqual(parseNodeHost(SAMPLE), { + cpu_count: 2, + load1: 0.5, + load5: 0.4, + load15: 0.3, + cpu_pct: 25, + mem_total: 2_048_000_000, + mem_used: 1_536_000_000, + mem_pct: 75, + containers_mem: 250_000_000, + }) +}) + +test('parseNodeHost caps CPU at 100% and reports no containers as null', () => { + const host = parseNodeHost(SAMPLE.replace('load 0.50', 'load 9.00').replace(/^cg .*$/gm, '')) + assert.equal(host.cpu_pct, 100) + assert.equal(host.containers_mem, null) +}) + +test('parseNodeHost returns null when essentials are missing', () => { + assert.equal(parseNodeHost(''), null) + assert.equal(parseNodeHost(SAMPLE.replace(/^MemAvailable:.*$/m, '')), null) + assert.equal(parseNodeHost(SAMPLE.replace('ncpu 2', 'ncpu ')), null) +}) + +test('NODE_HOST_COMMAND runs on Linux and parses', { skip: process.platform !== 'linux' }, () => { + const host = parseNodeHost(execFileSync('sh', ['-c', NODE_HOST_COMMAND], { encoding: 'utf8' })) + assert.ok(host && host.cpu_count >= 1 && host.mem_total > 0) +}) diff --git a/web/src/features/cluster/remote-list.tsx b/web/src/features/cluster/remote-list.tsx index af7f7b7b..4c4bd0ac 100644 --- a/web/src/features/cluster/remote-list.tsx +++ b/web/src/features/cluster/remote-list.tsx @@ -1,4 +1,4 @@ -import { fmtNum, fmtUsd } from '@/lib/format' +import { fmtBytes, fmtNum, fmtUsd } from '@/lib/format' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { EmptyState } from '@/components/empty-state' @@ -13,10 +13,12 @@ import { } from '@/features/cluster/model' import { PanelCard } from '@/features/overview/panel-card' +const HOST_HOT_PCT = 85 + const COL = { name: 'min-w-[108px] flex-[1.1] pl-3', host: 'min-w-[132px] flex-[1.3]', - link: 'min-w-[128px] flex-[1.1]', + link: 'min-w-[160px] flex-[1.1] whitespace-nowrap', metric: 'min-w-[64px] flex-[0.7] text-right', spend: 'min-w-[88px] flex-[0.9] pr-2 text-right', action: 'w-28 shrink-0 pr-2 text-right', @@ -55,11 +57,19 @@ export function RemoteList({ /> ) : (