mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+70
-1
@@ -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 } from './services/DockerController';
|
||||
import DockerController, { globalDockerNetwork, type CreateNetworkOptions } from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
import { ComposeService } from './services/ComposeService';
|
||||
import bcrypt from 'bcrypt';
|
||||
@@ -4405,6 +4405,75 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const allNetworks = await dockerController.getNetworks();
|
||||
const userNetworks = allNetworks.filter((n: any) => n.managedStatus !== 'system');
|
||||
|
||||
const inspected = await Promise.all(
|
||||
userNetworks.map(async (net: any) => {
|
||||
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,
|
||||
}));
|
||||
return { ...net, containers };
|
||||
} catch {
|
||||
return { ...net, containers: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json(inspected);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch network topology:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to fetch network topology' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
if (!id) return res.status(400).json({ error: 'Network ID is required' });
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const networkInfo = await dockerController.inspectNetwork(id);
|
||||
res.json(networkInfo);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to inspect network:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to inspect network' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/networks', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, driver, subnet, gateway, labels, internal, attachable } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Network name is required' });
|
||||
|
||||
const options: CreateNetworkOptions = { Name: name };
|
||||
|
||||
if (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 (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) {
|
||||
console.error('Failed to create network:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create network' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- App Templates Routes ---
|
||||
|
||||
app.get('/api/templates', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -37,6 +37,17 @@ export interface ClassifiedNetwork {
|
||||
managedStatus: 'managed' | 'unmanaged' | 'system';
|
||||
}
|
||||
|
||||
export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
|
||||
|
||||
export interface CreateNetworkOptions {
|
||||
Name: string;
|
||||
Driver?: NetworkDriver;
|
||||
IPAM?: { Config: Array<{ Subnet?: string; Gateway?: string }> };
|
||||
Labels?: Record<string, string>;
|
||||
Internal?: boolean;
|
||||
Attachable?: boolean;
|
||||
}
|
||||
|
||||
class DockerController {
|
||||
private docker: Docker;
|
||||
private nodeId: number;
|
||||
@@ -331,6 +342,18 @@ class DockerController {
|
||||
await network.remove({ force: true });
|
||||
}
|
||||
|
||||
public async inspectNetwork(id: string) {
|
||||
const network = this.docker.getNetwork(id);
|
||||
return await network.inspect();
|
||||
}
|
||||
|
||||
public async createNetwork(options: CreateNetworkOptions) {
|
||||
if (!options.Name || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(options.Name)) {
|
||||
throw new Error('Invalid network name. Use alphanumeric characters, hyphens, underscores, and dots.');
|
||||
}
|
||||
return await this.docker.createNetwork(options);
|
||||
}
|
||||
|
||||
public async getRunningContainers() {
|
||||
const containers = await this.docker.listContainers({ all: false });
|
||||
return this.validateApiData<any[]>(containers);
|
||||
|
||||
Reference in New Issue
Block a user