diff --git a/CHANGELOG.md b/CHANGELOG.md index cd1de5de..ec02419c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +* **resources:** Docker network management — create, inspect, and delete networks from the UI +* **resources:** Network inspect panel showing IPAM config, connected containers with IPs/MACs, and labels +* **resources:** Network creation dialog with driver, subnet, gateway, internal, and attachable options +* **resources:** Network topology visualization (Pro) — interactive graph of networks and connected containers using React Flow +* **resources:** List/Topology view toggle on the Networks tab + ## [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 c2d0c998..9586c192 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -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'); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 6c366ba2..032d851a 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 } 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) => { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index d9f4a71c..7ec42bb0 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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; + 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(containers); diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 9ab628fa..4766d484 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -62,12 +62,64 @@ Lists all Docker volumes. Columns: name, driver, mount point, size, and managed ### Networks -Lists all Docker networks. Columns: name, driver, scope (`local`, `global`, `swarm`), and managed status. +Lists all Docker networks. Columns: ID, name, driver, scope (`local`, `global`, `swarm`), and managed status. -**Filter buttons:** `All` · `Managed` · `External` · `System` +**Filter buttons:** `All` · `Managed` · `External` System networks (like `bridge`, `host`, `none`) are shown but cannot be deleted. + + Networks list view with filter, inspect, and create actions + + +#### Create network + +Click **+ Create Network** (admin only) to open the creation dialog. + + + Create Network dialog with name, driver, subnet, and gateway fields + + +| Field | Required | Description | +|-------|----------|-------------| +| **Name** | Yes | Alphanumeric, hyphens, underscores, and dots | +| **Driver** | No | `bridge` (default), `overlay`, `macvlan`, `host`, or `none` | +| **Subnet** | No | CIDR notation, e.g. `172.20.0.0/16` | +| **Gateway** | No | Gateway IP, e.g. `172.20.0.1` | +| **Internal** | No | Isolates the network from external access | +| **Attachable** | No | Allows containers to be manually attached | + +#### Inspect network + +Click the eye icon on any network row to open a detail panel showing: + +- **Overview** — ID, driver, scope, creation date, internal/attachable flags +- **IPAM configuration** — Subnet, gateway, and IP range +- **Connected containers** — Container name, IPv4 address, and MAC address +- **Labels** — All Docker labels applied to the network + + + Network inspect panel showing IPAM config and connected containers + + +#### Network topology Pro + +Switch to the **Topology** view to see an interactive graph of your Docker networks and the containers connected to them. System networks (`bridge`, `host`, `none`) are excluded for clarity. + + + Network topology visualization showing containers connected to networks + + +- **Network nodes** — Dashed border cards showing network name and driver, color-coded by status (managed, external, system) +- **Container nodes** — Cards showing container name with IP addresses per network +- **Edges** — Animated connections between networks and their containers +- Pan, zoom, and drag nodes to explore the topology +- A mini map in the bottom-right provides an overview + + + Network Topology requires a Sencho Pro license (Skipper or Admiral). Community users see an upgrade prompt. + + ### Unmanaged Lists containers running on the host that are not part of any Sencho-managed stack. This includes containers started with `docker run`, or Compose projects outside your `COMPOSE_DIR`. diff --git a/docs/images/networks/create-network.png b/docs/images/networks/create-network.png new file mode 100644 index 00000000..4a8cf154 Binary files /dev/null and b/docs/images/networks/create-network.png differ diff --git a/docs/images/networks/network-inspect.png b/docs/images/networks/network-inspect.png new file mode 100644 index 00000000..2d75faac Binary files /dev/null and b/docs/images/networks/network-inspect.png differ diff --git a/docs/images/networks/network-topology.png b/docs/images/networks/network-topology.png new file mode 100644 index 00000000..953749ee Binary files /dev/null and b/docs/images/networks/network-topology.png differ diff --git a/docs/images/networks/networks-list.png b/docs/images/networks/networks-list.png new file mode 100644 index 00000000..9d485146 Binary files /dev/null and b/docs/images/networks/networks-list.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d9ce1aa..1fa04e36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -3029,6 +3030,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -3059,6 +3069,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -3080,6 +3096,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3440,6 +3475,38 @@ "addons/*" ] }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3595,6 +3662,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3679,6 +3752,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -3734,6 +3829,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -3779,6 +3883,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5851,6 +5990,34 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index bbfa7fe5..b8d89004 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", "@xterm/xterm": "^6.0.0", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/frontend/src/components/NetworkTopologyView.tsx b/frontend/src/components/NetworkTopologyView.tsx new file mode 100644 index 00000000..4016fca1 --- /dev/null +++ b/frontend/src/components/NetworkTopologyView.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + ReactFlow, + Background, + Controls, + MiniMap, + useNodesState, + useEdgesState, + type Node, + type Edge, + type NodeTypes, + Handle, + Position, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { Container, Network, Loader2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface TopologyNetwork { + Id: string; + Name: string; + Driver: string; + managedStatus: 'managed' | 'unmanaged' | 'system'; + containers: Array<{ id: string; name: string; ip: string }>; +} + +interface ContainerNode { + id: string; + name: string; + networks: string[]; + ipAddresses: Record; +} + +// ── Custom Nodes ────────────────────────────────────────────────────────────── + +function ContainerNodeComponent({ data }: { data: { label: string; networks: string[]; ipAddresses: Record } }) { + return ( +
+ +
+ + {data.label} +
+ {data.networks.length > 0 && ( +
+ {data.networks.map(netName => ( +
+ {netName} + + {data.ipAddresses[netName]?.replace(/\/\d+$/, '') || ''} + +
+ ))} +
+ )} + +
+ ); +} + +function NetworkNodeComponent({ data }: { data: { label: string; driver: string; status: string } }) { + const statusColor = data.status === 'managed' ? 'text-success' : data.status === 'system' ? 'text-muted-foreground' : 'text-warning'; + return ( +
+ +
+ + {data.label} +
+ {data.driver} + +
+ ); +} + +const nodeTypes: NodeTypes = { + container: ContainerNodeComponent, + network: NetworkNodeComponent, +}; + +// React Flow's inline style objects cannot resolve CSS custom properties, +// so raw oklch values are used here as a necessary escape hatch. +const EDGE_COLORS = [ + 'oklch(0.75 0.08 192)', // brand teal + 'oklch(0.70 0.10 150)', // green + 'oklch(0.70 0.10 280)', // purple + 'oklch(0.70 0.10 30)', // orange + 'oklch(0.70 0.10 220)', // blue + 'oklch(0.70 0.10 340)', // pink +]; + +// ── Layout Helper ───────────────────────────────────────────────────────────── + +function layoutGraph( + networksList: TopologyNetwork[], +): { nodes: Node[]; edges: Edge[] } { + const flowNodes: Node[] = []; + const flowEdges: Edge[] = []; + const containerDataMap = new Map(); + + networksList.forEach(net => { + net.containers.forEach(c => { + if (!containerDataMap.has(c.id)) { + containerDataMap.set(c.id, { id: c.id, name: c.name, networks: [], ipAddresses: {} }); + } + const cd = containerDataMap.get(c.id)!; + cd.networks.push(net.Name); + cd.ipAddresses[net.Name] = c.ip; + }); + }); + + const networkSpacing = 240; + const containerSpacing = 220; + + networksList.forEach((net, i) => { + flowNodes.push({ + id: `net-${net.Id}`, + type: 'network', + position: { x: i * networkSpacing, y: 0 }, + data: { label: net.Name, driver: net.Driver, status: net.managedStatus }, + draggable: true, + }); + }); + + const allContainers = Array.from(containerDataMap.values()); + allContainers.forEach((container, i) => { + flowNodes.push({ + id: `ctr-${container.id}`, + type: 'container', + position: { x: i * containerSpacing, y: 180 }, + data: { + label: container.name, + networks: container.networks, + ipAddresses: container.ipAddresses, + }, + draggable: true, + }); + }); + + networksList.forEach((net, ni) => { + const color = EDGE_COLORS[ni % EDGE_COLORS.length]; + net.containers.forEach(c => { + flowEdges.push({ + id: `edge-${net.Id}-${c.id}`, + source: `net-${net.Id}`, + target: `ctr-${c.id}`, + animated: true, + style: { stroke: color, strokeWidth: 1.5 }, + }); + }); + }); + + return { nodes: flowNodes, edges: flowEdges }; +} + +// ── Main Component ──────────────────────────────────────────────────────────── + +export default function NetworkTopologyView() { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [loading, setLoading] = useState(true); + + const fetchTopology = useCallback(async () => { + setLoading(true); + try { + const res = await apiFetch('/system/networks/topology'); + if (!res.ok) throw new Error('Failed to fetch topology'); + const inspected = await res.json(); + + const { nodes: layoutNodes, edges: layoutEdges } = layoutGraph(inspected); + setNodes(layoutNodes); + setEdges(layoutEdges); + } catch (error) { + const err = error as Record; + toast.error(String(err?.message || err?.error || 'Something went wrong.')); + } finally { + setLoading(false); + } + }, [setNodes, setEdges]); + + useEffect(() => { fetchTopology(); }, [fetchTopology]); + + if (loading) { + return ( +
+ + Loading network topology... +
+ ); + } + + if (nodes.length === 0) { + return ( +
+ +

No user-created networks found.

+

Create a network or deploy stacks with custom networks to see the topology.

+
+ ); + } + + return ( +
+ + + + { + if (node.type === 'network') return 'oklch(0.75 0.08 192)'; + return 'oklch(0.50 0 0)'; + }} + maskColor="oklch(0 0 0 / 0.2)" + /> + +
+ ); +} diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 2836585e..4f48891f 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -7,14 +7,25 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Badge } from "@/components/ui/badge"; 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 { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck } from 'lucide-react'; +import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2 } from 'lucide-react'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; +import { ProGate } from './ProGate'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; +import { lazy, Suspense } from 'react'; + +const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); // ── Interfaces ───────────────────────────────────────────────────────────────── @@ -45,7 +56,10 @@ interface DockerVolume { managedStatus: 'managed' | 'unmanaged'; } -interface DockerNetwork { +const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const; +type NetworkDriver = (typeof NETWORK_DRIVERS)[number]; + +export interface DockerNetwork { Id: string; Name: string; Driver: string; @@ -62,6 +76,29 @@ interface UnmanagedContainer { Image: string; } +interface NetworkInspectData { + Id: string; + Name: string; + Created: string; + Scope: string; + Driver: string; + Internal: boolean; + Attachable: boolean; + Labels: Record; + IPAM: { + Driver: string; + Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>; + }; + Containers: Record; + Options: Record; +} + type ResourceFilter = 'all' | 'managed' | 'unmanaged'; type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; type PruneScope = 'managed' | 'all'; @@ -309,6 +346,8 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { export default function ResourcesView() { const { isAdmin } = useAuth(); const { activeNode } = useNodes(); + const { isPro } = useLicense(); + const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list'); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); const [volumes, setVolumes] = useState([]); @@ -327,6 +366,13 @@ export default function ResourcesView() { const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks'; id: string; name?: string } | null>(null); + // Network create/inspect state + const [showCreateNetwork, setShowCreateNetwork] = useState(false); + const [createNetworkForm, setCreateNetworkForm] = useState<{ name: string; driver: NetworkDriver; subnet: string; gateway: string; internal: boolean; attachable: boolean }>({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); + const [isCreatingNetwork, setIsCreatingNetwork] = useState(false); + const [inspectNetwork, setInspectNetwork] = useState(null); + const [isInspectLoading, setIsInspectLoading] = useState(false); + // Unmanaged container state const [selectedOrphans, setSelectedOrphans] = useState([]); const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); @@ -427,6 +473,51 @@ export default function ResourcesView() { } }; + const handleCreateNetwork = async () => { + setIsCreatingNetwork(true); + try { + const res = await apiFetch('/system/networks', { + method: 'POST', + body: JSON.stringify({ + name: createNetworkForm.name, + driver: createNetworkForm.driver, + subnet: createNetworkForm.subnet || undefined, + gateway: createNetworkForm.gateway || undefined, + internal: createNetworkForm.internal, + attachable: createNetworkForm.attachable, + }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data?.error || 'Failed to create network'); + } + toast.success(`Network "${createNetworkForm.name}" created`); + setShowCreateNetwork(false); + setCreateNetworkForm({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); + await fetchAllData(); + } catch (error) { + const err = error as Record; + toast.error(String(err?.message || err?.error || 'Something went wrong.')); + } finally { + setIsCreatingNetwork(false); + } + }; + + const handleInspectNetwork = async (id: string) => { + setIsInspectLoading(true); + try { + const res = await apiFetch(`/system/networks/${id}`); + if (!res.ok) throw new Error('Failed to inspect network'); + const data = await res.json(); + setInspectNetwork(data); + } catch (error) { + const err = error as Record; + toast.error(String(err?.message || err?.error || 'Something went wrong.')); + } finally { + setIsInspectLoading(false); + } + }; + // Derived filtered lists const filteredImages = images.filter(img => imageFilter === 'managed' ? img.managedStatus === 'managed' : @@ -462,7 +553,7 @@ export default function ResourcesView() {
{/* Disk Footprint */} - + Docker Disk Footprint @@ -488,7 +579,7 @@ export default function ResourcesView() { {/* Quick Clean */} - {isAdmin && + {isAdmin && Quick Clean @@ -540,7 +631,7 @@ export default function ResourcesView() { {/* Resource Tabs */}
@@ -611,8 +702,8 @@ export default function ResourcesView() {
- {isAdmin && } @@ -658,8 +749,8 @@ export default function ResourcesView() { {vol.Mountpoint} - {isAdmin && } @@ -671,15 +762,67 @@ export default function ResourcesView() { {/* Networks */} - n.managedStatus === 'managed').length, - unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, - }} - /> +
+ {networkViewMode === 'list' && ( + n.managedStatus === 'managed').length, + unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, + }} + /> + )} +
+
+ + +
+ {isAdmin && networkViewMode === 'list' && ( + + )} +
+
+ + {networkViewMode === 'topology' ? ( +
+ + + Loading topology... +
+ }> + + + +
+ ) : ( @@ -688,7 +831,7 @@ export default function ResourcesView() { Driver Scope Status - Action + Actions {isLoading ? : ( @@ -707,21 +850,33 @@ export default function ResourcesView() { {net.Scope} - {isAdmin && } +
+ + {isAdmin && } +
))} )}
+ )} {/* Unmanaged Containers */} @@ -743,7 +898,7 @@ export default function ResourcesView() { onClick={() => setBulkPurgeConfirm(true)} disabled={selectedOrphans.length === 0 || isActioning} > - + {isActioning ? 'Purging...' : `Purge Selected (${selectedOrphans.length})`} @@ -761,7 +916,7 @@ export default function ResourcesView() { {Object.entries(orphans).map(([project, containers], gi) => (
{/* Project header */} @@ -886,6 +1041,225 @@ export default function ResourcesView() { + + {/* Create Network Dialog */} + + + + Create Network + Create a new Docker network for inter-container communication. + +
+
+ + setCreateNetworkForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + +
+
+
+ + setCreateNetworkForm(f => ({ ...f, subnet: e.target.value }))} + /> +
+
+ + setCreateNetworkForm(f => ({ ...f, gateway: e.target.value }))} + /> +
+
+
+
+ setCreateNetworkForm(f => ({ ...f, internal: v }))} + /> + +
+
+ setCreateNetworkForm(f => ({ ...f, attachable: v }))} + /> + +
+
+
+ + + + +
+
+ + {/* Network Inspect Sheet */} + !open && setInspectNetwork(null)}> + + + + + {inspectNetwork?.Name} + + + {inspectNetwork && ( +
+ {/* Overview */} +
+

Overview

+
+
+ ID +

+ {inspectNetwork.Id.substring(0, 12)} + +

+
+
+ Driver + {inspectNetwork.Driver} +
+
+ Scope + {inspectNetwork.Scope} +
+
+ Created +

{new Date(inspectNetwork.Created).toLocaleString()}

+
+
+ Internal +

{inspectNetwork.Internal ? 'Yes' : 'No'}

+
+
+ Attachable +

{inspectNetwork.Attachable ? 'Yes' : 'No'}

+
+
+
+ + {/* IPAM Config */} + {inspectNetwork.IPAM?.Config?.length > 0 && ( +
+

IPAM Configuration

+
+ {inspectNetwork.IPAM.Config.map((cfg, i) => ( +
+ {cfg.Subnet && ( +
+ Subnet + {cfg.Subnet} +
+ )} + {cfg.Gateway && ( +
+ Gateway + {cfg.Gateway} +
+ )} + {cfg.IPRange && ( +
+ IP Range + {cfg.IPRange} +
+ )} +
+ ))} +
+
+ )} + + {/* Connected Containers */} +
+

+ Connected Containers ({Object.keys(inspectNetwork.Containers || {}).length}) +

+ {Object.keys(inspectNetwork.Containers || {}).length === 0 ? ( +

No containers connected to this network.

+ ) : ( +
+ {Object.entries(inspectNetwork.Containers).map(([id, container]) => ( +
+
+ + {container.Name} +
+
+
+ IPv4 +

{container.IPv4Address || '—'}

+
+
+ MAC +

{container.MacAddress || '—'}

+
+
+
+ ))} +
+ )} +
+ + {/* Labels */} + {inspectNetwork.Labels && Object.keys(inspectNetwork.Labels).length > 0 && ( +
+

Labels

+
+ + + {Object.entries(inspectNetwork.Labels).map(([key, val]) => ( + + {key} + {val} + + ))} + +
+
+
+ )} +
+ )} +
+
); }