From 960da0ef93f7f78c34d0ad875d2641b57811ee2d Mon Sep 17 00:00:00 2001 From: Sebastian Gieseler Date: Sat, 31 Oct 2026 00:09:33 +0100 Subject: [PATCH] feat(ui): the stack page shows its port world as a graph (ADR-0082) The second step of review card C. A Ports card on the stack page: per lane, main first, the fixed ports that lead to it (the main slot's go to the lane serving them), the lane, and its services and containers with their ports; a lane on a node with the ports forwarded from there. A port another stack uses too is red and names it. Instance ports hide behind a toggle, lanes without ports fold into one line, a port nothing listens on is dashed, hovering a port shows its path. Below 560 px the card is a grouped list. Fed by the daemon's /v1/ports and the hub's /hub/ports. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + docs/adr/0082-the-ports-of-a-machine.md | 27 +- ui/src/api/mock/index.ts | 2 + ui/src/api/mock/ports.ts | 5 +- ui/src/api/mock/stack.ts | 3 +- ui/src/api/transport.ts | 4 + ui/src/components/PortGraph.test.ts | 74 ++++ ui/src/components/PortGraph.vue | 528 ++++++++++++++++++++++++ ui/src/lib/portGraph.test.ts | 117 ++++++ ui/src/lib/portGraph.ts | 261 ++++++++++++ ui/src/views/StackPage.vue | 76 +++- 11 files changed, 1090 insertions(+), 13 deletions(-) create mode 100644 ui/src/components/PortGraph.test.ts create mode 100644 ui/src/components/PortGraph.vue create mode 100644 ui/src/lib/portGraph.test.ts create mode 100644 ui/src/lib/portGraph.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f51c30..62e832b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,12 @@ and listed with their assets on the /hub/ports` joins every registered stack's (a stopped stack's from what its stack file declares) and marks the ports two stacks share; `devstack doctor` lists them. +- **The stack page draws its ports** (ADR-0082): a Ports card with the + machine's fixed ports of the stack, the lane each leads to (the one + serving the main ports marked, a lane on a node by its forwards) and + each lane's services and containers with their own ports. A port + another stack uses too is red and names that stack; instance ports show + on request; on a narrow window it is a grouped list. ### Fixed diff --git a/docs/adr/0082-the-ports-of-a-machine.md b/docs/adr/0082-the-ports-of-a-machine.md index aa0fac2b..0b1b5e89 100644 --- a/docs/adr/0082-the-ports-of-a-machine.md +++ b/docs/adr/0082-the-ports-of-a-machine.md @@ -44,7 +44,7 @@ Decided by the agent alone (not asked): ## Built -Only point 1 is built so far (0.33.0): +Points 1 (0.33.0) and 2 are built: 1. **`GET /v1/ports`** answers `{ports: [PortEntry]}`, one entry per port and protocol a tree holds or uses: `port`, `proto` (`tcp`, `udp`), @@ -80,11 +80,28 @@ Only point 1 is built so far (0.33.0): way without it (each running daemon, each stopped stack's file). It does not start the hub for that. 4. The dashboard's mock answers both routes, with one overlap; the types - are in `ui/src/api/types.ts`. No view yet. + are in `ui/src/api/types.ts`. +5. **The stack page's Ports card** (point 2) reads the daemon's `/v1/ports` + and `/hub/ports` when the page opens, when the stack's lanes or the main + slot change, and every 10 s while it is shown; a stack that does not run + shows what its stack file declares, from the hub. It draws one band per + lane, `main` first: on the left the machine's fixed ports of the stack + (the main tree's fixed, held and published ports, in the band of the + lane they lead to, `target_lane` or `main`) and a lane on a node's + forwarded ports with the node's name; in the middle the lane, the one + serving the main ports marked; on the right its holders (services, + `.` containers) with their own ports. SVG connectors + join port, lane and holder; hovering or focusing one highlights its + path. A port another stack uses too is red, its title names that + stack, lane and holder, the header counts them and a list below the + graph repeats them. Instance ports are hidden behind a toggle; lanes + without ports collapse into one line; what does not listen is dashed. + Below 560 px of card width a grouped list (lane, holder, ports) says + the same. The mock's instance ports are stable, and its shop has a lane + on a node (`review-cart` on `build-server`) from the start. -What follows: the graph on the stack's page (point 2); then the shift with -the claims in the registry, `up --shift`, the stack page's button and the -start check (points 3 to 7). +What follows: the shift with the claims in the registry, `up --shift`, the +stack page's button and the start check (points 3 to 7). ## Consequences diff --git a/ui/src/api/mock/index.ts b/ui/src/api/mock/index.ts index 22e74820..617ba3c0 100644 --- a/ui/src/api/mock/index.ts +++ b/ui/src/api/mock/index.ts @@ -214,6 +214,8 @@ class MockHub { const now = Date.now(); const shop = new MockStack(shopSeed(now)); shop.serveMain('agent-login-fix', 'mcp', now - 12 * 60_000); + // On build-server, as the pool's lease for it says: a lane on a node, with its forwards (ADR-0082). + shop.startedLanes.add('review-cart'); const crm = new MockStack(crmSeed(now)); const docs = new MockStack(docsSeed(now)); this.stacks = [shop, crm, docs]; diff --git a/ui/src/api/mock/ports.ts b/ui/src/api/mock/ports.ts index 884daa89..431576cc 100644 --- a/ui/src/api/mock/ports.ts +++ b/ui/src/api/mock/ports.ts @@ -18,13 +18,14 @@ function hostPort(shown: string): { port: number; target: number } | null { function tree(s: MockStack, lane: string, offset: number): PortEntry[] { const out: PortEntry[] = []; const servedBy = lane === 'main' ? s.view.main_served?.lane : undefined; - for (const v of s.view.services) { + for (const [i, v] of s.view.services.entries()) { if (v.port === null || (lane !== 'main' && v.singleton)) continue; const port = v.port + offset; const running = v.status.state === 'healthy' || v.status.state === 'running'; const toLane = servedBy && !v.singleton ? { target_lane: servedBy } : {}; if (v.proxy) { - const instance = (v.proxy.upstream ?? 49000) + offset; + // Stable, not the view's random one, so the graph and its tests are deterministic. + const instance = 49010 + 10 * i + offset; out.push({ port, proto: 'tcp', lane, kind: 'fixed', holder: v.name, via: 'proxy', target: instance, ...toLane, listening: true }); if (!toLane.target_lane) out.push({ port: instance, proto: 'tcp', lane, kind: 'instance', holder: v.name, via: 'direct', listening: running }); } else if (v.kind === 'devserver') { diff --git a/ui/src/api/mock/stack.ts b/ui/src/api/mock/stack.ts index bfb7f39c..d76a5ba9 100644 --- a/ui/src/api/mock/stack.ts +++ b/ui/src/api/mock/stack.ts @@ -40,8 +40,7 @@ export class MockStack { this.laneOwnsMain = name !== 'main'; this.view.main_served = this.laneOwnsMain ? { lane: name, via, at } : undefined; } - /** Mock lane `review-cart` was started from the dashboard. */ - /** Mock lanes started from the dashboard (`agent-login-fix` runs from the start). */ + /** Mock lanes started from the dashboard (`agent-login-fix` runs from the start; the shop's `review-cart` too, on a node). */ startedLanes = new Set(); /** Worktrees without a stack file of their own (ADR-0055) until one is copied in. */ noStackFile = new Set(['C:\\work\\_wt\\1333']); diff --git a/ui/src/api/transport.ts b/ui/src/api/transport.ts index 8977fad1..89078a9c 100644 --- a/ui/src/api/transport.ts +++ b/ui/src/api/transport.ts @@ -116,6 +116,8 @@ export const paths = { poolSkill: (name: string) => `/hub/pool/skills/${enc(name)}`, poolSkillsRefresh: '/hub/pool/skills/refresh', hubSkills: '/hub/skills', + /** Every registered stack's ports and their overlaps (ADR-0082). */ + hubPorts: '/hub/ports', skillsSync: '/hub/skills/sync', skillsAgent: (id: string) => `/hub/skills/agents/${enc(id)}`, skillSet: (pool: string, set: string) => `/hub/skills/sets/${enc(pool)}/${enc(set)}`, @@ -168,6 +170,8 @@ export const v1 = { downTree: '/v1/down?infra=true', /** Without history: what a tree uses now. */ usageNow: '/v1/usage', + /** The ports the tree holds or uses; the main tree's include its lanes' (ADR-0082). */ + ports: '/v1/ports', logStream: (service: string, tail = 500, build?: string) => `/v1/services/${enc(service)}/logs/stream?tail=${tail}${build ? `&build=${enc(build)}` : ''}`, buildLog: (service: string) => `/v1/services/${enc(service)}/build-log`, diff --git a/ui/src/components/PortGraph.test.ts b/ui/src/components/PortGraph.test.ts new file mode 100644 index 00000000..d31b846b --- /dev/null +++ b/ui/src/components/PortGraph.test.ts @@ -0,0 +1,74 @@ +// The stack page's port graph (ADR-0082 point 2): an overlap with another +// stack is red and names it; instance ports show on request. +import { describe, expect, it } from 'vitest'; +import { createApp, h, nextTick } from 'vue'; +import type { PortEntry, PortOverlap } from '@/api/types'; +import PortGraph from './PortGraph.vue'; + +const entries: PortEntry[] = [ + { port: 4200, proto: 'tcp', lane: 'main', kind: 'fixed', holder: 'web', via: 'forward', target: 51200, listening: true }, + { port: 51200, proto: 'tcp', lane: 'main', kind: 'instance', holder: 'web', via: 'direct', listening: true }, + { port: 5080, proto: 'tcp', lane: 'main', kind: 'fixed', holder: 'api', via: 'proxy', target: 49010, listening: false }, +]; +const overlaps: PortOverlap[] = [ + { + port: 4200, + proto: 'tcp', + stacks: [ + { id: 'shop-1', name: 'shop', lane: 'main', holder: 'web', kind: 'fixed' }, + { id: 'docs-1', name: 'docs', lane: 'main', holder: 'site', kind: 'declared' }, + ], + }, +]; + +async function mount() { + const el = document.createElement('div'); + document.body.appendChild(el); + const app = createApp({ + render: () => h(PortGraph, { stack: 'shop-1', entries, overlaps, lanes: [{ name: 'main' }, { name: 'feat' }], servedBy: 'main', running: true }), + }); + app.mount(el); + await nextTick(); + return { el, unmount: () => (app.unmount(), el.remove()) }; +} + +describe('the ports card', () => { + it('draws an overlap red, naming the other stack, and counts it in the header', async () => { + const { el, unmount } = await mount(); + const port = el.querySelector('[data-testid="stack-ports-graph"] [data-testid="stack-port"][data-port="4200/tcp"]')!; + expect(port).not.toBeNull(); + expect(port.classList.contains('pg-overlap')).toBe(true); + expect(port.getAttribute('aria-label')).toContain('docs (lane main, site, declared)'); + expect(el.querySelector('[data-testid="stack-ports-overlaps"]')?.textContent?.trim()).toBe('1 overlap'); + expect(el.querySelector('[data-testid="stack-ports-shared"]')?.textContent).toContain('also used by docs'); + // Not this stack's overlap: plain; not listening: muted. + const api = el.querySelector('[data-testid="stack-ports-graph"] [data-port="5080/tcp"]')!; + expect(api.classList.contains('pg-overlap')).toBe(false); + expect(api.classList.contains('pg-muted')).toBe(true); + // A lane without ports is one line. + expect(el.querySelector('[data-testid="stack-ports-idle"]')?.textContent).toContain('feat'); + unmount(); + }); + + it('hides instance ports until the toggle shows them', async () => { + const { el, unmount } = await mount(); + const inGraph = () => el.querySelector('[data-testid="stack-ports-graph"] [data-port="51200/tcp"]'); + expect(inGraph()).toBeNull(); + el.querySelector('[data-testid="stack-ports-instance"]')!.click(); + await nextTick(); + expect(inGraph()).not.toBeNull(); + unmount(); + }); + + it('highlights the path of a port on focus', async () => { + const { el, unmount } = await mount(); + const port = el.querySelector('[data-testid="stack-ports-graph"] [data-port="4200/tcp"]')!; + port.dispatchEvent(new FocusEvent('focus')); + await nextTick(); + const web = [...el.querySelectorAll('[data-testid="stack-ports-holder"]')].find((x) => x.textContent?.includes('web'))!; + const api = [...el.querySelectorAll('[data-testid="stack-ports-holder"]')].find((x) => x.textContent?.includes('api'))!; + expect(web.classList.contains('pg-on')).toBe(true); + expect(api.classList.contains('pg-dim')).toBe(true); + unmount(); + }); +}); diff --git a/ui/src/components/PortGraph.vue b/ui/src/components/PortGraph.vue new file mode 100644 index 00000000..f76b099b --- /dev/null +++ b/ui/src/components/PortGraph.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/ui/src/lib/portGraph.test.ts b/ui/src/lib/portGraph.test.ts new file mode 100644 index 00000000..7cf11374 --- /dev/null +++ b/ui/src/lib/portGraph.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import type { PortEntry, PortOverlap } from '@/api/types'; +import { docsSeed, shopSeed } from '@/api/mock/data'; +import { daemonPorts, hubPorts } from '@/api/mock/ports'; +import { MockStack } from '@/api/mock/stack'; +import { portGraph } from './portGraph'; + +const e = (p: Partial & Pick): PortEntry => ({ proto: 'tcp', via: 'direct', listening: true, ...p }); + +describe('the port graph of a stack (ADR-0082 point 2)', () => { + const entries: PortEntry[] = [ + // The main slot, served by lane `feat`. + e({ port: 5080, lane: 'main', kind: 'fixed', holder: 'api', via: 'proxy', target: 5180, target_lane: 'feat' }), + e({ port: 4200, lane: 'main', kind: 'fixed', holder: 'web', via: 'forward', target: 4300, target_lane: 'feat' }), + // A container of the main tree. + e({ port: 15432, lane: 'main', kind: 'published', holder: 'data.db', via: 'docker', target: 5432, listening: false }), + // Lane `feat` here: its own ports. + e({ port: 5180, lane: 'feat', kind: 'fixed', holder: 'api', via: 'proxy', target: 49120 }), + e({ port: 49120, lane: 'feat', kind: 'instance', holder: 'api' }), + e({ port: 15532, lane: 'feat', kind: 'published', holder: 'data.db', via: 'docker', target: 5432 }), + // Lane `remote` on a node: its forwards. + e({ port: 5280, lane: 'remote', kind: 'node', holder: 'api', via: 'forward', target: 5280, node: 'build-server' }), + ]; + const overlaps: PortOverlap[] = [ + { + port: 4200, + proto: 'tcp', + stacks: [ + { id: 'shop', name: 'shop', lane: 'main', holder: 'web', kind: 'fixed' }, + { id: 'docs', name: 'docs', lane: 'main', holder: 'site', kind: 'declared' }, + ], + }, + // Not this stack's. + { port: 8080, proto: 'tcp', stacks: [{ id: 'a', name: 'a', lane: 'main', kind: 'fixed' }, { id: 'b', name: 'b', lane: 'main', kind: 'fixed' }] }, + ]; + const lanes = [{ name: 'main' }, { name: 'feat' }, { name: 'remote', node: 'pool:build-server' }, { name: 'idle-one' }]; + const g = portGraph({ stack: 'shop', entries, overlaps, lanes, instance: false }); + const lane = (n: string) => g.lanes.find((l) => l.name === n)!; + + it('puts the fixed ports in the band of the lane they lead to, main first', () => { + expect(g.lanes.map((l) => l.name)).toEqual(['main', 'feat', 'remote']); + expect(lane('feat').servesMain).toBe(true); + expect(lane('main').servesMain).toBe(false); + expect(lane('feat').inbound.map((p) => p.label)).toEqual(['4200/tcp', '5080/tcp']); + expect(lane('main').inbound.map((p) => p.label)).toEqual(['15432/tcp']); + }); + + it("gives each lane's holders their own ports, services before containers", () => { + expect(lane('feat').holders.map((h) => h.name)).toEqual(['api', 'web', 'data.db']); + expect(lane('feat').holders[0].ports.map((p) => p.label)).toEqual(['5180/tcp']); + expect(lane('feat').holders[2]).toMatchObject({ container: true, ports: [expect.objectContaining({ label: '15532/tcp', kind: 'published' })] }); + }); + + it('hides instance ports unless asked, and counts them', () => { + expect(g.hiddenInstance).toBe(1); + const all = portGraph({ stack: 'shop', entries, overlaps, lanes, instance: true }); + expect(all.hiddenInstance).toBe(0); + expect(all.lanes.find((l) => l.name === 'feat')!.holders[0].ports.map((p) => p.label)).toEqual(['5180/tcp', '49120/tcp']); + }); + + it('collapses the lanes without ports into one line', () => { + expect(g.idle).toEqual(['idle-one']); + }); + + it('draws fixed port -> lane -> holder, and a forward with its node', () => { + expect(g.edges).toContainEqual(expect.objectContaining({ from: 'p:main:5080/tcp:fixed', to: 'l:feat' })); + expect(g.edges).toContainEqual(expect.objectContaining({ from: 'l:feat', to: 'h:feat:api' })); + expect(g.edges).toContainEqual(expect.objectContaining({ from: 'p:remote:5280/tcp:node', to: 'l:remote', label: 'build-server' })); + expect(lane('remote').node).toBe('build-server'); + // Not listening: muted. + expect(g.edges.find((x) => x.from === 'p:main:15432/tcp:published')?.muted).toBe(true); + expect(g.related['p:main:5080/tcp:fixed']).toEqual(['p:main:5080/tcp:fixed', 'l:feat', 'h:feat:api']); + expect(g.related['h:feat:api']).toEqual(expect.arrayContaining(['p:main:5080/tcp:fixed', 'p:feat:5180/tcp:fixed', 'l:feat'])); + }); + + it('marks the overlaps of this stack with the other stack, its lane and holder', () => { + expect(g.overlaps).toBe(1); + const web = lane('feat').inbound.find((p) => p.port === 4200)!; + expect(web.overlap).toEqual([{ stack: 'docs', lane: 'main', holder: 'site', kind: 'declared' }]); + expect(web.title).toContain('also used by docs (lane main, site, declared)'); + expect(lane('feat').holders.find((h) => h.name === 'web')!.overlap).toBe(true); + expect(g.edges.find((x) => x.from === web.id)?.overlap).toBe(true); + }); + + it('draws the mock shop: a lane serving the main ports, a container, a node, an overlap with docs', () => { + const now = Date.now(); + const shop = new MockStack(shopSeed(now)); + shop.serveMain('agent-login-fix'); + const docs = new MockStack(docsSeed(now)); + const h = hubPorts([shop, docs], (s) => + s === shop + ? [ + { name: 'agent-login-fix', port_offset: 100 }, + { name: 'review-cart', port_offset: 200, node: 'build-server' }, + ] + : [], + ); + const mine = h.stacks.find((s) => s.name === 'shop')!; + const graph = portGraph({ stack: mine.id, entries: mine.ports, overlaps: h.overlaps, instance: false }); + expect(graph.lanes.map((l) => l.name)).toEqual(['main', 'agent-login-fix', 'review-cart']); + expect(graph.lanes[1].servesMain).toBe(true); + expect(graph.lanes[0].inbound.some((p) => p.kind === 'published' && p.holder === 'data.db')).toBe(true); + expect(graph.lanes[2]).toMatchObject({ node: 'build-server' }); + expect(graph.overlaps).toBe(1); + // Stable: two mock stacks (each with random instance ports in its view) give the same ports. + const instances = () => daemonPorts(new MockStack(shopSeed(now)), [{ name: 'agent-login-fix', port_offset: 100 }]).filter((x) => x.kind === 'instance').map((x) => x.port); + expect(instances().length).toBeGreaterThan(0); + expect(instances()).toEqual(instances()); + }); + + it("shows a stopped stack's declared ports in main", () => { + const d = portGraph({ stack: 'docs', entries: [e({ port: 4200, lane: 'main', kind: 'declared', holder: 'site', listening: false })], overlaps, instance: false }); + expect(d.lanes.map((l) => l.name)).toEqual(['main']); + expect(d.lanes[0].servesMain).toBe(false); + expect(d.lanes[0].inbound[0]).toMatchObject({ kind: 'declared', listening: false }); + }); +}); diff --git a/ui/src/lib/portGraph.ts b/ui/src/lib/portGraph.ts new file mode 100644 index 00000000..7440674e --- /dev/null +++ b/ui/src/lib/portGraph.ts @@ -0,0 +1,261 @@ +// The port world of one stack as a graph (ADR-0082 point 2): the machine's +// fixed ports on the left, the lanes in the middle, each lane's holders +// (services, `.` containers) with their own ports on the +// right. Each lane is a band: the ports that lead to it, it, its holders, +// so the connectors stay inside the band and never cross another lane's. +import type { PortEntry, PortOverlap } from '@/api/types'; + +/** Another stack's use of a port this stack uses too. */ +export type OverlapUse = { stack: string; lane: string; holder?: string; kind: PortEntry['kind'] }; + +export type GraphPort = { + /** Unique in the graph: `p::/:`. */ + id: string; + port: number; + proto: PortEntry['proto']; + /** `5080/tcp`. */ + label: string; + kind: PortEntry['kind']; + via: PortEntry['via']; + /** The tree the entry is of (`main` for the machine's fixed ports). */ + lane: string; + holder?: string; + target?: number; + targetLane?: string; + node?: string; + listening: boolean; + /** The other stacks that use it, or null. */ + overlap: OverlapUse[] | null; + /** What it is, in a sentence: its title and accessible label. */ + title: string; +}; + +export type GraphHolder = { + /** `h::`. */ + id: string; + lane: string; + name: string; + /** A Compose container (`.`), not a service. */ + container: boolean; + /** Its own ports in its lane (instance, a lane's fixed and published ports): the right column's chips. */ + ports: GraphPort[]; + /** Something of it listens. */ + listening: boolean; + /** One of its ports, or a fixed port leading to it, overlaps with another stack. */ + overlap: boolean; +}; + +export type GraphLane = { + /** `l:`. */ + id: string; + name: string; + /** The node it runs on (from its forwards or the lane list); null: this machine. */ + node: string | null; + /** It serves the main ports. */ + servesMain: boolean; + /** The left column of its band: the machine's fixed ports that lead to it, a lane on a node's forwards. */ + inbound: GraphPort[]; + holders: GraphHolder[]; +}; + +export type GraphEdge = { id: string; from: string; to: string; muted: boolean; overlap: boolean; label?: string }; + +export type PortGraph = { + /** The lanes with ports, `main` first, in the order given. */ + lanes: GraphLane[]; + /** The lanes without a port (not started): one line. */ + idle: string[]; + edges: GraphEdge[]; + /** How many ports this stack shares with another stack. */ + overlaps: number; + /** Instance ports left out (`instance: false`). */ + hiddenInstance: number; + /** For each node id, the ids on its paths: what hovering it highlights. */ + related: Record; +}; + +export type PortGraphInput = { + /** The stack's id, to tell its own uses in an overlap from the others'. */ + stack: string; + entries: PortEntry[]; + overlaps: PortOverlap[]; + /** The lanes known, `main` first; `node` names its node. Lanes only in `entries` follow. */ + lanes?: { name: string; node?: string | null }[]; + /** The lane serving the main ports, if known; else the one the main tree's fixed ports name. */ + servedBy?: string | null; + /** Show instance ports. */ + instance: boolean; +}; + +const MAIN = 'main'; +/** The kinds of the main tree that sit on the left: the machine's fixed ports of the stack. */ +const LEFT: PortEntry['kind'][] = ['fixed', 'held', 'published', 'declared']; + +const KIND_TEXT: Record = { + fixed: 'fixed port', + instance: 'instance port', + published: 'published port', + held: 'held port', + node: 'forwarded port', + declared: 'declared port', +}; +const VIA_TEXT: Record = { + proxy: "devstack's proxy", + forward: 'a devstack forward', + direct: 'the process itself', + docker: 'Docker', +}; + +function portTitle(e: PortEntry, overlap: OverlapUse[] | null): string { + const lane = e.lane === MAIN ? 'main' : `lane ${e.lane}`; + let s = `${e.port}/${e.proto}: ${KIND_TEXT[e.kind]} of ${e.holder ?? 'an unknown holder'} in ${lane}`; + if (e.kind !== 'declared') s += `, via ${VIA_TEXT[e.via]}`; + if (e.node) s += `, from node ${e.node}`; + if (e.target !== undefined) s += `, leads to ${e.target_lane ? `lane ${e.target_lane} ` : ''}:${e.target}`; + if (!e.listening) s += '; not listening'; + if (overlap) s += `; also used by ${overlap.map(useText).join('; ')}`; + return s; +} + +/** `docs (lane main, web, declared)`. */ +export function useText(u: OverlapUse): string { + return `${u.stack} (lane ${u.lane}${u.holder ? `, ${u.holder}` : ''}, ${u.kind})`; +} + +/** Turn a stack's port entries and the machine's overlaps into the graph the stack page draws. */ +export function portGraph(input: PortGraphInput): PortGraph { + const { stack, instance } = input; + + // The overlaps that concern this stack: port/proto -> the other stacks' uses. + const shared = new Map(); + for (const o of input.overlaps) { + if (!o.stacks.some((u) => u.id === stack)) continue; + const others = o.stacks.filter((u) => u.id !== stack).map((u) => ({ stack: u.name, lane: u.lane, ...(u.holder ? { holder: u.holder } : {}), kind: u.kind })); + if (others.length) shared.set(`${o.port}/${o.proto}`, others); + } + + const servedBy = + input.entries.find((e) => e.lane === MAIN && e.kind === 'fixed' && e.target_lane)?.target_lane ?? + input.servedBy ?? + (input.entries.some((e) => e.kind !== 'declared') ? MAIN : null); + + // The bands, in the order of the lanes given, then those only the entries name. + const bands = new Map(); + const band = (name: string): GraphLane => { + let b = bands.get(name); + if (!b) { + b = { id: `l:${name}`, name, node: null, servesMain: name === servedBy, inbound: [], holders: [] }; + bands.set(name, b); + } + return b; + }; + const order = [MAIN, ...(input.lanes ?? []).map((l) => l.name).filter((n) => n !== MAIN)]; + const nodes = new Map((input.lanes ?? []).filter((l) => l.node).map((l) => [l.name, l.node as string])); + + const holderOf = (b: GraphLane, name: string): GraphHolder => { + let h = b.holders.find((x) => x.name === name); + if (!h) { + h = { id: `h:${b.name}:${name}`, lane: b.name, name, container: name.includes('.'), ports: [], listening: false, overlap: false }; + b.holders.push(h); + } + return h; + }; + + let hiddenInstance = 0; + const seen = new Set(); + const portOf = (e: PortEntry): GraphPort => { + const key = `${e.port}/${e.proto}`; + const overlap = e.kind === 'instance' ? null : (shared.get(key) ?? null); + let id = `p:${e.lane}:${key}:${e.kind}`; + for (let i = 2; seen.has(id); i++) id = `p:${e.lane}:${key}:${e.kind}:${i}`; + seen.add(id); + return { + id, + port: e.port, + proto: e.proto, + label: key, + kind: e.kind, + via: e.via, + lane: e.lane, + ...(e.holder ? { holder: e.holder } : {}), + ...(e.target !== undefined ? { target: e.target } : {}), + ...(e.target_lane ? { targetLane: e.target_lane } : {}), + ...(e.node ? { node: e.node } : {}), + listening: e.listening, + overlap, + title: portTitle(e, overlap), + }; + }; + + /** Each holder's left ports, for its edge and its marks. */ + const leftOf = new Map(); + for (const e of input.entries) { + if (e.kind === 'instance' && !instance) { + // Left out, but its holder still shows in its lane. + hiddenInstance++; + if (e.holder) { + const h = holderOf(band(e.lane), e.holder); + h.listening ||= e.listening; + } + continue; + } + const p = portOf(e); + const left = (e.lane === MAIN && LEFT.includes(e.kind)) || e.kind === 'node'; + const b = band(left && e.lane === MAIN ? (e.target_lane ?? MAIN) : e.lane); + if (e.node && !b.node) b.node = e.node; + const h = e.holder ? holderOf(b, e.holder) : null; + if (left) { + b.inbound.push(p); + if (h) leftOf.set(h.id, [...(leftOf.get(h.id) ?? []), p]); + } else if (h) { + h.ports.push(p); + } else { + // An own port of a lane without a known holder: it leads to the lane. + b.inbound.push(p); + } + if (h) { + h.listening ||= e.listening; + h.overlap ||= !!p.overlap; + } + } + + const byPort = (a: GraphPort, b: GraphPort) => a.port - b.port || a.proto.localeCompare(b.proto); + const lanes: GraphLane[] = []; + const idle: string[] = []; + const names = [...order, ...[...bands.keys()].filter((n) => !order.includes(n))]; + for (const name of names) { + const b = bands.get(name); + if (!b || (!b.inbound.length && !b.holders.length)) { + if (!idle.includes(name)) idle.push(name); + continue; + } + b.node ??= nodes.get(name) ?? null; + b.inbound.sort(byPort); + for (const h of b.holders) h.ports.sort(byPort); + // Services first, then containers; each by name. + b.holders.sort((x, y) => Number(x.container) - Number(y.container) || x.name.localeCompare(y.name)); + lanes.push(b); + } + + // The edges, and what each node's path is: a fixed port, its lane, its + // holder; a holder, its lane, its own ports and the fixed ports leading to + // it; a chip, its holder and lane; a lane, its band. + const edges: GraphEdge[] = []; + const related: Record = {}; + for (const b of lanes) { + related[b.id] = [b.id, ...b.inbound.map((p) => p.id), ...b.holders.map((h) => h.id)]; + for (const p of b.inbound) { + const label = p.kind === 'node' ? p.node : undefined; + edges.push({ id: `${p.id}>${b.id}`, from: p.id, to: b.id, muted: !p.listening, overlap: !!p.overlap, ...(label ? { label } : {}) }); + const h = p.holder ? b.holders.find((x) => x.name === p.holder) : undefined; + related[p.id] = h ? [p.id, b.id, h.id] : [p.id, b.id]; + } + for (const h of b.holders) { + edges.push({ id: `${b.id}>${h.id}`, from: b.id, to: h.id, muted: !h.listening, overlap: h.overlap }); + related[h.id] = [h.id, b.id, ...h.ports.map((p) => p.id), ...(leftOf.get(h.id) ?? []).map((p) => p.id)]; + for (const p of h.ports) related[p.id] = [p.id, h.id, b.id]; + } + } + + return { lanes, idle, edges, overlaps: shared.size, hiddenInstance, related }; +} diff --git a/ui/src/views/StackPage.vue b/ui/src/views/StackPage.vue index d01c7223..26743ea2 100644 --- a/ui/src/views/StackPage.vue +++ b/ui/src/views/StackPage.vue @@ -4,14 +4,14 @@ // (start, stop, restart: every lane), who serves the main ports and the // control to give them to another lane, its lanes (`main`, the main working // tree, first; a row opens the lane's views; no lane actions here), the -// services that run once per machine (`singleton`, ADR-0080) and what each -// lane uses. A stack that does not run shows its start and its worktrees as +// services that run once per machine (`singleton`, ADR-0080), what each +// lane uses, and its ports as a graph (ADR-0082). A stack that does not run shows its start and its worktrees as // the hub lists them (ADR-0078). It follows the main tree's stream, without // making `main` the lane an older link opens. import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { api, paths, v1 } from '@/api'; -import type { LaneView, Load, UsageReport } from '@/api/types'; +import type { HubPorts, LaneView, Load, PortEntry, PortOverlap, UsageReport } from '@/api/types'; import { ago, bytes, clock, duration, percent, useNow } from '@/lib/format'; import { viaText } from '@/lib/journal'; import { laneLabel, lastTab, MAIN, nodeLabel, restartLanesText, started } from '@/lib/lanes'; @@ -21,6 +21,7 @@ import { useHub } from '@/stores/hub'; import { useStack } from '@/stores/stack'; import AgentPrompt from '@/components/AgentPrompt.vue'; import Icon from '@/components/icons/Icon.vue'; +import PortGraph from '@/components/PortGraph.vue'; import StatusPill from '@/components/StatusPill.vue'; const route = useRoute(); @@ -107,7 +108,10 @@ async function loadUsage() { ); if (sid === stack.id) usage.value = out; } -const timer = setInterval(() => void loadUsage(), 10_000); +const timer = setInterval(() => { + void loadUsage(); + void loadPorts(); +}, 10_000); onBeforeUnmount(() => clearInterval(timer)); watch(() => [stack.id, stack.running, lanes.value.filter(started).map((l) => l.name).join()] as const, () => void loadUsage(), { immediate: true }); /** What the stack's lanes use together, of those known. */ @@ -128,6 +132,58 @@ function open(l: LaneView) { if (stack.id) void router.push(laneView(stack.id, l.name, lastTab(route.name))); } +// ------------------------------------------------------------ ports + +/** + * The stack's ports (ADR-0082): its daemon's `/v1/ports` (the main tree's, + * with every lane's), and `/hub/ports` for the overlaps with other stacks; + * a stack that does not run has what its stack file declares, from the hub. + * Read when the page opens, when its lanes or the main slot change, and + * every 10 s while it is shown. + */ +const ports = ref<{ of: string; entries: PortEntry[] | null; overlaps: PortOverlap[]; error: string | null } | null>(null); +async function loadPorts() { + const sid = stack.id; + if (!sid || stack.foreign) return; + const running = stack.running; + const get = async (path: string): Promise => { + try { + const res = await api().request('GET', path); + return res.ok && res.body && typeof res.body === 'object' ? res.body : null; + } catch { + return null; + } + }; + const [own, all] = await Promise.all([running ? get<{ ports: PortEntry[] }>(paths.api(sid, v1.ports)) : null, get(paths.hubPorts)]); + if (sid !== stack.id) return; + const fromHub = Array.isArray(all?.stacks) ? all.stacks.find((x) => x.id === sid)?.ports : undefined; + const entries = Array.isArray(own?.ports) ? own.ports : Array.isArray(fromHub) ? fromHub : null; + const overlaps = Array.isArray(all?.overlaps) ? all.overlaps : []; + const before = ports.value?.of === sid ? ports.value : null; + ports.value = { + of: sid, + // A failed read keeps what was there. + entries: entries ?? before?.entries ?? null, + overlaps: all ? overlaps : (before?.overlaps ?? []), + error: entries ? null : 'Neither its daemon nor the hub says which ports it uses (an older devstack?).', + }; +} +watch( + () => + [ + stack.id, + stack.running, + lanes.value.map((l) => `${l.name}:${started(l)}:${l.node ?? ''}:${l.owns_main}`).join(), + stack.mainView?.main_served?.lane ?? '', + ] as const, + () => void loadPorts(), + { immediate: true }, +); +const portLanes = computed(() => lanes.value.map((l) => ({ name: l.name, node: l.node ? nodeLabel(l) : null }))); +function openLane(name: string) { + if (stack.id) void router.push(laneView(stack.id, name, lastTab(route.name))); +} + // ------------------------------------------------------------ main slot /** The lane on the main ports: a lane that holds them, else `main`. */ @@ -302,6 +358,18 @@ const singletons = computed(() => services.value.filter((s) => s.singleton)); + +