diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts index 1060242..86773b7 100644 --- a/console/src/fixtures.ts +++ b/console/src/fixtures.ts @@ -61,41 +61,47 @@ export const FIXTURE_RUNTIME_CONTEXT: RuntimeContext = { }; // Stand-in fleet-binding config so the browser build renders the config panel -// without a core. Two fleets on different accounts — the shape the panel lets -// the operator switch between. +// without a core. Two fleets that **share the `oab` cluster** (and one +// credential) but list different `members` — the exact "group by usage, not by +// cluster" shape the panel lets the operator switch between and filter the +// roster by. export const FIXTURE_FLEET_CONFIG: FleetConfig = { path: "~/.config/oab-studio/fleets.toml", default_cluster: "oab", fleets: [ { - name: "prod", + name: "orca", cluster: "oab", + members: ["oab-prod-orca"], region: "ap-east-2", - profile: "orca-prod", + profile: "oab-fleet", expected_principal: "arn:aws:iam::504190915686:role/openab-orca-task-role", }, { - name: "staging", - cluster: "oab-staging", - region: "ap-southeast-1", - profile: "orca-staging", + name: "mira", + cluster: "oab", + members: ["oab-prod-mira"], + region: "ap-east-2", + profile: "oab-fleet", expected_principal: null, }, ], text: `# OAB Studio fleet bindings — which credential manages which fleet. +# A fleet is a usage-based group: orca and mira share the oab cluster (one +# credential) but list different members. -[[fleet]] -name = "prod" +[fleet.orca] cluster = "oab" +members = ["oab-prod-orca"] region = "ap-east-2" -profile = "orca-prod" +profile = "oab-fleet" expected_principal = "arn:aws:iam::504190915686:role/openab-orca-task-role" -[[fleet]] -name = "staging" -cluster = "oab-staging" -region = "ap-southeast-1" -profile = "orca-staging" +[fleet.mira] +cluster = "oab" +members = ["oab-prod-mira"] +region = "ap-east-2" +profile = "oab-fleet" `, }; diff --git a/console/src/main.ts b/console/src/main.ts index 7034cdd..fe5be55 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -1,5 +1,10 @@ import { defaultSource } from "./source"; -import { renderRoster, renderIdentity, renderFleetConfig } from "./render"; +import { + renderRoster, + renderIdentity, + renderFleetConfig, + filterByMembers, +} from "./render"; import type { FleetConfig } from "./types"; import { createPane, bindBackend, type Level } from "./log"; import { EditorView, basicSetup } from "codemirror"; @@ -10,10 +15,15 @@ import { toml } from "@codemirror/legacy-modes/mode/toml"; const POLL_MS = 5000; const DEFAULT_CLUSTER = "oab"; -// The active fleet's cluster drives every read (roster + identity) and, through -// oab-mcp's per-cluster binding, which credential/account we manage as. Selecting -// a fleet in the config panel is the "switch" step of the ADR #19 loop. +// The selection is a fleet **identity** (name), not a cluster — a fleet is a +// usage-based group, so two fleets can share a cluster. The active fleet derives +// the `activeCluster` every read targets (and thus, via oab-mcp's binding, which +// credential/account we manage as) and the `activeMembers` the roster is filtered +// to. `null` = no fleet selected: the default cluster, roster unfiltered. +// Selecting a fleet in the config panel is the "switch" step of the ADR #19 loop. +let activeFleet: string | null = null; let activeCluster = DEFAULT_CLUSTER; +let activeMembers: string[] = []; let fleetConfig: FleetConfig | null = null; const roster = document.getElementById("roster"); @@ -85,7 +95,9 @@ let lastError = ""; async function tick(): Promise { if (!roster) return; try { - const deployments = await source.listDeployments(activeCluster); + const all = await source.listDeployments(activeCluster); + // Filter to the active fleet's members (empty ⇒ whole cluster). + const deployments = filterByMembers(all, activeMembers); renderRoster(roster, deployments); if (lastError) { note("info", `roster recovered — ${deployments.length} deployment(s)`); @@ -127,23 +139,29 @@ async function refreshConfig(): Promise { if (!configEl) return; try { fleetConfig = await source.fleetConfig(); - renderFleetConfig(configEl, fleetConfig, activeCluster); + renderFleetConfig(configEl, fleetConfig, activeFleet); } catch (e) { note("error", `fleet config: ${errText(e)}`); fleetConfig = null; - renderFleetConfig(configEl, null, activeCluster); + renderFleetConfig(configEl, null, activeFleet); } } -// Switch the active fleet: re-point every read at its cluster (and thus its -// bound credential) and refresh immediately, so "switch fleet" == "switch -// managing account" the ADR calls for. No-op if it's already active. -function selectCluster(cluster: string): void { - if (!cluster || cluster === activeCluster) return; - activeCluster = cluster; - if (clusterLabel) clusterLabel.textContent = activeCluster; - note("info", `switched to cluster "${activeCluster}"`); - if (configEl) renderFleetConfig(configEl, fleetConfig, activeCluster); +// Switch the active fleet by identity (name): re-point every read at its cluster +// (and thus its bound credential), filter the roster to its members, and refresh +// immediately — so "switch fleet" == "switch managing account + roster" the ADR +// calls for. Two fleets may share a cluster, so the key is the name, not the +// cluster. No-op if it's already active or unknown. +function selectFleet(name: string): void { + if (!name || name === activeFleet) return; + const fleet = fleetConfig?.fleets.find((f) => f.name === name); + if (!fleet) return; + activeFleet = name; + activeCluster = fleet.cluster; + activeMembers = fleet.members; + if (clusterLabel) clusterLabel.textContent = `${activeFleet} · ${activeCluster}`; + note("info", `switched to fleet "${activeFleet}" (cluster "${activeCluster}")`); + if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet); void refreshIdentity(); void tick(); } @@ -191,7 +209,7 @@ async function saveEditor(): Promise { try { // The backend validates the TOML and rejects (without writing) on error. fleetConfig = await source.writeFleetConfig(text); - if (configEl) renderFleetConfig(configEl, fleetConfig, activeCluster); + if (configEl) renderFleetConfig(configEl, fleetConfig, activeFleet); note("info", "fleet config saved"); closeEditor(); // A binding change may alter the active fleet's credential — re-observe. @@ -207,7 +225,7 @@ saveBtn?.addEventListener("click", () => void saveEditor()); cancelBtn?.addEventListener("click", () => closeEditor()); // One delegated listener on the config panel: "Edit config" opens the editor; -// a click on any fleet button switches to its cluster. +// a click on any fleet button switches to that fleet by name. if (configEl) { configEl.addEventListener("click", (ev) => { const target = ev.target as HTMLElement; @@ -215,8 +233,8 @@ if (configEl) { openEditor(); return; } - const btn = target.closest("[data-cluster]"); - if (btn?.dataset.cluster) selectCluster(btn.dataset.cluster); + const btn = target.closest("[data-fleet]"); + if (btn?.dataset.fleet) selectFleet(btn.dataset.fleet); }); } diff --git a/console/src/render.test.ts b/console/src/render.test.ts index f76e9a0..c3f29ae 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { rosterHtml, identityHtml, fleetConfigHtml } from "./render"; +import { + rosterHtml, + identityHtml, + fleetConfigHtml, + filterByMembers, + serviceName, +} from "./render"; import { FIXTURE_DEPLOYMENTS, FIXTURE_FLEET_CONFIG, @@ -122,26 +128,49 @@ describe("identityHtml", () => { }); describe("fleetConfigHtml", () => { - it("renders one switchable button per configured fleet", () => { - const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab"); + it("renders one button per fleet, switchable by name", () => { + const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "orca"); const buttons = html.match(/class="cfg-fleet/g) ?? []; expect(buttons.length).toBe(FIXTURE_FLEET_CONFIG.fleets.length); - expect(html).toContain('data-cluster="oab"'); - expect(html).toContain('data-cluster="oab-staging"'); + expect(html).toContain('data-fleet="orca"'); + expect(html).toContain('data-fleet="mira"'); }); - it("marks the active cluster and no other", () => { - const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab-staging"); + it("switches by fleet identity, not cluster (two fleets share a cluster)", () => { + // Both fixture fleets are on cluster "oab" — the switch key must be the name. + expect(FIXTURE_FLEET_CONFIG.fleets.every((f) => f.cluster === "oab")).toBe( + true, + ); + const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "mira"); const active = html.match(/cfg-fleet is-active/g) ?? []; expect(active.length).toBe(1); - // the active button is the staging one - const idx = html.indexOf("oab-staging"); + // the active button is the mira one + const idx = html.indexOf('data-fleet="mira"'); expect(html.lastIndexOf("is-active", idx)).toBeGreaterThan(-1); }); + it("marks no fleet active when the selection is null", () => { + const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, null); + expect(html).not.toContain("is-active"); + }); + + it("renders each fleet's members", () => { + const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "orca"); + expect(html).toContain("oab-prod-orca"); + expect(html).toContain("oab-prod-mira"); + expect(html).toContain('class="cfg-member"'); + }); + + it("shows 'whole cluster' when a fleet has no explicit members", () => { + const cfg = structuredClone(FIXTURE_FLEET_CONFIG); + cfg.fleets[0].members = []; + const html = fleetConfigHtml(cfg, "orca"); + expect(html).toContain("whole cluster"); + }); + it("shows the profile and region as the credential line", () => { - const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab"); - expect(html).toContain("orca-prod"); + const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "orca"); + expect(html).toContain("oab-fleet"); expect(html).toContain("ap-east-2"); }); @@ -149,7 +178,7 @@ describe("fleetConfigHtml", () => { const cfg = structuredClone(FIXTURE_FLEET_CONFIG); cfg.fleets[0].profile = null; cfg.fleets[0].region = null; - expect(fleetConfigHtml(cfg, "oab")).toContain("default chain"); + expect(fleetConfigHtml(cfg, "orca")).toContain("default chain"); }); it("renders an empty state with the config path when no fleets", () => { @@ -160,7 +189,7 @@ describe("fleetConfigHtml", () => { fleets: [], text: "", }, - "oab", + null, ); expect(html).toContain("No fleets configured"); expect(html).toContain("fleets.toml"); @@ -168,20 +197,56 @@ describe("fleetConfigHtml", () => { }); it("always offers the Edit config action (even with fleets)", () => { - expect(fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab")).toContain( + expect(fleetConfigHtml(FIXTURE_FLEET_CONFIG, "orca")).toContain( 'data-action="edit-config"', ); }); it("renders an unavailable state for null", () => { - expect(fleetConfigHtml(null, "oab")).toContain("fleet config unavailable"); + expect(fleetConfigHtml(null, null)).toContain("fleet config unavailable"); }); it("escapes fleet fields", () => { const cfg = structuredClone(FIXTURE_FLEET_CONFIG); cfg.fleets[0].name = ""; - const html = fleetConfigHtml(cfg, "oab"); + const html = fleetConfigHtml(cfg, "orca"); expect(html).toContain("<x>"); expect(html).not.toContain(""); }); }); + +describe("filterByMembers", () => { + it("keeps only deployments whose ECS service name is a member", () => { + const kept = filterByMembers(FIXTURE_DEPLOYMENTS, ["oab-prod-orca"]); + expect(kept.map((d) => d.name)).toEqual(["orca"]); + }); + + it("matches multiple members across the roster", () => { + const kept = filterByMembers(FIXTURE_DEPLOYMENTS, [ + "oab-prod-orca", + "oab-prod-mira", + ]); + expect(kept.map((d) => d.name).sort()).toEqual(["mira", "orca"]); + }); + + it("treats an empty member list as the whole cluster (unfiltered)", () => { + expect(filterByMembers(FIXTURE_DEPLOYMENTS, [])).toEqual( + FIXTURE_DEPLOYMENTS, + ); + }); + + it("also accepts the short deployment name as a member (like resolve_service)", () => { + const kept = filterByMembers(FIXTURE_DEPLOYMENTS, ["kirin"]); + expect(kept.map((d) => d.name)).toEqual(["kirin"]); + }); + + it("drops everything when no member matches", () => { + expect(filterByMembers(FIXTURE_DEPLOYMENTS, ["oab-prod-nobody"])).toEqual( + [], + ); + }); + + it("derives the ECS service name as oab-{namespace}-{name}", () => { + expect(serviceName({ ...FIXTURE_DEPLOYMENTS[0] })).toBe("oab-prod-orca"); + }); +}); diff --git a/console/src/render.ts b/console/src/render.ts index 1346976..ab7d41b 100644 --- a/console/src/render.ts +++ b/console/src/render.ts @@ -39,6 +39,29 @@ function rowHtml(d: Deployment): string { `; } +// The ECS service name a Deployment maps to — the key `fleets.toml` `members` +// are written as (`oab-{namespace}-{name}`). Kept here so the member/deployment +// join lives in one place; mirrors studio-cp's `oab-{ns}-{name}` convention. +export function serviceName(d: Deployment): string { + return `oab-${d.namespace}-${d.name}`; +} + +// Pure: keep only the deployments belonging to a fleet's `members`. An empty +// member list ⇒ the fleet covers the whole cluster (legacy semantics), so the +// roster is unfiltered. A member matches either the full ECS service name +// (`oab-{ns}-{name}`) or the short deployment name — mirroring studio-cp's +// `resolve_service`, which accepts both forms. +export function filterByMembers( + deployments: Deployment[], + members: string[], +): Deployment[] { + if (members.length === 0) return deployments; + const wanted = new Set(members); + return deployments.filter( + (d) => wanted.has(serviceName(d)) || wanted.has(d.name), + ); +} + // Pure: deployments -> roster table HTML. Kept side-effect-free so it is unit // testable without a DOM. export function rosterHtml(deployments: Deployment[]): string { @@ -129,26 +152,40 @@ function credLine(f: FleetConfig["fleets"][number]): string { return parts.map(escapeHtml).join(" · "); } +// The members line: the ECS services grouped into this fleet, or a note that an +// empty member list means the whole cluster (legacy semantics). +function membersLine(f: FleetConfig["fleets"][number]): string { + if (f.members.length === 0) { + return `whole cluster`; + } + const chips = f.members + .map((m) => `${escapeHtml(m)}`) + .join(""); + return `${chips}`; +} + function fleetButton( f: FleetConfig["fleets"][number], - activeCluster: string, + activeFleet: string | null, ): string { - const active = f.cluster === activeCluster; + const active = f.name === activeFleet; const cls = active ? "cfg-fleet is-active" : "cfg-fleet"; - return ``; } -// Pure: the fleet-binding config -> the config panel HTML. Each fleet is a -// button that switches the active cluster (the "switch" step). `activeCluster` -// marks which one is currently selected. An empty config still renders — it -// shows where to add bindings, which is exactly the "no panel for config" gap. +// Pure: the fleet-binding config -> the config panel HTML. A fleet is a +// usage-based group, so each button switches by fleet **identity** (name), not +// by cluster — two fleets may share a cluster. `activeFleet` (a name, or `null` +// when none is selected) marks the current one. An empty config still renders — +// it shows where to add bindings, which is exactly the "no panel for config" gap. export function fleetConfigHtml( cfg: FleetConfig | null, - activeCluster: string, + activeFleet: string | null, ): string { if (!cfg) { return `
fleet config unavailable
`; @@ -157,8 +194,8 @@ export function fleetConfigHtml( ? `${escapeHtml(cfg.path)}` : ""; const body = cfg.fleets.length - ? `
${cfg.fleets.map((f) => fleetButton(f, activeCluster)).join("")}
` - : `

