feat(resources): add network management with create, inspect, and topology visualization (#335)

* feat(resources): add network management with create, inspect, and topology visualization

Add full Docker network CRUD: create networks with custom drivers, subnets,
and IPAM config; inspect network details including connected containers and
IP addresses; interactive topology graph visualization (Pro-gated to
Skipper/Admiral tiers). Includes backend routes, DockerController methods,
unit tests, documentation with screenshots, and updated changelog.

* refactor(resources): address code review findings for network management

- Add batch GET /api/system/networks/topology endpoint to eliminate N+1
  HTTP calls from the topology view
- Export DockerNetwork type from ResourcesView, remove duplicate in
  NetworkTopologyView
- Wire up isInspectLoading state to Eye button (spinner + disabled)
- Remove unnecessary wrapper div around FilterToggle
- NetworkTopologyView is now self-contained (fetches from batch endpoint,
  no longer needs networks prop)

* fix(resources): align pre-existing UI with design system standards

- Replace hardcoded red-500 on image/volume delete buttons with
  text-destructive/60 hover:bg-destructive tokens
- Replace shadow-sm with shadow-card-bevel on all cards (Disk Footprint,
  Quick Clean, Resource Tabs, Unmanaged Containers)
- Add strokeWidth={1.5} to all action button Trash2 icons
- Type network drivers as union type instead of raw strings (backend
  NetworkDriver type + frontend NETWORK_DRIVERS constant)

* fix(resources): add generic type args to useNodesState/useEdgesState

Fixes TS2345 build error in CI where tsc -b (strict mode via
tsconfig.app.json) infers never[] from untyped empty array literals
passed to React Flow hooks.

* fix(resources): replace explicit any in catch blocks with unknown narrowing

ESLint no-explicit-any errors in CI for three catch blocks added by the
network management feature.
This commit is contained in:
Anso
2026-04-02 14:53:11 -04:00
committed by GitHub
parent c35ee2ed05
commit 4488637656
13 changed files with 1041 additions and 33 deletions
@@ -21,6 +21,7 @@ const { mockDocker } = vi.hoisted(() => {
pruneImages: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
pruneNetworks: vi.fn().mockResolvedValue({}),
pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
createNetwork: vi.fn(),
};
return { mockDocker };
});
@@ -376,3 +377,76 @@ describe('DockerController - error paths', () => {
await expect(dc.getDiskUsage()).rejects.toThrow('daemon not running');
});
});
// ── inspectNetwork ────────────────────────────────────────────────────
describe('DockerController - inspectNetwork', () => {
it('returns full network details from Docker API', async () => {
const inspectData = {
Id: 'abc123',
Name: 'my-network',
Driver: 'bridge',
Scope: 'local',
IPAM: { Config: [{ Subnet: '172.20.0.0/16', Gateway: '172.20.0.1' }] },
Containers: {
'ctr1': { Name: 'web', IPv4Address: '172.20.0.2/16', MacAddress: '02:42:ac:14:00:02' },
},
};
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });
const dc = DockerController.getInstance(1);
const result = await dc.inspectNetwork('abc123');
expect(result).toEqual(inspectData);
expect(mockDocker.getNetwork).toHaveBeenCalledWith('abc123');
});
it('propagates errors when network not found', async () => {
mockDocker.getNetwork.mockReturnValue({
inspect: vi.fn().mockRejectedValue(new Error('network not found')),
});
const dc = DockerController.getInstance(1);
await expect(dc.inspectNetwork('nonexistent')).rejects.toThrow('network not found');
});
});
// ── createNetwork ─────────────────────────────────────────────────────
describe('DockerController - createNetwork', () => {
it('creates a network with valid options', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-123' });
const dc = DockerController.getInstance(1);
const result = await dc.createNetwork({ Name: 'test-network', Driver: 'bridge' });
expect(result).toEqual({ id: 'new-net-123' });
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'test-network', Driver: 'bridge' });
});
it('rejects empty network name', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name');
});
it('rejects network name with invalid characters', async () => {
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name');
});
it('accepts names with hyphens, underscores, and dots', async () => {
mockDocker.createNetwork.mockResolvedValue({ id: 'ok-123' });
const dc = DockerController.getInstance(1);
await dc.createNetwork({ Name: 'my-net_v2.0' });
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-net_v2.0' });
});
it('propagates Docker daemon errors', async () => {
mockDocker.createNetwork.mockRejectedValue(new Error('network with name test already exists'));
const dc = DockerController.getInstance(1);
await expect(dc.createNetwork({ Name: 'test' })).rejects.toThrow('already exists');
});
});