Skip to content

Commit b72cb05

Browse files
brettchienOrca (ecs-claude)claude
authored
feat(studio): fleet config panel — view + switch active fleet (ADR #19 A+B) (#26)
The visible config surface the ADR #19 read model was missing: which fleet this Studio is managing, against which credential/account, and the ability to switch between fleets from the UI. - oab-mcp: new read-only `fleet_config` tool (9th tool) — lists the configured per-fleet bindings (name/cluster/region/profile/expected_principal) plus the config file path and default cluster. Server now retains the bindings path. Profiles are names, not secrets. Catalog test + module doc updated. - src-tauri: `fleet_config` bridge command (mirrors runtime_context). - console: FleetConfig view-model + Source method (Tauri + Mock fixture); a pure `fleetConfigHtml` renderer (one switchable button per fleet, empty state shows where to add bindings). Clicking a fleet re-points every read (roster + identity) at its cluster — the ADR "switch" step, which through oab-mcp's per-cluster binding changes the managing credential. 7 new render tests. Slice A (view) + B (switch) of the config panel. Editing (write-back to fleets.toml) is slice C, stacked on this. Co-authored-by: Orca (ecs-claude) <orca@ecs.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 152f1ed commit b72cb05

10 files changed

Lines changed: 373 additions & 17 deletions

File tree

console/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
</div>
1919
</header>
2020
<main class="content">
21+
<section id="config" class="config-wrap"></section>
2122
<section id="identity" class="identity-wrap"></section>
2223
<section class="logs">
2324
<nav class="tabs" id="tabs">

console/src/fixtures.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Deployment, RuntimeContext } from "./types";
1+
import type { Deployment, FleetConfig, RuntimeContext } from "./types";
22

33
// Stand-in data so the console renders without a live core. Mirrors the shape
44
// studio-cp's `deploy_list` / `deploy_get` return. Swapped for the Tauri source
@@ -59,3 +59,28 @@ export const FIXTURE_RUNTIME_CONTEXT: RuntimeContext = {
5959
expected_principal: "arn:aws:iam::504190915686:role/openab-orca-task-role",
6060
identity_matches: true,
6161
};
62+
63+
// Stand-in fleet-binding config so the browser build renders the config panel
64+
// without a core. Two fleets on different accounts — the shape the panel lets
65+
// the operator switch between.
66+
export const FIXTURE_FLEET_CONFIG: FleetConfig = {
67+
path: "~/.config/oab-studio/fleets.toml",
68+
default_cluster: "oab",
69+
fleets: [
70+
{
71+
name: "prod",
72+
cluster: "oab",
73+
region: "ap-east-2",
74+
profile: "orca-prod",
75+
expected_principal:
76+
"arn:aws:iam::504190915686:role/openab-orca-task-role",
77+
},
78+
{
79+
name: "staging",
80+
cluster: "oab-staging",
81+
region: "ap-southeast-1",
82+
profile: "orca-staging",
83+
expected_principal: null,
84+
},
85+
],
86+
};

console/src/main.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import { defaultSource } from "./source";
2-
import { renderRoster, renderIdentity } from "./render";
2+
import { renderRoster, renderIdentity, renderFleetConfig } from "./render";
3+
import type { FleetConfig } from "./types";
34
import { createPane, bindBackend, type Level } from "./log";
45

56
const POLL_MS = 5000;
6-
const CLUSTER = "oab";
7+
const DEFAULT_CLUSTER = "oab";
8+
9+
// The active fleet's cluster drives every read (roster + identity) and, through
10+
// oab-mcp's per-cluster binding, which credential/account we manage as. Selecting
11+
// a fleet in the config panel is the "switch" step of the ADR #19 loop.
12+
let activeCluster = DEFAULT_CLUSTER;
13+
let fleetConfig: FleetConfig | null = null;
714

