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:
Anso
2026-04-02 14:53:11 -04:00
committed by GitHub
parent c35ee2ed05
commit 4488637656
13 changed files with 1041 additions and 33 deletions
+10
View File
@@ -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)
@@ -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
View File
@@ -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) => {
+23
View File
@@ -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);
+54 -2
View File
@@ -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.
<Frame>
<img src="/images/networks/networks-list.png" alt="Networks list view with filter, inspect, and create actions" />
</Frame>
#### Create network
Click **+ Create Network** (admin only) to open the creation dialog.
<Frame>
<img src="/images/networks/create-network.png" alt="Create Network dialog with name, driver, subnet, and gateway fields" />
</Frame>
| 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
<Frame>
<img src="/images/networks/network-inspect.png" alt="Network inspect panel showing IPAM config and connected containers" />
</Frame>
#### Network topology <span style={{fontSize: '0.75em', color: 'var(--brand)'}}>Pro</span>
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.
<Frame>
<img src="/images/networks/network-topology.png" alt="Network topology visualization showing containers connected to networks" />
</Frame>
- **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
<Note>
Network Topology requires a Sencho Pro license (Skipper or Admiral). Community users see an upgrade prompt.
</Note>
### 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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+167
View File
@@ -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
}
}
}
}
}
+1
View File
@@ -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",
@@ -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<string, string>;
}
// ── Custom Nodes ──────────────────────────────────────────────────────────────
function ContainerNodeComponent({ data }: { data: { label: string; networks: string[]; ipAddresses: Record<string, string> } }) {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2 min-w-[160px]">
<Handle type="target" position={Position.Top} className="!bg-muted-foreground !w-2 !h-2" />
<div className="flex items-center gap-2 mb-1.5">
<Container className="w-3.5 h-3.5 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="text-xs font-medium truncate max-w-[140px]">{data.label}</span>
</div>
{data.networks.length > 0 && (
<div className="space-y-0.5">
{data.networks.map(netName => (
<div key={netName} className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{netName}</span>
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">
{data.ipAddresses[netName]?.replace(/\/\d+$/, '') || ''}
</span>
</div>
))}
</div>
)}
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground !w-2 !h-2" />
</div>
);
}
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 (
<div className={cn(
'rounded-lg border-2 border-dashed px-4 py-2.5 min-w-[140px] text-center',
data.status === 'managed' ? 'border-success/30 bg-success/5' :
data.status === 'system' ? 'border-muted-foreground/20 bg-muted/20' :
'border-warning/30 bg-warning/5'
)}>
<Handle type="target" position={Position.Top} className="!bg-transparent !border-none !w-0 !h-0" />
<div className="flex items-center justify-center gap-1.5 mb-1">
<Network className={cn('w-3.5 h-3.5', statusColor)} strokeWidth={1.5} />
<span className="text-xs font-medium">{data.label}</span>
</div>
<Badge variant="outline" className="text-[9px] h-4">{data.driver}</Badge>
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-none !w-0 !h-0" />
</div>
);
}
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<string, ContainerNode>();
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<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
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<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
} finally {
setLoading(false);
}
}, [setNodes, setEdges]);
useEffect(() => { fetchTopology(); }, [fetchTopology]);
if (loading) {
return (
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<Loader2 className="w-4 h-4 animate-spin" strokeWidth={1.5} />
<span className="text-sm">Loading network topology...</span>
</div>
);
}
if (nodes.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-[400px] text-muted-foreground gap-3">
<Network className="w-8 h-8 opacity-40" strokeWidth={1.5} />
<p className="text-sm">No user-created networks found.</p>
<p className="text-xs opacity-70">Create a network or deploy stacks with custom networks to see the topology.</p>
</div>
);
}
return (
<div className="h-[500px] w-full rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
proOptions={{ hideAttribution: true }}
className="bg-background"
>
<Background gap={20} size={1} className="opacity-30" />
<Controls className="!bg-card !border-card-border !shadow-card-bevel [&>button]:!bg-card [&>button]:!border-card-border [&>button]:!text-foreground [&>button:hover]:!bg-muted" />
<MiniMap
className="!bg-card !border-card-border !shadow-card-bevel"
nodeColor={(node) => {
if (node.type === 'network') return 'oklch(0.75 0.08 192)';
return 'oklch(0.50 0 0)';
}}
maskColor="oklch(0 0 0 / 0.2)"
/>
</ReactFlow>
</div>
);
}
+404 -30
View File
@@ -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<string, string>;
IPAM: {
Driver: string;
Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>;
};
Containers: Record<string, {
Name: string;
EndpointID: string;
MacAddress: string;
IPv4Address: string;
IPv6Address: string;
}>;
Options: Record<string, string>;
}
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<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
const [volumes, setVolumes] = useState<DockerVolume[]>([]);
@@ -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<NetworkInspectData | null>(null);
const [isInspectLoading, setIsInspectLoading] = useState(false);
// Unmanaged container state
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
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<string, unknown>;
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<string, unknown>;
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() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Disk Footprint */}
<Card className="col-span-1 border-border shadow-sm animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
<Card className="col-span-1 border-border shadow-card-bevel animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
Docker Disk Footprint
@@ -488,7 +579,7 @@ export default function ResourcesView() {
</Card>
{/* Quick Clean */}
{isAdmin && <Card className="col-span-1 md:col-span-2 border-border shadow-sm flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
{isAdmin && <Card className="col-span-1 md:col-span-2 border-border shadow-card-bevel flex flex-col animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-75">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground tracking-wide uppercase">
Quick Clean
@@ -540,7 +631,7 @@ export default function ResourcesView() {
{/* Resource Tabs */}
<Tabs
defaultValue="images"
className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-sm overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150"
className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-card-bevel overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150"
>
<div className="px-4 pt-3 pb-0 border-b border-glass-border bg-glass">
<TabsList className="grid grid-cols-4 w-full md:w-[680px] h-9 gap-1 p-0">
@@ -611,8 +702,8 @@ export default function ResourcesView() {
</div>
</TableCell>
<TableCell className="text-right">
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
<Trash2 className="w-3.5 h-3.5" />
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>}
</TableCell>
</TableRow>
@@ -658,8 +749,8 @@ export default function ResourcesView() {
<TableCell className="hidden md:table-cell text-xs text-muted-foreground truncate max-w-[300px]">{vol.Mountpoint}</TableCell>
<TableCell><ManagedBadge status={vol.managedStatus} managedBy={vol.managedBy} /></TableCell>
<TableCell className="text-right">
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
<Trash2 className="w-3.5 h-3.5" />
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>}
</TableCell>
</TableRow>
@@ -671,15 +762,67 @@ export default function ResourcesView() {
{/* Networks */}
<TabsContent value="networks" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<FilterToggle
value={networkFilter}
onChange={setNetworkFilter}
counts={{
all: networks.length,
managed: networks.filter(n => n.managedStatus === 'managed').length,
unmanaged: networks.filter(n => n.managedStatus !== 'managed').length,
}}
/>
<div className="flex items-center justify-between">
{networkViewMode === 'list' && (
<FilterToggle
value={networkFilter}
onChange={setNetworkFilter}
counts={{
all: networks.length,
managed: networks.filter(n => n.managedStatus === 'managed').length,
unmanaged: networks.filter(n => n.managedStatus !== 'managed').length,
}}
/>
)}
<div className="flex items-center gap-2 pr-3">
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
<button
onClick={() => setNetworkViewMode('list')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
List
</button>
<button
onClick={() => setNetworkViewMode('topology')}
className={cn(
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
>
Topology
{!isPro && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Pro</Badge>}
</button>
</div>
{isAdmin && networkViewMode === 'list' && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setShowCreateNetwork(true)}
>
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
Create Network
</Button>
)}
</div>
</div>
{networkViewMode === 'topology' ? (
<div className="p-4">
<ProGate featureName="Network Topology">
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView />
</Suspense>
</ProGate>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -688,7 +831,7 @@ export default function ResourcesView() {
<TableHead className="text-[11px]">Driver</TableHead>
<TableHead className="text-[11px]">Scope</TableHead>
<TableHead className="text-[11px]">Status</TableHead>
<TableHead className="text-right text-[11px]">Action</TableHead>
<TableHead className="text-right text-[11px]">Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading ? <TableSkeleton cols={6} /> : (
@@ -707,21 +850,33 @@ export default function ResourcesView() {
<TableCell><Badge variant="outline" className="text-[10px] h-5">{net.Scope}</Badge></TableCell>
<TableCell><ManagedBadge status={net.managedStatus} managedBy={net.managedBy} /></TableCell>
<TableCell className="text-right">
{isAdmin && <Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-red-500 hover:bg-red-500/10 transition-colors disabled:opacity-30"
disabled={net.managedStatus === 'system'}
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>}
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-foreground transition-colors"
disabled={isInspectLoading}
onClick={() => handleInspectNetwork(net.Id)}
>
{isInspectLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : <Eye className="w-3.5 h-3.5" strokeWidth={1.5} />}
</Button>
{isAdmin && <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30"
disabled={net.managedStatus === 'system'}
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
)}
</TabsContent>
{/* Unmanaged Containers */}
@@ -743,7 +898,7 @@ export default function ResourcesView() {
onClick={() => setBulkPurgeConfirm(true)}
disabled={selectedOrphans.length === 0 || isActioning}
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
{isActioning ? 'Purging...' : `Purge Selected (${selectedOrphans.length})`}
</Button>
</div>
@@ -761,7 +916,7 @@ export default function ResourcesView() {
{Object.entries(orphans).map(([project, containers], gi) => (
<div
key={project}
className="bg-card rounded-lg border shadow-sm overflow-hidden text-sm animate-in fade-in-0 slide-in-from-bottom-1 duration-200"
className="bg-card rounded-lg border shadow-card-bevel overflow-hidden text-sm animate-in fade-in-0 slide-in-from-bottom-1 duration-200"
style={{ animationDelay: `${gi * 60}ms` }}
>
{/* Project header */}
@@ -886,6 +1041,225 @@ export default function ResourcesView() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Create Network Dialog */}
<Dialog open={showCreateNetwork} onOpenChange={setShowCreateNetwork}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Network</DialogTitle>
<DialogDescription>Create a new Docker network for inter-container communication.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="net-name" className="text-xs font-medium">Name</Label>
<Input
id="net-name"
placeholder="my-network"
className="font-mono text-sm"
value={createNetworkForm.name}
onChange={e => setCreateNetworkForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="net-driver" className="text-xs font-medium">Driver</Label>
<Select
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>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="net-subnet" className="text-xs font-medium">Subnet <span className="text-muted-foreground">(optional)</span></Label>
<Input
id="net-subnet"
placeholder="172.20.0.0/16"
className="font-mono text-sm"
value={createNetworkForm.subnet}
onChange={e => setCreateNetworkForm(f => ({ ...f, subnet: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="net-gateway" className="text-xs font-medium">Gateway <span className="text-muted-foreground">(optional)</span></Label>
<Input
id="net-gateway"
placeholder="172.20.0.1"
className="font-mono text-sm"
value={createNetworkForm.gateway}
onChange={e => setCreateNetworkForm(f => ({ ...f, gateway: e.target.value }))}
/>
</div>
</div>
<div className="flex items-center gap-6 pt-1">
<div className="flex items-center gap-2">
<Switch
id="net-internal"
checked={createNetworkForm.internal}
onCheckedChange={v => setCreateNetworkForm(f => ({ ...f, internal: v }))}
/>
<Label htmlFor="net-internal" className="text-xs cursor-pointer">Internal <span className="text-muted-foreground">(no external access)</span></Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="net-attachable"
checked={createNetworkForm.attachable}
onCheckedChange={v => setCreateNetworkForm(f => ({ ...f, attachable: v }))}
/>
<Label htmlFor="net-attachable" className="text-xs cursor-pointer">Attachable</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setShowCreateNetwork(false)} disabled={isCreatingNetwork}>Cancel</Button>
<Button size="sm" onClick={handleCreateNetwork} disabled={!createNetworkForm.name.trim() || isCreatingNetwork}>
{isCreatingNetwork ? 'Creating...' : 'Create Network'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Network Inspect Sheet */}
<Sheet open={!!inspectNetwork} onOpenChange={open => !open && setInspectNetwork(null)}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Network className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
{inspectNetwork?.Name}
</SheetTitle>
</SheetHeader>
{inspectNetwork && (
<div className="space-y-6 mt-6">
{/* Overview */}
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Overview</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-xs text-muted-foreground">ID</span>
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
{inspectNetwork.Id.substring(0, 12)}
<button
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { navigator.clipboard.writeText(inspectNetwork.Id); toast.success('ID copied'); }}
>
<Copy className="w-3 h-3" strokeWidth={1.5} />
</button>
</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Driver</span>
<span className="text-xs mt-0.5 block"><Badge variant="outline" className="text-[10px] h-5">{inspectNetwork.Driver}</Badge></span>
</div>
<div>
<span className="text-xs text-muted-foreground">Scope</span>
<span className="text-xs mt-0.5 block"><Badge variant="outline" className="text-[10px] h-5">{inspectNetwork.Scope}</Badge></span>
</div>
<div>
<span className="text-xs text-muted-foreground">Created</span>
<p className="text-xs mt-0.5">{new Date(inspectNetwork.Created).toLocaleString()}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Internal</span>
<p className="text-xs mt-0.5">{inspectNetwork.Internal ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Attachable</span>
<p className="text-xs mt-0.5">{inspectNetwork.Attachable ? 'Yes' : 'No'}</p>
</div>
</div>
</div>
{/* IPAM Config */}
{inspectNetwork.IPAM?.Config?.length > 0 && (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">IPAM Configuration</h4>
<div className="space-y-2">
{inspectNetwork.IPAM.Config.map((cfg, i) => (
<div key={i} className="rounded-lg border border-card-border bg-card p-3 shadow-card-bevel space-y-1.5">
{cfg.Subnet && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Subnet</span>
<span className="font-mono text-xs tabular-nums">{cfg.Subnet}</span>
</div>
)}
{cfg.Gateway && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Gateway</span>
<span className="font-mono text-xs tabular-nums">{cfg.Gateway}</span>
</div>
)}
{cfg.IPRange && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">IP Range</span>
<span className="font-mono text-xs tabular-nums">{cfg.IPRange}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Connected Containers */}
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Connected Containers ({Object.keys(inspectNetwork.Containers || {}).length})
</h4>
{Object.keys(inspectNetwork.Containers || {}).length === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">No containers connected to this network.</p>
) : (
<div className="space-y-2">
{Object.entries(inspectNetwork.Containers).map(([id, container]) => (
<div key={id} className="rounded-lg border border-card-border border-t-card-border-top bg-card p-3 shadow-card-bevel space-y-1.5">
<div className="flex items-center gap-2">
<Container className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
<span className="text-sm font-medium truncate">{container.Name}</span>
</div>
<div className="grid grid-cols-2 gap-2 pl-5.5">
<div>
<span className="text-[10px] text-muted-foreground">IPv4</span>
<p className="font-mono text-xs tabular-nums">{container.IPv4Address || '—'}</p>
</div>
<div>
<span className="text-[10px] text-muted-foreground">MAC</span>
<p className="font-mono text-xs tabular-nums">{container.MacAddress || '—'}</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Labels */}
{inspectNetwork.Labels && Object.keys(inspectNetwork.Labels).length > 0 && (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Labels</h4>
<div className="rounded-lg border border-card-border bg-card shadow-card-bevel overflow-hidden">
<Table>
<TableBody>
{Object.entries(inspectNetwork.Labels).map(([key, val]) => (
<TableRow key={key} className="hover:bg-muted/30">
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}