mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(resources): add network management with create, inspect, and topology visualization (#335)
* feat(resources): add network management with create, inspect, and topology visualization
Add full Docker network CRUD: create networks with custom drivers, subnets,
and IPAM config; inspect network details including connected containers and
IP addresses; interactive topology graph visualization (Pro-gated to
Skipper/Admiral tiers). Includes backend routes, DockerController methods,
unit tests, documentation with screenshots, and updated changelog.
* refactor(resources): address code review findings for network management
- Add batch GET /api/system/networks/topology endpoint to eliminate N+1
HTTP calls from the topology view
- Export DockerNetwork type from ResourcesView, remove duplicate in
NetworkTopologyView
- Wire up isInspectLoading state to Eye button (spinner + disabled)
- Remove unnecessary wrapper div around FilterToggle
- NetworkTopologyView is now self-contained (fetches from batch endpoint,
no longer needs networks prop)
* fix(resources): align pre-existing UI with design system standards
- Replace hardcoded red-500 on image/volume delete buttons with
text-destructive/60 hover:bg-destructive tokens
- Replace shadow-sm with shadow-card-bevel on all cards (Disk Footprint,
Quick Clean, Resource Tabs, Unmanaged Containers)
- Add strokeWidth={1.5} to all action button Trash2 icons
- Type network drivers as union type instead of raw strings (backend
NetworkDriver type + frontend NETWORK_DRIVERS constant)
* fix(resources): add generic type args to useNodesState/useEdgesState
Fixes TS2345 build error in CI where tsc -b (strict mode via
tsconfig.app.json) infers never[] from untyped empty array literals
passed to React Flow hooks.
* fix(resources): replace explicit any in catch blocks with unknown narrowing
ESLint no-explicit-any errors in CI for three catch blocks added by the
network management feature.
This commit is contained in:
@@ -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
@@ -5,7 +5,7 @@ import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import DockerController, { globalDockerNetwork } from './services/DockerController';
|
||||
import DockerController, { globalDockerNetwork, type CreateNetworkOptions } from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
import { ComposeService } from './services/ComposeService';
|
||||
import bcrypt from 'bcrypt';
|
||||
@@ -4405,6 +4405,75 @@ app.post('/api/system/networks/delete', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks/topology', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const allNetworks = await dockerController.getNetworks();
|
||||
const userNetworks = allNetworks.filter((n: any) => n.managedStatus !== 'system');
|
||||
|
||||
const inspected = await Promise.all(
|
||||
userNetworks.map(async (net: any) => {
|
||||
try {
|
||||
const detail = await dockerController.inspectNetwork(net.Id);
|
||||
const containers = Object.entries(detail.Containers || {}).map(([id, c]: [string, any]) => ({
|
||||
id,
|
||||
name: c.Name,
|
||||
ip: c.IPv4Address,
|
||||
}));
|
||||
return { ...net, containers };
|
||||
} catch {
|
||||
return { ...net, containers: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json(inspected);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch network topology:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to fetch network topology' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/system/networks/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
if (!id) return res.status(400).json({ error: 'Network ID is required' });
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const networkInfo = await dockerController.inspectNetwork(id);
|
||||
res.json(networkInfo);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to inspect network:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to inspect network' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/networks', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, driver, subnet, gateway, labels, internal, attachable } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Network name is required' });
|
||||
|
||||
const options: CreateNetworkOptions = { Name: name };
|
||||
|
||||
if (driver) options.Driver = driver;
|
||||
if (subnet || gateway) {
|
||||
options.IPAM = { Config: [{}] };
|
||||
if (subnet) options.IPAM.Config[0].Subnet = subnet;
|
||||
if (gateway) options.IPAM.Config[0].Gateway = gateway;
|
||||
}
|
||||
if (labels && typeof labels === 'object') options.Labels = labels;
|
||||
if (internal) options.Internal = true;
|
||||
if (attachable) options.Attachable = true;
|
||||
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const network = await dockerController.createNetwork(options);
|
||||
res.status(201).json({ success: true, message: 'Network created', id: network.id });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create network:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create network' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- App Templates Routes ---
|
||||
|
||||
app.get('/api/templates', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -37,6 +37,17 @@ export interface ClassifiedNetwork {
|
||||
managedStatus: 'managed' | 'unmanaged' | 'system';
|
||||
}
|
||||
|
||||
export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
|
||||
|
||||
export interface CreateNetworkOptions {
|
||||
Name: string;
|
||||
Driver?: NetworkDriver;
|
||||
IPAM?: { Config: Array<{ Subnet?: string; Gateway?: string }> };
|
||||
Labels?: Record<string, string>;
|
||||
Internal?: boolean;
|
||||
Attachable?: boolean;
|
||||
}
|
||||
|
||||
class DockerController {
|
||||
private docker: Docker;
|
||||
private nodeId: number;
|
||||
@@ -331,6 +342,18 @@ class DockerController {
|
||||
await network.remove({ force: true });
|
||||
}
|
||||
|
||||
public async inspectNetwork(id: string) {
|
||||
const network = this.docker.getNetwork(id);
|
||||
return await network.inspect();
|
||||
}
|
||||
|
||||
public async createNetwork(options: CreateNetworkOptions) {
|
||||
if (!options.Name || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(options.Name)) {
|
||||
throw new Error('Invalid network name. Use alphanumeric characters, hyphens, underscores, and dots.');
|
||||
}
|
||||
return await this.docker.createNetwork(options);
|
||||
}
|
||||
|
||||
public async getRunningContainers() {
|
||||
const containers = await this.docker.listContainers({ all: false });
|
||||
return this.validateApiData<any[]>(containers);
|
||||
|
||||
@@ -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 |
Generated
+167
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user