diff --git a/console/index.html b/console/index.html index fcc9d62..6b11d2a 100644 --- a/console/index.html +++ b/console/index.html @@ -207,6 +207,7 @@ +

diff --git a/console/src/deploy.ts b/console/src/deploy.ts index f859cec..6e27ebc 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -172,6 +172,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null const acpTokenGenerateBtn = document.getElementById("deploy-acp-token-generate") as HTMLButtonElement | null; const agentNameInput = document.getElementById("deploy-name") as HTMLInputElement | null; const agentNameShuffleBtn = document.getElementById("deploy-name-shuffle") as HTMLButtonElement | null; + const agentNamePreviewEl = document.getElementById("deploy-name-preview"); const deployBtn = document.getElementById("deploy-deploy-btn") as HTMLButtonElement | null; const deployStatusEl = document.getElementById("deploy-deploy-status"); @@ -211,6 +212,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null !acpTokenGenerateBtn || !agentNameInput || !agentNameShuffleBtn || + !agentNamePreviewEl || !deployBtn ) { return null; @@ -328,6 +330,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null applyChatPlatformMode(); applyVendorMode(); agentNameInput.value = randomGreekName(); + updateNamePreview(); void loadVendorImage(); }; @@ -346,6 +349,19 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null ? k8sNamespaceNewInput.value.trim() : k8sNamespaceSel.value; + // The `oab-${namespace}-${name}` service-name convention (mirrored from the + // deploy submit handler below) was previously discoverable only by reading + // source — nothing in the wizard showed what actually lands in fleets.toml's + // `members` array (Brett, 2026-09-07). "add-instance" into an existing k8s + // fleet isn't wired through this wizard yet (see the isK8s check below), so + // it's always the ecs/"default" namespace outside "new-fleet" + k8s. + const updateNamePreview = (): void => { + const isK8s = mode?.kind === "new-fleet" && providerSel.value === "k8s"; + const namespace = isK8s ? currentNamespace() || "default" : "default"; + const name = agentNameInput.value.trim() || ""; + agentNamePreviewEl.textContent = `→ recorded in fleets.toml as oab-${namespace}-${name}`; + }; + // Toggle the AWS/k8s field groups per studio#104's design: switching // providers resets which group is visible; field *values* aren't cleared // here (identityForm.reset() already did that on open/close) since the @@ -441,6 +457,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null providerSel.addEventListener("change", () => { showProviderFields(providerSel.value); if (providerSel.value === "k8s") void loadK8sContexts(); + updateNamePreview(); }); k8sContextSel.addEventListener("change", () => { void loadK8sNamespaces(); @@ -449,10 +466,13 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null k8sNamespaceSel.addEventListener("change", () => { applyNamespaceMode(); void loadK8sServiceAccounts(); + updateNamePreview(); }); // "change" (fires on commit/blur), not "input" (every keystroke) — avoids a - // tool call per character typed into the new-namespace field. + // tool call per character typed into the new-namespace field. The name + // preview updates live regardless (no tool call involved). k8sNamespaceNewInput.addEventListener("change", () => void loadK8sServiceAccounts()); + k8sNamespaceNewInput.addEventListener("input", updateNamePreview); vendorSel.addEventListener("change", () => { applyVendorMode(); @@ -470,7 +490,9 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null }); agentNameShuffleBtn.addEventListener("click", () => { agentNameInput.value = randomGreekName(); + updateNamePreview(); }); + agentNameInput.addEventListener("input", updateNamePreview); const open = (m: DeployMode): void => { mode = m; diff --git a/console/src/fleetToml.test.ts b/console/src/fleetToml.test.ts index d6ab4bd..c0c7245 100644 --- a/console/src/fleetToml.test.ts +++ b/console/src/fleetToml.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { appendMember, appendFleetBlock, fleetBlockExists } from "./fleetToml"; +import { appendMember, appendFleetBlock, fleetBlockExists, removeFleetBlock } from "./fleetToml"; describe("appendMember", () => { const text = `default_cluster = "oab" @@ -136,3 +136,47 @@ describe("appendFleetBlock", () => { expect(out).toBe('default_cluster = "oab"\n\n[fleet.x]\nruntime = "ecs"\nmembers = ["m"]\n'); }); }); + +describe("removeFleetBlock", () => { + const text = `default_cluster = "oab" + +[fleet.a] +members = ["oab-default-a1"] + +[fleet.b] +members = ["oab-default-b1"] + +[fleet.c] +members = ["oab-default-c1"] +`; + + it("removes a middle block, leaving one blank line between its neighbors", () => { + const out = removeFleetBlock(text, "b"); + expect(out).toBe( + 'default_cluster = "oab"\n\n[fleet.a]\nmembers = ["oab-default-a1"]\n\n[fleet.c]\nmembers = ["oab-default-c1"]\n', + ); + }); + + it("removes the first block with no leading blank line left behind", () => { + const out = removeFleetBlock(text, "a"); + expect(out).toBe( + 'default_cluster = "oab"\n\n[fleet.b]\nmembers = ["oab-default-b1"]\n\n[fleet.c]\nmembers = ["oab-default-c1"]\n', + ); + }); + + it("removes the last block with no trailing blank line left behind", () => { + const out = removeFleetBlock(text, "c"); + expect(out).toBe( + 'default_cluster = "oab"\n\n[fleet.a]\nmembers = ["oab-default-a1"]\n\n[fleet.b]\nmembers = ["oab-default-b1"]\n', + ); + }); + + it("removes the only fleet block, leaving the rest of the file intact", () => { + const onlyOne = 'default_cluster = "oab"\n\n[fleet.a]\nmembers = ["oab-default-a1"]\n'; + expect(removeFleetBlock(onlyOne, "a")).toBe('default_cluster = "oab"\n'); + }); + + it("is a no-op when the fleet isn't found", () => { + expect(removeFleetBlock(text, "no-such-fleet")).toBe(text); + }); +}); diff --git a/console/src/fleetToml.ts b/console/src/fleetToml.ts index 10382f5..489aff2 100644 --- a/console/src/fleetToml.ts +++ b/console/src/fleetToml.ts @@ -71,6 +71,23 @@ export function appendMember(text: string, fleetName: string, member: string): s return text.slice(0, block.headerEnd) + newBody + text.slice(block.end); } +// Remove a `[fleet.]` block entirely — the inverse of `appendFleetBlock` +// (Fleet detail's "Delete fleet" action: the console had no way to remove a +// fleet short of hand-editing raw TOML via "Edit config"). `findFleetBlock`'s +// `end` already lands exactly at the next block's `[` (or EOF), so the blank +// line `appendFleetBlock` writes *before* each block travels with `before`, +// not `after` — a plain concatenation needs no separator patching. The +// `\n{3,}` collapse and single trailing newline are defensive tidy-ups, not +// load-bearing (see fleetToml.test.ts for why each is safe). A no-op if the +// fleet isn't found. +export function removeFleetBlock(text: string, name: string): string { + const block = findFleetBlock(text, name); + if (!block) return text; + const result = text.slice(0, block.start) + text.slice(block.end); + const collapsed = result.replace(/\n{3,}/g, "\n\n"); + return collapsed.trim() ? collapsed.replace(/\n+$/, "\n") : ""; +} + export interface NewFleetEntry { name: string; member: string; diff --git a/console/src/main.ts b/console/src/main.ts index 4b8cb86..3188c34 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -1,5 +1,6 @@ import { defaultSource } from "./source"; import { initConfigTab } from "./config"; +import { removeFleetBlock } from "./fleetToml"; import { renderRoster, renderFleetConfig, @@ -487,6 +488,31 @@ function deselectFleet(): void { void tick(); } +// Fleet detail's "Delete fleet" button: the only way to remove a fleet used +// to be hand-editing raw TOML via "Edit config" (Brett, 2026-09-07). This is +// the same text edit (`removeFleetBlock`), just behind a confirm dialog — +// removes the `[fleet.]` declaration only, it does not touch the +// underlying deployments/instances themselves. +async function deleteFleet(name: string): Promise { + if ( + !window.confirm( + `Delete fleet "${name}"? This removes it from fleets.toml — the instances themselves keep running.`, + ) + ) { + return; + } + try { + const current = fleetConfig ?? (await source.fleetConfig()); + fleetConfig = await source.writeFleetConfig(removeFleetBlock(current.text, name)); + note("info", `config: deleted fleet "${name}"`); + } catch (e) { + note("error", `config: delete fleet failed — ${errText(e)}`); + return; + } + if (activeFleet === name) deselectFleet(); + else if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet); +} + // After a deploy panel run succeeds (deploy_provision + fleets.toml write both // landed — `deploy.ts` guarantees that ordering): re-read fleets.toml, then // either land on the new fleet's detail screen (7.5.1 step 4) or, if we're @@ -681,11 +707,6 @@ if (configEl) { deployPanel?.open({ kind: "new-fleet" }); return; } - const debugBtn = target.closest('[data-action="fleet-debug"]'); - if (debugBtn) { - openDebugDrawer(debugBtn.dataset.fleet ?? ""); - return; - } const btn = target.closest("[data-fleet]"); if (btn?.dataset.fleet) selectFleet(btn.dataset.fleet); }); @@ -707,6 +728,10 @@ if (fleetDetailEl) { } if (target.closest('[data-action="fleet-debug"]') && activeFleet) { openDebugDrawer(activeFleet); + return; + } + if (target.closest('[data-action="delete-fleet"]') && activeFleet) { + void deleteFleet(activeFleet); } }); } diff --git a/console/src/render.test.ts b/console/src/render.test.ts index 0f46ff6..6ec8796 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -234,12 +234,9 @@ describe("fleetConfigHtml", () => { ).toContain('data-action="new-fleet"'); }); - it("gives each fleet row its own Debug-drawer gear, scoped by fleet name (ADR #83 slice 6, 7.2)", () => { + it("has no per-card Debug gear on the Fleets list (removed 2026-09-07 — Fleet detail's own [⚙] covers it)", () => { const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "orca"); - const gears = html.match(/data-action="fleet-debug"/g) ?? []; - expect(gears.length).toBe(FIXTURE_FLEET_CONFIG.fleets.length); - expect(html).toContain('data-action="fleet-debug" data-fleet="orca"'); - expect(html).toContain('data-action="fleet-debug" data-fleet="mira"'); + expect(html).not.toContain('data-action="fleet-debug"'); }); it("renders an unavailable state for null", () => { @@ -270,6 +267,11 @@ describe("fleetDetailHeaderHtml", () => { expect(html).not.toContain('data-action="fleet-debug" disabled'); }); + it("wires the Delete fleet action", () => { + const html = fleetDetailHeaderHtml("oab-prod-orca"); + expect(html).toContain('data-action="delete-fleet"'); + }); + it("escapes the fleet name", () => { const html = fleetDetailHeaderHtml(""); expect(html).toContain("<x>"); diff --git a/console/src/render.ts b/console/src/render.ts index ef92153..203e153 100644 --- a/console/src/render.ts +++ b/console/src/render.ts @@ -170,11 +170,11 @@ function membersLine(f: FleetConfig["fleets"][number]): string { return `${chips}`; } -// The `[⚙]` sits beside, not inside, the switch button — a fleet row is two -// independent click targets (select vs. debug), not one giant button, so -// they're siblings under a `.fleets-row` wrapper rather than nested -// ` - -
`; + `; } // Pure: the fleet-binding config -> the config panel HTML. A fleet is a @@ -237,7 +234,10 @@ export function renderFleetConfig( // "← Fleets" returns to the Fleets screen (Part A's drill-down). `[+ Add // instance]` is the slice 5 entry point (7.5.2: deploy into this fleet, no new // fleet-identity step). `[⚙]` is the slice 6 entry point — opens the Debug -// drawer (Activity/MCP/Config) scoped to this fleet. +// drawer (Activity/MCP/Config) scoped to this fleet. `[Delete fleet]` is a +// follow-up (Brett, 2026-09-07): the only way to remove a fleet before this +// was hand-editing raw TOML via "Edit config" — this just does that same +// text edit (`removeFleetBlock`) behind a button + confirm, in `main.ts`. export function fleetDetailHeaderHtml(fleetName: string): string { return `
@@ -246,6 +246,7 @@ export function fleetDetailHeaderHtml(fleetName: string): string { +
`; } diff --git a/console/src/styles.css b/console/src/styles.css index a3bfb55..4d43f07 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -712,14 +712,6 @@ button.act:disabled { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; } -.fleets-row { - display: flex; - align-items: stretch; - gap: 6px; -} -.fleets-row .fd-gear { - flex: 0 0 auto; -} .cfg-fleet { display: flex; flex: 1; @@ -830,6 +822,10 @@ button.act:disabled { .fd-gear { padding: 3px 8px; } +.fd-danger:hover { + color: var(--s-unhealthy); + border-color: var(--s-unhealthy); +} .fd-sep { color: var(--muted); }