From b164d89dd49dd3898399f75a66bc0b5196af4e66 Mon Sep 17 00:00:00 2001 From: dofastted Date: Mon, 19 Oct 2026 20:36:56 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(web):=20=E7=A7=8D=E5=AD=90=E9=A1=B5?= =?UTF-8?q?=E6=8C=89=E5=BD=93=E5=89=8D=E6=A7=BD=20settings.json=20?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账本列出文件里的真实配置项,claude.setting 可编辑后播种。遥测开/关与进程状态分开,种子页打开时 2 秒刷新。 --- PRODUCT.md | 38 ++ web/src/features/vm/detail-page.tsx | 21 +- web/src/features/vm/queries.ts | 4 +- web/src/features/vm/seed-policy-card.test.ts | 13 +- web/src/features/vm/seed-policy-card.tsx | 367 +++++++++++-------- web/src/features/vm/seed-settings.test.ts | 124 +++++++ web/src/features/vm/seed-settings.ts | 159 ++++++++ 7 files changed, 548 insertions(+), 178 deletions(-) create mode 100644 PRODUCT.md create mode 100644 web/src/features/vm/seed-settings.test.ts create mode 100644 web/src/features/vm/seed-settings.ts 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/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..77715cd1a 100644 --- a/web/src/features/vm/seed-policy-card.tsx +++ b/web/src/features/vm/seed-policy-card.tsx @@ -1,212 +1,255 @@ 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' + DEFAULT_CLAUDE_SETTING, + 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' +export { DEFAULT_CLAUDE_SETTING } -const REJECT_FLAGS: [string, string][] = [ - ['reject_client_settings', '拒客户端 settings'], - ['reject_client_metadata_identity', '拒客户端身份'], -] +type ObservedTelemetry = NonNullable -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} +
+
+ )} +
+ +