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
38 changes: 22 additions & 16 deletions console/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
`,
};
58 changes: 38 additions & 20 deletions console/src/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -85,7 +95,9 @@ let lastError = "";
async function tick(): Promise<void> {
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)`);
Expand Down Expand Up @@ -127,23 +139,29 @@ async function refreshConfig(): Promise<void> {
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();
}
Expand Down Expand Up @@ -191,7 +209,7 @@ async function saveEditor(): Promise<void> {
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.
Expand All @@ -207,16 +225,16 @@ 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;
if (target.closest('[data-action="edit-config"]')) {
openEditor();
return;
}
const btn = target.closest<HTMLElement>("[data-cluster]");
if (btn?.dataset.cluster) selectCluster(btn.dataset.cluster);
const btn = target.closest<HTMLElement>("[data-fleet]");
if (btn?.dataset.fleet) selectFleet(btn.dataset.fleet);
});
}

Expand Down
97 changes: 81 additions & 16 deletions console/src/render.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -122,34 +128,57 @@ 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");
});

it("falls back to 'default chain' when a fleet has no profile", () => {
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", () => {
Expand All @@ -160,28 +189,64 @@ describe("fleetConfigHtml", () => {
fleets: [],
text: "",
},
"oab",
null,
);
expect(html).toContain("No fleets configured");
expect(html).toContain("fleets.toml");
expect(html).not.toContain("cfg-fleet");
});

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 = "<x>";
const html = fleetConfigHtml(cfg, "oab");
const html = fleetConfigHtml(cfg, "orca");
expect(html).toContain("&lt;x&gt;");
expect(html).not.toContain("<x>");
});
});

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");
});
});
Loading
Loading