Files
Redflag/aggregator-web/src/components/AgentStorage.tsx
Fimeg 2da93e442e fix: migration runner and scan logging fixes
- Fix migration conflicts and duplicate key errors
- Remove duplicate scan logging from agents
- Fix AgentHealth UI and Storage page triggers
- Prevent scans from appearing on wrong pages

Fixes duplicate key violations on fresh installs and
storage scans appearing on Updates page.
2025-12-19 20:59:12 -05:00

321 lines
13 KiB
TypeScript

import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
HardDrive,
RefreshCw,
MemoryStick,
} from 'lucide-react';
import { formatBytes, formatRelativeTime } from '@/lib/utils';
import { agentApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
interface AgentStorageProps {
agentId: string;
}
interface DiskInfo {
mountpoint: string;
total: number;
available: number;
used: number;
used_percent: number;
filesystem: string;
is_root: boolean;
is_largest: boolean;
disk_type: string;
device: string;
}
interface StorageMetrics {
cpu_percent: number;
memory_percent: number;
memory_used_gb: number;
memory_total_gb: number;
disk_used_gb: number;
disk_total_gb: number;
disk_percent: number;
largest_disk_used_gb: number;
largest_disk_total_gb: number;
largest_disk_percent: number;
largest_disk_mount: string;
uptime: string;
}
export function AgentStorage({ agentId }: AgentStorageProps) {
const [isScanning, setIsScanning] = useState(false);
// Fetch agent details and storage metrics
const { data: agentData } = useQuery({
queryKey: ['agent', agentId],
queryFn: async () => {
return await agentApi.getAgent(agentId);
},
refetchInterval: 30000, // Refresh every 30 seconds
});
// Fetch storage metrics from dedicated endpoint
const { data: storageData, refetch: refetchStorage } = useQuery({
queryKey: ['storage-metrics', agentId],
queryFn: async () => {
return await agentApi.getStorageMetrics(agentId);
},
refetchInterval: 30000, // Refresh every 30 seconds
});
const handleFullStorageScan = async () => {
setIsScanning(true);
try {
// Trigger storage scan only (not full system scan)
await agentApi.triggerSubsystem(agentId, 'storage');
toast.success('Storage scan initiated');
// Refresh data after a short delay
setTimeout(() => {
refetchStorage();
setIsScanning(false);
}, 3000);
} catch (error) {
toast.error('Failed to initiate storage scan');
setIsScanning(false);
}
};
// Process storage metrics data
const storageMetrics: StorageMetrics | null = useMemo(() => {
if (!storageData?.metrics || storageData.metrics.length === 0) {
return null;
}
// Find root disk for summary metrics
const rootDisk = storageData.metrics.find((m: any) => m.is_root) || storageData.metrics[0];
const largestDisk = storageData.metrics.find((m: any) => m.is_largest) || rootDisk;
return {
cpu_percent: 0, // CPU not included in storage metrics, comes from system metrics
memory_percent: 0, // Memory not included in storage metrics, comes from system metrics
memory_used_gb: 0,
memory_total_gb: 0,
disk_used_gb: largestDisk ? largestDisk.used_bytes / (1024 * 1024 * 1024) : 0,
disk_total_gb: largestDisk ? largestDisk.total_bytes / (1024 * 1024 * 1024) : 0,
disk_percent: largestDisk ? largestDisk.used_percent : 0,
largest_disk_used_gb: largestDisk ? largestDisk.used_bytes / (1024 * 1024 * 1024) : 0,
largest_disk_total_gb: largestDisk ? largestDisk.total_bytes / (1024 * 1024 * 1024) : 0,
largest_disk_percent: largestDisk ? largestDisk.used_percent : 0,
largest_disk_mount: largestDisk ? largestDisk.mountpoint : '',
uptime: '', // Uptime not included in storage metrics
};
}, [storageData]);
// Parse disk info from storage metrics
const parseDiskInfo = (): DiskInfo[] => {
if (!storageData?.metrics) return [];
return storageData.metrics.map((disk: any) => ({
mountpoint: disk.mountpoint,
device: disk.device,
disk_type: disk.disk_type,
total: disk.total_bytes,
available: disk.available_bytes,
used: disk.used_bytes,
used_percent: disk.used_percent,
filesystem: disk.filesystem,
is_root: disk.is_root || false,
is_largest: disk.is_largest || false,
}));
};
if (!agentData) {
return (
<div className="space-y-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white p-6 rounded-lg border border-gray-200">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-3"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
))}
</div>
</div>
</div>
);
}
const disks = parseDiskInfo();
return (
<div className="space-y-8">
{/* Clean minimal header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900">System Resources</h2>
<button
onClick={handleFullStorageScan}
disabled={isScanning}
className="text-sm text-gray-500 hover:text-gray-900 flex items-center space-x-1.5"
>
<RefreshCw className={cn('h-4 w-4', isScanning && 'animate-spin')} />
<span>{isScanning ? 'Scanning...' : 'Refresh'}</span>
</button>
</div>
{/* Memory & Disk - matching Overview styling */}
<div className="space-y-4">
{/* Memory - GREEN to differentiate from disks */}
{storageMetrics && storageMetrics.memory_total_gb > 0 && (
<div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 flex items-center">
<MemoryStick className="h-4 w-4 mr-1" />
Memory
</p>
<p className="text-sm font-medium text-gray-900">
{storageMetrics.memory_used_gb.toFixed(1)} GB / {storageMetrics.memory_total_gb.toFixed(1)} GB
</p>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-green-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min(storageMetrics.memory_percent, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{storageMetrics.memory_percent.toFixed(0)}% used
</p>
</div>
)}
{/* Quick Overview - Simple disk bars for at-a-glance view */}
{disks.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900">Disk Usage (Overview)</h3>
{disks.map((disk, index) => (
<div key={`overview-${index}`}>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 flex items-center">
<HardDrive className="h-4 w-4 mr-1" />
{disk.mountpoint} ({disk.filesystem})
</p>
<p className="text-sm font-medium text-gray-900">
{formatBytes(disk.used)} / {formatBytes(disk.total)} ({disk.used_percent.toFixed(0)}%)
</p>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min(disk.used_percent, 100)}%` }}
/>
</div>
</div>
))}
</div>
)}
{/* Enhanced Disk Table - Shows all partitions with full details */}
{disks.length > 0 && (
<div className="overflow-hidden">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-900">Disk Partitions (Detailed)</h3>
<span className="text-xs text-gray-500">{disks.length} {disks.length === 1 ? 'partition' : 'partitions'} detected</span>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50">
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 font-medium text-gray-700">Mount</th>
<th className="text-left py-2 px-3 font-medium text-gray-700">Device</th>
<th className="text-left py-2 px-3 font-medium text-gray-700">Type</th>
<th className="text-left py-2 px-3 font-medium text-gray-700">FS</th>
<th className="text-right py-2 px-3 font-medium text-gray-700">Size</th>
<th className="text-right py-2 px-3 font-medium text-gray-700">Used</th>
<th className="text-center py-2 px-3 font-medium text-gray-700">%</th>
<th className="text-center py-2 px-3 font-medium text-gray-700">Flags</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{disks.map((disk, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="py-2 px-3 text-gray-900">
<div className="flex items-center">
<HardDrive className="h-3 w-3 mr-1 text-gray-400" />
<span className="font-medium text-xs">{disk.mountpoint}</span>
{disk.is_root && <span className="ml-1 text-[10px] text-blue-600 bg-blue-100 px-1 rounded">ROOT</span>}
{disk.is_largest && <span className="ml-1 text-[10px] text-purple-600 bg-purple-100 px-1 rounded">LARGEST</span>}
</div>
</td>
<td className="py-2 px-3 text-gray-700 text-xs font-mono">{disk.device}</td>
<td className="py-2 px-3 text-gray-700 text-xs capitalize">{disk.disk_type.toLowerCase()}</td>
<td className="py-2 px-3 text-gray-700 text-xs font-mono">{disk.filesystem}</td>
<td className="py-2 px-3 text-gray-900 text-xs text-right">{formatBytes(disk.total)}</td>
<td className="py-2 px-3 text-gray-900 text-xs text-right">{formatBytes(disk.used)}</td>
<td className="py-2 px-3 text-center">
<div className="inline-flex items-center">
<div className="w-12 bg-gray-200 rounded-full h-1.5 mr-2">
<div
className="bg-blue-600 h-1.5 rounded-full"
style={{ width: `${Math.min(disk.used_percent, 100)}%` }}
/>
</div>
<span className="text-xs font-medium">{disk.used_percent.toFixed(0)}%</span>
</div>
</td>
<td className="py-2 px-3 text-center text-xs text-gray-500">
{disk.severity !== 'low' && (
<span className={cn(
disk.severity === 'critical' ? 'text-red-600' :
disk.severity === 'important' ? 'text-amber-600' :
'text-yellow-600'
)}>
{disk.severity.toUpperCase()}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 text-xs text-gray-500">
Showing {disks.length} disk partitions Auto-refreshes every 30 seconds
</div>
</div>
)}
{/* Fallback if no disk array but we have metadata */}
{disks.length === 0 && storageMetrics && storageMetrics.disk_total_gb > 0 && (
<div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 flex items-center">
<HardDrive className="h-4 w-4 mr-1" />
Disk (/)
</p>
<p className="text-sm font-medium text-gray-900">
{storageMetrics.disk_used_gb.toFixed(1)} GB / {storageMetrics.disk_total_gb.toFixed(1)} GB
</p>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min(storageMetrics.disk_percent, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{storageMetrics.disk_percent.toFixed(0)}% used
</p>
</div>
)}
</div>
{/* Refresh info */}
<div className="text-xs text-gray-400 border-t border-gray-200 pt-4">
Auto-refreshes every 30 seconds Last updated {agentData?.last_seen ? formatRelativeTime(agentData.last_seen) : 'unknown'}
</div>
</div>
);
}