diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d49de441..643c060ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - VM 详情页按角色收起无权限的查询与按钮,租户与 super 不再反复弹「当前权限无法执行此操作」;切换槽位重置页面状态(代理选择、测试结果、时区、凭证输入);账号页与概览「今日」改用当日计数;开机 kernel 未就绪时给出警告,关机提示会关闭调度;确认框回车不再重复提交;各按钮单独显示进行中;内存提升弹窗重置目标档位。(#521) - 面板其它页面:Anthropic 控制台保存已有地址只提交变更字段,不再覆盖模型、名称与代理;代理池、顶栏与计费页按角色隐藏无权限操作,租户计费不再把 GPT 费用算进 Claude;全槽更新与数据面切换部分失败时显示逐槽明细;风控与模型页不再冲掉未保存草稿,「恢复默认」需确认;日志导出遵循当前筛选;系统提示词页只回写 compatibility;Key 额度用完显示「额度用完」;禁用代理前确认;若干失败误报为成功的提示改为如实报告;健康缓存间隔输入不再逐键钳制。 - 修复集群节点上的槽被 kernel 看门狗每隔几分钟重启:节点槽改经本地中转探测 kernel(超时 3 秒,跨洋往返约 110ms 时 800ms 会误判);中转未就绪时只在容器确实缺失、停止或重启中才拉起;节点槽启动后写回中转 socket 路径。 +- 定时测试「同步到全部槽」改为整块复制:启用开关、频率、时刻/分钟、星期、时区、模型、Prompt、token/effort;Claude 与 Codex 互为另一类的槽只同步开关与时间,保留各自模型与 Prompt;请求先整体校验,不会写一半。定时测试表单每 30 秒刷新,外部修改在未编辑时自动载入,编辑中给出提示而不再静默覆盖;切换槽位重新载入。 ## 1.3.188 — 2026-10-18 diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs index 14ea5121f..1fed44a93 100644 --- a/src/lib/admin/panel-routes.mjs +++ b/src/lib/admin/panel-routes.mjs @@ -748,6 +748,7 @@ export function createPanelHandler(ctx) { scope: body?.scope, vmIds: body?.vm_ids, schedule: body?.schedule, + test: body?.test ?? null, }) return json(res, 200, panel.ok(result)) } catch (error) { diff --git a/src/lib/admin/vm-test-schedule.mjs b/src/lib/admin/vm-test-schedule.mjs index 3ffdfe36b..99f622c6c 100644 --- a/src/lib/admin/vm-test-schedule.mjs +++ b/src/lib/admin/vm-test-schedule.mjs @@ -244,6 +244,36 @@ export function normalizeVmTestSchedule(input, vm) { } } +/** Validated once before any slot is written, so a bad request never half-applies. */ +function normalizeSyncTest(test, structure) { + if (!test || typeof test !== 'object' || Array.isArray(test)) { + throw new VmTestScheduleError('test must be an object') + } + if (typeof test.enabled !== 'boolean') throw new VmTestScheduleError('test.enabled must be a boolean') + const codex = test.reasoning_effort != null && String(test.reasoning_effort).trim() !== '' + const config = normalizeVmTestSchedule( + { + enabled: test.enabled, + ...structure, + model: test.model, + prompt: test.prompt, + max_tokens: test.max_tokens ?? null, + reasoning_effort: test.reasoning_effort ?? null, + }, + { platform: codex ? 'openai' : 'anthropic' }, + ) + return { + enabled: config.enabled, + codex, + dialog: { + model: config.model, + prompt: config.prompt, + max_tokens: config.max_tokens, + reasoning_effort: config.reasoning_effort, + }, + } +} + function compactError(error) { const code = String(error?.code || 'test_failed') .replace(/[\r\n\t]/g, ' ') @@ -491,8 +521,14 @@ export class VmTestScheduleRunner { return this.get(vmId) } - syncSchedules({ scope, vmIds = [], schedule }) { + /** + * `test` copies the whole scheduled test (switch, model, prompt, token/effort). + * A slot of the other kind (Claude vs Codex) keeps its own dialog params, since + * a model of one kind cannot run on the other; switch and timing still apply. + */ + syncSchedules({ scope, vmIds = [], schedule, test = null }) { const structure = normalizeScheduleStructure(schedule) + const full = test == null ? null : normalizeSyncTest(test, structure) let targets = [] if (scope === 'all') { targets = this.listVms().map((vm) => vm.id) @@ -510,6 +546,11 @@ export class VmTestScheduleRunner { missing.push(vmId) continue } + if (full) { + this._applyFullTest(vmId, structure, full) + updated.push(vmId) + continue + } const hadPlan = !!this._settings().get(keyFor(vmId)) this.applyScheduleStructure(vmId, structure, { preserveDialog: hadPlan }) if (!hadPlan) { @@ -525,6 +566,24 @@ export class VmTestScheduleRunner { return { updated, missing, schedule: structure } } + _applyFullTest(vmId, structure, full) { + const vm = this._vm(vmId) + const previous = this._settings().get(keyFor(vmId)) || {} + let dialog + if (isCodexVm(vm) === full.codex) dialog = full.dialog + else if (previous.model && previous.prompt) { + dialog = { + model: previous.model, + prompt: previous.prompt, + max_tokens: previous.max_tokens ?? null, + reasoning_effort: previous.reasoning_effort ?? null, + } + } else dialog = defaultDialogParams(vm) + const config = normalizeVmTestSchedule({ enabled: full.enabled, ...structure, ...dialog }, vm) + this._settings().set(keyFor(vmId), { ...config, ...savedResult(previous), revision: randomUUID() }) + if (this.started) this._syncJob(vmId, config) + } + remove(vmId) { this.jobs.get(vmId)?.stop() this.jobs.delete(vmId) diff --git a/test/unit/vm-test-schedule.test.mjs b/test/unit/vm-test-schedule.test.mjs index 089487177..21249af08 100644 --- a/test/unit/vm-test-schedule.test.mjs +++ b/test/unit/vm-test-schedule.test.mjs @@ -230,3 +230,69 @@ test('unparseable stored cron surfaces parse_error without exposing cron string' assert.equal(view.parse_error, true) assert.equal(view.cron, undefined) }) + +test('sync with test copies switch and dialog to every slot of the same kind', () => { + const codexVm = { id: 'vm-02', platform: 'openai' } + const otherClaude = { id: 'vm-03', platform: 'anthropic' } + const { settings, schedule } = runner({ list: () => [claudeVm, codexVm, otherClaude] }) + schedule.save('vm-01', { ...claudeConfig, enabled: false }) + schedule.save('vm-02', { + enabled: false, + frequency: 'daily', + time: '08:00', + timezone: 'UTC', + model: 'gpt-5.5', + prompt: 'codex-own', + max_tokens: null, + reasoning_effort: 'low', + }) + const result = schedule.syncSchedules({ + scope: 'all', + schedule: normalizeScheduleStructure({ frequency: 'hourly', minute: 0, timezone: 'Asia/Shanghai' }), + test: { enabled: true, model: 'claude-haiku-5-5', prompt: 'ping', max_tokens: 32 }, + }) + assert.deepEqual(result.updated.sort(), ['vm-01', 'vm-02', 'vm-03']) + for (const id of ['vm-01', 'vm-03']) { + const row = settings.get(`${VM_TEST_SCHEDULE_PREFIX}${id}`) + assert.equal(row.enabled, true) + assert.equal(row.model, 'claude-haiku-5-5') + assert.equal(row.prompt, 'ping') + assert.equal(row.max_tokens, 32) + assert.equal(row.frequency, 'hourly') + assert.equal(row.timezone, 'Asia/Shanghai') + } + const codex = settings.get(`${VM_TEST_SCHEDULE_PREFIX}vm-02`) + assert.equal(codex.enabled, true, 'the switch applies to every slot') + assert.equal(codex.prompt, 'codex-own', 'a Codex slot keeps its own model and prompt') + assert.equal(codex.reasoning_effort, 'low') + assert.equal(codex.max_tokens, null) + + schedule.syncSchedules({ + scope: 'all', + schedule: normalizeScheduleStructure({ frequency: 'hourly', minute: 0, timezone: 'Asia/Shanghai' }), + test: { enabled: false, model: 'claude-haiku-5-5', prompt: 'ping', max_tokens: 32 }, + }) + for (const id of ['vm-01', 'vm-02', 'vm-03']) { + assert.equal(settings.get(`${VM_TEST_SCHEDULE_PREFIX}${id}`).enabled, false, 'switching off also syncs') + } +}) + +test('sync with an invalid test writes nothing', () => { + const { settings, schedule } = runner({ list: () => [claudeVm, { id: 'vm-03', platform: 'anthropic' }] }) + schedule.save('vm-01', { ...claudeConfig, enabled: false }) + const before = settings.all() + for (const test of [ + { enabled: 'yes', model: 'm', prompt: 'p', max_tokens: 1 }, + { enabled: true, model: '', prompt: 'p', max_tokens: 1 }, + { enabled: true, model: 'm', prompt: 'p' }, + ]) { + assert.throws(() => + schedule.syncSchedules({ + scope: 'all', + schedule: normalizeScheduleStructure({ frequency: 'hourly', minute: 0, timezone: 'Asia/Shanghai' }), + test, + }), + ) + } + assert.deepEqual(settings.all(), before) +}) diff --git a/web/src/features/vm/detail-page.tsx b/web/src/features/vm/detail-page.tsx index 5342cf8a6..c010dd582 100644 --- a/web/src/features/vm/detail-page.tsx +++ b/web/src/features/vm/detail-page.tsx @@ -534,7 +534,7 @@ function VmDetailBody({ credType={credType} isCodex={isCodexVm(vm)} dataplane={vm.resolved_dataplane} - schedule={} + schedule={} /> ) : null} {perms.admin ? ( diff --git a/web/src/features/vm/vm-test-dialog.tsx b/web/src/features/vm/vm-test-dialog.tsx index 86a38b32a..cfc18ccd0 100644 --- a/web/src/features/vm/vm-test-dialog.tsx +++ b/web/src/features/vm/vm-test-dialog.tsx @@ -22,7 +22,7 @@ function TestBody({ vm }: { vm: Vm }) { credType={credTypeOf(vm)} isCodex={isCodexVm(vm)} dataplane={vm.resolved_dataplane} - schedule={} + schedule={} /> ) } diff --git a/web/src/features/vm/vm-test-schedule.test.ts b/web/src/features/vm/vm-test-schedule.test.ts new file mode 100644 index 000000000..6a20db35f --- /dev/null +++ b/web/src/features/vm/vm-test-schedule.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { + reconcileSchedule, + type FormState, + type TestSchedule, +} from './vm-test-schedule' + +const stored = (over: Partial = {}): TestSchedule => ({ + enabled: false, + frequency: 'hourly', + minute: 0, + timezone: 'Asia/Shanghai', + model: 'claude-haiku-5-5', + prompt: 'hello', + max_tokens: 64, + reasoning_effort: null, + last_run_at: null, + last_ok: null, + last_duration_ms: null, + last_error: null, + next_run_at: null, + running: false, + ...over, +}) + +const formOf = (s: TestSchedule, over: Partial = {}): FormState => ({ + enabled: s.enabled, + structure: { + frequency: s.frequency, + minute: s.minute, + time: '09:00', + weekdays: [1], + timezone: s.timezone, + }, + model: s.model, + prompt: s.prompt, + maxTokens: s.max_tokens ?? 0, + reasoningEffort: '', + codex: false, + ...over, +}) + +describe('scheduled test form follows changes made elsewhere', () => { + it('a plan enabled elsewhere replaces an untouched form', () => { + const base = stored() + expect( + reconcileSchedule(formOf(base), base, stored({ enabled: true })) + ).toBe('adopt') + }) + + it('a refresh with the same plan or bookkeeping-only changes keeps the form', () => { + const base = stored() + expect( + reconcileSchedule( + formOf(base), + base, + stored({ last_run_at: '2026-10-18T00:00:00Z', last_ok: true }) + ) + ).toBe('keep') + }) + + it('never overwrites edits in progress; warns instead', () => { + const base = stored() + const editing = formOf(base, { prompt: 'edited' }) + expect(reconcileSchedule(editing, base, stored({ enabled: true }))).toBe( + 'stale' + ) + }) + + it('after a sync the form already shows the new plan', () => { + const base = stored() + const next = stored({ enabled: true }) + expect(reconcileSchedule(formOf(next), base, next)).toBe('track') + }) +}) diff --git a/web/src/features/vm/vm-test-schedule.tsx b/web/src/features/vm/vm-test-schedule.tsx index 32d8877bb..5ee3c0628 100644 --- a/web/src/features/vm/vm-test-schedule.tsx +++ b/web/src/features/vm/vm-test-schedule.tsx @@ -33,7 +33,7 @@ import { TimezonePicker } from '@/features/vm/timezone-picker' type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' -type TestSchedule = { +export type TestSchedule = { enabled: boolean frequency: ScheduleFrequency minute?: number @@ -53,7 +53,7 @@ type TestSchedule = { running: boolean } -type ScheduleStructure = Pick< +export type ScheduleStructure = Pick< TestSchedule, 'frequency' | 'minute' | 'time' | 'weekdays' | 'timezone' > @@ -132,6 +132,56 @@ function structureFromSchedule( } } +export type FormState = { + enabled: boolean + structure: ScheduleStructure + model: string + prompt: string + maxTokens: number + reasoningEffort: string + codex: boolean +} + +/** + * A refreshed plan: unchanged → keep; form already shows it → track; form + * untouched since the last load → adopt; user is editing → stale (warn, never overwrite). + */ +export function reconcileSchedule( + form: FormState, + base: TestSchedule | null, + schedule: TestSchedule +): 'keep' | 'track' | 'adopt' | 'stale' { + if (base && formShows(formFrom(base), schedule)) return 'keep' + if (formShows(form, schedule)) return 'track' + if (!base || formShows(form, base)) return 'adopt' + return 'stale' +} + +function formFrom(schedule: TestSchedule): FormState { + return { + enabled: schedule.enabled, + structure: structureFromSchedule(schedule), + model: schedule.model, + prompt: schedule.prompt, + maxTokens: schedule.max_tokens ?? 0, + reasoningEffort: schedule.reasoning_effort ?? '', + codex: schedule.reasoning_effort != null, + } +} + +/** True when the form still shows exactly this stored plan. */ +function formShows(form: FormState, schedule: TestSchedule) { + return ( + form.enabled === schedule.enabled && + structureEqual(form.structure, structureFromSchedule(schedule)) && + form.model === schedule.model && + form.prompt.trim() === schedule.prompt && + (form.codex + ? form.reasoningEffort === schedule.reasoning_effort + : form.maxTokens === schedule.max_tokens) + ) +} + function structureEqual(a: ScheduleStructure, b: ScheduleStructure) { return ( a.frequency === b.frequency && @@ -162,6 +212,9 @@ export function VmTestSchedule({ const queryKey = ['panel', 'vm', vm.id, 'test-schedule'] as const const endpoint = `/api/panel/vms/${encodeURIComponent(vm.id)}/test-schedule` const loaded = useRef(false) + // The stored plan the form was last filled from; later refreshes compare against it. + const base = useRef(null) + const [stale, setStale] = useState(false) const [enabled, setEnabled] = useState(false) const [structure, setStructure] = useState(defaultStructure) @@ -175,8 +228,8 @@ export function VmTestSchedule({ { signal } ), retry: false, - refetchInterval: (query) => - query.state.data?.schedule?.enabled ? 30_000 : false, + // Another tab, another admin or "sync to all" may change this plan while it is open. + refetchInterval: 30_000, }) const defaultQuery = useQuery({ queryKey: ['panel', 'test-schedule', 'default'] as const, @@ -186,22 +239,48 @@ export function VmTestSchedule({ }) const saved = scheduleQuery.data?.schedule || null + const selectedModel = model || models[0]?.id || '' + const form: FormState = { + enabled, + structure, + model: selectedModel, + prompt, + maxTokens, + reasoningEffort, + codex, + } + const adopt = (schedule: TestSchedule) => { + base.current = schedule + setStale(false) + setEnabled(schedule.enabled) + setStructure(structureFromSchedule(schedule)) + onLoadSchedule(schedule) + } + useEffect(() => { - if (loaded.current || !scheduleQuery.data) return - loaded.current = true + if (!scheduleQuery.data) return const schedule = scheduleQuery.data.schedule - if (!schedule) { - if (defaultQuery.data?.schedule) { + if (!loaded.current) { + loaded.current = true + if (schedule) adopt(schedule) + else if (defaultQuery.data?.schedule) { setStructure(structureFromSchedule(defaultQuery.data.schedule)) } return } - setEnabled(schedule.enabled) - setStructure(structureFromSchedule(schedule)) - onLoadSchedule(schedule) - }, [defaultQuery.data?.schedule, onLoadSchedule, scheduleQuery.data]) + if (!schedule) { + base.current = null + return + } + const action = reconcileSchedule(form, base.current, schedule) + if (action === 'track') { + base.current = schedule + setStale(false) + } else if (action === 'adopt') adopt(schedule) + else if (action === 'stale') setStale(true) + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-run on new data, not on each keystroke + }, [defaultQuery.data?.schedule, scheduleQuery.data]) - const selectedModel = model || models[0]?.id || '' const savedStructure = useMemo( () => (saved ? structureFromSchedule(saved) : null), [saved] @@ -235,6 +314,8 @@ export function VmTestSchedule({ onSuccess: (data) => { qc.setQueryData(queryKey, data) if (data.schedule) { + base.current = data.schedule + setStale(false) setEnabled(data.schedule.enabled) setStructure(structureFromSchedule(data.schedule)) onPromptChange(data.schedule.prompt) @@ -251,6 +332,8 @@ export function VmTestSchedule({ mutationFn: () => api(endpoint, { method: 'DELETE' }), onSuccess: (data) => { qc.setQueryData(queryKey, data) + base.current = null + setStale(false) setEnabled(false) setStructure(defaultStructure()) toast.success('测试计划已删除') @@ -278,11 +361,33 @@ export function VmTestSchedule({ scope, ...(scope === 'ids' ? { vm_ids: [vm.id] } : {}), schedule: structure, + // "All slots" copies the whole scheduled test, switch included. + ...(scope === 'all' + ? { + test: { + enabled, + model: selectedModel, + prompt: prompt.trim(), + ...(codex + ? { reasoning_effort: reasoningEffort } + : { max_tokens: maxTokens }), + }, + } + : {}), }), }), - onSuccess: (data) => { - toast.success(`已覆盖 ${data.updated.length} 个槽的调度时间`) - void scheduleQuery.refetch() + onSuccess: (data, scope) => { + toast.success( + scope === 'all' + ? `已把定时测试同步到 ${data.updated.length} 个槽` + : `已覆盖 ${data.updated.length} 个槽的调度时间` + ) + void qc.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'panel' && + query.queryKey[1] === 'vm' && + query.queryKey[3] === 'test-schedule', + }) }, onError: (error: Error) => toast.error(error.message), onSettled: () => { @@ -325,6 +430,24 @@ export function VmTestSchedule({ + {stale ? ( +
+ 此槽的计划已在别处修改,保存会覆盖它。 + +
+ ) : null} + {scheduleQuery.isPending ? (

正在读取计划…

) : scheduleQuery.isError ? ( @@ -490,7 +613,7 @@ export function VmTestSchedule({