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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 22 additions & 5 deletions docs/adr/0082-the-ports-of-a-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down Expand Up @@ -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,
`<group>.<service>` 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

Expand Down
2 changes: 2 additions & 0 deletions ui/src/api/mock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
5 changes: 3 additions & 2 deletions ui/src/api/mock/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
3 changes: 1 addition & 2 deletions ui/src/api/mock/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
/** Worktrees without a stack file of their own (ADR-0055) until one is copied in. */
noStackFile = new Set<string>(['C:\\work\\_wt\\1333']);
Expand Down
4 changes: 4 additions & 0 deletions ui/src/api/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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`,
Expand Down
74 changes: 74 additions & 0 deletions ui/src/components/PortGraph.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-testid="stack-ports-graph"] [data-port="4200/tcp"]')!;
port.dispatchEvent(new FocusEvent('focus'));
await nextTick();
const web = [...el.querySelectorAll<HTMLElement>('[data-testid="stack-ports-holder"]')].find((x) => x.textContent?.includes('web'))!;
const api = [...el.querySelectorAll<HTMLElement>('[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();
});
});
Loading
Loading