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
1 change: 1 addition & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
</div>
</header>
<main class="content">
<section id="config" class="config-wrap"></section>
<section id="identity" class="identity-wrap"></section>
<section class="logs">
<nav class="tabs" id="tabs">
Expand Down
27 changes: 26 additions & 1 deletion console/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
},
],
};
56 changes: 50 additions & 6 deletions console/src/main.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -67,7 +75,7 @@ let lastError = "";
async function tick(): Promise<void> {
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)`);
Expand Down Expand Up @@ -96,13 +104,48 @@ async function tick(): Promise<void> {
async function refreshIdentity(): Promise<void> {
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<void> {
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<HTMLElement>("[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 = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
Expand Down Expand Up @@ -188,10 +231,11 @@ function setupUpdater(): void {
async function boot(): Promise<void> {
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);
Expand Down
62 changes: 60 additions & 2 deletions console/src/render.test.ts
Original file line number Diff line number Diff line change
@@ -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>): RuntimeContext {
Expand Down Expand Up @@ -116,3 +120,57 @@ describe("identityHtml", () => {
expect(html).not.toContain("<script>");
});
});

describe("fleetConfigHtml", () => {
it("renders one switchable button per configured fleet", () => {
const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab");
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"');
});

it("marks the active cluster and no other", () => {
const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab-staging");
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");
expect(html.lastIndexOf("is-active", idx)).toBeGreaterThan(-1);
});

it("shows the profile and region as the credential line", () => {
const html = fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab");
expect(html).toContain("orca-prod");
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");
});

it("renders an empty state with the config path when no fleets", () => {
const html = fleetConfigHtml(
{ path: "~/.config/oab-studio/fleets.toml", default_cluster: "oab", fleets: [] },
"oab",
);
expect(html).toContain("No fleets configured");
expect(html).toContain("fleets.toml");
expect(html).not.toContain("cfg-fleet");
});

it("renders an unavailable state for null", () => {
expect(fleetConfigHtml(null, "oab")).toContain("fleet config unavailable");
});

it("escapes fleet fields", () => {
const cfg = structuredClone(FIXTURE_FLEET_CONFIG);
cfg.fleets[0].name = "<x>";
const html = fleetConfigHtml(cfg, "oab");
expect(html).toContain("&lt;x&gt;");
expect(html).not.toContain("<x>");
});
});
64 changes: 63 additions & 1 deletion console/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AgentState, Deployment, RuntimeContext } from "./types";
import type {
AgentState,
Deployment,
FleetConfig,
RuntimeContext,
} from "./types";

const STATE_CLASS: Record<AgentState, string> = {
Starting: "s-starting",
Expand Down Expand Up @@ -113,3 +118,60 @@ export function identityHtml(ctx: RuntimeContext | null): string {
export function renderIdentity(el: HTMLElement, ctx: RuntimeContext | null): void {
el.innerHTML = identityHtml(ctx);
}

// ---- Fleet config panel (ADR #19: the "declare" side) ------------------------

function credLine(f: FleetConfig["fleets"][number]): string {
// Profile-first (assume-role is later work); region pins the fleet's location.
const parts = [f.profile ?? "default chain", f.region].filter(
(p): p is string => Boolean(p),
);
return parts.map(escapeHtml).join(" · ");
}

function fleetButton(
f: FleetConfig["fleets"][number],
activeCluster: string,
): string {
const active = f.cluster === activeCluster;
const cls = active ? "cfg-fleet is-active" : "cfg-fleet";
return `<button class="${cls}" type="button" data-cluster="${escapeHtml(f.cluster)}" aria-pressed="${active}">
<span class="cfg-name">${escapeHtml(f.name || f.cluster)}</span>
<span class="cfg-cluster">${escapeHtml(f.cluster)}</span>
<span class="cfg-cred">${credLine(f)}</span>
</button>`;
}

// 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.
export function fleetConfigHtml(
cfg: FleetConfig | null,
activeCluster: string,
): string {
if (!cfg) {
return `<div class="config"><span class="muted">fleet config unavailable</span></div>`;
}
const path = cfg.path
? `<span class="cfg-path" title="edit this file to configure fleets"><code>${escapeHtml(cfg.path)}</code></span>`
: "";
const body = cfg.fleets.length
? `<div class="cfg-list">${cfg.fleets.map((f) => fleetButton(f, activeCluster)).join("")}</div>`
: `<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>`;
return `<div class="config">
<div class="cfg-head">
<span class="cfg-label">fleets</span>
${path}
</div>
${body}
</div>`;
}

export function renderFleetConfig(
el: HTMLElement,
cfg: FleetConfig | null,
activeCluster: string,
): void {
el.innerHTML = fleetConfigHtml(cfg, activeCluster);
}
15 changes: 13 additions & 2 deletions console/src/source.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { Deployment, RuntimeContext } from "./types";
import { FIXTURE_DEPLOYMENTS, FIXTURE_RUNTIME_CONTEXT } from "./fixtures";
import type { Deployment, FleetConfig, RuntimeContext } from "./types";
import {
FIXTURE_DEPLOYMENTS,
FIXTURE_FLEET_CONFIG,
FIXTURE_RUNTIME_CONTEXT,
} from "./fixtures";

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

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

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

// Pick a source: Tauri when running inside the shell, else the mock.
Expand Down
71 changes: 71 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,74 @@ td.counts.warn {
margin-top: 8px;
color: var(--ok);
}

/* ---- Fleet config panel (ADR #19: the "declare" side) ---- */
.config-wrap {
margin: 0 0 12px;
}
.config {
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
padding: 10px 12px;
font-size: 13px;
}
.cfg-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 8px;
}
.cfg-label {
color: var(--muted);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
}
.cfg-path {
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.cfg-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.cfg-fleet {
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
cursor: pointer;
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--text);
padding: 8px 10px;
font: inherit;
}
.cfg-fleet:hover {
border-color: var(--s-starting);
}
.cfg-fleet.is-active {
border-left-color: var(--s-starting);
box-shadow: inset 0 0 0 1px var(--s-starting);
}
.cfg-name {
font-weight: 600;
}
.cfg-cluster {
color: var(--muted);
font-size: 12px;
}
.cfg-cred {
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.cfg-empty {
color: var(--muted);
margin: 0;
}
Loading
Loading