Skip to content
Closed
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="identity" class="identity-wrap"></section>
<section class="logs">
<nav class="tabs" id="tabs">
<button class="tab is-active" data-target="log">Activity</button>
Expand Down
24 changes: 23 additions & 1 deletion console/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Deployment } from "./types";
import type { Deployment, 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 @@ -37,3 +37,25 @@ export const FIXTURE_DEPLOYMENTS: Deployment[] = [
instances: [],
},
];

// Stand-in identity so the browser build renders the panel without a core.
// A healthy example: a task role that matches its binding's expectation.
export const FIXTURE_RUNTIME_CONTEXT: RuntimeContext = {
cluster: "oab",
principal:
"arn:aws:sts::504190915686:assumed-role/openab-orca-task-role/session",
principal_kind: "role",
scope: "504190915686",
location: "ap-east-2",
source: "container-credentials (task/pod role)",
caller_id: "AROAEXAMPLE:session",
binding: {
name: "prod",
profile: null,
region: "ap-east-2",
expected_principal:
"arn:aws:iam::504190915686:role/openab-orca-task-role",
},
expected_principal: "arn:aws:iam::504190915686:role/openab-orca-task-role",
identity_matches: true,
};
17 changes: 16 additions & 1 deletion console/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { defaultSource } from "./source";
import { renderRoster } from "./render";
import { renderRoster, renderIdentity } from "./render";
import { createPane, bindBackend, type Level } from "./log";

const POLL_MS = 5000;
const CLUSTER = "oab";

const roster = document.getElementById("roster");
const identityEl = document.getElementById("identity");
const clusterLabel = document.getElementById("cluster-label");
const pollStatus = document.getElementById("poll-status");
const logEl = document.getElementById("log");
Expand Down Expand Up @@ -89,6 +90,19 @@ async function tick(): Promise<void> {
}
}

// The effective managing identity for this cluster (ADR #19). Fetched once on
// boot and refreshed when the roster recovers — it changes rarely, so it does
// not need the 5s poll (and each call is a live STS lookup server-side).
async function refreshIdentity(): Promise<void> {
if (!identityEl) return;
try {
renderIdentity(identityEl, await source.runtimeContext(CLUSTER));
} catch (e) {
note("error", `identity: ${errText(e)}`);
renderIdentity(identityEl, null);
}
}

// 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 @@ -178,6 +192,7 @@ async function boot(): Promise<void> {
note("info", `polling cluster "${CLUSTER}" every ${POLL_MS / 1000}s`);
setupUpdater();
await startCore();
void refreshIdentity();
void tick();
window.setInterval(() => void tick(), POLL_MS);
}
Expand Down
59 changes: 56 additions & 3 deletions console/src/render.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, it, expect } from "vitest";
import { rosterHtml } from "./render";
import { FIXTURE_DEPLOYMENTS } from "./fixtures";
import { AGENT_STATES, type Deployment } from "./types";
import { rosterHtml, identityHtml } from "./render";
import { FIXTURE_DEPLOYMENTS, FIXTURE_RUNTIME_CONTEXT } from "./fixtures";
import { AGENT_STATES, type Deployment, type RuntimeContext } from "./types";

function ctx(partial: Partial<RuntimeContext>): RuntimeContext {
return { ...structuredClone(FIXTURE_RUNTIME_CONTEXT), ...partial };
}

function dep(partial: Partial<Deployment>): Deployment {
return {
Expand Down Expand Up @@ -63,3 +67,52 @@ describe("rosterHtml", () => {
expect(html).not.toContain("<x>");
});
});

