From ac28da7da4e20ff2268c11e1337d2b7c4e5e4f4e Mon Sep 17 00:00:00 2001 From: Dan Mo Date: Wed, 28 Oct 2026 06:52:56 +1100 Subject: [PATCH] studio-app: admin machine start rules panel (h3-studio#695) Admins can read, preview and save the prepared start rules per exact pool/configuration/mode, with the audit reason, Idempotency-Key replay, confirmation digest and change history from the #695 routes. Until the planner is switched on the panel says these are prepared settings only. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + studio-app/src/AdminHome.jsx | 2 + studio-app/src/StartRulesPanel.jsx | 309 ++++++++++++++++++++++++ studio-app/src/capacity-settings.css | 47 ++++ studio-app/src/i18n/capacityCatalogs.js | 63 +++++ studio-app/src/start-rules-model.js | 78 ++++++ studio-app/src/start-rules.test.js | 79 ++++++ 7 files changed, 581 insertions(+) create mode 100644 studio-app/src/StartRulesPanel.jsx create mode 100644 studio-app/src/start-rules-model.js create mode 100644 studio-app/src/start-rules.test.js diff --git a/.gitignore b/.gitignore index 990fe735..9e16fc1f 100644 --- a/.gitignore +++ b/.gitignore @@ -468,3 +468,6 @@ design/inkseq-v2-prototype/dist/ !studio-app/src/inky/inky.css !studio-app/src/inky/inkyCatalogs.js !studio-app/src/inky-model.test.js +!studio-app/src/StartRulesPanel.jsx +!studio-app/src/start-rules-model.js +!studio-app/src/start-rules.test.js diff --git a/studio-app/src/AdminHome.jsx b/studio-app/src/AdminHome.jsx index 89f54ffa..b375d48d 100644 --- a/studio-app/src/AdminHome.jsx +++ b/studio-app/src/AdminHome.jsx @@ -15,6 +15,7 @@ import { } from 'lucide-react'; import StudioHeader from './StudioHeader.jsx'; import CapacitySettings from './CapacitySettings.jsx'; +import StartRulesPanel from './StartRulesPanel.jsx'; import { AdminBudgetPanel } from './OperatorBudgetPanel.jsx'; import { createP4OperatorController } from './p4-operator-controller.js'; import { readBrowserLocale } from './i18n/locale.js'; @@ -602,6 +603,7 @@ function Limits({ api, active, account }) { ) : ( {t('adminHome.noPolicy')} )} + diff --git a/studio-app/src/StartRulesPanel.jsx b/studio-app/src/StartRulesPanel.jsx new file mode 100644 index 00000000..ecaed9a0 --- /dev/null +++ b/studio-app/src/StartRulesPanel.jsx @@ -0,0 +1,309 @@ +import React, { useEffect, useEffectEvent, useState } from 'react'; +import { SettingRow, SettingsSection } from './ui/Settings.jsx'; +import { Disclosure } from './ui/Disclosure.jsx'; +import { Button } from './ui/Button.jsx'; +import { Badge, Note } from './ui/Note.jsx'; +import { Spinner } from './ui/Field.jsx'; +import { useI18n } from './i18n/I18nProvider.jsx'; +import { duration } from './admin-overview-model.js'; +import { apiDate } from './credits.js'; +import { + START_RULE_FIELDS, + draftFromRules, + parseRules, + refusalCode, + ruleTargets, + sameRules, + targetLabels, +} from './start-rules-model.js'; + +const PATH = '/v1/operator/dstack/admin/start-rules'; +const CHANGED = ['start_rules_conflict', 'start_rules_confirmation_changed']; + +function RuleChanges({ before, after }) { + const { t } = useI18n(); + const text = (id, value) => (id === 'minimum_jobs' ? value : duration(value)); + return ( +
    + {START_RULE_FIELDS.filter(([id]) => before?.[id] !== after[id]).map(([id]) => ( +
  • + {t('rules.field.' + id)}: {text(id, before?.[id])} → {text(id, after[id])} +
  • + ))} +
+ ); +} + +function RuleHistory({ api, profileId }) { + const { t, locale } = useI18n(); + const [items, setItems] = useState(null); + const load = () => + api + .request(PATH + '/history?profile_id=' + encodeURIComponent(profileId)) + .then((body) => setItems(body.items || [])) + .catch(() => setItems([])); + return ( + event.currentTarget.open && items === null && void load()} + > + {items === null ? ( + + ) : items.length ? ( +
    + {items.map((item) => ( +
  • + + {apiDate(item.at)?.toLocaleString(locale) || ''} ·{' '} + {String(item.actor_id || '').replace(/^browser:/, '')} + + + {item.reason && {item.reason}} +
  • + ))} +
+ ) : ( +

{t('rules.noHistory')}

+ )} +
+ ); +} + +function TargetPicker({ targets, value, busy, onChange }) { + const { t } = useI18n(); + const { labels, shared } = targetLabels(targets); + if (targets.length === 1) return

{labels[targets[0].id]}

; + return ( + + + + ); +} + +function RuleConfirm({ preview, reason, busy, onReason, onCancel, onSave }) { + const { t } = useI18n(); + return ( +
{ + event.preventDefault(); + onSave(); + }} + > + {t('rules.reviewTitle')} + + +
+ + +
+ + ); +} + +// Admin start rules for the automatic GPU start planner. Until the planner is switched on +// (`execution_connected`), these are prepared settings that nothing uses yet. +export default function StartRulesPanel({ api, active }) { + const { t } = useI18n(); + const [targets, setTargets] = useState(null), + [profileId, setProfileId] = useState(''), + [rules, setRules] = useState(null), + [draft, setDraft] = useState(draftFromRules()), + [review, setReview] = useState(null), + [reason, setReason] = useState(''), + [busy, setBusy] = useState(false), + [notice, setNotice] = useState(''), + [error, setError] = useState(''); + + const loadTargets = useEffectEvent(() => + api.request('/v1/operator/dstack/catalog').then( + (body) => { + const list = ruleTargets(body.profiles); + setTargets(list); + setProfileId((current) => current || list[0]?.id || ''); + }, + () => setTargets([]), + ), + ); + useEffect(() => { + if (active) void loadTargets(); + }, [api, active]); + + const loadRules = async () => { + const body = await api.request(PATH + '?profile_id=' + encodeURIComponent(profileId)); + setRules(body); + setDraft(draftFromRules(body.value)); + setReview(null); + }; + const loadRulesEvent = useEffectEvent(loadRules); + useEffect(() => { + if (!profileId) return; + setRules(null); + setNotice(''); + setError(''); + loadRulesEvent().catch((failure) => setError(failure.message)); + }, [profileId]); + + if (targets === null) return ; + if (!targets.length) return null; + + const parsed = parseRules(draft); + const changed = rules && parsed.value && !sameRules(parsed.value, rules.value); + const edit = (id, text) => { + setReview(null); + setNotice(''); + setDraft((current) => ({ ...current, [id]: text })); + }; + const fail = (failure) => { + const code = refusalCode(failure); + if (CHANGED.includes(code)) { + setError(t('rules.err.changed')); + void loadRules().catch(() => {}); + } else setError(code === 'start_rules_invalid' ? t('rules.err.invalid') : failure.message); + }; + const run = async (step) => { + setBusy(true); + setError(''); + try { + await step(); + } catch (failure) { + fail(failure); + } finally { + setBusy(false); + } + }; + const check = () => + run(async () => { + const body = { + profile_id: profileId, + expected_revision: rules.revision, + value: parsed.value, + }; + const preview = await api.request(PATH + '/preview', { method: 'POST', body }); + // One key per reviewed change, so a retried save replays instead of writing twice. + setReview({ body, preview, key: 'start-rules-' + crypto.randomUUID() }); + }); + const save = () => + run(async () => { + const saved = await api.request(PATH, { + method: 'PUT', + key: review.key, + body: { + ...review.body, + reason: reason.trim(), + confirmation_digest: review.preview.confirmation_digest, + }, + }); + setRules(saved); + setDraft(draftFromRules(saved.value)); + setReview(null); + setReason(''); + setNotice(t(saved.execution_connected ? 'rules.saved' : 'rules.savedPrepared')); + }); + + const value = parsed.value || rules?.value; + return ( + + {t('rules.title')}{' '} + {rules && ( + + {t(rules.execution_connected ? 'rules.inUse' : 'rules.prepared')} + + )} + + } + help={t('rules.intro')} + actions={ + review ? null : ( + + ) + } + > + {rules && !rules.execution_connected && {t('rules.notConnected')}} + + {!rules ? ( + error ? null : ( + + ) + ) : ( + <> + {START_RULE_FIELDS.map(([id, min, max]) => ( + + {t('rules.err.range', { min, max })} + + ) + } + > + edit(id, event.target.value)} + /> + + ))} + {value && ( +

