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
78 changes: 39 additions & 39 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,45 @@
<section id="fleet-detail" class="fleet-detail-wrap" hidden>
<div id="fd-header"></div>
<section id="roster" class="roster-wrap"></section>
<section id="agents" class="agents-wrap">
<div class="agents-head">
<span class="agents-label">agent consoles</span>
<span class="agents-sub">select an agent to open its console — chat + read-only config</span>
</div>
<div id="agent-list" class="agent-list"></div>
<div id="agent-console" class="agent-console" hidden>
<div class="ac-head">
<span class="ac-title">agent console</span>
<span class="ac-spacer"></span>
<button class="ac-close" type="button" data-action="close-console">Close</button>
</div>
<div id="ac-config" class="ac-config"></div>
<div class="ac-files">
<div class="ac-files-head">
<span class="ac-files-label">Files</span>
<span id="ac-files-title" class="ac-files-title muted"></span>
</div>
<div class="ac-files-body">
<div id="ac-files-list" class="ac-files-list"></div>
<div id="ac-files-viewer" class="ac-files-viewer"></div>
</div>
</div>
<div class="ac-chat chat-wrap">
<div class="chat-head">
<span class="chat-title">Chat</span>
<span class="chat-conn" id="ac-chat-conn">disconnected</span>
</div>
<div id="ac-chat-log" class="chat-log"></div>
<form id="ac-chat-form" class="chat-input">
<textarea id="ac-chat-text" class="chat-text" rows="2" placeholder="Message this agent… (Enter to send · Shift+Enter for a newline)"></textarea>
<div class="chat-actions">
<button type="button" id="ac-chat-stop" class="chat-btn chat-stop" hidden>Stop</button>
<button type="submit" id="ac-chat-send" class="chat-btn chat-send">Send</button>
</div>
</form>
</div>
</div>
</section>
</section>
<section id="config-editor" class="cfg-editor-wrap" hidden>
<div class="cfg-editor-head">
Expand Down Expand Up @@ -64,45 +103,6 @@
</div>
</form>
</section>
<section id="agents" class="agents-wrap">
<div class="agents-head">
<span class="agents-label">agent consoles</span>
<span class="agents-sub">select an agent to open its console — chat + read-only config</span>
</div>
<div id="agent-list" class="agent-list"></div>
<div id="agent-console" class="agent-console" hidden>
<div class="ac-head">
<span class="ac-title">agent console</span>
<span class="ac-spacer"></span>
<button class="ac-close" type="button" data-action="close-console">Close</button>
</div>
<div id="ac-config" class="ac-config"></div>
<div class="ac-files">
<div class="ac-files-head">
<span class="ac-files-label">Files</span>
<span id="ac-files-title" class="ac-files-title muted"></span>
</div>
<div class="ac-files-body">
<div id="ac-files-list" class="ac-files-list"></div>
<div id="ac-files-viewer" class="ac-files-viewer"></div>
</div>
</div>
<div class="ac-chat chat-wrap">
<div class="chat-head">
<span class="chat-title">Chat</span>
<span class="chat-conn" id="ac-chat-conn">disconnected</span>
</div>
<div id="ac-chat-log" class="chat-log"></div>
<form id="ac-chat-form" class="chat-input">
<textarea id="ac-chat-text" class="chat-text" rows="2" placeholder="Message this agent… (Enter to send · Shift+Enter for a newline)"></textarea>
<div class="chat-actions">
<button type="button" id="ac-chat-stop" class="chat-btn chat-stop" hidden>Stop</button>
<button type="submit" id="ac-chat-send" class="chat-btn chat-send">Send</button>
</div>
</form>
</div>
</div>
</section>
<section class="logs">
<nav class="tabs" id="tabs">
<button class="tab is-active" data-target="log">Activity</button>
Expand Down
67 changes: 63 additions & 4 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,23 @@ async function refreshRemote(): Promise<void> {

// The console's top-level drill-down (ADR #83 Part A): Fleets ↔ Fleet detail.
// `activeFleet === null` shows the Fleets screen (the `#config` list); a
// selected fleet shows Fleet detail (breadcrumb header + the members roster)
// instead. This is screen 7.2 vs 7.3 — slice 2's scope; the further drill into
// an Agent console (7.4) is slice 3.
// selected fleet shows Fleet detail (breadcrumb header + the members roster,
// with each member drilling further into its Agent console — slice 3) instead.
function updateScreen(): void {
if (configEl) configEl.hidden = activeFleet !== null;
if (fleetDetailEl) fleetDetailEl.hidden = activeFleet === null;
if (activeFleet && fdHeaderEl) renderFleetDetailHeader(fdHeaderEl, activeFleet);
}

// Leaving the fleet (back to Fleets, or switching to another one) also leaves
// any open Agent console — reuses the console's own wired Close button (see
// `openAgentForRow`'s note on why: `agentConsole.ts` stays untouched).
function closeOpenAgentConsole(): void {
document
.querySelector<HTMLButtonElement>('#agent-console [data-action="close-console"]')
?.click();
}

// 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
Expand All @@ -290,6 +298,7 @@ function selectFleet(name: string): void {
if (!name || name === activeFleet) return;
const fleet = fleetConfig?.fleets.find((f) => f.name === name);
if (!fleet) return;
closeOpenAgentConsole();
activeFleet = name;
activeCluster = fleet.cluster;
activeMembers = fleet.members;
Expand All @@ -305,6 +314,7 @@ function selectFleet(name: string): void {
// unfiltered roster (whole default cluster) until another fleet is picked.
function deselectFleet(): void {
if (!activeFleet) return;
closeOpenAgentConsole();
activeFleet = null;
activeCluster = DEFAULT_CLUSTER;
activeMembers = [];
Expand All @@ -316,6 +326,23 @@ function deselectFleet(): void {
void tick();
}

// Fleet detail shows either the members roster or the open Agent console, never
// both (Part A: "the only navigation model," no tab-peer surfaces) — mirror
// `#agent-console`'s own `hidden` state (owned by `agentConsole.ts`, untouched)
// onto the roster rather than adding a second source of truth for what's open.
(function watchAgentConsoleVisibility(): void {
const consoleEl = document.getElementById("agent-console");
if (!consoleEl || !roster) return;
const sync = (): void => {
roster.hidden = !consoleEl.hidden;
};
new MutationObserver(sync).observe(consoleEl, {
attributes: true,
attributeFilter: ["hidden"],
});
sync();
})();

// ---- TOML editor (fleets.toml + remote.toml) ---------------------------------
// One CodeMirror TOML editor, shared by both config files (which one is set by
// `editorTarget`). Kept imperative (CM owns real DOM) and separate from the
Expand Down Expand Up @@ -604,14 +631,46 @@ async function scale(
}
}

// Drill into a roster row's Agent console (ADR #83 slice 3: mockup 7.4). The
// agent-console shell (`agentConsole.ts`) is untouched — its selector
// (`#agent-list`, now hidden via CSS in favor of this roster-row entry point)
// already opens-on-click for a `[data-agent]` button, so reuse that exact,
// already-tested path via a synthetic click rather than duplicating the
// open/dial/teardown logic here. Tries the service-name form first, then the
// short name — the same precedence `filterByMembers` uses for fleet members.
function openAgentForRow(svc: string, alt: string): void {
const btn =
document.querySelector<HTMLButtonElement>(
`#agent-list [data-agent="${CSS.escape(svc)}"]`,
) ??
document.querySelector<HTMLButtonElement>(
`#agent-list [data-agent="${CSS.escape(alt)}"]`,
);
if (btn) {
btn.click();
} else {
note(
"info",
`agents: no agent console registered for "${svc}" — add it to agents.toml to open one`,
);
}
}

// One delegated listener on the roster. Start executes on click; Stop is
// disruptive (kills the running instance, though reversible), so it arms on the
// first click and only executes on a confirming second click within 3s — a
// webview-safe confirm that needs no dialog plugin. The 5s poll re-renders the
// roster and would reset an armed button on its own; the 3s timer is tighter.
if (roster) {
roster.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement).closest<HTMLButtonElement>("button.act");
const target = ev.target as HTMLElement;
const openBtn = target.closest<HTMLButtonElement>("button.row-open");
if (openBtn) {
const { openAgent, openAgentAlt } = openBtn.dataset;
if (openAgent) openAgentForRow(openAgent, openAgentAlt ?? openAgent);
return;
}
const btn = target.closest<HTMLButtonElement>("button.act");
if (!btn) return;
const action = btn.dataset.action;
const { name, namespace } = btn.dataset;
Expand Down
13 changes: 13 additions & 0 deletions console/src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ describe("rosterHtml", () => {
expect(html).not.toContain("act-pending");
expect(html).toContain('data-action="stop"');
});

it("makes each row's name a button carrying both agent-lookup identities (ADR #83 slice 3)", () => {
const html = rosterHtml([dep({ name: "orca", namespace: "prod" })]);
expect(html).toContain('class="row-open"');
expect(html).toContain('data-open-agent="oab-prod-orca"');
expect(html).toContain('data-open-agent-alt="orca"');
});

it("escapes the row-open data attributes", () => {
const html = rosterHtml([dep({ name: '"x', namespace: "n" })]);
expect(html).toContain("&quot;x");
expect(html).not.toContain('data-open-agent-alt=""x"');
});
});

describe("deploymentKey", () => {
Expand Down
9 changes: 8 additions & 1 deletion console/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,21 @@ function actionButton(d: Deployment, pending: boolean): string {
return `<button class="${cls}" type="button" data-action="${action}" data-name="${escapeHtml(d.name)}" data-namespace="${escapeHtml(d.namespace)}">${label}</button>`;
}

// The row's name cell is a button, not plain text — clicking a member drills
// into its Agent console (ADR #83 slice 3: mockup 7.4), if one is registered.
// `data-open-agent`/`data-open-agent-alt` carry both candidate identities
// (service name and short name) the delegated handler tries against the
// registry, same precedence as `filterByMembers`'s member match.
function rowHtml(d: Deployment, pending: ReadonlySet<string>): string {
const phases = d.instances.length
? d.instances.map((i) => badge(i.state)).join(" ")
: `<span class="muted">—</span>`;
const health = d.ready === d.desired ? "ok" : "warn";
const name = escapeHtml(`${d.namespace}/${d.name}`);
const svc = escapeHtml(serviceName(d));
const shortName = escapeHtml(d.name);
return `<tr>
<td class="name">${name}</td>
<td class="name"><button class="row-open" type="button" data-open-agent="${svc}" data-open-agent-alt="${shortName}" title="open agent console">${name}</button></td>
<td class="counts ${health}">${d.ready}/${d.desired}<span class="muted"> · cur ${d.current}</span></td>
<td class="phases">${phases}</td>
<td class="actions">${actionButton(d, pending.has(deploymentKey(d)))}</td>
Expand Down
32 changes: 27 additions & 5 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,23 @@ td.name {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* A roster row's name is a button (ADR #83 slice 3) — drills into that
member's Agent console. Reset button chrome so it still reads as a name. */
button.row-open {
appearance: none;
cursor: pointer;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 0;
}
button.row-open:hover {
color: var(--s-starting);
text-decoration: underline;
}
td.counts {
font-variant-numeric: tabular-nums;
}
Expand Down Expand Up @@ -1207,15 +1224,20 @@ button.act:disabled {
font: inherit;
}

/* ---- Agent consoles (ADR agent-consoles Parts B/C) ------------------------ */
/* ---- Agent consoles (ADR agent-consoles Parts B/C; ADR #83 slice 3: reached
by drilling into a roster row instead) ------------------------------------ */
.agents-wrap {
margin: 0 0 12px;
}
/* The flat endpoint selector is superseded as a reachability path by roster
rows (`button.row-open`, main.ts's `openAgentForRow`) — kept in the DOM
(still rendered by `agentConsole.ts`, untouched) since that's the synthetic
click target the roster-row handler reuses; just not shown. */
.agents-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 8px;
display: none;
}
#agent-list {
display: none;
}
.agents-label {
font-weight: 600;
Expand Down
Loading