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
70 changes: 62 additions & 8 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
renderFleetConfig,
renderRemote,
filterByMembers,
deploymentKey,
} from "./render";
import type { FleetConfig, RemoteConfig } from "./types";
import type { Deployment, FleetConfig, RemoteConfig } from "./types";
import { createPane, bindBackend, type Level } from "./log";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
Expand Down Expand Up @@ -96,13 +97,54 @@ function errText(e: unknown): string {

let lastError = "";

// ---- in-flight scale guard --------------------------------------------------
// Deployments with a scale in flight (or awaiting the observed desiredCount
// flip), keyed by `deploymentKey` → the count we're driving toward. Held in
// module state (not on the button DOM) so the 5s poll's re-render can't wash out
// the disabled guard. Pruned when the roster observes the target count, or by a
// safety timeout so a never-observed flip can't wedge a button forever.
const scaling = new Map<string, number>();
const scaleTimers = new Map<string, number>();
let lastDeployments: Deployment[] = [];
const SCALE_MAX_HOLD_MS = 15000;

function pendingKeys(): ReadonlySet<string> {
return new Set(scaling.keys());
}

function clearPending(key: string): void {
scaling.delete(key);
const timer = scaleTimers.get(key);
if (timer !== undefined) {
window.clearTimeout(timer);
scaleTimers.delete(key);
}
}

// Drop the guard for any deployment whose observed desiredCount has reached the
// target we drove toward — the action landed, so its button re-enables.
function prunePending(deployments: Deployment[]): void {
for (const d of deployments) {
const key = deploymentKey(d);
if (scaling.get(key) === d.desired) clearPending(key);
}
}

// Re-render the roster from the last poll's data with the current pending
// overlay — instant feedback on click, no fetch needed.
function repaintRoster(): void {
if (roster) renderRoster(roster, lastDeployments, pendingKeys());
}

async function tick(): Promise<void> {
if (!roster) return;
try {
const all = await source.listDeployments(activeCluster);
// Filter to the active fleet's members (empty ⇒ whole cluster).
const deployments = filterByMembers(all, activeMembers);
renderRoster(roster, deployments);
lastDeployments = deployments;
prunePending(deployments);
renderRoster(roster, deployments, pendingKeys());
if (lastError) {
note("info", `roster recovered — ${deployments.length} deployment(s)`);
lastError = "";
Expand Down Expand Up @@ -305,23 +347,35 @@ if (remoteEl) {

// ---- 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.
// desiredCount 0 — so this needs no state store. The in-flight guard lives in
// `scaling` (module state), so the button stays disabled across poll re-renders
// until the observed desiredCount flips (or the safety timeout fires).
async function scale(
action: "start" | "stop",
name: string,
namespace: string,
btn: HTMLButtonElement,
): Promise<void> {
const key = `${namespace}/${name}`;
if (scaling.has(key)) return; // already in flight — poll-immune re-entry guard
const size = action === "start" ? 1 : 0;
btn.disabled = true;
scaling.set(key, size);
scaleTimers.set(
key,
window.setTimeout(() => {
clearPending(key);
repaintRoster();
}, SCALE_MAX_HOLD_MS),
);
repaintRoster(); // disable the button immediately
try {
await source.scaleDeployment(name, size, namespace, activeCluster);
note("info", `${action === "start" ? "started" : "stopped"} ${namespace}/${name}`);
// tick() observes the new desiredCount and prunes the guard when it flips.
await tick();
} catch (e) {
note("error", `${action} ${namespace}/${name}: ${errText(e)}`);
btn.disabled = false;
clearPending(key);
repaintRoster();
}
}

Expand Down Expand Up @@ -350,7 +404,7 @@ if (roster) {
}, 3000);
return;
}
void scale(action, name, namespace, btn);
void scale(action, name, namespace);
});
}

