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 @@ -2,6 +2,7 @@

## 未发布

- 槽位种子页按当前槽 `settings.json` 列出真实配置;`claude.setting` 可编辑播种;缺失文件返回 `null`、空文件返回空串,不用策略默认冒充槽内文件;遥测开关与 sidecar 进程状态分开展示,保存不因遥测进程刷新失败而 502。
- fleet 遥测刷新在 kin-egress 二进制被替换(含 `(deleted)` 旧进程与脏 pidfile)时,会按 egress 配置扫停全部 helper、等待 `/proc/<pid>` 消失后再绑定新二进制;同一 SOCKS 出口的 stop/start 与 `ensureProxyEgress` 共用 per-proxy 锁,避免并发 telemetry 互相 SIGTERM。(#604)
- 面板:侧栏「GitHub / 文档」两枚链接不再折行,字体偏宽时「文档」不会逐字竖排,空间不够先缩箭头再省略文字;VM 列表搜索框关闭浏览器自动填充,不再被回填成登录邮箱。
- 集群页「扩展节点」新增 CPU / 内存 / 容器三列:健康检查(每 30 秒)经同一条 SSH 连接读节点 `/proc` 和 cgroup,口径同本机宿主列;容器 = 节点上全部 Docker 容器内存合计(扣除 inactive_file,同 `docker stats`)。修复本机宿主列 CPU 最高只显示 10%(`Math.min(100, …×1000)/10` 写错上限)。链路列不再折行。
Expand Down
38 changes: 38 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Product

<!-- impeccable:product-schema 1 -->

## Platform

web

## Users

本机管理员。在深色运维控制台里打开某个虚拟机槽位,核对并改写该槽 Claude Code 的种子配置。

## Product Purpose

vm2api 把 Claude / Codex 账号做成可调度的虚拟机槽,并对外提供兼容上游的 API。种子页的成功标准:管理员不用进容器,就能看见当前槽 `settings.json` 里实际生效的配置项,改完能播种,并实时看到遥测开或关。

## Positioning

种子不是一份预设表单。它读当前槽家目录里已经写好的 Claude 配置,按文件内容逐项显示,再把改动写回同一份文件。

## Operating Context

管理员在 `/vm/$id` 的「种子」页签工作。数据来自该槽 `cli-home/.claude/settings.json`、`claude.setting` 和种子策略。遥测进程状态来自槽详情里的实时观测。

## Capabilities and Constraints

- 显示当前槽 `settings.json` 的真实键值,而不是只显示种子策略里的默认串。
- `claude.setting` 以可编辑文本提供,保存时写回并重新播种。
- 遥测开、关状态实时刷新;配置开关和进程是否在跑是两件事。
- 「拒客户端 settings」和「拒客户端身份」按当前策略准确显示,并可切换。
- 平台是现有 React 面板,不另起一套视觉系统。

## Product Principles

- 槽内文件是事实,表单默认值不是。
- 开和关都要能被看见,未知不能伪装成关闭。
- 能改的项和只读观测分开。
- 播种是显式动作,不在切换开关时偷偷写盘。
16 changes: 1 addition & 15 deletions src/lib/admin/panel-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3535,7 +3535,7 @@ export function createPanelHandler(ctx) {
.replace(/\r\n?/g, '\n')
.replace(/\n+$/g, '')
} catch {
claude_setting = defaultSeedPolicy(vm.seed_policy || {}).claude_setting
// Missing file stays null. The policy default is not this slot's file.
}
try {
kin_seed = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'kin-seed.json'), 'utf8'))
Expand Down Expand Up @@ -3593,20 +3593,6 @@ export function createPanelHandler(ctx) {
})
service = report.items?.[0] || report
}
if (service?.ok === false) {
return json(res, 502, {
ok: false,
error: {
message: service.error || 'telemetry service update failed',
code: service.code || 'telemetry_update_failed',
},
data: {
vm_id: id,
seed_policy: next,
service,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Save toast hides sidecar failure

Medium Severity

PUT /seed-settings now stays 200 when telemetry sidecar refresh fails, but seed save still toasts 已播种 and the settings telemetry pane counts those writes as successes. Nothing reads the response service field, so a failed process restart looks like a complete seed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2af464e. Configure here.

})
}
return json(
res,
200,
Expand Down
75 changes: 75 additions & 0 deletions test/unit/panel-vm-seed-settings.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createPanelHandler } from '../../src/lib/admin/panel-routes.mjs'
import { DEFAULT_CLAUDE_SETTING } from '../../src/lib/protocol/seed-policy.mjs'

function seed(root, id) {
const dir = path.join(root, 'vms')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, `${id}.json`),
JSON.stringify({
id,
name: id,
status: 'stopped',
seed_policy: { claude_setting: 'FROM_POLICY=1' },
}),
)
}

function handler(project) {
const response = {}
const handlePanel = createPanelHandler({
cfg: { paths: { project }, vm: { id: null } },
requireAuth(req) {
req.apiKeyKind = 'master'
req.panelRole = 'admin'
return true
},
json(_res, status, payload) {
response.status = status
response.body = payload
return true
},
readBody: async () => ({}),
proxyPool: { unbindVm() {} },
})
return { handlePanel, response }
}

