import { useState, useEffect, useRef } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from "@/components/ui/tabs"; import { springs } from '@/lib/motion'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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 { Combobox } from "@/components/ui/combobox"; 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, 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 { CapabilityGate } from './CapabilityGate'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; import { lazy, Suspense } from 'react'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); // ── Interfaces ───────────────────────────────────────────────────────────────── interface UsageData { reclaimableImages: number; reclaimableContainers: number; reclaimableVolumes: number; managedImageBytes: number; unmanagedImageBytes: number; managedVolumeBytes: number; unmanagedVolumeBytes: number; } interface DockerImage { Id: string; RepoTags: string[]; Size: number; Containers: number; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; } interface DockerVolume { Name: string; Driver: string; Mountpoint: string; managedBy: string | null; managedStatus: 'managed' | 'unmanaged'; } 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; Scope: string; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'system'; } interface UnmanagedContainer { Id: string; Names: string[]; State: string; Status: string; Image: string; } interface NetworkInspectData { Id: string; Name: string; Created: string; Scope: string; Driver: string; Internal: boolean; Attachable: boolean; Labels: Record; IPAM: { Driver: string; Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>; }; Containers: Record; Options: Record; } type ResourceFilter = 'all' | 'managed' | 'unmanaged'; type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; type PruneScope = 'managed' | 'all'; // ── Disk Footprint Widget ────────────────────────────────────────────────────── interface FootprintWidgetProps { usage: UsageData; onFilter: (filter: ResourceFilter) => void; } function FootprintWidget({ usage, onFilter }: FootprintWidgetProps) { const [animated, setAnimated] = useState(false); const ref = useRef(null); const managedBytes = usage.managedImageBytes + usage.managedVolumeBytes; const unmanagedBytes = usage.unmanagedImageBytes + usage.unmanagedVolumeBytes; const reclaimable = usage.reclaimableImages; const total = managedBytes + unmanagedBytes + reclaimable; useEffect(() => { // Trigger bar animation on mount const t = setTimeout(() => setAnimated(true), 60); return () => clearTimeout(t); }, []); if (total === 0) { return (
No disk usage data available.
); } const pct = (n: number) => `${Math.max(0, (n / total) * 100).toFixed(1)}%`; const segments: { bytes: number; color: string; label: string; filter: ResourceFilter | null; hoverClass: string }[] = [ { bytes: managedBytes, color: 'bg-success', label: 'Sencho Managed', filter: 'managed', hoverClass: 'hover:bg-success/80' }, { bytes: unmanagedBytes, color: 'bg-warning', label: 'External Projects', filter: 'unmanaged', hoverClass: 'hover:bg-warning/80' }, { bytes: reclaimable, color: 'bg-muted-foreground/20', label: 'Reclaimable', filter: null, hoverClass: '' }, ]; return (
{/* Stacked bar */}
{segments.map((seg, i) => seg.bytes > 0 ? (
seg.filter && onFilter(seg.filter)} /> ) : null )}
{/* Legend */}
{segments.map((seg, i) => seg.bytes > 0 ? ( ) : null )}
); } // ── Filter Toggle - Segmented Control ───────────────────────────────────────── interface FilterToggleProps { value: ResourceFilter; onChange: (v: ResourceFilter) => void; counts: { all: number; managed: number; unmanaged: number }; } function FilterToggle({ value, onChange, counts }: FilterToggleProps) { const options: { key: ResourceFilter; label: string; count: number }[] = [ { key: 'all', label: 'All', count: counts.all }, { key: 'managed', label: 'Managed', count: counts.managed }, { key: 'unmanaged', label: 'External', count: counts.unmanaged }, ]; return (
{options.map(({ key, label, count }) => ( ))}
); } // ── Managed Status Badge ─────────────────────────────────────────────────────── function ManagedBadge({ status, managedBy }: { status: 'managed' | 'unmanaged' | 'unused' | 'system'; managedBy: string | null; }) { if (status === 'managed') { return ( {managedBy} ); } if (status === 'unmanaged') { return ( External ); } if (status === 'system') { return ( System ); } return null; } // ── Quick Clean Prune Button ─────────────────────────────────────────────────── interface PruneButtonProps { target: PruneTarget; icon: React.ReactNode; label: string; accentClass: string; onManaged: () => void; onAll: () => void; } function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: PruneButtonProps) { return (
{target !== 'containers' && ( All Docker (includes external) )}
); } // ── Table Skeleton ───────────────────────────────────────────────────────────── function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { return ( {Array.from({ length: rows }).map((_, r) => ( {Array.from({ length: cols }).map((_, c) => ( ))} ))} ); } // ── Main Component ───────────────────────────────────────────────────────────── export default function ResourcesView() { const { isAdmin } = useAuth(); const { activeNode } = useNodes(); const { isPro } = useLicense(); const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list'); const [usage, setUsage] = useState(null); const [images, setImages] = useState([]); const [volumes, setVolumes] = useState([]); const [networks, setNetworks] = useState([]); const [orphans, setOrphans] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [isActioning, setIsActioning] = useState(false); // Filter state const [imageFilter, setImageFilter] = useState('all'); const [volumeFilter, setVolumeFilter] = useState('all'); const [networkFilter, setNetworkFilter] = useState('all'); // Modal states const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks'; id: string; name?: string } | null>(null); // Network create/inspect state const [showCreateNetwork, setShowCreateNetwork] = useState(false); const [createNetworkForm, setCreateNetworkForm] = useState<{ name: string; driver: NetworkDriver; subnet: string; gateway: string; internal: boolean; attachable: boolean }>({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); const [isCreatingNetwork, setIsCreatingNetwork] = useState(false); const [inspectNetwork, setInspectNetwork] = useState(null); const [isInspectLoading, setIsInspectLoading] = useState(false); // Unmanaged container state const [selectedOrphans, setSelectedOrphans] = useState([]); const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); const fetchAllData = async () => { setIsLoading(true); try { const [usageRes, resourcesRes, orphansRes] = await Promise.all([ apiFetch('/system/docker-df'), apiFetch('/system/resources'), apiFetch('/system/orphans'), ]); setUsage(await usageRes.json()); const resources = await resourcesRes.json(); setImages(resources.images ?? []); setVolumes(resources.volumes ?? []); setNetworks(resources.networks ?? []); setOrphans(await orphansRes.json()); setSelectedOrphans([]); } catch (err) { console.error('Failed to fetch data', err); toast.error('Failed to load resources data'); } finally { setIsLoading(false); } }; useEffect(() => { fetchAllData(); }, [activeNode]); const handlePrune = async () => { if (!confirmPrune) return; setIsActioning(true); try { const res = await apiFetch('/system/prune/system', { method: 'POST', body: JSON.stringify({ target: confirmPrune.target, scope: confirmPrune.scope }) }); const data = await res.json(); const scopeLabel = confirmPrune.scope === 'managed' ? 'Sencho-managed' : 'all'; toast.success( data.reclaimedBytes !== undefined ? `Pruned ${scopeLabel} ${confirmPrune.target}. Reclaimed ${formatBytes(data.reclaimedBytes)}.` : `Pruned ${scopeLabel} ${confirmPrune.target}.` ); await fetchAllData(); } catch { toast.error(confirmPrune ? `Failed to prune ${confirmPrune.target}` : 'Prune failed'); } finally { setIsActioning(false); setConfirmPrune(null); } }; const handleDelete = async () => { if (!confirmDelete) return; setIsActioning(true); try { const res = await apiFetch(`/system/${confirmDelete.type}/delete`, { method: 'POST', body: JSON.stringify({ id: confirmDelete.id }) }); if (!res.ok) throw new Error(); toast.success(`Deleted ${confirmDelete.type.slice(0, -1)}`); await fetchAllData(); } catch { toast.error(`Failed to delete ${confirmDelete.type.slice(0, -1)}`); } finally { setIsActioning(false); setConfirmDelete(null); } }; const toggleOrphan = (id: string) => setSelectedOrphans(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); const totalOrphansCount = Object.values(orphans).flat().length; const selectAllOrphans = () => { const allIds = Object.values(orphans).flat().map(c => c.Id); setSelectedOrphans(selectedOrphans.length === allIds.length ? [] : allIds); }; const handlePurgeOrphans = async () => { setIsActioning(true); try { const res = await apiFetch('/system/prune/orphans', { method: 'POST', body: JSON.stringify({ containerIds: selectedOrphans }) }); if (!res.ok) throw new Error(); toast.success(`Purged ${selectedOrphans.length} unmanaged container(s)`); setBulkPurgeConfirm(false); await fetchAllData(); } catch { toast.error('Failed to purge selected containers.'); } finally { setIsActioning(false); } }; const handleCreateNetwork = async () => { setIsCreatingNetwork(true); try { const res = await apiFetch('/system/networks', { method: 'POST', body: JSON.stringify({ name: createNetworkForm.name, driver: createNetworkForm.driver, subnet: createNetworkForm.subnet || undefined, gateway: createNetworkForm.gateway || undefined, internal: createNetworkForm.internal, attachable: createNetworkForm.attachable, }), }); if (!res.ok) { const data = await res.json(); throw new Error(data?.error || 'Failed to create network'); } toast.success(`Network "${createNetworkForm.name}" created`); setShowCreateNetwork(false); setCreateNetworkForm({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false }); await fetchAllData(); } catch (error) { const err = error as Record; toast.error(String(err?.message || err?.error || 'Something went wrong.')); } finally { setIsCreatingNetwork(false); } }; const handleInspectNetwork = async (id: string) => { setIsInspectLoading(true); try { const res = await apiFetch(`/system/networks/${id}`); if (!res.ok) throw new Error('Failed to inspect network'); const data = await res.json(); setInspectNetwork(data); } catch (error) { const err = error as Record; toast.error(String(err?.message || err?.error || 'Something went wrong.')); } finally { setIsInspectLoading(false); } }; // Derived filtered lists const filteredImages = images.filter(img => imageFilter === 'managed' ? img.managedStatus === 'managed' : imageFilter === 'unmanaged' ? img.managedStatus !== 'managed' : true ); const filteredVolumes = volumes.filter(vol => volumeFilter === 'managed' ? vol.managedStatus === 'managed' : volumeFilter === 'unmanaged' ? vol.managedStatus !== 'managed' : true ); const filteredNetworks = networks.filter(net => networkFilter === 'managed' ? net.managedStatus === 'managed' : networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true ); const handleFootprintFilter = (filter: ResourceFilter) => { setImageFilter(filter); setVolumeFilter(filter); }; return (
{/* Header */}

