Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 签名。没有按账号归属过滤签名。
Expand Down
2 changes: 1 addition & 1 deletion src/lib/admin/panel-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/cluster/cluster-manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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) })
Expand All @@ -244,6 +248,7 @@ export class ClusterManager {
latency_ms: latencyMs == null ? null : Math.round(latencyMs),
docker,
containers,
host,
checked_at: new Date().toISOString(),
})
}
Expand Down
62 changes: 62 additions & 0 deletions src/lib/cluster/node-host.mjs
Original file line number Diff line number Diff line change
@@ -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,
}
}
45 changes: 45 additions & 0 deletions test/unit/cluster-node-host.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
50 changes: 47 additions & 3 deletions web/src/features/cluster/remote-list.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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',
Expand Down Expand Up @@ -55,11 +57,19 @@ export function RemoteList({
/>
) : (
<div className='overflow-x-auto'>
<div className='min-w-[840px]'>
<div className='min-w-[1064px]'>
<div className='flex h-8 items-center border-b bg-muted/30 text-[11px] font-medium tracking-wide text-muted-foreground/80'>
<div className={COL.name}>节点</div>
<div className={COL.host}>IP</div>
<div className={COL.link}>链路</div>
<div className={COL.metric}>CPU</div>
<div className={COL.metric}>内存</div>
<div
className={COL.metric}
title='全部容器内存合计(不含文件缓存)'
>
容器
</div>
<div className={COL.metric} title='运行中 / 全部容器(含出口)'>
Docker
</div>
Expand All @@ -79,6 +89,7 @@ export function RemoteList({
live &&
node.latencyMs != null &&
node.latencyMs > LINK_LATENCY_WARN_MS
const hs = live ? node.remote?.health?.host : null
return (
<div
key={node.id}
Expand Down Expand Up @@ -118,6 +129,39 @@ export function RemoteList({
</span>
) : null}
</div>
<div
className={cn(
COL.metric,
'field-metric',
hs && hs.cpu_pct >= HOST_HOT_PCT && 'text-caution-3'
)}
title={
hs
? `${hs.cpu_count} 核 · load ${[hs.load1, hs.load5, hs.load15].map((n) => (n == null ? '—' : n.toFixed(2))).join(' / ')}`
: undefined
}
>
{hs ? `${hs.cpu_pct.toFixed(0)}%` : '—'}
</div>
<div
className={cn(
COL.metric,
'field-metric',
hs && hs.mem_pct >= HOST_HOT_PCT && 'text-caution-3'
)}
title={
hs
? `${fmtBytes(hs.mem_used)} / ${fmtBytes(hs.mem_total)}`
: undefined
}
>
{hs ? `${hs.mem_pct.toFixed(0)}%` : '—'}
</div>
<div className={cn(COL.metric, 'field-metric')}>
{hs?.containers_mem != null
? fmtBytes(hs.containers_mem)
: '—'}
</div>
<div
className={cn(COL.metric, 'field-metric')}
title={
Expand Down
15 changes: 15 additions & 0 deletions web/src/types/panel-cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type ClusterApiNode = {
docker: { ok: boolean; error: string | null }
/** Docker `/info` 计数;Docker 不可用时为 null。 */
containers: { total: number | null; running: number | null } | null
/** 节点宿主负载,口径同本机宿主列;远端读不到时为 null。 */
host?: NodeHostStats | null
checked_at: string
} | null
bridge: {
Expand All @@ -39,6 +41,19 @@ export type ClusterApiNode = {
install: DockerInstallJob | null
}

export type NodeHostStats = {
cpu_count: number
load1: number
load5: number | null
load15: number | null
cpu_pct: number
mem_total: number
mem_used: number
mem_pct: number
/** 全部 Docker 容器内存合计(扣除 inactive_file);读不到 cgroup 时为 null。 */
containers_mem: number | null
}

export type DockerInstallJob = {
status: 'running' | 'done' | 'failed'
log: string
Expand Down
Loading