describe("identityHtml", () => {
it("shows principal, account, region and the role kind badge", () => {
const html = identityHtml(FIXTURE_RUNTIME_CONTEXT);
expect(html).toContain('class="kind k-role"');
expect(html).toContain("504190915686");
expect(html).toContain("ap-east-2");
expect(html).toContain("openab-orca-task-role");
});

it("flags a mismatch and shows the expected principal", () => {
const html = identityHtml(
ctx({
principal: "arn:aws:iam::916371022086:user/brett.chien",
principal_kind: "user",
scope: "916371022086",
identity_matches: false,
}),
);
expect(html).toContain('class="identity mismatch"');
expect(html).toContain("identity mismatch");
expect(html).toContain("class=\"kind k-user\"");
expect(html).toContain("arn:aws:iam::504190915686:role/openab-orca-task-role");
});

it("shows a matches verdict when identity_matches is true", () => {
expect(identityHtml(ctx({ identity_matches: true }))).toContain(
"matches expected",
);
});

it("shows no verdict when there is no expectation", () => {
const html = identityHtml(
ctx({ identity_matches: null, expected_principal: null }),
);
expect(html).not.toContain("mismatch");
expect(html).not.toContain("matches expected");
});

it("renders an unavailable state for null", () => {
expect(identityHtml(null)).toContain("identity unavailable");
});

it("escapes the principal ARN", () => {
const html = identityHtml(ctx({ principal: "<script>" }));
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain("<script>");
});
});
58 changes: 57 additions & 1 deletion console/src/render.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AgentState, Deployment } from "./types";
import type { AgentState, Deployment, RuntimeContext } from "./types";

