From ec0f421137b5ed71ae13f7f7e980fef9b2b0cc0e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 12:55:17 +0000 Subject: [PATCH] feat(control-plane): add Stop and Restart app controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/apps/:id/stop → docker stop hiy-{id} - POST /api/apps/:id/restart → docker restart hiy-{id} Dashboard (apps table): Stop / Restart buttons alongside Deploy and Delete. App detail page: container status badge + Stop / Restart buttons in the nav bar. https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- server/src/main.rs | 2 ++ server/src/routes/apps.rs | 36 ++++++++++++++++++++++++++++++++++++ server/src/routes/ui.rs | 31 ++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/server/src/main.rs b/server/src/main.rs index ea694e1..37b78ef 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -67,6 +67,8 @@ async fn main() -> anyhow::Result<()> { .route("/api/apps/:id", get(routes::apps::get_one) .put(routes::apps::update) .delete(routes::apps::delete)) + .route("/api/apps/:id/stop", post(routes::apps::stop)) + .route("/api/apps/:id/restart", post(routes::apps::restart)) // ── Deploys API ─────────────────────────────────────────── .route("/api/apps/:id/deploy", post(routes::deploys::trigger)) .route("/api/apps/:id/deploys", get(routes::deploys::list)) diff --git a/server/src/routes/apps.rs b/server/src/routes/apps.rs index 596fb7d..ef24b37 100644 --- a/server/src/routes/apps.rs +++ b/server/src/routes/apps.rs @@ -103,6 +103,42 @@ pub async fn delete( Ok(StatusCode::NO_CONTENT) } +pub async fn stop( + State(_s): State, + Path(id): Path, +) -> Result { + let name = format!("hiy-{}", id); + let out = tokio::process::Command::new("docker") + .args(["stop", &name]) + .output() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if out.status.success() { + Ok(StatusCode::NO_CONTENT) + } else { + tracing::warn!("docker stop {}: {}", name, String::from_utf8_lossy(&out.stderr)); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } +} + +pub async fn restart( + State(_s): State, + Path(id): Path, +) -> Result { + let name = format!("hiy-{}", id); + let out = tokio::process::Command::new("docker") + .args(["restart", &name]) + .output() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if out.status.success() { + Ok(StatusCode::NO_CONTENT) + } else { + tracing::warn!("docker restart {}: {}", name, String::from_utf8_lossy(&out.stderr)); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } +} + async fn fetch_app(s: &AppState, id: &str) -> Result { sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?") .bind(id) diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index 99823bc..81f6717 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -198,6 +198,8 @@ pub async fn index(State(s): State) -> Result, StatusCode {d_badge} + + "#, @@ -284,6 +286,16 @@ pub async fn index(State(s): State) -> Result, StatusCode await fetch('/api/apps/' + id, {{method: 'DELETE'}}); window.location.reload(); }} + async function stopApp(id) {{ + if (!confirm('Stop ' + id + '?')) return; + const r = await fetch('/api/apps/' + id + '/stop', {{method: 'POST'}}); + if (!r.ok) alert('Error stopping app'); + }} + async function restartApp(id) {{ + if (!confirm('Restart ' + id + '?')) return; + const r = await fetch('/api/apps/' + id + '/restart', {{method: 'POST'}}); + if (!r.ok) alert('Error restarting app'); + }} function deployBadgeHtml(s) {{ const cls = {{success:'badge-success',failed:'badge-failed',building:'badge-building',queued:'badge-building'}}[s] || 'badge-unknown'; @@ -355,6 +367,7 @@ pub async fn app_detail( .await .unwrap_or_default(); + let container_state = get_container_status(&app_id).await; let latest_deploy_id = deploys.first().map(|d| d.id.as_str()).unwrap_or(""); let mut deploy_rows = String::new(); @@ -396,7 +409,12 @@ pub async fn app_detail( let body = format!( r#"

{repo} @@ -525,6 +543,16 @@ pub async fn app_detail( await fetch('/api/apps/' + APP_ID + '/env/' + key, {{method:'DELETE'}}); window.location.reload(); }} + async function stopApp() {{ + if (!confirm('Stop ' + APP_ID + '?')) return; + const r = await fetch('/api/apps/' + APP_ID + '/stop', {{method:'POST'}}); + if (!r.ok) alert('Error stopping app'); + }} + async function restartApp() {{ + if (!confirm('Restart ' + APP_ID + '?')) return; + const r = await fetch('/api/apps/' + APP_ID + '/restart', {{method:'POST'}}); + if (!r.ok) alert('Error restarting app'); + }} "#, name = app.name, repo = app.repo_url, @@ -536,6 +564,7 @@ pub async fn app_detail( deploy_rows = deploy_rows, env_rows = env_rows, latest_deploy_id = latest_deploy_id, + c_badge = container_badge(&container_state), ); Html(page(&app.name, &body)).into_response()