test('GET seed-settings keeps a missing claude.setting null', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-get-'))
try {
seed(root, 'vm-01')
const { handlePanel, response } = handler(root)
await handlePanel({ method: 'GET' }, {}, new URL('http://localhost/api/panel/vms/vm-01/seed-settings'))
assert.equal(response.status, 200, JSON.stringify(response.body).slice(0, 400))
assert.equal(response.body.data.claude_setting, null)
assert.equal(response.body.data.settings_json, null)
assert.equal(response.body.data.seed_policy.claude_setting, 'FROM_POLICY=1')
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})

test('GET seed-settings returns an empty claude.setting file as empty', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-empty-'))
try {
seed(root, 'vm-01')
const dir = path.join(root, 'vms', 'vm-01', 'cli-home', '.claude')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'claude.setting'), '\n')
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ env: { TZ: 'UTC' } }))
const { handlePanel, response } = handler(root)
await handlePanel({ method: 'GET' }, {}, new URL('http://localhost/api/panel/vms/vm-01/seed-settings'))
assert.equal(response.status, 200, JSON.stringify(response.body).slice(0, 400))
assert.equal(response.body.data.claude_setting, '')
assert.equal(response.body.data.seed_policy.claude_setting, '')
assert.equal(response.body.data.settings_json.env.TZ, 'UTC')
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
21 changes: 12 additions & 9 deletions web/src/features/vm/detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,15 @@ function VmDetailBody({
enabled: perms.known && !perms.dashboard,
})
const proxies = useQuery({ ...proxiesQueryOptions(), enabled: perms.admin })
const detail = useQuery(vmQueryOptions(id))
const seed = useQuery({ ...vmSeedQueryOptions(id), enabled: perms.admin })
const detail = useQuery({
...vmQueryOptions(id),
refetchInterval: wantedTab === 'seed' ? 2000 : false,
})
const seed = useQuery({
...vmSeedQueryOptions(id),
enabled: perms.admin && wantedTab === 'seed',
refetchInterval: wantedTab === 'seed' ? 2000 : false,
})
const routing = useQuery({ ...routingQueryOptions(), enabled: perms.admin })
usePoolSeatStream()

Expand Down Expand Up @@ -260,8 +267,6 @@ function VmDetailBody({
// 本地代理固定第一。已绑到本槽的留在下拉里,否则正在用它时列表里没有。
const free = proxiesForVmBind(pool, id, 5, vm.node_id ?? null)
const bindTarget = pickBindTarget(bindId, free)
const pol =
(seed.data?.seed_policy as Record<string, unknown> | undefined) || {}
const syncTelemetry =
(routing.data?.official_cc as { sync_telemetry?: boolean } | undefined)
?.sync_telemetry === true
Expand Down Expand Up @@ -564,13 +569,11 @@ function VmDetailBody({
}}
onDelete={() => setConfirmDel(true)}
/>
<TabsContent value='seed' className='space-y-3 pt-4'>
<p className='text-sm text-muted-foreground'>
官方 Claude Code 初装之后的后置覆写。遥测只看这一只开关。
</p>
<TabsContent value='seed' className='pt-4'>
{seed.data ? (
<SeedPolicyCard
policy={pol}
seed={seed.data}
telemetry={kernel?.telemetry}
saving={saveSeed.isPending}
syncTelemetry={syncTelemetry}
onSave={(next) => saveSeed.mutate(next)}
Expand Down
4 changes: 3 additions & 1 deletion web/src/features/vm/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
VmCreateOptions,
VmCredentialResponse,
VmDetailPayload,
VmSeedSettings,
VmsListResponse,
} from '@/types/panel-vm'
import { api } from '@/lib/api'
Expand Down Expand Up @@ -46,9 +47,10 @@ export function vmSeedQueryOptions(id: string) {
return queryOptions({
queryKey: ['panel', 'vm', id, 'seed'] as const,
queryFn: () =>
api<Record<string, unknown>>(
api<VmSeedSettings>(
`/api/panel/vms/${encodeURIComponent(id)}/seed-settings`
),
refetchInterval: false,
})
}

Expand Down
13 changes: 7 additions & 6 deletions web/src/features/vm/seed-policy-card.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { describe, expect, it } from 'vitest'
import { alignTelemetryDraft } from './seed-policy-card'
import { seedSaveBody } from './seed-settings'
import { telemetryEnabled } from './telemetry-policy'

describe('seed card follows the telemetry bit', () => {
describe('seed save follows the telemetry bit', () => {
it('drops contradictory mirrors so the switch stays on', () => {
const draft = alignTelemetryDraft({
const body = seedSaveBody({
telemetry_disabled: false,
disable_nonessential_traffic: false,
do_not_track: true,
reject_client_settings: true,
reject_client_metadata_identity: true,
claude_setting: 'A=1',
})
expect(telemetryEnabled(draft)).toBe(true)
expect(draft.disable_nonessential_traffic).toBe(true)
expect(draft.do_not_track).toBe(false)
expect(telemetryEnabled(body)).toBe(true)
expect(body.disable_nonessential_traffic).toBe(true)
expect(body.do_not_track).toBe(false)
})
})
Loading
Loading