diff --git a/console/src/main.ts b/console/src/main.ts index fe5be55..51d9071 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -238,6 +238,57 @@ if (configEl) { }); } +// ---- start / stop (ADR-2 write model: stop = scale→0, start = scale→1) ------- +// Scale a deployment off (0) or on (1). Reversible — ECS keeps the Spec at +// desiredCount 0 — so this needs no state store. On success `tick()` re-renders +// the roster (which recycles the button DOM), so we only re-enable on error. +async function scale( + action: "start" | "stop", + name: string, + namespace: string, + btn: HTMLButtonElement, +): Promise { + const size = action === "start" ? 1 : 0; + btn.disabled = true; + try { + await source.scaleDeployment(name, size, namespace, activeCluster); + note("info", `${action === "start" ? "started" : "stopped"} ${namespace}/${name}`); + await tick(); + } catch (e) { + note("error", `${action} ${namespace}/${name}: ${errText(e)}`); + btn.disabled = false; + } +} + +// One delegated listener on the roster. Start executes on click; Stop is +// disruptive (kills the running instance, though reversible), so it arms on the +// first click and only executes on a confirming second click within 3s — a +// webview-safe confirm that needs no dialog plugin. The 5s poll re-renders the +// roster and would reset an armed button on its own; the 3s timer is tighter. +if (roster) { + roster.addEventListener("click", (ev) => { + const btn = (ev.target as HTMLElement).closest("button.act"); + if (!btn) return; + const action = btn.dataset.action; + const { name, namespace } = btn.dataset; + if ((action !== "start" && action !== "stop") || !name || !namespace) return; + if (action === "stop" && btn.dataset.armed !== "1") { + btn.dataset.armed = "1"; + btn.textContent = "Confirm stop"; + btn.classList.add("armed"); + window.setTimeout(() => { + if (btn.isConnected && btn.dataset.armed === "1") { + btn.dataset.armed = ""; + btn.textContent = "Stop"; + btn.classList.remove("armed"); + } + }, 3000); + return; + } + void scale(action, name, namespace, btn); + }); +} + // The Tauri command bridge — present only inside the desktop shell (the browser // build has no `__TAURI__`, so callers no-op / hide their UI). type Invoke = (cmd: string, args?: Record) => Promise; diff --git a/console/src/render.test.ts b/console/src/render.test.ts index c3f29ae..c2f2d5a 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -76,6 +76,29 @@ describe("rosterHtml", () => { expect(html).toContain("<x>"); expect(html).not.toContain(""); }); + + it("offers Stop for a running deployment and Start for a stopped one", () => { + const html = rosterHtml([ + dep({ name: "on", namespace: "prod", desired: 1 }), + dep({ name: "off", namespace: "prod", desired: 0, instances: [] }), + ]); + expect(html).toContain('data-action="stop"'); + expect(html).toContain('data-action="start"'); + expect(html).toContain(">Stop"); + expect(html).toContain(">Start"); + }); + + it("carries name + namespace on the action button for the scale call", () => { + const html = rosterHtml([dep({ name: "orca", namespace: "prod" })]); + expect(html).toContain('data-name="orca"'); + expect(html).toContain('data-namespace="prod"'); + }); + + it("escapes name + namespace in action button data attributes", () => { + const html = rosterHtml([dep({ name: '"x', namespace: "n" })]); + expect(html).toContain(""x"); + expect(html).not.toContain('data-name=""x"'); + }); }); describe("identityHtml", () => { diff --git a/console/src/render.ts b/console/src/render.ts index ab7d41b..da6ca41 100644 --- a/console/src/render.ts +++ b/console/src/render.ts @@ -26,6 +26,19 @@ function badge(state: AgentState): string { return `${state}`; } +// Start (scale→1) when the deployment is off, Stop (scale→0) when it's on. +// Stop keeps the Spec — ECS retains the service at desiredCount 0 — so it's +// reversible, no state store needed. The `data-*` carry the identity the +// delegated handler needs; the managing credential is resolved per-cluster, so +// the row only needs name + namespace (service = `oab-{namespace}-{name}`). +function actionButton(d: Deployment): string { + const off = d.desired === 0; + const action = off ? "start" : "stop"; + const label = off ? "Start" : "Stop"; + const cls = off ? "act act-start" : "act act-stop"; + return ``; +} + function rowHtml(d: Deployment): string { const phases = d.instances.length ? d.instances.map((i) => badge(i.state)).join(" ") @@ -36,6 +49,7 @@ function rowHtml(d: Deployment): string { ${name} ${d.ready}/${d.desired} · cur ${d.current} ${phases} + ${actionButton(d)} `; } @@ -76,7 +90,7 @@ export function rosterHtml(deployments: Deployment[]): string { .join(""); return ` - + ${rows}
DeploymentReady / DesiredInstances · 6-state
DeploymentReady / DesiredInstances · 6-stateActions
`; diff --git a/console/src/source.ts b/console/src/source.ts index c2b58d0..f7c1f0b 100644 --- a/console/src/source.ts +++ b/console/src/source.ts @@ -14,6 +14,16 @@ export interface Source { // Persist the raw TOML `text` of the config file, returning the reloaded // config. Rejects (without writing) when the text doesn't parse. writeFleetConfig(text: string): Promise; + // Scale a deployment on (size 1) or off (size 0) — the start/stop action. + // Reversible: ECS keeps the Spec at desiredCount 0, so no state store is + // needed. `namespace` is required (the service is `oab-{namespace}-{name}`); + // the managing credential is resolved per-cluster from `cluster`. + scaleDeployment( + name: string, + size: 0 | 1, + namespace: string, + cluster?: string, + ): Promise; } // Fixture-backed source for the standalone / browser build — no core required. @@ -32,6 +42,9 @@ export class MockSource implements Source { async writeFleetConfig(text: string): Promise { return { ...structuredClone(FIXTURE_FLEET_CONFIG), text }; } + // Browser preview: no core, so scaling is a no-op — the fixture roster is + // re-cloned each poll, so nothing would persist anyway. + async scaleDeployment(): Promise {} } // Minimal shape of the Tauri global bridge (v2, `withGlobalTauri`). Accessed via @@ -65,6 +78,19 @@ export class TauriSource implements Source { async writeFleetConfig(text: string): Promise { return this.invoke()("fleet_config_write", { text }); } + async scaleDeployment( + name: string, + size: 0 | 1, + namespace: string, + cluster?: string, + ): Promise { + await this.invoke()("deploy_scale", { + name, + size, + namespace, + cluster, + }); + } } // Pick a source: Tauri when running inside the shell, else the mock. diff --git a/console/src/styles.css b/console/src/styles.css index 99322e4..1fef15f 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -241,6 +241,43 @@ td.counts.warn { padding: 24px 10px; } +/* ---- roster actions (start / stop) ---- */ +th.actions-h, +td.actions { + text-align: right; + white-space: nowrap; +} +button.act { + cursor: pointer; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--muted); + font: inherit; + font-size: 12px; + padding: 3px 12px; +} +button.act:hover { + color: var(--text); +} +button.act-start:hover { + border-color: var(--s-running); + color: var(--s-running); +} +button.act-stop:hover { + border-color: var(--s-unhealthy); + color: var(--s-unhealthy); +} +button.act.armed { + border-color: var(--s-unhealthy); + background: var(--s-unhealthy); + color: #fff; +} +button.act:disabled { + opacity: 0.5; + cursor: default; +} + .badge { display: inline-block; padding: 2px 8px; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f6033e..6d8c955 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -169,6 +169,40 @@ async fn fleet_config_write(core: tauri::State<'_, Core>, text: String) -> Resul } } +/// Bridge command: start (size 1) / stop (size 0) a deployment via the sidecar's +/// `deploy_scale` tool (ADR-2 write model — stop = scale→0, start = scale→1; the +/// Spec is kept by ECS, so it's reversible). An OAB service runs a single bot +/// token, so size is 0/1 only; `namespace` is required upstream to resolve the +/// service (`oab-{namespace}-{name}`) and the managing credential is per-cluster. +#[tauri::command] +async fn deploy_scale( + core: tauri::State<'_, Core>, + name: String, + size: i64, + namespace: Option, + cluster: Option, +) -> Result { + let cluster = cluster.unwrap_or_else(default_cluster); + let client = { + let guard = core.0.lock().await; + guard + .as_ref() + .cloned() + .ok_or_else(|| "core not started yet".to_string())? + }; + let mut params = json!({ "name": name, "size": size, "cluster": cluster }); + if let Some(ns) = namespace { + params["namespace"] = json!(ns); + } + match client.call_tool("deploy_scale", params).await { + Ok(v) => Ok(v), + Err(e) => { + client.log("error", &format!("deploy_scale: {e}")); + Err(e) + } + } +} + /// What the frontend needs to render the "update available" state: the version /// on the release vs. what's running, plus the release notes. #[derive(serde::Serialize)] @@ -239,6 +273,7 @@ pub fn run() { runtime_context, fleet_config, fleet_config_write, + deploy_scale, check_update, install_update ])