815
const roster = document.getElementById("roster");
916
const identityEl = document.getElementById("identity");
17+
const configEl = document.getElementById("config");
1018
const clusterLabel = document.getElementById("cluster-label");
1119
const pollStatus = document.getElementById("poll-status");
1220
const logEl = document.getElementById("log");
@@ -67,7 +75,7 @@ let lastError = "";
6775
async function tick(): Promise<void> {
6876
if (!roster) return;
6977
try {
70-
const deployments = await source.listDeployments(CLUSTER);
78+
const deployments = await source.listDeployments(activeCluster);
7179
renderRoster(roster, deployments);
7280
if (lastError) {
7381
note("info", `roster recovered — ${deployments.length} deployment(s)`);
@@ -96,13 +104,48 @@ async function tick(): Promise<void> {
96104
async function refreshIdentity(): Promise<void> {
97105
if (!identityEl) return;
98106
try {
99-
renderIdentity(identityEl, await source.runtimeContext(CLUSTER));
107+
renderIdentity(identityEl, await source.runtimeContext(activeCluster));
100108
} catch (e) {
101109
note("error", `identity: ${errText(e)}`);
102110
renderIdentity(identityEl, null);
103111
}
104112
}
105113

114+
// The fleet-binding config panel (ADR #19 "declare"). Fetched once on boot; the
115+
// bindings are read at core startup, so they don't change under us at runtime.
116+
async function refreshConfig(): Promise<void> {
117+
if (!configEl) return;
118+
try {
119+
fleetConfig = await source.fleetConfig();
120+
renderFleetConfig(configEl, fleetConfig, activeCluster);
121+
} catch (e) {
122+
note("error", `fleet config: ${errText(e)}`);
123+
fleetConfig = null;
124+
renderFleetConfig(configEl, null, activeCluster);
125+
}
126+
}
127+
128+
// Switch the active fleet: re-point every read at its cluster (and thus its
129+
// bound credential) and refresh immediately, so "switch fleet" == "switch
130+
// managing account" the ADR calls for. No-op if it's already active.
131+
function selectCluster(cluster: string): void {
132+
if (!cluster || cluster === activeCluster) return;
133+
activeCluster = cluster;
134+
if (clusterLabel) clusterLabel.textContent = activeCluster;
135+
note("info", `switched to cluster "${activeCluster}"`);
136+
if (configEl) renderFleetConfig(configEl, fleetConfig, activeCluster);
137+
void refreshIdentity();
138+
void tick();
139+
}
140+
141+
// One delegated listener: a click on any fleet button switches to its cluster.
142+
if (configEl) {
143+
configEl.addEventListener("click", (ev) => {
144+
const btn = (ev.target as HTMLElement).closest<HTMLElement>("[data-cluster]");
145+
if (btn?.dataset.cluster) selectCluster(btn.dataset.cluster);
146+
});
147+
}
148+
106149
// The Tauri command bridge — present only inside the desktop shell (the browser
107150
// build has no `__TAURI__`, so callers no-op / hide their UI).
108151
type Invoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
@@ -188,10 +231,11 @@ function setupUpdater(): void {
188231
async function boot(): Promise<void> {
189232
note("info", `OAB Studio ${BUILD} (built ${__BUILD_TIME__})`);
190233
if (activity && mcp) await bindBackend(activity, mcp);
191-
if (clusterLabel) clusterLabel.textContent = CLUSTER;
192-
note("info", `polling cluster "${CLUSTER}" every ${POLL_MS / 1000}s`);
234+
if (clusterLabel) clusterLabel.textContent = activeCluster;
235+
note("info", `polling cluster "${activeCluster}" every ${POLL_MS / 1000}s`);
193236
setupUpdater();
194237
await startCore();
238+
void refreshConfig();
195239
void refreshIdentity();
196240
void tick();
197241
window.setInterval(() => void tick(), POLL_MS);

console/src/render.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, it, expect } from "vitest";
2-
import { rosterHtml, identityHtml } from "./render";
3-
import { FIXTURE_DEPLOYMENTS, FIXTURE_RUNTIME_CONTEXT } from "./fixtures";
2+
import { rosterHtml, identityHtml, fleetConfigHtml } from "./render";
3+
import {
4+
FIXTURE_DEPLOYMENTS,
5+
FIXTURE_FLEET_CONFIG,
6+
FIXTURE_RUNTIME_CONTEXT,
7+
} from "./fixtures";
48
import { AGENT_STATES, type Deployment, type RuntimeContext } from "./types";
59

610
function ctx(partial: Partial<RuntimeContext>): RuntimeContext {
@@ -116,3 +120,57 @@ describe("identityHtml", () => {
116120
expect(html).not.toContain("<script>");
117121
});
118122
});
123+
124+
describe("fleetConfigHtml", () => {
125+
it("renders one switchable button per configured fleet", () => {
126+
const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab");
127+
const buttons = html.match(/class="cfg-fleet/g) ?? [];
128+
expect(buttons.length).toBe(FIXTURE_FLEET_CONFIG.fleets.length);
129+
expect(html).toContain('data-cluster="oab"');
130+
expect(html).toContain('data-cluster="oab-staging"');
131+
});
132+
133+
it("marks the active cluster and no other", () => {
134+
const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab-staging");
135+
const active = html.match(/cfg-fleet is-active/g) ?? [];
136+
expect(active.length).toBe(1);
137+
// the active button is the staging one
138+
const idx = html.indexOf("oab-staging");
139+
expect(html.lastIndexOf("is-active", idx)).toBeGreaterThan(-1);
140+
});
141+
142+
it("shows the profile and region as the credential line", () => {
143+
const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab");
144+
expect(html).toContain("orca-prod");
145+
expect(html).toContain("ap-east-2");
146+
});
147+
148+
it("falls back to 'default chain' when a fleet has no profile", () => {
149+
const cfg = structuredClone(FIXTURE_FLEET_CONFIG);
150+
cfg.fleets[0].profile = null;
151+
cfg.fleets[0].region = null;
152+
expect(fleetConfigHtml(cfg, "oab")).toContain("default chain");
153+
});
154+
155+
it("renders an empty state with the config path when no fleets", () => {
156+
const html = fleetConfigHtml(
157+
{ path: "~/.config/oab-studio/fleets.toml", default_cluster: "oab", fleets: [] },
158+
"oab",
159+
);
160+
expect(html).toContain("No fleets configured");
161+
expect(html).toContain("fleets.toml");
162+
expect(html).not.toContain("cfg-fleet");
163+
});
164+
165+
it("renders an unavailable state for null", () => {
166+
expect(fleetConfigHtml(null, "oab")).toContain("fleet config unavailable");
167+
});
168+
169+
it("escapes fleet fields", () => {
170+
const cfg = structuredClone(FIXTURE_FLEET_CONFIG);
171+
cfg.fleets[0].name = "<x>";
172+
const html = fleetConfigHtml(cfg, "oab");
173+
expect(html).toContain("&lt;x&gt;");
174+
expect(html).not.toContain("<x>");
175+
});
176+
});

console/src/render.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { AgentState, Deployment, RuntimeContext } from "./types";
1+
import type {
2+
AgentState,
3+
Deployment,
4+
FleetConfig,
5+
RuntimeContext,
6+
} from "./types";
27

38
const STATE_CLASS: Record<AgentState, string> = {
49
Starting: "s-starting",
@@ -113,3 +118,60 @@ export function identityHtml(ctx: RuntimeContext | null): string {
113118
export function renderIdentity(el: HTMLElement, ctx: RuntimeContext | null): void {
114119
el.innerHTML = identityHtml(ctx);
115120
}
121+
122+
// ---- Fleet config panel (ADR #19: the "declare" side) ------------------------
123+
124+
function credLine(f: FleetConfig["fleets"][number]): string {
125+
// Profile-first (assume-role is later work); region pins the fleet's location.
126+
const parts = [f.profile ?? "default chain", f.region].filter(
127+
(p): p is string => Boolean(p),
128+
);
129+
return parts.map(escapeHtml).join(" · ");
130+
}
131+
132+
function fleetButton(
133+
f: FleetConfig["fleets"][number],
134+
activeCluster: string,
135+
): string {
136+
const active = f.cluster === activeCluster;
137+
const cls = active ? "cfg-fleet is-active" : "cfg-fleet";
138+
return `<button class="${cls}" type="button" data-cluster="${escapeHtml(f.cluster)}" aria-pressed="${active}">
139+
<span class="cfg-name">${escapeHtml(f.name || f.cluster)}</span>
140+
<span class="cfg-cluster">${escapeHtml(f.cluster)}</span>
141+
<span class="cfg-cred">${credLine(f)}</span>
142+
</button>`;
143+
}
144+
145+
// Pure: the fleet-binding config -> the config panel HTML. Each fleet is a
146+
// button that switches the active cluster (the "switch" step). `activeCluster`
147+
// marks which one is currently selected. An empty config still renders — it
148+
// shows where to add bindings, which is exactly the "no panel for config" gap.
149+
export function fleetConfigHtml(
150+
cfg: FleetConfig | null,
151+
activeCluster: string,
152+
): string {
153+
if (!cfg) {
154+
return `<div class="config"><span class="muted">fleet config unavailable</span></div>`;
155+
}
156+
const path = cfg.path
157+
? `<span class="cfg-path" title="edit this file to configure fleets"><code>${escapeHtml(cfg.path)}</code></span>`
158+
: "";
159+
const body = cfg.fleets.length
160+
? `<div class="cfg-list">${cfg.fleets.map((f) => fleetButton(f, activeCluster)).join("")}</div>`
161+
: `<p class="cfg-empty">No fleets configured — add <code>[[fleet]]</code> entries to the config file above. Managing <code>${escapeHtml(cfg.default_cluster)}</code> via the default credential chain.</p>`;
162+
return `<div class="config">
163+
<div class="cfg-head">
164+
<span class="cfg-label">fleets</span>
165+
${path}
166+
</div>
167+
${body}
168+
</div>`;
169+
}
170+
171+
export function renderFleetConfig(
172+
el: HTMLElement,
173+
cfg: FleetConfig | null,
174+
activeCluster: string,
175+
): void {
176+
el.innerHTML = fleetConfigHtml(cfg, activeCluster);
177+
}

console/src/source.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
import type { Deployment, RuntimeContext } from "./types";
2-
import { FIXTURE_DEPLOYMENTS, FIXTURE_RUNTIME_CONTEXT } from "./fixtures";
1+
import type { Deployment, FleetConfig, RuntimeContext } from "./types";
2+
import {
3+
FIXTURE_DEPLOYMENTS,
4+
FIXTURE_FLEET_CONFIG,
5+
FIXTURE_RUNTIME_CONTEXT,
6+
} from "./fixtures";
37

48
// A read source for the console. Desktop (Tauri → studio-cp) and the standalone
59
// browser build implement this identically, so the UI never knows which it is.
610
export interface Source {
711
listDeployments(cluster?: string): Promise<Deployment[]>;
812
runtimeContext(cluster?: string): Promise<RuntimeContext>;
13+
fleetConfig(): Promise<FleetConfig>;
914
}
1015

1116
// Fixture-backed source for the standalone / browser build — no core required.
@@ -16,6 +21,9 @@ export class MockSource implements Source {
1621
async runtimeContext(): Promise<RuntimeContext> {
1722
return structuredClone(FIXTURE_RUNTIME_CONTEXT);
1823
}
24+
async fleetConfig(): Promise<FleetConfig> {
25+
return structuredClone(FIXTURE_FLEET_CONFIG);
26+
}
1927
}
2028

2129
// Minimal shape of the Tauri global bridge (v2, `withGlobalTauri`). Accessed via
@@ -43,6 +51,9 @@ export class TauriSource implements Source {
4351
async runtimeContext(cluster?: string): Promise<RuntimeContext> {
4452
return this.invoke()<RuntimeContext>("runtime_context", { cluster });
4553
}
54+
async fleetConfig(): Promise<FleetConfig> {
55+
return this.invoke()<FleetConfig>("fleet_config");
56+
}
4657
}
4758

4859
// Pick a source: Tauri when running inside the shell, else the mock.

console/src/styles.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,74 @@ td.counts.warn {
354354
margin-top: 8px;
355355
color: var(--ok);
356356
}
357+
358+
/* ---- Fleet config panel (ADR #19: the "declare" side) ---- */
359+
.config-wrap {
360+
margin: 0 0 12px;
361+
}
362+
.config {
363+
border: 1px solid var(--border);
364+
border-radius: 6px;
365+
background: var(--panel);
366+
padding: 10px 12px;
367+
font-size: 13px;
368+
}
369+
.cfg-head {
370+
display: flex;
371+
align-items: baseline;
372+
gap: 10px;
373+
margin-bottom: 8px;
374+
}
375+
.cfg-label {
376+
color: var(--muted);
377+
text-transform: uppercase;
378+
font-size: 11px;
379+
letter-spacing: 0.04em;
380+
}
381+
.cfg-path {
382+
color: var(--muted);
383+
font-size: 12px;
384+
overflow-wrap: anywhere;
385+
}
386+
.cfg-list {
387+
display: grid;
388+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
389+
gap: 8px;
390+
}
391+
.cfg-fleet {
392+
display: flex;
393+
flex-direction: column;
394+
gap: 2px;
395+
text-align: left;
396+
cursor: pointer;
397+
border: 1px solid var(--border);
398+
border-left: 3px solid var(--border);
399+
border-radius: 5px;
400+
background: var(--bg);
401+
color: var(--text);
402+
padding: 8px 10px;
403+
font: inherit;
404+
}
405+
.cfg-fleet:hover {
406+
border-color: var(--s-starting);
407+
}
408+
.cfg-fleet.is-active {
409+
border-left-color: var(--s-starting);
410+
box-shadow: inset 0 0 0 1px var(--s-starting);
411+
}
412+
.cfg-name {
413+
font-weight: 600;
414+
}
415+
.cfg-cluster {
416+
color: var(--muted);
417+
font-size: 12px;
418+
}
419+
.cfg-cred {
420+
color: var(--muted);
421+
font-size: 12px;
422+
overflow-wrap: anywhere;
423+
}
424+
.cfg-empty {
425+
color: var(--muted);
426+
margin: 0;
427+
}

0 commit comments

Comments
 (0)