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 @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/lib/admin/panel-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
61 changes: 60 additions & 1 deletion src/lib/admin/vm-test-schedule.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' ')
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions test/unit/vm-test-schedule.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
2 changes: 1 addition & 1 deletion web/src/features/vm/detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ function VmDetailBody({
credType={credType}
isCodex={isCodexVm(vm)}
dataplane={vm.resolved_dataplane}
schedule={<VmTestSchedule vm={vm} {...test} />}
schedule={<VmTestSchedule key={vm.id} vm={vm} {...test} />}
/>
) : null}
{perms.admin ? (
Expand Down
2 changes: 1 addition & 1 deletion web/src/features/vm/vm-test-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function TestBody({ vm }: { vm: Vm }) {
credType={credTypeOf(vm)}
isCodex={isCodexVm(vm)}
dataplane={vm.resolved_dataplane}
schedule={<VmTestSchedule vm={vm} {...chat} />}
schedule={<VmTestSchedule key={vm.id} vm={vm} {...chat} />}
/>
)
}
Expand Down
75 changes: 75 additions & 0 deletions web/src/features/vm/vm-test-schedule.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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')
})
})
Loading
Loading