diff --git a/backend/src/__tests__/network-topology.test.ts b/backend/src/__tests__/network-topology.test.ts new file mode 100644 index 00000000..bbd823ea --- /dev/null +++ b/backend/src/__tests__/network-topology.test.ts @@ -0,0 +1,584 @@ +/** + * Unit tests for DockerController.getTopologyData() - network topology + * data assembly, filtering, container-to-network mapping, and edge cases. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Hoisted mocks ────────────────────────────────────────────────────── + +const { mockDocker } = vi.hoisted(() => { + const mockDocker = { + df: vi.fn(), + listImages: vi.fn().mockResolvedValue([]), + listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }), + listNetworks: vi.fn().mockResolvedValue([]), + listContainers: vi.fn().mockResolvedValue([]), + getContainer: vi.fn(), + getImage: vi.fn(), + getVolume: vi.fn(), + getNetwork: vi.fn(), + pruneContainers: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }), + pruneImages: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }), + pruneNetworks: vi.fn().mockResolvedValue({}), + pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }), + createNetwork: vi.fn(), + }; + return { mockDocker }; +}); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDocker: () => mockDocker, + getDefaultNodeId: () => 1, + }), + }, +})); + +// Prevent COMPOSE_DIR filesystem reads (resolveProjectNameMap reads compose files). +// With these mocked, fs.readFile returns ENOENT and the map falls back to +// { stackName: stackName } for each stack passed in. +vi.mock('child_process', () => ({ exec: vi.fn() })); +vi.mock('util', () => ({ promisify: () => vi.fn() })); + +import DockerController from '../services/DockerController'; +import { CacheService } from '../services/CacheService'; + +beforeEach(() => { + vi.clearAllMocks(); + // The project-name-map is cached for 60 seconds. Invalidate between tests so + // that different stack-name inputs do not leak across tests. + CacheService.getInstance().invalidate('project-name-map'); +}); + +// ── Basic happy path ────────────────────────────────────────────────── + +describe('DockerController.getTopologyData - basic', () => { + it('returns networks with their containers', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { + Id: 'net-a', + Name: 'app_default', + Driver: 'bridge', + Scope: 'local', + Labels: { 'com.docker.compose.project': 'app' }, + }, + { + Id: 'net-b', + Name: 'monitoring_default', + Driver: 'bridge', + Scope: 'local', + Labels: { 'com.docker.compose.project': 'monitoring' }, + }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c1', + Names: ['/app-web'], + State: 'running', + Image: 'nginx:latest', + Labels: { 'com.docker.compose.project': 'app' }, + NetworkSettings: { + Networks: { app_default: { NetworkID: 'net-a', IPAddress: '172.20.0.2' } }, + }, + }, + { + Id: 'c2', + Names: ['/monitoring-prom'], + State: 'running', + Image: 'prom/prometheus', + Labels: { 'com.docker.compose.project': 'monitoring' }, + NetworkSettings: { + Networks: { monitoring_default: { NetworkID: 'net-b', IPAddress: '172.21.0.5' } }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData(['app', 'monitoring'], false); + + expect(result).toHaveLength(2); + + const appNet = result.find(n => n.Id === 'net-a')!; + expect(appNet.Name).toBe('app_default'); + expect(appNet.managedStatus).toBe('managed'); + expect(appNet.managedBy).toBe('app'); + expect(appNet.containers).toHaveLength(1); + expect(appNet.containers[0]).toMatchObject({ + id: 'c1', + name: 'app-web', + ip: '172.20.0.2', + state: 'running', + image: 'nginx:latest', + stack: 'app', + }); + + const monNet = result.find(n => n.Id === 'net-b')!; + expect(monNet.containers).toHaveLength(1); + expect(monNet.containers[0].name).toBe('monitoring-prom'); + }); + + it('returns empty array when no networks exist', async () => { + mockDocker.listNetworks.mockResolvedValue([]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result).toEqual([]); + }); + + it('returns networks with empty containers array when no containers are connected', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'net-x', Name: 'isolated', Driver: 'bridge', Scope: 'local', Labels: {} }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result).toHaveLength(1); + expect(result[0].containers).toEqual([]); + }); +}); + +// ── System network filtering ────────────────────────────────────────── + +describe('DockerController.getTopologyData - system network filtering', () => { + const systemNetworks = [ + { Id: 'sys-bridge', Name: 'bridge', Driver: 'bridge', Scope: 'local' }, + { Id: 'sys-host', Name: 'host', Driver: 'host', Scope: 'local' }, + { Id: 'sys-none', Name: 'none', Driver: 'null', Scope: 'local' }, + ]; + const userNetwork = { + Id: 'net-user', + Name: 'app_default', + Driver: 'bridge', + Scope: 'local', + Labels: { 'com.docker.compose.project': 'app' }, + }; + const bridgeContainer = { + Id: 'c-bridge', + Names: ['/host-container'], + State: 'running', + Image: 'busybox', + Labels: {}, + NetworkSettings: { + Networks: { bridge: { NetworkID: 'sys-bridge', IPAddress: '172.17.0.2' } }, + }, + }; + const userContainer = { + Id: 'c-user', + Names: ['/app-svc'], + State: 'running', + Image: 'nginx', + Labels: { 'com.docker.compose.project': 'app' }, + NetworkSettings: { + Networks: { app_default: { NetworkID: 'net-user', IPAddress: '172.20.0.2' } }, + }, + }; + + it('excludes system networks when includeSystem is false', async () => { + mockDocker.listNetworks.mockResolvedValue([...systemNetworks, userNetwork]); + mockDocker.listContainers.mockResolvedValue([bridgeContainer, userContainer]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData(['app'], false); + + expect(result).toHaveLength(1); + expect(result[0].Name).toBe('app_default'); + // Bridge container is not visible because its only network was filtered out + expect(result[0].containers.find(c => c.id === 'c-bridge')).toBeUndefined(); + }); + + it('includes system networks when includeSystem is true', async () => { + mockDocker.listNetworks.mockResolvedValue([...systemNetworks, userNetwork]); + mockDocker.listContainers.mockResolvedValue([bridgeContainer, userContainer]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData(['app'], true); + + expect(result).toHaveLength(4); + const bridge = result.find(n => n.Name === 'bridge')!; + expect(bridge.managedStatus).toBe('system'); + expect(bridge.containers).toHaveLength(1); + expect(bridge.containers[0].id).toBe('c-bridge'); + }); + + it('system networks always have managedStatus system and managedBy null', async () => { + mockDocker.listNetworks.mockResolvedValue(systemNetworks); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], true); + + expect(result).toHaveLength(3); + for (const net of result) { + expect(net.managedStatus).toBe('system'); + expect(net.managedBy).toBeNull(); + } + }); +}); + +// ── Stack resolution ────────────────────────────────────────────────── + +describe('DockerController.getTopologyData - stack resolution', () => { + it('classifies networks with matching compose project label as managed', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { + Id: 'n1', + Name: 'my-stack_default', + Driver: 'bridge', + Scope: 'local', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData(['my-stack'], false); + + expect(result[0].managedStatus).toBe('managed'); + expect(result[0].managedBy).toBe('my-stack'); + }); + + it('classifies unlabeled networks as unmanaged', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'custom-net', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData(['my-stack'], false); + + expect(result[0].managedStatus).toBe('unmanaged'); + expect(result[0].managedBy).toBeNull(); + }); + + it('classifies networks with unrecognized project label as unmanaged', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { + Id: 'n1', + Name: 'other_default', + Driver: 'bridge', + Scope: 'local', + Labels: { 'com.docker.compose.project': 'other-stack' }, + }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData(['my-stack'], false); + + expect(result[0].managedStatus).toBe('unmanaged'); + expect(result[0].managedBy).toBeNull(); + }); + + it('resolves container stack from compose project label', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { + Id: 'n1', + Name: 'my-stack_default', + Driver: 'bridge', + Scope: 'local', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c1', + Names: ['/my-svc'], + State: 'running', + Image: 'nginx', + Labels: { 'com.docker.compose.project': 'my-stack' }, + NetworkSettings: { + Networks: { my_stack_default: { NetworkID: 'n1', IPAddress: '172.20.0.2' } }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData(['my-stack'], false); + + expect(result[0].containers[0].stack).toBe('my-stack'); + }); + + it('container stack is null when no compose label matches', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'custom', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c1', + Names: ['/standalone'], + State: 'running', + Image: 'busybox', + Labels: {}, + NetworkSettings: { + Networks: { custom: { NetworkID: 'n1', IPAddress: '172.25.0.2' } }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].containers[0].stack).toBeNull(); + }); +}); + +// ── Container deduplication ────────────────────────────────────────── + +describe('DockerController.getTopologyData - container deduplication', () => { + it('container on multiple networks appears in each network container list', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'net-a', Name: 'frontend', Driver: 'bridge', Scope: 'local' }, + { Id: 'net-b', Name: 'backend', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'multi', + Names: ['/api-gateway'], + State: 'running', + Image: 'gateway:1.0', + Labels: {}, + NetworkSettings: { + Networks: { + frontend: { NetworkID: 'net-a', IPAddress: '172.30.0.2' }, + backend: { NetworkID: 'net-b', IPAddress: '172.31.0.2' }, + }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result).toHaveLength(2); + expect(result.find(n => n.Id === 'net-a')!.containers).toHaveLength(1); + expect(result.find(n => n.Id === 'net-b')!.containers).toHaveLength(1); + }); + + it('container IP is network-specific', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'net-a', Name: 'frontend', Driver: 'bridge', Scope: 'local' }, + { Id: 'net-b', Name: 'backend', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'multi', + Names: ['/api-gateway'], + State: 'running', + Image: 'gateway:1.0', + Labels: {}, + NetworkSettings: { + Networks: { + frontend: { NetworkID: 'net-a', IPAddress: '172.30.0.2' }, + backend: { NetworkID: 'net-b', IPAddress: '172.31.0.2' }, + }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + const frontendEntry = result.find(n => n.Id === 'net-a')!.containers[0]; + const backendEntry = result.find(n => n.Id === 'net-b')!.containers[0]; + expect(frontendEntry.ip).toBe('172.30.0.2'); + expect(backendEntry.ip).toBe('172.31.0.2'); + }); +}); + +// ── Edge cases ──────────────────────────────────────────────────────── + +describe('DockerController.getTopologyData - edge cases', () => { + it('handles containers with missing NetworkSettings', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { Id: 'c1', Names: ['/orphan'], State: 'running', Image: 'busybox', Labels: {} }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].containers).toEqual([]); + }); + + it('handles containers with empty Networks object', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c1', + Names: ['/orphan'], + State: 'running', + Image: 'busybox', + Labels: {}, + NetworkSettings: { Networks: {} }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].containers).toEqual([]); + }); + + it('skips network entries without NetworkID', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c1', + Names: ['/svc'], + State: 'running', + Image: 'busybox', + Labels: {}, + NetworkSettings: { + Networks: { + 'broken-net': { NetworkID: undefined, IPAddress: '10.0.0.1' }, + 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' }, + }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].containers).toHaveLength(1); + expect(result[0].containers[0].ip).toBe('172.20.0.2'); + }); + + it('falls back to truncated container ID when Names is empty', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'abc123def4567890abcdef1234567890', + Names: [], + State: 'running', + Image: 'busybox', + Labels: {}, + NetworkSettings: { + Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].containers[0].name).toBe('abc123def456'); + }); + + it('falls back to truncated container ID when Names is undefined', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'deadbeef00112233445566778899aabb', + State: 'running', + Image: 'busybox', + Labels: {}, + NetworkSettings: { + Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].containers[0].name).toBe('deadbeef0011'); + }); + + it('defaults Driver to bridge when absent from network data', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].Driver).toBe('bridge'); + }); + + it('defaults State to unknown when absent from container data', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' }, + ]); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c1', + Names: ['/svc'], + Image: 'busybox', + Labels: {}, + NetworkSettings: { + Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } }, + }, + }, + ]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].containers[0].state).toBe('unknown'); + }); + + it('defaults Scope to local when absent from network data', async () => { + mockDocker.listNetworks.mockResolvedValue([ + { Id: 'n1', Name: 'user-net', Driver: 'bridge' }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getTopologyData([], false); + + expect(result[0].Scope).toBe('local'); + }); +}); + +// ── Error handling ──────────────────────────────────────────────────── + +describe('DockerController.getTopologyData - error handling', () => { + it('propagates Docker API errors from listNetworks', async () => { + mockDocker.listNetworks.mockRejectedValue(new Error('Cannot connect to Docker daemon')); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + await expect(dc.getTopologyData([], false)).rejects.toThrow('Cannot connect to Docker daemon'); + }); + + it('propagates Docker API errors from listContainers', async () => { + mockDocker.listNetworks.mockResolvedValue([]); + mockDocker.listContainers.mockRejectedValue(new Error('Docker daemon unavailable')); + + const dc = DockerController.getInstance(1); + await expect(dc.getTopologyData([], false)).rejects.toThrow('Docker daemon unavailable'); + }); + + it('throws when Docker returns HTML string for networks (wrong port)', async () => { + mockDocker.listNetworks.mockResolvedValue('Not Docker'); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + await expect(dc.getTopologyData([], false)).rejects.toThrow('Invalid response from Docker API'); + }); + + it('throws when Docker returns HTML string for containers (wrong port)', async () => { + mockDocker.listNetworks.mockResolvedValue([]); + mockDocker.listContainers.mockResolvedValue('Not Docker'); + + const dc = DockerController.getInstance(1); + await expect(dc.getTopologyData([], false)).rejects.toThrow('Invalid response from Docker API'); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 49d0eda7..a8a58c37 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5730,6 +5730,7 @@ app.get('/api/system/networks/topology', async (req: Request, res: Response) => const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); const dockerController = DockerController.getInstance(req.nodeId); const topology = await dockerController.getTopologyData(knownStacks, includeSystem); + console.log(`[Resources] Topology fetched: ${topology.length} networks, includeSystem=${includeSystem}`); if (isDebugEnabled()) console.debug('[Resources:debug] Topology fetched', { networkCount: topology.length, includeSystem }); res.json(topology); } catch (error: unknown) { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 8ecd2532..90281637 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -438,6 +438,8 @@ class DockerController { knownStackNames: string[], includeSystem: boolean, ): Promise { + const debug = isDebugEnabled(); + const t0 = debug ? Date.now() : 0; const knownSet = new Set(knownStackNames); const [rawNetworks, rawContainers, projectToStack] = await Promise.all([ @@ -505,7 +507,7 @@ class DockerController { topology.containers.push({ id: c.Id, - name: (c.Names?.[0] ?? '').replace(/^\//, ''), + name: (c.Names?.[0] ?? '').replace(/^\//, '') || (c.Id ?? '').substring(0, 12), ip: netInfo.IPAddress ?? '', state: c.State ?? 'unknown', image: c.Image ?? '', @@ -514,7 +516,20 @@ class DockerController { } } - return Array.from(networkMap.values()); + const result = Array.from(networkMap.values()); + + if (debug) { + const totalContainers = result.reduce((sum, n) => sum + n.containers.length, 0); + console.debug('[Resources:debug] Topology built', { + ms: Date.now() - t0, + networks: result.length, + containers: totalContainers, + systemFiltered: !includeSystem, + stacksKnown: knownStackNames.length, + }); + } + + return result; } /** Resolves a Docker Compose project label to a known Sencho stack name, or null. */ diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 2dd23fc2..291720c1 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -116,6 +116,7 @@ Switch to the **Topology** view to see an interactive graph of your Docker netwo - **Container nodes** - Cards showing container name, state indicator, stack badge, image name, and IP addresses per network - **Edges** - Animated color-coded connections between networks and their containers - **Click-to-logs** - Click any running container node to open its log viewer directly +- **Refresh** - Use the refresh button in the toolbar to re-fetch the topology on demand - Pan, zoom, and drag nodes to explore the topology - A mini map in the bottom-right provides an overview diff --git a/docs/images/networks/network-topology-toggle.png b/docs/images/networks/network-topology-toggle.png index 54c782ae..a2ffaf01 100644 Binary files a/docs/images/networks/network-topology-toggle.png and b/docs/images/networks/network-topology-toggle.png differ diff --git a/docs/images/networks/network-topology.png b/docs/images/networks/network-topology.png index 04673fbe..3c2f881b 100644 Binary files a/docs/images/networks/network-topology.png and b/docs/images/networks/network-topology.png differ diff --git a/frontend/src/components/NetworkTopologyView.tsx b/frontend/src/components/NetworkTopologyView.tsx index e50a7dc3..335d4962 100644 --- a/frontend/src/components/NetworkTopologyView.tsx +++ b/frontend/src/components/NetworkTopologyView.tsx @@ -16,10 +16,11 @@ import '@xyflow/react/dist/style.css'; import dagre from '@dagrejs/dagre'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { Container, Network, Loader2 } from 'lucide-react'; +import { Container, Network, Loader2, RefreshCw } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -309,6 +310,16 @@ export default function NetworkTopologyView({ onContainerClick }: NetworkTopolog +
}> { window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { detail: { containerId: id, containerName: name },