diff --git a/CHANGELOG.md b/CHANGELOG.md index cac79f10..3fe207a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index 9586c192..3ef6fb5b 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -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'); + }); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 032d851a..51ddd4df 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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; + 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 }); } }); diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 899d6b48..94da519c 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -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: diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 4f48891f..dcc8fad8 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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() {
- + onValueChange={v => setCreateNetworkForm(f => ({ ...f, driver: (v || 'bridge') as NetworkDriver }))} + placeholder="Select driver..." + searchPlaceholder="Search drivers..." + emptyText="No matching driver." + />