Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<HTMLButtonElement>("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 = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
Expand Down
23 changes: 23 additions & 0 deletions console/src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,29 @@ describe("rosterHtml", () => {
expect(html).toContain("&lt;x&gt;");
expect(html).not.toContain("<x>");
});

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</button>");
expect(html).toContain(">Start</button>");
});

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("&quot;x");
expect(html).not.toContain('data-name=""x"');
});
});

describe("identityHtml", () => {
Expand Down
16 changes: 15 additions & 1 deletion console/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ function badge(state: AgentState): string {
return `<span class="badge ${STATE_CLASS[state]}">${state}</span>`;
}

// 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 `<button class="${cls}" type="button" data-action="${action}" data-name="${escapeHtml(d.name)}" data-namespace="${escapeHtml(d.namespace)}">${label}</button>`;
}

function rowHtml(d: Deployment): string {
const phases = d.instances.length
? d.instances.map((i) => badge(i.state)).join(" ")
Expand All @@ -36,6 +49,7 @@ function rowHtml(d: Deployment): string {
<td class="name">${name}</td>
<td class="counts ${health}">${d.ready}/${d.desired}<span class="muted"> · cur ${d.current}</span></td>
<td class="phases">${phases}</td>
<td class="actions">${actionButton(d)}</td>
</tr>`;
}

Expand Down Expand Up @@ -76,7 +90,7 @@ export function rosterHtml(deployments: Deployment[]): string {
.join("");
return `<table class="roster">
<thead>
<tr><th>Deployment</th><th>Ready / Desired</th><th>Instances · 6-state</th></tr>
<tr><th>Deployment</th><th>Ready / Desired</th><th>Instances · 6-state</th><th class="actions-h">Actions</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
Expand Down
26 changes: 26 additions & 0 deletions console/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FleetConfig>;
// 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<void>;
}

// Fixture-backed source for the standalone / browser build — no core required.
Expand All @@ -32,6 +42,9 @@ export class MockSource implements Source {
async writeFleetConfig(text: string): Promise<FleetConfig> {
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<void> {}
}

// Minimal shape of the Tauri global bridge (v2, `withGlobalTauri`). Accessed via
Expand Down Expand Up @@ -65,6 +78,19 @@ export class TauriSource implements Source {
async writeFleetConfig(text: string): Promise<FleetConfig> {
return this.invoke()<FleetConfig>("fleet_config_write", { text });
}
async scaleDeployment(
name: string,
size: 0 | 1,
namespace: string,
cluster?: string,
): Promise<void> {
await this.invoke()<unknown>("deploy_scale", {
name,
size,
namespace,
cluster,
});
}
}

// Pick a source: Tauri when running inside the shell, else the mock.
Expand Down
37 changes: 37 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
cluster: Option<String>,
) -> Result<Value, String> {
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)]
Expand Down Expand Up @@ -239,6 +273,7 @@ pub fn run() {
runtime_context,
fleet_config,
fleet_config_write,
deploy_scale,
check_update,
install_update
])
Expand Down
Loading