diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ac6ad94..72dfa50c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 未发布 +- 槽位种子页按当前槽 `settings.json` 列出真实配置;`claude.setting` 可编辑播种;缺失文件返回 `null`、空文件返回空串,不用策略默认冒充槽内文件;遥测开关与 sidecar 进程状态分开展示,保存不因遥测进程刷新失败而 502。 - fleet 遥测刷新在 kin-egress 二进制被替换(含 `(deleted)` 旧进程与脏 pidfile)时,会按 egress 配置扫停全部 helper、等待 `/proc/` 消失后再绑定新二进制;同一 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` 写错上限)。链路列不再折行。 diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..629754c59 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,38 @@ +# Product + + + +## 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 + +- 槽内文件是事实,表单默认值不是。 +- 开和关都要能被看见,未知不能伪装成关闭。 +- 能改的项和只读观测分开。 +- 播种是显式动作,不在切换开关时偷偷写盘。 diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs index 19ff72aca..e2fb1027f 100644 --- a/src/lib/admin/panel-routes.mjs +++ b/src/lib/admin/panel-routes.mjs @@ -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')) @@ -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, - }, - }) - } return json( res, 200, diff --git a/test/unit/panel-vm-seed-settings.test.mjs b/test/unit/panel-vm-seed-settings.test.mjs new file mode 100644 index 000000000..5926df0bb --- /dev/null +++ b/test/unit/panel-vm-seed-settings.test.mjs @@ -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 }) + } +}) diff --git a/web/src/features/vm/detail-page.tsx b/web/src/features/vm/detail-page.tsx index 2a82fd87b..c8b5fc32b 100644 --- a/web/src/features/vm/detail-page.tsx +++ b/web/src/features/vm/detail-page.tsx @@ -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() @@ -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 | undefined) || {} const syncTelemetry = (routing.data?.official_cc as { sync_telemetry?: boolean } | undefined) ?.sync_telemetry === true @@ -564,13 +569,11 @@ function VmDetailBody({ }} onDelete={() => setConfirmDel(true)} /> - -

- 官方 Claude Code 初装之后的后置覆写。遥测只看这一只开关。 -

+ {seed.data ? ( saveSeed.mutate(next)} diff --git a/web/src/features/vm/queries.ts b/web/src/features/vm/queries.ts index 8acc260b1..c8a917ed9 100644 --- a/web/src/features/vm/queries.ts +++ b/web/src/features/vm/queries.ts @@ -6,6 +6,7 @@ import type { VmCreateOptions, VmCredentialResponse, VmDetailPayload, + VmSeedSettings, VmsListResponse, } from '@/types/panel-vm' import { api } from '@/lib/api' @@ -46,9 +47,10 @@ export function vmSeedQueryOptions(id: string) { return queryOptions({ queryKey: ['panel', 'vm', id, 'seed'] as const, queryFn: () => - api>( + api( `/api/panel/vms/${encodeURIComponent(id)}/seed-settings` ), + refetchInterval: false, }) } diff --git a/web/src/features/vm/seed-policy-card.test.ts b/web/src/features/vm/seed-policy-card.test.ts index f6ac473ff..69a3333c1 100644 --- a/web/src/features/vm/seed-policy-card.test.ts +++ b/web/src/features/vm/seed-policy-card.test.ts @@ -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) }) }) diff --git a/web/src/features/vm/seed-policy-card.tsx b/web/src/features/vm/seed-policy-card.tsx index 2ff1573f9..561e01544 100644 --- a/web/src/features/vm/seed-policy-card.tsx +++ b/web/src/features/vm/seed-policy-card.tsx @@ -1,212 +1,252 @@ import { useEffect, useState } from 'react' +import type { VmKernelSnapshot, VmSeedSettings } from '@/types/panel-vm' +import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { - telemetryEnabled, - telemetrySeedFlags, -} from '@/features/vm/telemetry-policy' + REJECT_FLAGS, + seedDraftOf, + seedSaveBody, + settingsRows, + sameDraft, + telemetryLive, + type SeedDraft, +} from '@/features/vm/seed-settings' +import { telemetryEnabled } from '@/features/vm/telemetry-policy' -export const DEFAULT_CLAUDE_SETTING = - 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1' +type ObservedTelemetry = NonNullable -const REJECT_FLAGS: [string, string][] = [ - ['reject_client_settings', '拒客户端 settings'], - ['reject_client_metadata_identity', '拒客户端身份'], -] - -type SeedPolicy = Record - -export const SEED_PRESETS: Record = { - standard: { - ...telemetrySeedFlags(false), - claude_setting: DEFAULT_CLAUDE_SETTING, - reject_client_settings: true, - reject_client_metadata_identity: true, - }, - open: { - ...telemetrySeedFlags(true), - claude_setting: DEFAULT_CLAUDE_SETTING, - reject_client_settings: false, - reject_client_metadata_identity: false, - }, - strict: { - ...telemetrySeedFlags(false), - claude_setting: DEFAULT_CLAUDE_SETTING, - reject_client_settings: true, - reject_client_metadata_identity: true, - }, -} - -const PRESET_LABELS: [string, string, string][] = [ - ['standard', '标准', '拒客户端覆写,遥测关;需手动开启'], - ['open', '开放', '不拒客户端;遥测开,非必要流量=1'], - ['strict', '关闭遥测', '关遥测 + DNT;非必要流量=0'], -] - -/** Mirrors follow the telemetry bit. Reject flags are left alone. */ -export function alignTelemetryDraft(draft: SeedPolicy): SeedPolicy { - return { ...draft, ...telemetrySeedFlags(telemetryEnabled(draft)) } -} - -/** - * 预设名 → 提交给后端的种子策略。`theme: 'dark'` 是固定值(非用户可选项)。 - */ -export function seedPolicyOf(name: string): Record { - return { ...(SEED_PRESETS[name] || SEED_PRESETS.standard), theme: 'dark' } -} - -export function matchSeedPreset(policy: Record): string { - const on = telemetryEnabled(policy) - for (const [name, preset] of Object.entries(SEED_PRESETS)) { - if (telemetryEnabled(preset) !== on) continue - if (REJECT_FLAGS.every(([k]) => !!preset[k] === !!policy[k])) return name - } - return 'custom' +function Lamp({ tone }: { tone: 'on' | 'off' | 'unknown' }) { + return ( + + {tone !== 'unknown' ? ( + + ) : null} + + ) } -function normalize(policy: Record): SeedPolicy { - const out: SeedPolicy = { ...telemetrySeedFlags(telemetryEnabled(policy)) } - out.claude_setting = - typeof policy.claude_setting === 'string' - ? policy.claude_setting - : DEFAULT_CLAUDE_SETTING - for (const [k] of REJECT_FLAGS) { - out[k] = k in policy ? !!policy[k] : !!SEED_PRESETS.standard[k] - } - return out +function ToggleRow({ + id, + label, + detail, + checked, + disabled, + onCheckedChange, +}: { + id: string + label: string + detail: string + checked: boolean + disabled: boolean + onCheckedChange: (next: boolean) => void +}) { + return ( +
+
+ +

{detail}

+
+
+ + {checked ? '开' : '关'} + + +
+
+ ) } export function SeedPolicyCard({ - policy, + seed, + telemetry, onSave, saving, syncTelemetry = false, }: { - policy: Record - onSave: (next: SeedPolicy) => void + seed: VmSeedSettings + telemetry?: ObservedTelemetry | null + onSave: (next: Record) => void saving: boolean /** External official_cc.sync_telemetry. True means the next init turns telemetry back on. */ syncTelemetry?: boolean }) { - const [draft, setDraft] = useState(() => - alignTelemetryDraft(normalize(policy)) - ) + const saved = seedDraftOf(seed) + const [draft, setDraft] = useState(saved) useEffect(() => { - setDraft(alignTelemetryDraft(normalize(policy))) - }, [policy]) + setDraft(seedDraftOf(seed)) + }, [seed]) - const current = matchSeedPreset(draft) - const saved = normalize(policy) - const dirty = - telemetryEnabled(draft) !== telemetryEnabled(saved) || - REJECT_FLAGS.some(([k]) => !!draft[k] !== !!saved[k]) || - draft.claude_setting !== saved.claude_setting + const dirty = !sameDraft(draft, saved) const on = telemetryEnabled(draft) + const live = telemetryLive(seed, telemetry) + const fileSetting = + typeof seed.claude_setting === 'string' ? seed.claude_setting : '' + const rows = settingsRows(seed.settings_json, fileSetting) + const fileMissing = seed.settings_json == null + + const patch = (next: Partial) => + setDraft((current) => ({ ...current, ...next })) return ( - - - 种子策略 - - -
-
- {PRESET_LABELS.map(([key, label, desc]) => ( - - ))} - {current === 'custom' ? ( - - ) : null} -
-

- {current === 'custom' - ? '当前组合不匹配任何预设。' - : PRESET_LABELS.find(([k]) => k === current)?.[2]} +

+
+
+

当前槽 settings.json

+

+ {fileMissing ? '文件未写入' : `${rows.length} 项`} +

+
+ {fileMissing ? ( +

+ 这槽还没有 settings.json。播种之后,这里显示写进文件的项。 +

+ ) : rows.length === 0 ? ( +

+ 文件在,但没有 env 项。

+ ) : ( +
+ + + {rows.map((row) => ( + + + + + ))} + +
+ + {row.inClaudeSetting ? ( + + ) : ( + + )} + {row.key} + + + {row.value === '' ? '空' : row.value} +
+
+ )} +
+ +