mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +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:
@@ -21,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **resources:** Network topology visualization (Pro) — interactive graph of networks and connected containers using React Flow
|
||||
* **resources:** List/Topology view toggle on the Networks tab
|
||||
|
||||
### Fixed
|
||||
|
||||
* **resources:** Network topology now correctly classifies networks as managed/unmanaged/system instead of showing all as unmanaged
|
||||
* **resources:** Network inspect returns proper 404 status when a network no longer exists instead of generic 500
|
||||
* **resources:** Network create route validates driver against allowed values and rejects array-typed labels
|
||||
* **resources:** Replaced all `any` types in network routes with proper `unknown` narrowing
|
||||
|
||||
## [0.26.0](https://github.com/AnsoCode/Sencho/compare/v0.25.3...v0.26.0) (2026-04-02)
|
||||
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -181,6 +181,84 @@ DELETE FROM global_settings WHERE key IN ('auth_username', 'auth_password_hash',
|
||||
|
||||
---
|
||||
|
||||
## Network creation fails with "name is required"
|
||||
|
||||
**Symptom:** Clicking Create in the Create Network dialog returns an error about a missing name.
|
||||
|
||||
**Cause:** The network name field was left empty or contains only whitespace.
|
||||
|
||||
**Fix:** Enter a valid network name. Names must be non-empty and can contain letters, numbers, hyphens (`-`), underscores (`_`), and dots (`.`). Names cannot start with a dot or hyphen.
|
||||
|
||||
---
|
||||
|
||||
## Network creation fails with "invalid name"
|
||||
|
||||
**Symptom:** You enter a network name but the API rejects it as invalid.
|
||||
|
||||
**Cause:** The name contains characters that are not allowed, or starts with a special character.
|
||||
|
||||
**Allowed characters:** Letters, numbers, hyphens, underscores, and dots. The name must start with a letter, number, or underscore.
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Name | Valid? | Reason |
|
||||
|------|--------|--------|
|
||||
| `my-network` | Yes | |
|
||||
| `app_net.v2` | Yes | |
|
||||
| `.hidden` | No | Cannot start with a dot |
|
||||
| `-leading` | No | Cannot start with a hyphen |
|
||||
| `my network` | No | Spaces not allowed |
|
||||
| `net/work` | No | Slashes not allowed |
|
||||
|
||||
---
|
||||
|
||||
## Cannot delete a system network (bridge, host, none)
|
||||
|
||||
**Symptom:** The delete button is missing or disabled for certain networks.
|
||||
|
||||
**Expected behavior:** Docker system networks (`bridge`, `host`, `none`) cannot be deleted — they are created by the Docker daemon and are required for normal operation. Sencho intentionally hides the delete action for these networks.
|
||||
|
||||
If you need to clean up unused *user-created* networks, use the **Prune Networks** button at the top of the Networks tab.
|
||||
|
||||
---
|
||||
|
||||
## Network inspect shows "Network not found"
|
||||
|
||||
**Symptom:** Clicking the inspect (eye) icon on a network row returns a "not found" error.
|
||||
|
||||
**Cause:** The network was deleted between the time the list loaded and when you clicked inspect. This can happen if another user or an external tool removed the network.
|
||||
|
||||
**Fix:** Refresh the Resources page to reload the current network list.
|
||||
|
||||
---
|
||||
|
||||
## Network topology is empty
|
||||
|
||||
**Symptom:** The Topology tab shows "No user-created networks found" even though you have running containers.
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
- **All containers are on system networks only.** The topology view excludes Docker's built-in `bridge`, `host`, and `none` networks. If your containers only use the default bridge, they won't appear. Create a custom network in your compose file to see them in the topology.
|
||||
- **No containers are connected.** Networks with zero connected containers still appear as nodes, but if you have no user-created networks at all, the view will be empty.
|
||||
|
||||
**Fix:** Define custom networks in your compose files:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
frontend:
|
||||
backend:
|
||||
|
||||
services:
|
||||
web:
|
||||
networks: [frontend, backend]
|
||||
api:
|
||||
networks: [backend]
|
||||
```
|
||||
|
||||
After deploying, the topology will show these networks and their connected containers.
|
||||
|
||||
---
|
||||
|
||||
## Checking the health endpoint
|
||||
|
||||
Sencho exposes a health endpoint for monitoring and container health checks:
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -1062,19 +1062,14 @@ export default function ResourcesView() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="net-driver" className="text-xs font-medium">Driver</Label>
|
||||
<Select
|
||||
<Combobox
|
||||
options={NETWORK_DRIVERS.map(d => ({ value: d, label: d }))}
|
||||
value={createNetworkForm.driver}
|
||||
onValueChange={v => setCreateNetworkForm(f => ({ ...f, driver: v as NetworkDriver }))}
|
||||
>
|
||||
<SelectTrigger id="net-driver" className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NETWORK_DRIVERS.map(d => (
|
||||
<SelectItem key={d} value={d}>{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onValueChange={v => setCreateNetworkForm(f => ({ ...f, driver: (v || 'bridge') as NetworkDriver }))}
|
||||
placeholder="Select driver..."
|
||||
searchPlaceholder="Search drivers..."
|
||||
emptyText="No matching driver."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user