+ {t('rules.summary', { + boot: duration(value.boot_seconds), + jobs: value.minimum_jobs, + job: duration(value.job_seconds), + })} +

+ )} + + )} + {review && ( + setReview(null)} + onSave={() => void save()} + /> + )} + {error && ( +

+ {error} +

+ )} + {notice && {notice}} + {rules && } +
+ ); +} diff --git a/studio-app/src/capacity-settings.css b/studio-app/src/capacity-settings.css index 06764926..7b0144be 100644 --- a/studio-app/src/capacity-settings.css +++ b/studio-app/src/capacity-settings.css @@ -236,3 +236,50 @@ font-weight: 400; font-size: 12px; } + +/* Machine start rules (h3-studio#695) share the settings layout. */ +.rules { + margin-top: 24px; +} +.rules-target { + margin: 0; +} +.rules-summary { + margin: 0; + padding: 12px 14px; + border-radius: 10px; + background: var(--surface-2); + font-size: 13px; +} +.rules-diff, +.rules-history { + margin: 0; + padding-left: 18px; + display: grid; + gap: 4px; +} +.rules-history > li { + display: grid; + gap: 2px; + padding: 8px 0; +} +.rules-confirm { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; +} +.rules-confirm label { + display: grid; + gap: 6px; +} +.rules-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +/* Machine names are long and differ only at the end (pool); show them whole. */ +.ui-settings.rules .ui-setting-control select { + width: min(480px, 100%); +} diff --git a/studio-app/src/i18n/capacityCatalogs.js b/studio-app/src/i18n/capacityCatalogs.js index 9d3b5061..d0a74f90 100644 --- a/studio-app/src/i18n/capacityCatalogs.js +++ b/studio-app/src/i18n/capacityCatalogs.js @@ -1,5 +1,39 @@ export const capacityCatalogs = { en: { + 'rules.title': 'Machine start rules', + 'rules.intro': + 'How the automatic start planner decides when one more machine is worth starting, and how long an idle machine stays on.', + 'rules.prepared': 'Prepared', + 'rules.inUse': 'In use', + 'rules.notConnected': + 'Prepared settings: the planner that reads them is not switched on yet, so machines start and stop exactly as they do today.', + 'rules.target': 'Machine type', + 'rules.targetHelp': 'Each model, mode and pool has its own rules.', + 'rules.field.job_seconds': 'Time per job', + 'rules.field.boot_seconds': 'Time to boot', + 'rules.field.minimum_jobs': 'Jobs per extra machine', + 'rules.help.job_seconds': 'Expected generation time for one job on a ready machine.', + 'rules.help.boot_seconds': + 'From starting a machine to its first job. An idle machine also stays on this long before it stops.', + 'rules.help.minimum_jobs': + 'Start one more machine for every this many jobs still waiting once a new machine is ready.', + 'rules.unit.seconds': 's', + 'rules.unit.jobs': 'jobs', + 'rules.summary': + 'With these numbers: if at least {jobs} jobs would still be waiting after a {boot} boot (about {job} each), start another machine. With work queued and no machine, one machine always starts.', + 'rules.check': 'Review change', + 'rules.reviewTitle': 'Save this change?', + 'rules.reason': 'Reason (kept in the audit log)', + 'rules.save': 'Save rules', + 'rules.saved': 'Saved. The next start decision uses the new rules.', + 'rules.savedPrepared': + 'Saved as prepared rules. Nothing uses them until the planner is switched on.', + 'rules.history': 'Change history', + 'rules.noHistory': 'No changes yet. These are the default rules.', + 'rules.err.changed': + 'Someone changed these rules. The latest values are loaded; check them and try again.', + 'rules.err.invalid': 'The server refused these values. Check each number is in range.', + 'rules.err.range': 'Use a whole number from {min} to {max}.', 'capacity.title': 'Machines and spending limits', 'capacity.intro': 'Every number here is a setting. Changes apply to the next machine or job that starts; work already running is not stopped.', @@ -109,6 +143,35 @@ export const capacityCatalogs = { 'These are counted as unknown, not as zero. Reconcile them in the supplier console.', }, zh: { + 'rules.title': '开机规则', + 'rules.intro': '自动开机在什么时候值得多开一台机器,以及空闲机器保留多久。', + 'rules.prepared': '已准备', + 'rules.inUse': '使用中', + 'rules.notConnected': + '这些是预先准备的设置:读取它们的开机规划还没打开,机器的开关方式和现在完全一样。', + 'rules.target': '机器类型', + 'rules.targetHelp': '每种模型、模式和机器池各有一套规则。', + 'rules.field.job_seconds': '每个任务用时', + 'rules.field.boot_seconds': '开机用时', + 'rules.field.minimum_jobs': '每多开一台需要的任务数', + 'rules.help.job_seconds': '一台准备好的机器生成一个任务的预计用时。', + 'rules.help.boot_seconds': '从开机到开始第一个任务的时间。空闲机器也会保留这么久再关机。', + 'rules.help.minimum_jobs': '新机器准备好时,还在排队的任务每满这么多个,就多开一台。', + 'rules.unit.seconds': '秒', + 'rules.unit.jobs': '个任务', + 'rules.summary': + '按这些数字:开机 {boot} 后如果还有至少 {jobs} 个任务在排队(每个约 {job}),就再开一台。有任务排队又没有机器时,总会开一台。', + 'rules.check': '检查改动', + 'rules.reviewTitle': '保存这个改动?', + 'rules.reason': '原因(会记入审计日志)', + 'rules.save': '保存规则', + 'rules.saved': '已保存。下一次开机判断会用新规则。', + 'rules.savedPrepared': '已保存为准备好的规则。开机规划打开前不会用到。', + 'rules.history': '修改记录', + 'rules.noHistory': '还没有改过,现在是默认规则。', + 'rules.err.changed': '这些规则刚被别人改过。已载入最新数值,请核对后再试。', + 'rules.err.invalid': '服务器拒绝了这些数值。请检查每个数字是否在范围内。', + 'rules.err.range': '请填 {min} 到 {max} 之间的整数。', 'capacity.title': '机器和花费上限', 'capacity.intro': '这里每个数字都是设置,可以随时改。改动从下一台开机或下一个任务开始生效;已经在跑的不会被停掉。', diff --git a/studio-app/src/start-rules-model.js b/studio-app/src/start-rules-model.js new file mode 100644 index 00000000..95ed5a35 --- /dev/null +++ b/studio-app/src/start-rules-model.js @@ -0,0 +1,78 @@ +// Prepared machine start rules (h3-studio#695): one rule set per exact pool, configuration and +// mode. The server validates again; this only keeps the form honest before asking for a preview. +export const START_RULE_FIELDS = [ + ['job_seconds', 1, 86400], + ['boot_seconds', 1, 86400], + ['minimum_jobs', 1, 10000], +]; + +// Profiles that share pool, configuration and mode share one rule set; keep the first of each. +export function ruleTargets(profiles = []) { + const seen = new Map(); + for (const profile of profiles) { + if (!profile?.id || !profile.pool || !profile.configuration_id || !profile.mode) continue; + const key = [profile.pool, profile.configuration_id, profile.mode].join('\u0000'); + if (!seen.has(key)) seen.set(key, profile); + } + return [...seen.values()].sort( + (a, b) => + String(a.model_id).localeCompare(String(b.model_id)) || + String(a.mode).localeCompare(String(b.mode)) || + String(a.pool).localeCompare(String(b.pool)), + ); +} + +export function draftFromRules(value = {}) { + return Object.fromEntries(START_RULE_FIELDS.map(([id]) => [id, String(value[id] ?? '')])); +} + +// Whole numbers within the server's bounds; returns the value to send or the first bad field. +export function parseRules(draft) { + const value = {}; + for (const [id, min, max] of START_RULE_FIELDS) { + const text = String(draft[id] ?? '').trim(); + const number = /^\d+$/.test(text) ? Number(text) : NaN; + if (!Number.isSafeInteger(number) || number < min || number > max) + return { error: { id, min, max } }; + value[id] = number; + } + return { value }; +} + +export function sameRules(a, b) { + return START_RULE_FIELDS.every(([id]) => a?.[id] === b?.[id]); +} + +// Operator routes refuse with {code, message}; FastAPI defaults use {detail}. +export function refusalCode(failure) { + const detail = failure?.detail?.detail ?? failure?.detail; + return typeof detail === 'string' ? detail : detail?.code || ''; +} + +// Option names show only what tells the targets apart; the parts they all share come back +// separately. The configuration is added only when model, mode, GPU and pool still collide. +export function targetLabels(targets = []) { + const parts = (item) => [ + item.model_id, + item.mode?.toUpperCase(), + (item.gpu_names || []).join('/'), + item.pool, + ]; + const rows = targets.map(parts); + const same = (column) => rows.length > 1 && rows.every((row) => row[column] === rows[0][column]); + const shared = rows.length ? rows[0].filter((part, column) => part && same(column)) : []; + const names = rows.map((row) => row.filter((part, column) => part && !same(column)).join(' · ')); + const counts = new Map(); + for (const name of names) counts.set(name, (counts.get(name) || 0) + 1); + return { + shared: shared.join(' · '), + labels: Object.fromEntries( + targets.map((item, index) => [ + item.id, + counts.get(names[index]) > 1 + ? [names[index], item.configuration_id].filter(Boolean).join(' · ') + : names[index], + ]), + ), + }; +} diff --git a/studio-app/src/start-rules.test.js b/studio-app/src/start-rules.test.js new file mode 100644 index 00000000..15581543 --- /dev/null +++ b/studio-app/src/start-rules.test.js @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + draftFromRules, + parseRules, + refusalCode, + ruleTargets, + sameRules, + targetLabels, +} from './start-rules-model.js'; + +test('one start-rule target per exact pool, configuration and mode', () => { + const profile = (id, mode, configuration_id = 'cfg', pool = 'vast') => ({ + id, + mode, + configuration_id, + pool, + model_id: 'wan', + }); + const targets = ruleTargets([ + profile('a', 'ref'), + profile('b', 'ref'), + profile('c', 'fl'), + profile('d', 'ref', 'cfg2'), + { id: 'x', mode: 'ref' }, + ]); + assert.deepEqual( + targets.map((item) => item.id), + ['c', 'a', 'd'], + ); +}); + +test('start rules accept only whole numbers inside the server bounds', () => { + const draft = draftFromRules({ job_seconds: 30, boot_seconds: 600, minimum_jobs: 20 }); + assert.deepEqual(draft, { job_seconds: '30', boot_seconds: '600', minimum_jobs: '20' }); + assert.deepEqual(parseRules(draft).value, { + job_seconds: 30, + boot_seconds: 600, + minimum_jobs: 20, + }); + for (const bad of ['0', '1.5', '-3', '', '86401', 'abc']) + assert.equal(parseRules({ ...draft, job_seconds: bad }).error.id, 'job_seconds', bad); + assert.equal(parseRules({ ...draft, minimum_jobs: '10001' }).error.id, 'minimum_jobs'); + assert.ok( + sameRules(parseRules(draft).value, { job_seconds: 30, boot_seconds: 600, minimum_jobs: 20 }), + ); + assert.ok( + !sameRules(parseRules(draft).value, { job_seconds: 31, boot_seconds: 600, minimum_jobs: 20 }), + ); +}); + +test('start-rule refusal codes read both FastAPI shapes', () => { + assert.equal(refusalCode({ detail: { detail: 'start_rules_conflict' } }), 'start_rules_conflict'); + assert.equal(refusalCode({ detail: { code: 'start_rules_invalid' } }), 'start_rules_invalid'); + assert.equal(refusalCode(new Error('x')), ''); +}); + +test('start-rule targets are named by what differs', () => { + const item = (id, pool, configuration_id) => ({ + id, + pool, + configuration_id, + mode: 'ref', + model_id: 'm', + gpu_names: ['RTX5090'], + }); + assert.deepEqual(targetLabels([item('a', 'vast', 'x'), item('b', 'lium', 'y')]), { + shared: 'm · REF · RTX5090', + labels: { a: 'vast', b: 'lium' }, + }); + assert.deepEqual(targetLabels([item('a', 'vast', 'x'), item('b', 'vast', 'y')]), { + shared: 'm · REF · RTX5090 · vast', + labels: { a: 'x', b: 'y' }, + }); + assert.deepEqual(targetLabels([item('a', 'vast', 'x')]), { + shared: '', + labels: { a: 'm · REF · RTX5090 · vast' }, + }); +});