feat(fleet): add read-only dependency map tab (#1324)

* feat(fleet): add read-only dependency map tab

Add a fleet-wide Dependencies tab to Fleet view that maps how stacks,
services, networks, volumes, and ports relate, with flags for missing
dependencies, port conflicts, orphaned resources, and cross-stack shared
resources. Read-only; filterable by stack, node, and flag; collapsed by
default with a list-view fallback at scale.

The graph is derived at request time from Docker and compose metadata, so
no new table or persisted state is introduced. A per-node graph endpoint
feeds a hub aggregation endpoint that fans out across the fleet and
degrades gracefully, surfacing unreachable or unparseable nodes inline
while the rest of the map still renders.

* fix(fleet): harden dependency map flag detection and remote merge

Address review findings on the dependency map:
- Port-conflict detection now does pairwise host-scope overlap, so an
  unrelated bind on the same port and protocol but a different specific host
  IP is no longer flagged, and the flag lands on the exact scoped port node.
- A running service's depends_on target is only considered satisfied when it
  is actually running, so a crashed (exited) dependency is surfaced while a
  deliberately stopped stack stays quiet.
- Declared external networks and volumes are reported missing when they do
  not exist on the host instead of being assumed present.
- The hub deep-validates each remote node-graph payload before merging, so a
  reachable-but-malformed remote degrades to a single node error rather than
  failing the whole fleet map, and the validation failure is logged.
- Searching or filtering on a network, volume, or port now also reveals the
  services that claim it and their stacks.
This commit is contained in:
Anso
2026-06-06 11:18:36 -04:00
committed by GitHub
parent 13b6acab16
commit af4083175c
14 changed files with 2386 additions and 2 deletions
@@ -0,0 +1,96 @@
/**
* Unit tests for the dependency-map compose parser: depends_on / network /
* named-volume / declared-port extraction, external + name: resolution, and
* the fail-soft parseError paths.
*/
import { describe, it, expect } from 'vitest';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
const svc = (name: string, body: string) => `services:\n ${name}:\n${body.split('\n').map((l) => l ? ' ' + l : l).join('\n')}\n`;
describe('parseComposeDependencies - services and depends_on', () => {
it('extracts depends_on in list form', () => {
const r = parseComposeDependencies(svc('web', 'image: nginx\ndepends_on:\n - db\n - cache'));
expect(r.services[0].dependsOn).toEqual(['db', 'cache']);
});
it('extracts depends_on in map form', () => {
const r = parseComposeDependencies('services:\n web:\n depends_on:\n db:\n condition: service_healthy\n');
expect(r.services[0].dependsOn).toEqual(['db']);
});
it('extracts service networks in list and map form', () => {
const list = parseComposeDependencies(svc('web', 'networks:\n - frontend\n - backend'));
expect(list.services[0].networks).toEqual(['frontend', 'backend']);
const map = parseComposeDependencies('services:\n web:\n networks:\n frontend:\n aliases: [w]\n');
expect(map.services[0].networks).toEqual(['frontend']);
});
});
describe('parseComposeDependencies - volumes', () => {
it('keeps named volumes and drops binds and anonymous volumes', () => {
const r = parseComposeDependencies(svc('web', 'volumes:\n - db_data:/var/lib\n - ./local:/app\n - /abs/path:/data\n - /anon-target'));
expect(r.services[0].volumes).toEqual(['db_data']);
});
it('keeps a long-form named volume and drops a long-form bind', () => {
const r = parseComposeDependencies('services:\n web:\n volumes:\n - type: volume\n source: data\n target: /d\n - type: bind\n source: /host\n target: /b\n');
expect(r.services[0].volumes).toEqual(['data']);
});
});
describe('parseComposeDependencies - ports', () => {
it('parses short-form host:container with default tcp', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "8080:80"'));
expect(r.services[0].ports).toEqual([{ hostIp: '', publishedPort: 8080, protocol: 'tcp' }]);
});
it('parses ip:host:container and the /udp protocol', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "127.0.0.1:5353:53/udp"'));
expect(r.services[0].ports).toEqual([{ hostIp: '127.0.0.1', publishedPort: 5353, protocol: 'udp' }]);
});
it('drops a container-only port (no host publish)', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "80"'));
expect(r.services[0].ports).toEqual([]);
});
it('takes the low end of a published range', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "8000-8002:80"'));
expect(r.services[0].ports[0].publishedPort).toBe(8000);
});
it('parses long-form ports with host_ip and protocol', () => {
const r = parseComposeDependencies('services:\n web:\n ports:\n - target: 80\n published: 8080\n host_ip: 10.0.0.5\n protocol: udp\n');
expect(r.services[0].ports).toEqual([{ hostIp: '10.0.0.5', publishedPort: 8080, protocol: 'udp' }]);
});
});
describe('parseComposeDependencies - top-level resources', () => {
it('normalizes external (bool), legacy external object, and name: override', () => {
const r = parseComposeDependencies('services:\n web:\n image: nginx\nnetworks:\n a:\n b:\n external: true\n c:\n external:\n name: legacy_net\n d:\n name: custom_net\nvolumes:\n v:\n external: true\n');
expect(r.networks.a).toEqual({ external: false });
expect(r.networks.b).toEqual({ external: true });
expect(r.networks.c).toEqual({ name: 'legacy_net', external: true });
expect(r.networks.d).toEqual({ name: 'custom_net', external: false });
expect(r.volumes.v).toEqual({ external: true });
});
});
describe('parseComposeDependencies - fail soft', () => {
it('reports a parseError for invalid YAML and never throws', () => {
const r = parseComposeDependencies('services:\n web:\n - this: : is broken\n :::');
expect(r.parseError).toBeTruthy();
expect(r.services).toEqual([]);
});
it('reports a parseError when there are no services', () => {
const r = parseComposeDependencies('networks:\n a:\n');
expect(r.parseError).toBe('No services found in this file.');
});
it('reports a parseError for an oversized file', () => {
const r = parseComposeDependencies('x'.repeat(1_048_577));
expect(r.parseError).toBe('Compose file is too large to parse.');
});
});