Skip to content

Commit 468d445

Browse files
brettchienOrca (ecs-claude)claude
authored
feat(console): start/stop buttons on the roster (scale 0/1, no state store) (#39)
Wire a Start/Stop action per deployment row to the sidecar's existing `deploy_scale` MCP tool. Stop = scale→0, Start = scale→1 (ADR-2 §5 write model): the Spec is kept by ECS at desiredCount 0, so it's reversible and needs no durable state store (ADR-4 / Fleet Store #18 not required). - render.ts: one contextual action button per row — Start when a deployment is off (desired 0), Stop when on. Carries name + namespace via data-* (the service is oab-{namespace}-{name}; the managing credential is per-cluster, so the row needs no cluster). - source.ts: `scaleDeployment(name, size, namespace, cluster?)` on the Source contract; Tauri impl invokes `deploy_scale`, mock no-ops (browser preview). - main.ts: delegated roster listener. Start executes on click; Stop is disruptive so it arms on the first click and executes on a confirming second click within 3s — webview-safe, no dialog plugin. On success tick() re-renders. - src-tauri: `deploy_scale` bridge command (mirrors fleet_config_write), passes namespace explicitly so prod services resolve (handler defaults to "default"). - styles + tests. Note: namespace MUST be sent — the MCP handler defaults it to "default", which would target oab-default-{name} instead of the real oab-prod-{name}. Verified: console typecheck + 34 vitest + vite build green. The Rust bridge is compiled by CI's macOS `tauri build` (desktop.yml). Live click-test needs the macOS app. Co-authored-by: Orca (ecs-claude) <orca@ecs.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ae934ea commit 468d445

6 files changed

Lines changed: 187 additions & 1 deletion

File tree

console/src/main.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,57 @@ if (configEl) {
238238
});
239239
}
240240

241+
// ---- start / stop (ADR-2 write model: stop = scale→0, start = scale→1) -------
242+
// Scale a deployment off (0) or on (1). Reversible — ECS keeps the Spec at
243+
// desiredCount 0 — so this needs no state store. On success `tick()` re-renders
244+
// the roster (which recycles the button DOM), so we only re-enable on error.
245+
async function scale(
246+
action: "start" | "stop",
247+
name: string,
248+
namespace: string,
249+
btn: HTMLButtonElement,
250+
): Promise<void> {
251+
const size = action === "start" ? 1 : 0;
252+
btn.disabled = true;
253+
try {
254+
await source.scaleDeployment(name, size, namespace, activeCluster);
255+
note("info", `${action === "start" ? "started" : "stopped"} ${namespace}/${name}`);
256+
await tick();
257+
} catch (e) {
258+
note("error", `${action} ${namespace}/${name}: ${errText(e)}`);
259+
btn.disabled = false;
260+
}
261+
}
262+
263+
// One delegated listener on the roster. Start executes on click; Stop is
264+
// disruptive (kills the running instance, though reversible), so it arms on the
265+
// first click and only executes on a confirming second click within 3s — a
266+
// webview-safe confirm that needs no dialog plugin. The 5s poll re-renders the
267+
// roster and would reset an armed button on its own; the 3s timer is tighter.
268+
if (roster) {
269+
roster.addEventListener("click", (ev) => {
270+
const btn = (ev.target as HTMLElement).closest<HTMLButtonElement>("button.act");
271+
if (!btn) return;
272+
const action = btn.dataset.action;
273+
const { name, namespace } = btn.dataset;
274+
if ((action !== "start" && action !== "stop") || !name || !namespace) return;
275+
if (action === "stop" && btn.dataset.armed !== "1") {
276+
btn.dataset.armed = "1";
277+
btn.textContent = "Confirm stop";
278+
btn.classList.add("armed");
279+
window.setTimeout(() => {
280+
if (btn.isConnected && btn.dataset.armed === "1") {
281+
btn.dataset.armed = "";
282+
btn.textContent = "Stop";
283+
btn.classList.remove("armed");
284+
}
285+
}, 3000);
286+
return;
287+
}
288+
void scale(action, name, namespace, btn);
289+
});
290+
}
291+
241292
// The Tauri command bridge — present only inside the desktop shell (the browser
242293
// build has no `__TAURI__`, so callers no-op / hide their UI).
243294
type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;

