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 { 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 { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { formatBytes } from '@/lib/utils'; import { cn } from '@/lib/utils'; // ── 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'; } 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; } 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 [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); // 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); } }; // 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 */} n.managedStatus === 'managed').length, unmanaged: networks.filter(n => n.managedStatus !== 'managed').length, }} /> ID Name Driver Scope Status Action {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'}
); }