mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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) => {
|
app.post('/api/system/prune/system', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { target } = req.body; // 'containers', 'images', 'networks'
|
const { target } = req.body; // 'containers', 'images', 'networks', 'volumes'
|
||||||
let command = '';
|
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||||
|
|
||||||
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 {
|
|
||||||
return res.status(400).json({ error: 'Invalid prune target' });
|
return res.status(400).json({ error: 'Invalid prune target' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout, stderr } = await execAsync(command, {
|
const dockerController = DockerController.getInstance();
|
||||||
env: {
|
const result = await dockerController.pruneSystem(target);
|
||||||
...process.env,
|
|
||||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Prune completed', stdout, stderr });
|
res.json({ message: 'Prune completed', ...result });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('System prune error:', error);
|
console.error('System prune error:', error);
|
||||||
res.status(500).json({ error: 'System prune failed', details: error.message });
|
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)
|
// Serve static files in production (for Docker deployment)
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|||||||
@@ -24,6 +24,65 @@ class DockerController {
|
|||||||
return DockerController.instance;
|
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() {
|
public async getRunningContainers() {
|
||||||
const containers = await this.docker.listContainers({ all: false });
|
const containers = await this.docker.listContainers({ all: false });
|
||||||
return containers;
|
return containers;
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { Button } from './ui/button';
|
|||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
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 {
|
interface MaintenanceModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -36,9 +37,19 @@ interface ContainerInfo {
|
|||||||
Image: string;
|
Image: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DockerUsage {
|
||||||
|
reclaimableImages: number;
|
||||||
|
reclaimableContainers: number;
|
||||||
|
reclaimableVolumes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalProps) {
|
export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<'ghosts' | 'system'>('ghosts');
|
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
|
// Ghost Hunter state
|
||||||
const [orphans, setOrphans] = useState<Record<string, ContainerInfo[]>>({});
|
const [orphans, setOrphans] = useState<Record<string, ContainerInfo[]>>({});
|
||||||
const [isLoadingOrphans, setIsLoadingOrphans] = useState(false);
|
const [isLoadingOrphans, setIsLoadingOrphans] = useState(false);
|
||||||
@@ -52,16 +63,32 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr
|
|||||||
// Confirm Modals state
|
// Confirm Modals state
|
||||||
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false);
|
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false);
|
||||||
const [pruneConfirmOpen, setPruneConfirmOpen] = 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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && activeTab === 'ghosts') {
|
if (isOpen && activeTab === 'ghosts') {
|
||||||
fetchOrphans();
|
fetchOrphans();
|
||||||
} else {
|
} else if (isOpen && activeTab === 'system') {
|
||||||
|
fetchDockerUsage();
|
||||||
setPruneResult(null); // Reset when tab changes
|
setPruneResult(null); // Reset when tab changes
|
||||||
|
} else {
|
||||||
|
setPruneResult(null);
|
||||||
}
|
}
|
||||||
}, [isOpen, activeTab]);
|
}, [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 () => {
|
const fetchOrphans = async () => {
|
||||||
setIsLoadingOrphans(true);
|
setIsLoadingOrphans(true);
|
||||||
try {
|
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);
|
setPruneTarget(target);
|
||||||
setPruneConfirmOpen(true);
|
setPruneConfirmOpen(true);
|
||||||
};
|
};
|
||||||
@@ -135,7 +162,14 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setPruneResult(data);
|
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to prune ${pruneTarget}: `, error);
|
console.error(`Failed to prune ${pruneTarget}: `, error);
|
||||||
toast.error(`Failed to prune ${pruneTarget}.`);
|
toast.error(`Failed to prune ${pruneTarget}.`);
|
||||||
@@ -255,10 +289,43 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="system" className="flex-1 mt-4 p-4 border rounded-lg bg-card">
|
<TabsContent value="system" className="flex-1 mt-4 p-4 border rounded-lg bg-card overflow-y-auto">
|
||||||
<h3 className="text-lg font-semibold mb-6">Global Docker Pruning</h3>
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-24 flex flex-col items-center justify-center gap-2"
|
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" />
|
<PackageMinus className="w-6 h-6 text-blue-500" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-bold">Prune Images</div>
|
<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>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -294,7 +361,20 @@ export default function MaintenanceModal({ isOpen, onClose }: MaintenanceModalPr
|
|||||||
<Network className="w-6 h-6 text-green-500" />
|
<Network className="w-6 h-6 text-green-500" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-bold">Prune Networks</div>
|
<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>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,3 +4,12 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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