mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +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:
@@ -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