diff --git a/backend/src/index.ts b/backend/src/index.ts index be8b7334..ea2302d0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -811,33 +811,32 @@ app.post('/api/system/prune/orphans', async (req: Request, res: Response) => { app.post('/api/system/prune/system', async (req: Request, res: Response) => { try { - const { target } = req.body; // 'containers', 'images', 'networks' - let command = ''; - - if (target === 'containers') { - command = 'docker container prune -f'; - } else if (target === 'images') { - command = 'docker image prune -a -f'; - } else if (target === 'networks') { - command = 'docker network prune -f'; - } else { + const { target } = req.body; // 'containers', 'images', 'networks', 'volumes' + if (!['containers', 'images', 'networks', 'volumes'].includes(target)) { return res.status(400).json({ error: 'Invalid prune target' }); } - const { stdout, stderr } = await execAsync(command, { - env: { - ...process.env, - PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' - } - }); + const dockerController = DockerController.getInstance(); + const result = await dockerController.pruneSystem(target); - res.json({ message: 'Prune completed', stdout, stderr }); + res.json({ message: 'Prune completed', ...result }); } catch (error: any) { console.error('System prune error:', error); res.status(500).json({ error: 'System prune failed', details: error.message }); } }); +app.get('/api/system/docker-df', async (req: Request, res: Response) => { + try { + const dockerController = DockerController.getInstance(); + const df = await dockerController.getDiskUsage(); + res.json(df); + } catch (error) { + console.error('Failed to fetch docker disk usage:', error); + res.status(500).json({ error: 'Failed to fetch docker disk usage' }); + } +}); + // Serve static files in production (for Docker deployment) if (process.env.NODE_ENV === 'production') { app.use(express.static('public')); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index d64e1f5a..4d7fcee8 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -24,6 +24,65 @@ class DockerController { return DockerController.instance; } + public async getDiskUsage() { + const df = await this.docker.df(); + + const calculateReclaimableContainers = (items: any[]) => { + if (!items || !Array.isArray(items)) return 0; + return items.filter(i => i.State !== 'running').reduce((acc, item) => { + let size = item.SizeRw || item.SizeRootFs || 0; + if (item.UsageData && typeof item.UsageData.Size === 'number') { + size = item.UsageData.Size; + } + return acc + size; + }, 0); + }; + + const calculateReclaimableImages = (items: any[]) => { + if (!items || !Array.isArray(items)) return 0; + return items.filter(i => i.Containers === 0).reduce((acc, item) => { + let size = item.VirtualSize || item.Size || item.SharedSize || 0; + if (item.UsageData && typeof item.UsageData.Size === 'number') { + size = item.UsageData.Size; + } + return acc + size; + }, 0); + }; + + const calculateReclaimableVolumes = (items: any[]) => { + if (!items || !Array.isArray(items)) return 0; + return items.filter(i => i.UsageData?.RefCount === 0).reduce((acc, item) => { + let size = item.UsageData?.Size || 0; + return acc + size; + }, 0); + }; + + return { + reclaimableImages: df.Images ? calculateReclaimableImages(df.Images) : 0, + reclaimableContainers: df.Containers ? calculateReclaimableContainers(df.Containers) : 0, + reclaimableVolumes: df.Volumes ? calculateReclaimableVolumes(df.Volumes) : 0, + }; + } + + public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes') { + let result: any = {}; + if (target === 'containers') { + result = await this.docker.pruneContainers(); + } else if (target === 'images') { + // Remove all unused images, not just dangling ones + result = await this.docker.pruneImages({ filters: { dangling: { 'false': true } } }); + } else if (target === 'networks') { + result = await this.docker.pruneNetworks(); + } else if (target === 'volumes') { + result = await this.docker.pruneVolumes(); + } + + return { + success: true, + reclaimedBytes: result?.SpaceReclaimed || 0 + }; + } + public async getRunningContainers() { const containers = await this.docker.listContainers({ all: false }); return containers; diff --git a/frontend/src/components/MaintenanceModal.tsx b/frontend/src/components/MaintenanceModal.tsx index c889db9b..734b67da 100644 --- a/frontend/src/components/MaintenanceModal.tsx +++ b/frontend/src/components/MaintenanceModal.tsx @@ -21,7 +21,8 @@ import { Button } from './ui/button'; import { Badge } from './ui/badge'; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; -import { Trash2, AlertTriangle, MonitorX, PackageMinus, Network } from 'lucide-react'; +import { Trash2, AlertTriangle, MonitorX, PackageMinus, Network, HardDrive } from 'lucide-react'; +import { formatBytes } from '@/lib/utils'; interface MaintenanceModalProps { isOpen: boolean; @@ -36,9 +37,19 @@ interface ContainerInfo { Image: string; } +interface DockerUsage { + reclaimableImages: number; + reclaimableContainers: number; + reclaimableVolumes: number; +} + export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalProps) { const [activeTab, setActiveTab] = useState<'ghosts' | 'system'>('ghosts'); + // Docker Usage State + const [dockerUsage, setDockerUsage] = useState(null); + const [isLoadingUsage, setIsLoadingUsage] = useState(false); + // Ghost Hunter state const [orphans, setOrphans] = useState>({}); const [isLoadingOrphans, setIsLoadingOrphans] = useState(false); @@ -52,16 +63,32 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr // Confirm Modals state const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false); const [pruneConfirmOpen, setPruneConfirmOpen] = useState(false); - const [pruneTarget, setPruneTarget] = useState<'containers' | 'images' | 'networks' | null>(null); + const [pruneTarget, setPruneTarget] = useState<'containers' | 'images' | 'networks' | 'volumes' | null>(null); useEffect(() => { if (isOpen && activeTab === 'ghosts') { fetchOrphans(); - } else { + } else if (isOpen && activeTab === 'system') { + fetchDockerUsage(); setPruneResult(null); // Reset when tab changes + } else { + setPruneResult(null); } }, [isOpen, activeTab]); + const fetchDockerUsage = async () => { + setIsLoadingUsage(true); + try { + const res = await apiFetch('/system/docker-df'); + const data = await res.json(); + setDockerUsage(data); + } catch (error) { + console.error('Failed to fetch docker usage:', error); + } finally { + setIsLoadingUsage(false); + } + }; + const fetchOrphans = async () => { setIsLoadingOrphans(true); try { @@ -118,7 +145,7 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr } }; - const requestPruneSystem = (target: 'containers' | 'images' | 'networks') => { + const requestPruneSystem = (target: 'containers' | 'images' | 'networks' | 'volumes') => { setPruneTarget(target); setPruneConfirmOpen(true); }; @@ -135,7 +162,14 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr }); const data = await res.json(); setPruneResult(data); - toast.success(`Successfully pruned ${pruneTarget}`); + + if (data.reclaimedBytes !== undefined) { + toast.success(`Prune complete! Reclaimed ${formatBytes(data.reclaimedBytes)}.`); + } else { + toast.success(`Successfully pruned ${pruneTarget}`); + } + + await fetchDockerUsage(); } catch (error) { console.error(`Failed to prune ${pruneTarget}: `, error); toast.error(`Failed to prune ${pruneTarget}.`); @@ -255,10 +289,43 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr )} - -

Global Docker Pruning

+ +
+
+ +

Reclaimable Space Summary

+
+ {isLoadingUsage && !dockerUsage ? ( +
Calculating reclaimable space...
+ ) : dockerUsage ? ( +
+
+ +
Stopped Containers +
+ {formatBytes(dockerUsage.reclaimableContainers)} +
+
+ +
Unused & Dangling Images +
+ {formatBytes(dockerUsage.reclaimableImages)} +
+
+ +
Unused Volumes +
+ {formatBytes(dockerUsage.reclaimableVolumes)} +
+
+ ) : ( +
Reclaimable space data unavailable.
+ )} +
-
+

Global Docker Pruning

+ +
@@ -294,7 +361,20 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr
Prune Networks
-
Removes all unused networks
+
Removes unused networks
+
+ + +
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index bd0c391d..d09767ee 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,12 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function formatBytes(bytes: number, decimals = 2) { + if (!+bytes) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +}