feat(control-plane): add Stop and Restart app controls

- 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
This commit is contained in:
Claude 2026-03-19 12:55:17 +00:00
parent 217bafc464
commit ec0f421137
No known key found for this signature in database
3 changed files with 68 additions and 1 deletions

View file

@ -67,6 +67,8 @@ async fn main() -> anyhow::Result<()> {
.route("/api/apps/:id", get(routes::apps::get_one) .route("/api/apps/:id", get(routes::apps::get_one)
.put(routes::apps::update) .put(routes::apps::update)
.delete(routes::apps::delete)) .delete(routes::apps::delete))
.route("/api/apps/:id/stop", post(routes::apps::stop))
.route("/api/apps/:id/restart", post(routes::apps::restart))
// ── Deploys API ─────────────────────────────────────────── // ── Deploys API ───────────────────────────────────────────
.route("/api/apps/:id/deploy", post(routes::deploys::trigger)) .route("/api/apps/:id/deploy", post(routes::deploys::trigger))
.route("/api/apps/:id/deploys", get(routes::deploys::list)) .route("/api/apps/:id/deploys", get(routes::deploys::list))

View file

@ -103,6 +103,42 @@ pub async fn delete(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn stop(
State(_s): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<App, StatusCode> { async fn fetch_app(s: &AppState, id: &str) -> Result<App, StatusCode> {
sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?") sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?")
.bind(id) .bind(id)

View file

@ -198,6 +198,8 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
<td data-deploy-badge>{d_badge}</td> <td data-deploy-badge>{d_badge}</td>
<td> <td>
<button class="primary" onclick="deploy('{id}')">Deploy</button> <button class="primary" onclick="deploy('{id}')">Deploy</button>
<button onclick="stopApp('{id}')">Stop</button>
<button onclick="restartApp('{id}')">Restart</button>
<button class="danger" onclick="del('{id}')">Delete</button> <button class="danger" onclick="del('{id}')">Delete</button>
</td> </td>
</tr>"#, </tr>"#,
@ -284,6 +286,16 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
await fetch('/api/apps/' + id, {{method: 'DELETE'}}); await fetch('/api/apps/' + id, {{method: 'DELETE'}});
window.location.reload(); 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) {{ function deployBadgeHtml(s) {{
const cls = {{success:'badge-success',failed:'badge-failed',building:'badge-building',queued:'badge-building'}}[s] || 'badge-unknown'; 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 .await
.unwrap_or_default(); .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 latest_deploy_id = deploys.first().map(|d| d.id.as_str()).unwrap_or("");
let mut deploy_rows = String::new(); let mut deploy_rows = String::new();
@ -396,7 +409,12 @@ pub async fn app_detail(
let body = format!( let body = format!(
r#"<nav> r#"<nav>
<h1><a href="/" style="color:inherit">HIY</a> / {name}</h1> <h1><a href="/" style="color:inherit">HIY</a> / {name}</h1>
<button class="primary" onclick="deploy()">Deploy Now</button> <div style="display:flex;gap:8px;align-items:center">
{c_badge}
<button class="primary" onclick="deploy()">Deploy Now</button>
<button onclick="stopApp()">Stop</button>
<button onclick="restartApp()">Restart</button>
</div>
</nav> </nav>
<p class="subtitle"> <p class="subtitle">
<a href="{repo}" target="_blank">{repo}</a> <a href="{repo}" target="_blank">{repo}</a>
@ -525,6 +543,16 @@ pub async fn app_detail(
await fetch('/api/apps/' + APP_ID + '/env/' + key, {{method:'DELETE'}}); await fetch('/api/apps/' + APP_ID + '/env/' + key, {{method:'DELETE'}});
window.location.reload(); 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');
}}
</script>"#, </script>"#,
name = app.name, name = app.name,
repo = app.repo_url, repo = app.repo_url,
@ -536,6 +564,7 @@ pub async fn app_detail(
deploy_rows = deploy_rows, deploy_rows = deploy_rows,
env_rows = env_rows, env_rows = env_rows,
latest_deploy_id = latest_deploy_id, latest_deploy_id = latest_deploy_id,
c_badge = container_badge(&container_state),
); );
Html(page(&app.name, &body)).into_response() Html(page(&app.name, &body)).into_response()