diff --git a/console/index.html b/console/index.html index a737b15..ad8254e 100644 --- a/console/index.html +++ b/console/index.html @@ -18,6 +18,7 @@ + diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts index b71cfbf..9bfbca7 100644 --- a/console/src/fixtures.ts +++ b/console/src/fixtures.ts @@ -1,4 +1,4 @@ -import type { Deployment, RuntimeContext } from "./types"; +import type { Deployment, FleetConfig, RuntimeContext } from "./types"; // Stand-in data so the console renders without a live core. Mirrors the shape // studio-cp's `deploy_list` / `deploy_get` return. Swapped for the Tauri source @@ -59,3 +59,28 @@ export const FIXTURE_RUNTIME_CONTEXT: RuntimeContext = { expected_principal: "arn:aws:iam::504190915686:role/openab-orca-task-role", identity_matches: true, }; + +// 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. +export const FIXTURE_FLEET_CONFIG: FleetConfig = { + path: "~/.config/oab-studio/fleets.toml", + default_cluster: "oab", + fleets: [ + { + name: "prod", + cluster: "oab", + region: "ap-east-2", + profile: "orca-prod", + expected_principal: + "arn:aws:iam::504190915686:role/openab-orca-task-role", + }, + { + name: "staging", + cluster: "oab-staging", + region: "ap-southeast-1", + profile: "orca-staging", + expected_principal: null, + }, + ], +}; diff --git a/console/src/main.ts b/console/src/main.ts index 9be421a..b6eea23 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -1,12 +1,20 @@ import { defaultSource } from "./source"; -import { renderRoster, renderIdentity } from "./render"; +import { renderRoster, renderIdentity, renderFleetConfig } from "./render"; +import type { FleetConfig } from "./types"; import { createPane, bindBackend, type Level } from "./log"; const POLL_MS = 5000; -const CLUSTER = "oab"; +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. +let activeCluster = DEFAULT_CLUSTER; +let fleetConfig: FleetConfig | null = null; const roster = document.getElementById("roster"); const identityEl = document.getElementById("identity"); +const configEl = document.getElementById("config"); const clusterLabel = document.getElementById("cluster-label"); const pollStatus = document.getElementById("poll-status"); const logEl = document.getElementById("log"); @@ -67,7 +75,7 @@ let lastError = ""; async function tick(): Promise { if (!roster) return; try { - const deployments = await source.listDeployments(CLUSTER); + const deployments = await source.listDeployments(activeCluster); renderRoster(roster, deployments); if (lastError) { note("info", `roster recovered — ${deployments.length} deployment(s)`); @@ -96,13 +104,48 @@ async function tick(): Promise { async function refreshIdentity(): Promise { if (!identityEl) return; try { - renderIdentity(identityEl, await source.runtimeContext(CLUSTER)); + renderIdentity(identityEl, await source.runtimeContext(activeCluster)); } catch (e) { note("error", `identity: ${errText(e)}`); renderIdentity(identityEl, null); } } +// The fleet-binding config panel (ADR #19 "declare"). Fetched once on boot; the +// bindings are read at core startup, so they don't change under us at runtime. +async function refreshConfig(): Promise { + if (!configEl) return; + try { + fleetConfig = await source.fleetConfig(); + renderFleetConfig(configEl, fleetConfig, activeCluster); + } catch (e) { + note("error", `fleet config: ${errText(e)}`); + fleetConfig = null; + renderFleetConfig(configEl, null, activeCluster); + } +} + +// 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); + void refreshIdentity(); + void tick(); +} + +// One delegated listener: a click on any fleet button switches to its cluster. +if (configEl) { + configEl.addEventListener("click", (ev) => { + const btn = (ev.target as HTMLElement).closest("[data-cluster]"); + if (btn?.dataset.cluster) selectCluster(btn.dataset.cluster); + }); +} + // 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 = (cmd: string, args?: Record) => Promise; @@ -188,10 +231,11 @@ function setupUpdater(): void { async function boot(): Promise { note("info", `OAB Studio ${BUILD} (built ${__BUILD_TIME__})`); if (activity && mcp) await bindBackend(activity, mcp); - if (clusterLabel) clusterLabel.textContent = CLUSTER; - note("info", `polling cluster "${CLUSTER}" every ${POLL_MS / 1000}s`); + if (clusterLabel) clusterLabel.textContent = activeCluster; + note("info", `polling cluster "${activeCluster}" every ${POLL_MS / 1000}s`); setupUpdater(); await startCore(); + void refreshConfig(); void refreshIdentity(); void tick(); window.setInterval(() => void tick(), POLL_MS); diff --git a/console/src/render.test.ts b/console/src/render.test.ts index e4f062f..b0fcc23 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; -import { rosterHtml, identityHtml } from "./render"; -import { FIXTURE_DEPLOYMENTS, FIXTURE_RUNTIME_CONTEXT } from "./fixtures"; +import { rosterHtml, identityHtml, fleetConfigHtml } from "./render"; +import { + FIXTURE_DEPLOYMENTS, + FIXTURE_FLEET_CONFIG, + FIXTURE_RUNTIME_CONTEXT, +} from "./fixtures"; import { AGENT_STATES, type Deployment, type RuntimeContext } from "./types"; function ctx(partial: Partial): RuntimeContext { @@ -116,3 +120,57 @@ describe("identityHtml", () => { expect(html).not.toContain("