mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user