console/src/render.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,29 @@ describe("rosterHtml", () => {
7676
expect(html).toContain("&lt;x&gt;");
7777
expect(html).not.toContain("<x>");
7878
});
79+
80+
it("offers Stop for a running deployment and Start for a stopped one", () => {
81+
const html = rosterHtml([
82+
dep({ name: "on", namespace: "prod", desired: 1 }),
83+
dep({ name: "off", namespace: "prod", desired: 0, instances: [] }),
84+
]);
85+
expect(html).toContain('data-action="stop"');
86+
expect(html).toContain('data-action="start"');
87+
expect(html).toContain(">Stop</button>");
88+
expect(html).toContain(">Start</button>");
89+
});
90+
91+
it("carries name + namespace on the action button for the scale call", () => {
92+
const html = rosterHtml([dep({ name: "orca", namespace: "prod" })]);
93+
expect(html).toContain('data-name="orca"');
94+
expect(html).toContain('data-namespace="prod"');
95+
});
96+
97+
it("escapes name + namespace in action button data attributes", () => {
98+
const html = rosterHtml([dep({ name: '"x', namespace: "n" })]);
99+
expect(html).toContain("&quot;x");
100+
expect(html).not.toContain('data-name=""x"');
101+
});
79102
});
80103

81104
describe("identityHtml", () => {

console/src/render.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,19 @@ function badge(state: AgentState): string {
2626
return `<span class="badge ${STATE_CLASS[state]}">${state}</span>`;
2727
}
2828

29+
// Start (scale→1) when the deployment is off, Stop (scale→0) when it's on.
30+
// Stop keeps the Spec — ECS retains the service at desiredCount 0 — so it's
31+
// reversible, no state store needed. The `data-*` carry the identity the
32+
// delegated handler needs; the managing credential is resolved per-cluster, so
33+
// the row only needs name + namespace (service = `oab-{namespace}-{name}`).
34+
function actionButton(d: Deployment): string {
35+
const off = d.desired === 0;
36+
const action = off ? "start" : "stop";
37+
const label = off ? "Start" : "Stop";
38+
const cls = off ? "act act-start" : "act act-stop";
39+
return `<button class="${cls}" type="button" data-action="${action}" data-name="${escapeHtml(d.name)}" data-namespace="${escapeHtml(d.namespace)}">${label}</button>`;
40+
}
41+
2942
function rowHtml(d: Deployment): string {
3043
const phases = d.instances.length
3144
? d.instances.map((i) => badge(i.state)).join(" ")
@@ -36,6 +49,7 @@ function rowHtml(d: Deployment): string {
3649
<td class="name">${name}</td>
3750
<td class="counts ${health}">${d.ready}/${d.desired}<span class="muted"> · cur ${d.current}</span></td>
3851
<td class="phases">${phases}</td>
52+
<td class="actions">${actionButton(d)}</td>
3953
</tr>`;
4054
}
4155

@@ -76,7 +90,7 @@ export function rosterHtml(deployments: Deployment[]): string {
7690
.join("");
7791
return `<table class="roster">
7892
<thead>
79-
<tr><th>Deployment</th><th>Ready / Desired</th><th>Instances · 6-state</th></tr>
93+
<tr><th>Deployment</th><th>Ready / Desired</th><th>Instances · 6-state</th><th class="actions-h">Actions</th></tr>
8094
</thead>
8195
<tbody>${rows}</tbody>
8296
</table>`;

console/src/source.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ export interface Source {
1414
// Persist the raw TOML `text` of the config file, returning the reloaded
1515
// config. Rejects (without writing) when the text doesn't parse.
1616
writeFleetConfig(text: string): Promise<FleetConfig>;
17+
// Scale a deployment on (size 1) or off (size 0) — the start/stop action.
18+
// Reversible: ECS keeps the Spec at desiredCount 0, so no state store is
19+
// needed. `namespace` is required (the service is `oab-{namespace}-{name}`);
20+
// the managing credential is resolved per-cluster from `cluster`.
21+
scaleDeployment(
22+
name: string,
23+
size: 0 | 1,
24+
namespace: string,
25+
cluster?: string,
26+
): Promise<void>;
1727
}
1828

1929
// Fixture-backed source for the standalone / browser build — no core required.
@@ -32,6 +42,9 @@ export class MockSource implements Source {
3242
async writeFleetConfig(text: string): Promise<FleetConfig> {
3343
return { ...structuredClone(FIXTURE_FLEET_CONFIG), text };
3444
}
45+
// Browser preview: no core, so scaling is a no-op — the fixture roster is
46+
// re-cloned each poll, so nothing would persist anyway.
47+
async scaleDeployment(): Promise<void> {}
3548
}
3649

3750
// Minimal shape of the Tauri global bridge (v2, `withGlobalTauri`). Accessed via
@@ -65,6 +78,19 @@ export class TauriSource implements Source {
6578
async writeFleetConfig(text: string): Promise<FleetConfig> {
6679
return this.invoke()<FleetConfig>("fleet_config_write", { text });
6780
}
81+
async scaleDeployment(
82+
name: string,
83+
size: 0 | 1,
84+
namespace: string,
85+
cluster?: string,
86+
): Promise<void> {
87+
await this.invoke()<unknown>("deploy_scale", {
88+
name,
89+
size,
90+
namespace,
91+
cluster,
92+
});
93+
}
6894
}
6995

7096
// Pick a source: Tauri when running inside the shell, else the mock.

console/src/styles.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,43 @@ td.counts.warn {
241241
padding: 24px 10px;
242242
}
243243

244+
/* ---- roster actions (start / stop) ---- */
245+
th.actions-h,
246+
td.actions {
247+
text-align: right;
248+
white-space: nowrap;
249+
}
250+
button.act {
251+
cursor: pointer;
252+
border: 1px solid var(--border);
253+
border-radius: 5px;
254+
background: var(--bg);
255+
color: var(--muted);
256+
font: inherit;
257+
font-size: 12px;
258+
padding: 3px 12px;
259+
}
260+
button.act:hover {
261+
color: var(--text);
262+
}
263+
button.act-start:hover {
264+
border-color: var(--s-running);
265+
color: var(--s-running);
266+
}
267+
button.act-stop:hover {
268+
border-color: var(--s-unhealthy);
269+
color: var(--s-unhealthy);
270+
}
271+
button.act.armed {
272+
border-color: var(--s-unhealthy);
273+
background: var(--s-unhealthy);
274+
color: #fff;
275+
}
276+
button.act:disabled {
277+
opacity: 0.5;
278+
cursor: default;
279+
}
280+
244281
.badge {
245282
display: inline-block;
246283
padding: 2px 8px;

src-tauri/src/lib.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,40 @@ async fn fleet_config_write(core: tauri::State<'_, Core>, text: String) -> Resul
169169
}
170170
}
171171

172+
/// Bridge command: start (size 1) / stop (size 0) a deployment via the sidecar's
173+
/// `deploy_scale` tool (ADR-2 write model — stop = scale→0, start = scale→1; the
174+
/// Spec is kept by ECS, so it's reversible). An OAB service runs a single bot
175+
/// token, so size is 0/1 only; `namespace` is required upstream to resolve the
176+
/// service (`oab-{namespace}-{name}`) and the managing credential is per-cluster.
177+
#[tauri::command]
178+
async fn deploy_scale(
179+
core: tauri::State<'_, Core>,
180+
name: String,
181+
size: i64,
182+
namespace: Option<String>,
183+
cluster: Option<String>,
184+
) -> Result<Value, String> {
185+
let cluster = cluster.unwrap_or_else(default_cluster);
186+
let client = {
187+
let guard = core.0.lock().await;
188+
guard
189+
.as_ref()
190+
.cloned()
191+
.ok_or_else(|| "core not started yet".to_string())?
192+
};
193+
let mut params = json!({ "name": name, "size": size, "cluster": cluster });
194+
if let Some(ns) = namespace {
195+
params["namespace"] = json!(ns);
196+
}
197+
match client.call_tool("deploy_scale", params).await {
198+
Ok(v) => Ok(v),
199+
Err(e) => {
200+
client.log("error", &format!("deploy_scale: {e}"));
201+
Err(e)
202+
}
203+
}
204+
}
205+
172206
/// What the frontend needs to render the "update available" state: the version
173207
/// on the release vs. what's running, plus the release notes.
174208
#[derive(serde::Serialize)]
@@ -239,6 +273,7 @@ pub fn run() {
239273
runtime_context,
240274
fleet_config,
241275
fleet_config_write,
276+
deploy_scale,
242277
check_update,
243278
install_update
244279
])

0 commit comments

Comments
 (0)