Expand Down
33 changes: 33 additions & 0 deletions console/src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
remoteHtml,
filterByMembers,
serviceName,
deploymentKey,
} from "./render";
import {
FIXTURE_DEPLOYMENTS,
Expand Down Expand Up @@ -101,6 +102,38 @@ describe("rosterHtml", () => {
expect(html).toContain("&quot;x");
expect(html).not.toContain('data-name=""x"');
});

it("renders a disabled placeholder for a deployment with a scale in flight", () => {
const d = dep({ name: "orca", namespace: "prod" });
const html = rosterHtml([d], new Set([deploymentKey(d)]));
expect(html).toContain("act-pending");
expect(html).toContain("disabled");
// no live action attributes on a pending button
expect(html).not.toContain('data-action="stop"');
expect(html).not.toContain('data-action="start"');
});

it("leaves non-pending deployments interactive when another is in flight", () => {
const busy = dep({ name: "orca", namespace: "prod" });
const free = dep({ name: "mira", namespace: "prod", desired: 0 });
const html = rosterHtml([busy, free], new Set([deploymentKey(busy)]));
// orca is pending → placeholder; mira is free → a live Start button
expect(html).toContain("act-pending");
expect(html).toContain('data-action="start"');
expect(html).toContain('data-name="mira"');
});

it("defaults to no pending set (all buttons live)", () => {
const html = rosterHtml([dep({ name: "orca", namespace: "prod" })]);
expect(html).not.toContain("act-pending");
expect(html).toContain('data-action="stop"');
});
});

describe("deploymentKey", () => {
it("is the namespace/name pair (not the ECS service name)", () => {
expect(deploymentKey({ ...FIXTURE_DEPLOYMENTS[0] })).toBe("prod/orca");
});
});

describe("identityHtml", () => {
Expand Down
39 changes: 31 additions & 8 deletions console/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,35 @@ function badge(state: AgentState): string {
return `<span class="badge ${STATE_CLASS[state]}">${state}</span>`;
}

// A deployment's identity within the roster — the key the in-flight scale guard
// (`main.ts`) and the render agree on. Not the ECS service name; just the
// namespace/name pair a scale action targets.
export function deploymentKey(d: Deployment): string {
return `${d.namespace}/${d.name}`;
}

// 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 {
//
// `pending` ⇒ a scale is in flight (or the observed count hasn't flipped yet):
// render a disabled placeholder so the 5s poll re-render can't hand back a fresh
// enabled button mid-action. The guard lives in module state, not on the DOM
// node, so it survives the re-render.
function actionButton(d: Deployment, pending: boolean): string {
if (pending) {
return `<button class="act act-pending" type="button" disabled>…</button>`;
}
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 {
function rowHtml(d: Deployment, pending: ReadonlySet<string>): string {
const phases = d.instances.length
? d.instances.map((i) => badge(i.state)).join(" ")
: `<span class="muted">—</span>`;
Expand All @@ -50,7 +65,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>
<td class="actions">${actionButton(d, pending.has(deploymentKey(d)))}</td>
</tr>`;
}

Expand Down Expand Up @@ -78,16 +93,20 @@ export function filterByMembers(
}

// Pure: deployments -> roster table HTML. Kept side-effect-free so it is unit
// testable without a DOM.
export function rosterHtml(deployments: Deployment[]): string {
// testable without a DOM. `pending` is the set of `deploymentKey`s with a scale
// in flight — their action buttons render disabled.
export function rosterHtml(
deployments: Deployment[],
pending: ReadonlySet<string> = new Set(),
): string {
if (deployments.length === 0) {
return `<p class="empty">No deployments in this cluster.</p>`;
}
const rows = [...deployments]
.sort((a, b) =>
`${a.namespace}/${a.name}`.localeCompare(`${b.namespace}/${b.name}`),
)
.map(rowHtml)
.map((d) => rowHtml(d, pending))
.join("");
return `<table class="roster">
<thead>
Expand All @@ -97,8 +116,12 @@ export function rosterHtml(deployments: Deployment[]): string {
</table>`;
}

export function renderRoster(el: HTMLElement, deployments: Deployment[]): void {
el.innerHTML = rosterHtml(deployments);
export function renderRoster(
el: HTMLElement,
deployments: Deployment[],
pending: ReadonlySet<string> = new Set(),
): void {
el.innerHTML = rosterHtml(deployments, pending);
}

// ---- Runtime identity / context panel (ADR #19) ------------------------------
Expand Down
Loading