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 (
+
+ );
+}
+
+// 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' },
+ });
+});