add graceful checkbox to restart confirm dialog

Restart now offers the same graceful-vs-hard choice stop already has:
single-agent menu item and the bulk-select action bar both grow a
'restart gracefully' checkbox that routes through the existing
submit::graceful_restart DAG (signal -> drain -> stop -> reconcile)
instead of a hard restart. Backend gains a ?graceful=true query param
on POST /api/restart/<name>, mirroring post_kill's shape (renamed
KillParams -> GracefulParams since it's now shared).
This commit is contained in:
iris 2026-07-15 17:44:57 +02:00 committed by mara
commit d8567fc546
2 changed files with 27 additions and 8 deletions

View file

@ -244,7 +244,7 @@ function buildAgentMenu(c, forgeBase) {
danger: true, danger: true,
confirmLabel: opts.confirmLabel || 'confirm', confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful checkboxes: opts.graceful
? [{ name: 'graceful', label: 'stop gracefully — let the agent finish its turn and flush state before the container stops' }] ? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
: [], : [],
}); });
if (!r) return; if (!r) return;
@ -277,7 +277,12 @@ function buildAgentMenu(c, forgeBase) {
// Show only actions that are applicable in the current state. // Show only actions that are applicable in the current state.
if (c.running) { if (c.running) {
dropdown.append( dropdown.append(
menuItem('↺ R3ST4RT', { action: '/api/restart/', confirm: `restart ${c.name}?` }), menuItem('↺ R3ST4RT', {
action: '/api/restart/',
confirm: `restart ${c.name}?`,
graceful: true,
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
}),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }), menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
); );
} else { } else {
@ -929,6 +934,8 @@ export function renderSelectionBar(containers) {
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, { addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
action: '/api/restart/', action: '/api/restart/',
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
graceful: true,
gracefulLabel: 'restart gracefully — let each agent finish its turn and flush state before the container restarts',
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)), disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
}); });
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, { addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
@ -1106,7 +1113,7 @@ function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
danger: true, danger: true,
confirmLabel: opts.confirmLabel || 'confirm', confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful checkboxes: opts.graceful
? [{ name: 'graceful', label: 'stop gracefully — let each agent finish its turn and flush state before the container stops' }] ? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let each agent finish its turn and flush state before the container stops' }]
: [], : [],
}); });
if (!r) return; if (!r) return;

View file

@ -15,11 +15,12 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
/// Query params for `post_kill`. `?graceful=1` routes to the graceful-stop /// Query params for `post_kill` / `post_restart`. `?graceful=1` routes to
/// orchestration (quiesce the harness, flush `/state`, then container stop) /// the graceful-stop/-restart orchestration (quiesce the harness, flush
/// instead of an immediate hard stop. Defaults false → today's hard kill. /// `/state`, then container stop/restart) instead of an immediate hard
/// action. Defaults false → today's hard kill/restart.
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct KillParams { pub(super) struct GracefulParams {
#[serde(default)] #[serde(default)]
graceful: bool, graceful: bool,
} }
@ -48,7 +49,7 @@ pub(super) async fn post_rebuild(
pub(super) async fn post_kill( pub(super) async fn post_kill(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
Query(params): Query<KillParams>, Query(params): Query<GracefulParams>,
) -> Response { ) -> Response {
let logical = strip_container_prefix(&name); let logical = strip_container_prefix(&name);
if let Some(reject) = guard_agent_name(&state, &logical).await { if let Some(reject) = guard_agent_name(&state, &logical).await {
@ -92,11 +93,22 @@ pub(super) async fn post_kill(
pub(super) async fn post_restart( pub(super) async fn post_restart(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
Query(params): Query<GracefulParams>,
) -> Response { ) -> Response {
let logical = strip_container_prefix(&name); let logical = strip_container_prefix(&name);
if let Some(reject) = guard_agent_name(&state, &logical).await { if let Some(reject) = guard_agent_name(&state, &logical).await {
return reject; return reject;
} }
if params.graceful {
submit::graceful_restart(
&state.coord,
&logical,
Source::Manual,
"manual via dashboard graceful restart".to_owned(),
)
.await;
return (StatusCode::OK, "ok").into_response();
}
submit::restart( submit::restart(
&state.coord, &state.coord,
&logical, &logical,