No fleets configured — add [[fleet]] entries to the config file above. Managing ${escapeHtml(cfg.default_cluster)} via the default credential chain.

`; + ? `
${cfg.fleets.map((f) => fleetButton(f, activeFleet)).join("")}
` + : `

No fleets configured — add [fleet.<name>] entries to the config file above. Managing ${escapeHtml(cfg.default_cluster)} via the default credential chain.

`; return `
fleets @@ -172,7 +209,7 @@ export function fleetConfigHtml( export function renderFleetConfig( el: HTMLElement, cfg: FleetConfig | null, - activeCluster: string, + activeFleet: string | null, ): void { - el.innerHTML = fleetConfigHtml(cfg, activeCluster); + el.innerHTML = fleetConfigHtml(cfg, activeFleet); } diff --git a/console/src/styles.css b/console/src/styles.css index e54362b..99322e4 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -416,6 +416,26 @@ td.counts.warn { color: var(--muted); font-size: 12px; } +.cfg-members { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 2px 0; +} +.cfg-member { + font-size: 11px; + color: var(--text); + background: var(--bg-alt, rgba(127, 127, 127, 0.12)); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 6px; + overflow-wrap: anywhere; +} +.cfg-members-all { + color: var(--muted); + font-size: 12px; + font-style: italic; +} .cfg-cred { color: var(--muted); font-size: 12px; diff --git a/console/src/types.ts b/console/src/types.ts index 2f0dc2d..e1da3b1 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -44,11 +44,16 @@ export interface FleetBinding { } // One configured fleet → managing-credential binding — mirrors an entry of -// oab-mcp's `fleet_config` tool (ADR #19). `cluster` is the switch key: selecting -// a fleet makes subsequent calls target its cluster (and thus its credential). +// oab-mcp's `fleet_config` tool (ADR #19, fleet-grouping ADR). `name` is the +// switch key: a fleet is a usage-based logical group, so two fleets may share a +// `cluster` (and thus one credential) while listing different `members`. +// Selecting a fleet targets its `cluster` for reads and filters the roster to +// its `members`. `members` are the ECS service names in the group; empty ⇒ the +// fleet covers the whole cluster (legacy `[[fleet]]` semantics). export interface FleetConfigEntry { name: string; cluster: string; + members: string[]; region: string | null; profile: string | null; expected_principal: string | null;