Resources Hub

{activeNode?.type === 'remote' && ( - {activeNode.name} )}
{/* Top row: Footprint + Quick Clean */}
{/* Disk Footprint */} Docker Disk Footprint Click a segment to filter the tabs below {usage ? ( ) : (
)}
{/* Quick Clean */} {isAdmin && Quick Clean Primary actions target Sencho-managed resources only. Use for all-Docker operations.
} label="Prune Unused Images" accentClass="text-blue-500" onManaged={() => setConfirmPrune({ target: 'images', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'images', scope: 'all' })} /> } label="Prune Unused Volumes" accentClass="text-purple-500" onManaged={() => setConfirmPrune({ target: 'volumes', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'volumes', scope: 'all' })} /> } label="Prune Dead Networks" accentClass="text-success" onManaged={() => setConfirmPrune({ target: 'networks', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'networks', scope: 'all' })} /> } label="Purge Unmanaged Containers" accentClass="text-warning" onManaged={() => setConfirmPrune({ target: 'containers', scope: 'managed' })} onAll={() => setConfirmPrune({ target: 'containers', scope: 'all' })} />
}
{/* Resource Tabs */}
{(['images', 'volumes', 'networks'] as const).map(tab => ( {tab} ))} Unmanaged {totalOrphansCount > 0 && ( {totalOrphansCount} )}
{/* Images */} i.managedStatus === 'managed').length, unmanaged: images.filter(i => i.managedStatus !== 'managed').length, }} /> ID Repository:Tag Size Status Action {isLoading ? : ( {filteredImages.length === 0 ? ( No images found. ) : filteredImages.map((img, i) => ( {img.Id.split(':')[1]?.substring(0, 12)} {img.RepoTags?.[0] || ':'} {formatBytes(img.Size)}
0 ? "default" : "secondary"} className="text-[10px] h-5"> {img.Containers > 0 ? "In Use" : "Unused"}
{isAdmin && }
))}
)}
{/* Volumes */} v.managedStatus === 'managed').length, unmanaged: volumes.filter(v => v.managedStatus !== 'managed').length, }} /> Name Driver Mountpoint Status Action {isLoading ? : ( {filteredVolumes.length === 0 ? ( No volumes found. ) : filteredVolumes.map((vol, i) => ( {vol.Name} {vol.Driver} {vol.Mountpoint} {isAdmin && } ))} )}
{/* Networks */}
{networkViewMode === 'list' && ( n.managedStatus === 'managed').length, unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, }} /> )}
{isAdmin && networkViewMode === 'list' && ( )}
{networkViewMode === 'topology' ? (
Loading topology...
}>
) : ( ID Name Driver Scope Status Actions {isLoading ? : ( {filteredNetworks.length === 0 ? ( No networks found. ) : filteredNetworks.map((net, i) => ( {net.Id.substring(0, 12)} {net.Name} {net.Driver} {net.Scope}
{isAdmin && }
))}
)}
)} {/* Unmanaged Containers */}
0} className="rounded border-border focus:ring-ring h-4 w-4 accent-foreground" /> Select all
{totalOrphansCount === 0 ? (

No unmanaged containers

All running containers are managed by Sencho.

) : (
{Object.entries(orphans).map(([project, containers], gi) => (
{/* Project header */}
External Project: {project} {containers.length} container{containers.length !== 1 ? 's' : ''}
{containers.map((container: UnmanagedContainer) => (
toggleOrphan(container.Id)} className="rounded border-border h-4 w-4 accent-foreground" />
{container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)} {container.State}
{container.Image}
))}
))}
)}
{/* ── Dialogs ── */} {/* Prune Confirm */} !open && setConfirmPrune(null)}> {confirmPrune?.scope === 'all' ? ( <> Prune All Docker {confirmPrune?.target} This will prune all unused {confirmPrune?.target} from the Docker daemon - including those from external projects not managed by Sencho. This cannot be undone. ) : ( <> Prune Sencho-Managed {confirmPrune?.target} Only unused {confirmPrune?.target} belonging to your Sencho stacks will be removed. External Docker resources are not affected. )} Cancel {isActioning ? 'Pruning...' : confirmPrune?.scope === 'all' ? 'Prune All' : 'Prune'} {/* Delete Confirm */} !open && setConfirmDelete(null)}> Delete {confirmDelete?.type.slice(0, -1)} Permanently delete {confirmDelete?.name || confirmDelete?.id.substring(0, 12)}? This cannot be undone. Cancel {isActioning ? 'Deleting...' : 'Delete'} {/* Unmanaged Container Purge Confirm */} Purge Selected Unmanaged Containers Permanently remove {selectedOrphans.length} container{selectedOrphans.length !== 1 ? 's' : ''} from external projects? This will force-stop and remove them. This cannot be undone. Cancel {isActioning ? 'Purging...' : 'Purge'} {/* Create Network Dialog */} Create Network Create a new Docker network for inter-container communication.
setCreateNetworkForm(f => ({ ...f, name: e.target.value }))} />
({ value: d, label: d }))} value={createNetworkForm.driver} onValueChange={v => setCreateNetworkForm(f => ({ ...f, driver: (v || 'bridge') as NetworkDriver }))} placeholder="Select driver..." searchPlaceholder="Search drivers..." emptyText="No matching driver." />
setCreateNetworkForm(f => ({ ...f, subnet: e.target.value }))} />
setCreateNetworkForm(f => ({ ...f, gateway: e.target.value }))} />
setCreateNetworkForm(f => ({ ...f, internal: v }))} />
setCreateNetworkForm(f => ({ ...f, attachable: v }))} />
{/* Network Inspect Sheet */} !open && setInspectNetwork(null)}> {inspectNetwork?.Name} {inspectNetwork && (
{/* Overview */}

Overview

ID

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

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

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

Internal

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

Attachable

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

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

IPAM Configuration

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

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

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

No containers connected to this network.

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

{container.IPv4Address || '—'}

MAC

{container.MacAddress || '—'}

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

Labels

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