mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
feat(resources): add network management with create, inspect, and topology (#338)
* 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.
* test(resources): add network management edge case tests and fix bugs
Fix topology route using unclassified networks (missing managedStatus),
fix inspect route returning 500 instead of 404 for missing networks,
add driver validation and array-type labels rejection on create route,
replace all any types with proper unknown narrowing, and add comprehensive
edge case tests for createNetwork and inspectNetwork.
* fix(tests): add nullish guard for Containers in inspectNetwork test
Dockerode types NetworkInspectInfo.Containers as potentially undefined,
causing TS2769 under strict mode when passed directly to Object.keys().
* refactor(resources): replace Select with Combobox for network driver picker
Use the existing reusable Combobox component (same as Auto-Update and
Scheduled Task modals) for the driver selection in Create Network dialog.
Provides inline search filtering and consistent UX across all modals.
This commit is contained in:
@@ -449,4 +449,109 @@ describe('DockerController - createNetwork', () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: 'test' })).rejects.toThrow('already exists');
|
||||
});
|
||||
|
||||
it('rejects names starting with a dot', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: '.hidden' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('rejects names starting with a hyphen', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: '-leading-dash' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('rejects names with spaces', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: 'has spaces' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('rejects names with slashes (path traversal)', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.createNetwork({ Name: 'foo/bar' })).rejects.toThrow('Invalid network name');
|
||||
});
|
||||
|
||||
it('passes IPAM config through to Docker', async () => {
|
||||
mockDocker.createNetwork.mockResolvedValue({ id: 'ipam-net' });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const options = {
|
||||
Name: 'ipam-test',
|
||||
Driver: 'bridge' as const,
|
||||
IPAM: { Config: [{ Subnet: '10.0.0.0/24', Gateway: '10.0.0.1' }] },
|
||||
Internal: true,
|
||||
Attachable: true,
|
||||
};
|
||||
await dc.createNetwork(options);
|
||||
|
||||
expect(mockDocker.createNetwork).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it('passes labels through to Docker', async () => {
|
||||
mockDocker.createNetwork.mockResolvedValue({ id: 'labeled-net' });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const options = {
|
||||
Name: 'labeled',
|
||||
Labels: { env: 'test', team: 'infra' },
|
||||
};
|
||||
await dc.createNetwork(options);
|
||||
|
||||
expect(mockDocker.createNetwork).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it('accepts single-character name', async () => {
|
||||
mockDocker.createNetwork.mockResolvedValue({ id: 'single' });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await dc.createNetwork({ Name: 'a' });
|
||||
|
||||
expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'a' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── inspectNetwork edge cases ─────────────────────────────────────────
|
||||
|
||||
describe('DockerController - inspectNetwork edge cases', () => {
|
||||
it('returns network with empty containers map', async () => {
|
||||
const inspectData = {
|
||||
Id: 'empty-net',
|
||||
Name: 'isolated',
|
||||
Driver: 'bridge',
|
||||
Containers: {},
|
||||
};
|
||||
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.inspectNetwork('empty-net');
|
||||
|
||||
expect(result.Containers).toEqual({});
|
||||
});
|
||||
|
||||
it('returns network with multiple containers', async () => {
|
||||
const inspectData = {
|
||||
Id: 'multi-net',
|
||||
Name: 'busy-network',
|
||||
Driver: 'bridge',
|
||||
Containers: {
|
||||
'c1': { Name: 'web', IPv4Address: '172.20.0.2/16' },
|
||||
'c2': { Name: 'db', IPv4Address: '172.20.0.3/16' },
|
||||
'c3': { Name: 'cache', IPv4Address: '172.20.0.4/16' },
|
||||
},
|
||||
};
|
||||
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.inspectNetwork('multi-net');
|
||||
|
||||
expect(Object.keys(result.Containers ?? {})).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('propagates Docker daemon connection errors', async () => {
|
||||
mockDocker.getNetwork.mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error('Cannot connect to Docker daemon')),
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await expect(dc.inspectNetwork('any-id')).rejects.toThrow('Cannot connect to Docker daemon');
|
||||
});
|
||||
});
|
||||
|
||||
+27
-17
@@ -5,7 +5,7 @@ import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import DockerController, { globalDockerNetwork, type CreateNetworkOptions } from './services/DockerController';
|
||||
import DockerController, { globalDockerNetwork, type CreateNetworkOptions, type NetworkDriver } from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
import { ComposeService } from './services/ComposeService';
|
||||
import bcrypt from 'bcrypt';
|
||||
@@ -4407,19 +4407,19 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
|
||||
|
||||
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const allNetworks = await dockerController.getNetworks();
|
||||
const userNetworks = allNetworks.filter((n: any) => n.managedStatus !== 'system');
|
||||
const { networks } = await dockerController.getClassifiedResources(knownStacks);
|
||||
const userNetworks = networks.filter(n => n.managedStatus !== 'system');
|
||||
|
||||
const inspected = await Promise.all(
|
||||
userNetworks.map(async (net: any) => {
|
||||
userNetworks.map(async (net) => {
|
||||
try {
|
||||
const detail = await dockerController.inspectNetwork(net.Id);
|
||||
const containers = Object.entries(detail.Containers || {}).map(([id, c]: [string, any]) => ({
|
||||
id,
|
||||
name: c.Name,
|
||||
ip: c.IPv4Address,
|
||||
}));
|
||||
const containers = Object.entries(detail.Containers || {}).map(([id, c]) => {
|
||||
const info = c as { Name: string; IPv4Address: string };
|
||||
return { id, name: info.Name, ip: info.IPv4Address };
|
||||
});
|
||||
return { ...net, containers };
|
||||
} catch {
|
||||
return { ...net, containers: [] };
|
||||
@@ -4428,9 +4428,10 @@ app.get('/api/system/networks/topology', async (req: Request, res: Response) =>
|
||||
);
|
||||
|
||||
res.json(inspected);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to fetch network topology';
|
||||
console.error('Failed to fetch network topology:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to fetch network topology' });
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4441,9 +4442,13 @@ app.get('/api/system/networks/:id', async (req: Request, res: Response) => {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const networkInfo = await dockerController.inspectNetwork(id);
|
||||
res.json(networkInfo);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to inspect network:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to inspect network' });
|
||||
const err = error as Record<string, unknown>;
|
||||
const status = (typeof err.statusCode === 'number' ? err.statusCode : null)
|
||||
|| (error instanceof Error && error.message.includes('404') ? 404 : 500);
|
||||
const msg = error instanceof Error ? error.message : 'Failed to inspect network';
|
||||
res.status(status).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4455,22 +4460,27 @@ app.post('/api/system/networks', async (req: Request, res: Response) => {
|
||||
|
||||
const options: CreateNetworkOptions = { Name: name };
|
||||
|
||||
if (driver) options.Driver = driver;
|
||||
const VALID_DRIVERS: NetworkDriver[] = ['bridge', 'overlay', 'macvlan', 'host', 'none'];
|
||||
if (driver) {
|
||||
if (!VALID_DRIVERS.includes(driver)) return res.status(400).json({ error: 'Invalid network driver' });
|
||||
options.Driver = driver;
|
||||
}
|
||||
if (subnet || gateway) {
|
||||
options.IPAM = { Config: [{}] };
|
||||
if (subnet) options.IPAM.Config[0].Subnet = subnet;
|
||||
if (gateway) options.IPAM.Config[0].Gateway = gateway;
|
||||
}
|
||||
if (labels && typeof labels === 'object') options.Labels = labels;
|
||||
if (labels && typeof labels === 'object' && !Array.isArray(labels)) options.Labels = labels;
|
||||
if (internal) options.Internal = true;
|
||||
if (attachable) options.Attachable = true;
|
||||
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const network = await dockerController.createNetwork(options);
|
||||
res.status(201).json({ success: true, message: 'Network created', id: network.id });
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create network';
|
||||
console.error('Failed to create network:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create network' });
|
||||
res.status(500).json({ error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user