import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; import { Trash2, HardDrive, Network, PackageMinus, MonitorX, PieChart as ChartIcon } from 'lucide-react'; import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; import { formatBytes } from '@/lib/utils'; interface UsageData { reclaimableImages: number; reclaimableContainers: number; reclaimableVolumes: number; } interface DockerImage { Id: string; RepoTags: string[]; Size: number; Containers: number; } interface DockerVolume { Name: string; Driver: string; Mountpoint: string; } interface DockerNetwork { Id: string; Name: string; Driver: string; Scope: string; } interface OrphanContainer { Id: string; Names: string[]; State: string; Status: string; Image: string; } export default function ResourcesView() { 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); // Modal states const [confirmPruneType, setConfirmPruneType] = useState<'containers' | 'images' | 'networks' | 'volumes' | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks', id: string, name?: string } | null>(null); // Ghost hunting state const [selectedOrphans, setSelectedOrphans] = useState([]); const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); const fetchAllData = async () => { setIsLoading(true); try { const [usageRes, imagesRes, volumesRes, networksRes, orphansRes] = await Promise.all([ apiFetch('/system/docker-df'), apiFetch('/system/images'), apiFetch('/system/volumes'), apiFetch('/system/networks'), apiFetch('/system/orphans'), ]); setUsage(await usageRes.json()); setImages(await imagesRes.json()); setVolumes(await volumesRes.json()); setNetworks(await networksRes.json()); setOrphans(await orphansRes.json()); setSelectedOrphans([]); } catch (error) { console.error('Failed to fetch data', error); toast.error('Failed to load resources data'); } finally { setIsLoading(false); } }; useEffect(() => { fetchAllData(); }, []); const handlePrune = async () => { if (!confirmPruneType) return; setIsActioning(true); try { const res = await apiFetch('/system/prune/system', { method: 'POST', body: JSON.stringify({ target: confirmPruneType }) }); const data = await res.json(); if (data.reclaimedBytes !== undefined) { toast.success(`Pruned ${confirmPruneType}. Reclaimed ${formatBytes(data.reclaimedBytes)}.`); } else { toast.success(`Pruned ${confirmPruneType}.`); } await fetchAllData(); } catch (error) { toast.error(`Failed to prune ${confirmPruneType}`); } finally { setIsActioning(false); setConfirmPruneType(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 (error) { toast.error(`Failed to delete ${confirmDelete.type.slice(0, -1)}`); } finally { setIsActioning(false); setConfirmDelete(null); } }; const toggleOrphanSelection = (containerId: string) => { setSelectedOrphans(prev => prev.includes(containerId) ? prev.filter(id => id !== containerId) : [...prev, containerId]); }; const totalOrphansCount = Object.values(orphans).flat().length; const selectAllOrphans = () => { const allIds = Object.values(orphans).flat().map(c => c.Id); if (selectedOrphans.length === allIds.length) setSelectedOrphans([]); else setSelectedOrphans(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} ghost container(s)`); setBulkPurgeConfirm(false); await fetchAllData(); } catch (error) { toast.error('Failed to purge selected containers.'); } finally { setIsActioning(false); } }; const chartData = [ { name: 'Unused Images', value: usage?.reclaimableImages || 0, color: '#3b82f6' }, { name: 'Unused Volumes', value: usage?.reclaimableVolumes || 0, color: '#a855f7' }, { name: 'Stopped Containers', value: usage?.reclaimableContainers || 0, color: '#f97316' }, ]; const totalReclaimable = chartData.reduce((acc, curr) => acc + curr.value, 0); const CustomTooltip = ({ active, payload }: any) => { if (active && payload && payload.length) { return (

{payload[0].name}

{formatBytes(payload[0].value)}

); } return null; }; if (isLoading && !usage) { return
Loading resources...
; } return (

Resources Hub

Reclaimable Space {totalReclaimable > 0 ? ( {chartData.map((entry, index) => ( ))} } /> ) : (
Your system is clean
)}
{chartData.filter(d => d.value > 0).map((entry, index) => (
{entry.name}
))}
Quick Clean Free up disk space with bulk prune operations
Images Volumes Networks Ghost Containers {totalOrphansCount > 0 && ( {totalOrphansCount} )}
ID Repository:Tag Size Status Action {images.length === 0 ? ( No images found. ) : images.map((img) => ( {img.Id.split(':')[1]?.substring(0, 12)} {img.RepoTags?.[0] || ':'} {formatBytes(img.Size)} 0 ? "default" : "secondary"}> {img.Containers > 0 ? "In Use" : "Unused"} ))}
Name Driver Mountpoint Action {volumes.length === 0 ? ( No volumes found. ) : volumes.map((vol) => ( {vol.Name} {vol.Driver} {vol.Mountpoint} ))}
ID Name Driver Scope Action {networks.length === 0 ? ( No networks found. ) : networks.map((net) => ( {net.Id.substring(0, 12)} {net.Name} {net.Driver} {net.Scope} ))}
0} className="rounded border-gray-300 focus:ring-primary h-4 w-4 ml-2" /> Select All
{totalOrphansCount === 0 ? (

No orphaned containers detected.

Your system is clean!

) : (
{Object.entries(orphans).map(([project, containers]) => (
Project: {project}
{containers.map((container: OrphanContainer) => (
toggleOrphanSelection(container.Id)} className="rounded border-gray-300 h-4 w-4" />
{container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12)} {container.State}
Image: {container.Image}
))}
))}
)}
{/* Prune Bulk Action Confirm Dialog */} !open && setConfirmPruneType(null)}> Prune {confirmPruneType} Are you sure you want to prune all unused {confirmPruneType}? This action cannot be undone and will permanently free up disk space. Cancel {isActioning ? 'Pruning...' : 'Prune'} {/* Granular Delete Confirm Dialog */} !open && setConfirmDelete(null)}> Delete {confirmDelete?.type.slice(0, -1)} Are you sure you want to delete {confirmDelete?.name || confirmDelete?.id.substring(0, 12)}? This cannot be undone. Cancel {isActioning ? 'Deleting...' : 'Delete'} {/* Ghost Container Purge Confirm Dialog */} Purge Selected Ghosts Are you sure you want to permanently remove the {selectedOrphans.length} selected orphan container(s)? Cancel {isActioning ? 'Purging...' : 'Purge'}
); }