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
@@ -44,6 +44,7 @@ vi.mock('util', () => ({
}));
import DockerController from '../services/DockerController';
import { CacheService } from '../services/CacheService';
beforeEach(() => {
vi.clearAllMocks();
@@ -1111,3 +1112,70 @@ describe('DockerController - disconnectContainerFromNetwork', () => {
});
});
});
// ── getDependencySnapshot ──────────────────────────────────────────────
describe('DockerController - getDependencySnapshot', () => {
beforeEach(() => {
// resolveProjectNameMap caches under a constant key; clear it so each test
// resolves stack ownership from its own mocked compose set.
CacheService.getInstance().invalidate('project-name-map');
});
it('maps service identity, networks, volume mounts, and published ports', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'abc123def456',
Names: ['/web-1'],
Image: 'nginx:alpine',
State: 'running',
Labels: { 'com.docker.compose.project': 'web', 'com.docker.compose.service': 'api' },
NetworkSettings: { Networks: { web_frontend: { NetworkID: 'net1', IPAddress: '172.18.0.2' } } },
Mounts: [
{ Type: 'volume', Name: 'web_data', Destination: '/data' },
{ Type: 'bind', Source: '/host/path', Destination: '/app' },
],
Ports: [
{ IP: '0.0.0.0', PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
{ PrivatePort: 9090, Type: 'tcp' },
],
},
]);
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'b', Name: 'bridge' },
{ Id: 'net1', Name: 'web_frontend', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'web' } },
]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'web_data', Driver: 'local', Labels: { 'com.docker.compose.project': 'web' } }] });
const dc = DockerController.getInstance(1);
const snap = await dc.getDependencySnapshot(['web']);
const c = snap.containers[0];
expect(c.service).toBe('api');
expect(c.stack).toBe('web');
expect(c.networks).toEqual([{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }]);
expect(c.volumes).toEqual(['web_data']); // bind mount dropped
expect(c.ports).toEqual([{ ip: '0.0.0.0', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }]); // unpublished 9090 dropped
expect(snap.networks.find((n) => n.name === 'bridge')?.isSystem).toBe(true);
const frontend = snap.networks.find((n) => n.name === 'web_frontend');
expect(frontend?.isSystem).toBe(false);
expect(frontend?.stack).toBe('web');
expect(snap.volumes[0]).toMatchObject({ name: 'web_data', stack: 'web', composeProject: 'web' });
});
it('classifies a non-compose container as having no service or stack', async () => {
mockDocker.listContainers.mockResolvedValue([
{ Id: 'x', Names: ['/manual'], Image: 'redis', State: 'running', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
]);
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
const dc = DockerController.getInstance(1);
const snap = await dc.getDependencySnapshot([]);
expect(snap.containers[0].service).toBeNull();
expect(snap.containers[0].stack).toBeNull();
expect(snap.containers[0].composeProject).toBeNull();
});
});