const STATE_CLASS: Record<AgentState, string> = {
Starting: "s-starting",
Expand Down Expand Up @@ -57,3 +57,59 @@ export function rosterHtml(deployments: Deployment[]): string {
export function renderRoster(el: HTMLElement, deployments: Deployment[]): void {
el.innerHTML = rosterHtml(deployments);
}

// ---- Runtime identity / context panel (ADR #19) ------------------------------

const KIND_CLASS: Record<string, string> = {
role: "k-role",
user: "k-user",
unknown: "k-unknown",
};

function kindBadge(kind: string): string {
return `<span class="kind ${KIND_CLASS[kind] ?? "k-unknown"}">${escapeHtml(kind)}</span>`;
}

function field(label: string, value: string, mono = true): string {
const v = mono ? `<code>${escapeHtml(value)}</code>` : escapeHtml(value);
return `<div class="id-field"><span class="k">${label}</span>${v}</div>`;
}

// Pure: a RuntimeContext -> the identity panel HTML. `null` renders an
// unavailable state (core not started / call failed). Highlights a mismatch
// when the resolved principal doesn't satisfy the binding's expectation.
export function identityHtml(ctx: RuntimeContext | null): string {
if (!ctx) {
return `<div class="identity"><span class="muted">identity unavailable</span></div>`;
}
const mismatch = ctx.identity_matches === false;
const matched = ctx.identity_matches === true;
const cls = mismatch ? "identity mismatch" : matched ? "identity ok" : "identity";
const binding = ctx.binding
? field("binding", ctx.binding.name || ctx.binding.profile || "—", false)
: `<div class="id-field"><span class="k">binding</span><span class="muted">none (default chain)</span></div>`;
const verdict = mismatch
? `<div class="id-warn">⚠ identity mismatch — expected <code>${escapeHtml(ctx.expected_principal ?? "")}</code></div>`
: matched
? `<div class="id-ok">✓ matches expected principal</div>`
: "";
return `<div class="${cls}">
<div class="id-head">
<span class="id-label">managing</span>
<span class="id-cluster">${escapeHtml(ctx.cluster)}</span>
<span class="id-as">as</span> ${kindBadge(ctx.principal_kind)}
</div>
<div class="id-grid">
${field("principal", ctx.principal || "—")}
${field("account", ctx.scope || "—")}
${field("region", ctx.location || "—")}
${field("source", ctx.source || "—", false)}
${binding}
</div>
${verdict}
</div>`;
}

export function renderIdentity(el: HTMLElement, ctx: RuntimeContext | null): void {
el.innerHTML = identityHtml(ctx);
}
21 changes: 17 additions & 4 deletions console/src/source.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import type { Deployment } from "./types";
import { FIXTURE_DEPLOYMENTS } from "./fixtures";
import type { Deployment, RuntimeContext } from "./types";
import { FIXTURE_DEPLOYMENTS, 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>;
}

// Fixture-backed source for the standalone / browser build — no core required.
export class MockSource implements Source {
async listDeployments(): Promise<Deployment[]> {
return structuredClone(FIXTURE_DEPLOYMENTS);
}
async runtimeContext(): Promise<RuntimeContext> {
return structuredClone(FIXTURE_RUNTIME_CONTEXT);
}
}

// Minimal shape of the Tauri global bridge (v2, `withGlobalTauri`). Accessed via
Expand All @@ -24,11 +28,20 @@ interface TauriGlobal {
// Desktop source: invokes the Tauri `deploy_list` command, which bridges to
// studio-cp. Active only inside the Tauri shell.
export class TauriSource implements Source {
async listDeployments(cluster?: string): Promise<Deployment[]> {
private invoke(): <T>(
cmd: string,
args?: Record<string, unknown>,
) => Promise<T> {
const tauri = (globalThis as { __TAURI__?: TauriGlobal }).__TAURI__;
const invoke = tauri?.core?.invoke;
if (!invoke) throw new Error("Tauri bridge unavailable");
return invoke<Deployment[]>("deploy_list", { cluster });
return invoke;
}
async listDeployments(cluster?: string): Promise<Deployment[]> {
return this.invoke()<Deployment[]>("deploy_list", { cluster });
}
async runtimeContext(cluster?: string): Promise<RuntimeContext> {
return this.invoke()<RuntimeContext>("runtime_context", { cluster });
}
}

Expand Down
85 changes: 85 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,88 @@ td.counts.warn {
.s-stopped {
background: var(--s-stopped);
}

/* ---- Runtime identity / context panel (ADR #19) ---- */
.identity-wrap {
margin: 0 0 12px;
}
.identity {
border: 1px solid var(--border);
border-left: 3px solid var(--s-running);
border-radius: 6px;
background: var(--panel);
padding: 10px 12px;
font-size: 13px;
}
.identity.mismatch {
border-left-color: var(--s-unhealthy);
}
.identity.ok {
border-left-color: var(--ok);
}
.id-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.id-label {
color: var(--muted);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
}
.id-cluster {
font-weight: 600;
}
.id-as {
color: var(--muted);
}
.kind {
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border);
}
.kind.k-role {
color: var(--ok);
}
.kind.k-user {
color: var(--s-unhealthy);
}
.kind.k-unknown {
color: var(--muted);
}
.id-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 4px 16px;
}
.id-field {
display: flex;
gap: 6px;
align-items: baseline;
min-width: 0;
}
.id-field .k {
color: var(--muted);
min-width: 64px;
flex: none;
}
.id-field code {
font-size: 12px;
overflow-wrap: anywhere;
}
.id-warn {
margin-top: 8px;
color: var(--s-unhealthy);
font-weight: 600;
}
.id-warn code {
font-weight: 400;
}
.id-ok {
margin-top: 8px;
color: var(--ok);
}
25 changes: 25 additions & 0 deletions console/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,28 @@ export interface Deployment {
ready: number;
instances: InstancePhase[];
}

// The fleet binding in effect for a cluster (ADR #19). Mirrors oab-mcp's
// `runtime_context.binding`.
export interface FleetBinding {
name: string;
profile: string | null;
region: string | null;
expected_principal: string | null;
}

// The effective runtime identity/context the control plane resolved for a
// cluster — mirrors oab-mcp's `runtime_context` tool (ADR #19). `identity_matches`
// is `null` when the binding declares no `expected_principal`.
export interface RuntimeContext {
cluster: string;
principal: string;
principal_kind: string; // "role" | "user" | "unknown"
scope: string; // AWS account id
location: string; // region
source: string;
caller_id: string;
binding: FleetBinding | null;
expected_principal: string | null;
identity_matches: boolean | null;
}
Loading
Loading