mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 20:00:08 +00:00
feat: implement Docker disk usage retrieval and enhance system prune functionality
This commit is contained in:
+16
-17
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DockerUsage | null>(null);
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
|
||||
|
||||
// Ghost Hunter state
|
||||
const [orphans, setOrphans] = useState<Record<string, ContainerInfo[]>>({});
|
||||
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
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system" className="flex-1 mt-4 p-4 border rounded-lg bg-card">
|
||||
<h3 className="text-lg font-semibold mb-6">Global Docker Pruning</h3>
|
||||
<TabsContent value="system" className="flex-1 mt-4 p-4 border rounded-lg bg-card overflow-y-auto">
|
||||
<div className="rounded-xl border bg-card text-card-foreground shadow-sm mb-6 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<HardDrive className="w-5 h-5 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-lg">Reclaimable Space Summary</h3>
|
||||
</div>
|
||||
{isLoadingUsage && !dockerUsage ? (
|
||||
<div className="text-sm text-muted-foreground animate-pulse">Calculating reclaimable space...</div>
|
||||
) : dockerUsage ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center text-sm p-3 rounded-lg bg-muted/30 border">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500 shadow-sm"></div> Stopped Containers
|
||||
</span>
|
||||
<span className="font-mono bg-background px-2 py-1 rounded shadow-sm border">{formatBytes(dockerUsage.reclaimableContainers)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm p-3 rounded-lg bg-muted/30 border">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 shadow-sm"></div> Unused & Dangling Images
|
||||
</span>
|
||||
<span className="font-mono bg-background px-2 py-1 rounded shadow-sm border">{formatBytes(dockerUsage.reclaimableImages)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm p-3 rounded-lg bg-muted/30 border">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<div className="w-3 h-3 rounded-full bg-purple-500 shadow-sm"></div> Unused Volumes
|
||||
</span>
|
||||
<span className="font-mono bg-background px-2 py-1 rounded shadow-sm border">{formatBytes(dockerUsage.reclaimableVolumes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Reclaimable space data unavailable.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4">Global Docker Pruning</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-24 flex flex-col items-center justify-center gap-2"
|
||||
@@ -281,7 +348,7 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr
|
||||
<PackageMinus className="w-6 h-6 text-blue-500" />
|
||||
<div className="text-center">
|
||||
<div className="font-bold">Prune Images</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">Removes unused & dangling images</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">Removes unused images</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -294,7 +361,20 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr
|
||||
<Network className="w-6 h-6 text-green-500" />
|
||||
<div className="text-center">
|
||||
<div className="font-bold">Prune Networks</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">Removes all unused networks</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">Removes unused networks</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-24 flex flex-col items-center justify-center gap-2"
|
||||
onClick={() => requestPruneSystem('volumes')}
|
||||
disabled={isPruning}
|
||||
>
|
||||
<HardDrive className="w-6 h-6 text-purple-500" />
|
||||
<div className="text-center">
|
||||
<div className="font-bold">Prune Volumes</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">Removes unused volumes</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user