395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
import { RefreshCw, Cpu, MemoryStick, HardDrive, Network, Activity, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
interface LiveStatsData {
|
|
cpuPercent: number | null
|
|
memoryPercent: number | null
|
|
memoryUsedMb: number | null
|
|
memoryTotalMb: number | null
|
|
diskReadMbps: number | null
|
|
diskWriteMbps: number | null
|
|
networkInMbps: number | null
|
|
networkOutMbps: number | null
|
|
containersRunning: number | null
|
|
containersStopped: number | null
|
|
timestamp?: Date | string
|
|
}
|
|
|
|
interface LiveStatsPanelProps {
|
|
data: LiveStatsData | null | undefined
|
|
isRefreshing: boolean
|
|
onRefresh: () => void
|
|
autoRefreshInterval?: number // in milliseconds, default 15000 (15 seconds)
|
|
}
|
|
|
|
interface GaugeConfig {
|
|
label: string
|
|
value: number | null
|
|
max: number
|
|
unit: string
|
|
icon: React.ReactNode
|
|
thresholds: {
|
|
warning: number // percentage at which to show warning (yellow)
|
|
critical: number // percentage at which to show critical (red)
|
|
}
|
|
description?: string
|
|
secondaryValue?: string
|
|
}
|
|
|
|
// ============================================================================
|
|
// Enhanced Circular Gauge Component
|
|
// ============================================================================
|
|
|
|
function CircularGauge({
|
|
label,
|
|
value,
|
|
max,
|
|
unit,
|
|
icon,
|
|
thresholds,
|
|
description,
|
|
secondaryValue,
|
|
isRefreshing
|
|
}: GaugeConfig & { isRefreshing: boolean }) {
|
|
const displayValue = value ?? 0
|
|
const percentage = Math.min((displayValue / max) * 100, 100)
|
|
|
|
// Determine status color based on thresholds
|
|
const getStatusColor = () => {
|
|
if (value === null) return { stroke: '#9ca3af', bg: '#f3f4f6', text: 'text-gray-500' }
|
|
if (percentage >= thresholds.critical) return { stroke: '#ef4444', bg: '#fef2f2', text: 'text-red-600' }
|
|
if (percentage >= thresholds.warning) return { stroke: '#f59e0b', bg: '#fffbeb', text: 'text-amber-600' }
|
|
return { stroke: '#22c55e', bg: '#f0fdf4', text: 'text-green-600' }
|
|
}
|
|
|
|
const colors = getStatusColor()
|
|
|
|
// SVG circle parameters
|
|
const size = 120
|
|
const strokeWidth = 8
|
|
const radius = (size - strokeWidth) / 2
|
|
const circumference = 2 * Math.PI * radius
|
|
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
|
|
|
// Get status icon
|
|
const getStatusIcon = () => {
|
|
if (value === null) return null
|
|
if (percentage >= thresholds.critical) return <XCircle className="h-3 w-3 text-red-500" />
|
|
if (percentage >= thresholds.warning) return <AlertTriangle className="h-3 w-3 text-amber-500" />
|
|
return <CheckCircle2 className="h-3 w-3 text-green-500" />
|
|
}
|
|
|
|
return (
|
|
<div className={cn(
|
|
"relative flex flex-col items-center p-4 rounded-xl border transition-all duration-300",
|
|
colors.bg,
|
|
isRefreshing && "animate-pulse"
|
|
)}>
|
|
{/* Status indicator */}
|
|
<div className="absolute top-2 right-2">
|
|
{getStatusIcon()}
|
|
</div>
|
|
|
|
{/* Circular gauge */}
|
|
<div className="relative" style={{ width: size, height: size }}>
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
className="transform -rotate-90"
|
|
>
|
|
{/* Background circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#e5e7eb"
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
{/* Progress circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={colors.stroke}
|
|
strokeWidth={strokeWidth}
|
|
strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={strokeDashoffset}
|
|
className="transition-all duration-500 ease-out"
|
|
/>
|
|
</svg>
|
|
|
|
{/* Center content */}
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<div className={cn("mb-1", colors.text)}>
|
|
{icon}
|
|
</div>
|
|
<div className={cn("text-2xl font-bold", colors.text)}>
|
|
{value !== null ? displayValue.toFixed(1) : '--'}
|
|
</div>
|
|
<div className="text-xs text-gray-500 font-medium">
|
|
{unit}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Label and description */}
|
|
<div className="text-center mt-3">
|
|
<div className="text-sm font-semibold text-gray-900">{label}</div>
|
|
{description && (
|
|
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
|
|
)}
|
|
{secondaryValue && (
|
|
<div className="text-xs text-gray-600 mt-1 font-medium bg-white/50 px-2 py-0.5 rounded">
|
|
{secondaryValue}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Compact Stats Bar Component
|
|
// ============================================================================
|
|
|
|
function CompactStatBar({
|
|
label,
|
|
value,
|
|
max,
|
|
unit,
|
|
color
|
|
}: {
|
|
label: string
|
|
value: number | null
|
|
max: number
|
|
unit: string
|
|
color: string
|
|
}) {
|
|
const displayValue = value ?? 0
|
|
const percentage = Math.min((displayValue / max) * 100, 100)
|
|
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-xs text-gray-500 w-16 text-right">{label}</div>
|
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full transition-all duration-500"
|
|
style={{ width: `${percentage}%`, backgroundColor: color }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs font-medium text-gray-700 w-16">
|
|
{value !== null ? `${displayValue.toFixed(1)} ${unit}` : '--'}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Live Stats Panel
|
|
// ============================================================================
|
|
|
|
export function LiveStatsPanel({
|
|
data,
|
|
isRefreshing,
|
|
onRefresh,
|
|
autoRefreshInterval = 15000
|
|
}: LiveStatsPanelProps) {
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
|
const [countdown, setCountdown] = useState(autoRefreshInterval / 1000)
|
|
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
const countdownRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
// Update last updated time when data changes
|
|
useEffect(() => {
|
|
if (data?.timestamp) {
|
|
setLastUpdated(new Date(data.timestamp))
|
|
}
|
|
}, [data?.timestamp])
|
|
|
|
// Auto-refresh interval
|
|
useEffect(() => {
|
|
// Initial refresh on mount
|
|
onRefresh()
|
|
|
|
// Set up auto-refresh interval
|
|
intervalRef.current = setInterval(() => {
|
|
onRefresh()
|
|
setCountdown(autoRefreshInterval / 1000)
|
|
}, autoRefreshInterval)
|
|
|
|
// Countdown timer
|
|
countdownRef.current = setInterval(() => {
|
|
setCountdown(prev => Math.max(0, prev - 1))
|
|
}, 1000)
|
|
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
if (countdownRef.current) clearInterval(countdownRef.current)
|
|
}
|
|
}, [autoRefreshInterval, onRefresh])
|
|
|
|
// Reset countdown when manually refreshing
|
|
const handleManualRefresh = useCallback(() => {
|
|
onRefresh()
|
|
setCountdown(autoRefreshInterval / 1000)
|
|
}, [onRefresh, autoRefreshInterval])
|
|
|
|
// Format time ago
|
|
const formatTimeAgo = (date: Date | null) => {
|
|
if (!date) return 'Never'
|
|
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
|
|
if (seconds < 60) return `${seconds}s ago`
|
|
const minutes = Math.floor(seconds / 60)
|
|
if (minutes < 60) return `${minutes}m ago`
|
|
return `${Math.floor(minutes / 60)}h ago`
|
|
}
|
|
|
|
// Gauge configurations
|
|
const gauges: GaugeConfig[] = [
|
|
{
|
|
label: 'CPU Usage',
|
|
value: data?.cpuPercent ?? null,
|
|
max: 100,
|
|
unit: '%',
|
|
icon: <Cpu className="h-5 w-5" />,
|
|
thresholds: { warning: 70, critical: 90 },
|
|
description: 'Processor utilization'
|
|
},
|
|
{
|
|
label: 'Memory',
|
|
value: data?.memoryPercent ?? null,
|
|
max: 100,
|
|
unit: '%',
|
|
icon: <MemoryStick className="h-5 w-5" />,
|
|
thresholds: { warning: 75, critical: 90 },
|
|
description: 'RAM utilization',
|
|
secondaryValue: data?.memoryUsedMb && data?.memoryTotalMb
|
|
? `${(data.memoryUsedMb / 1024).toFixed(1)} / ${(data.memoryTotalMb / 1024).toFixed(1)} GB`
|
|
: undefined
|
|
},
|
|
{
|
|
label: 'Disk Read',
|
|
value: data?.diskReadMbps ?? null,
|
|
max: 100,
|
|
unit: 'MB/s',
|
|
icon: <HardDrive className="h-5 w-5" />,
|
|
thresholds: { warning: 50, critical: 80 },
|
|
description: 'Storage throughput'
|
|
},
|
|
{
|
|
label: 'Network In',
|
|
value: data?.networkInMbps ?? null,
|
|
max: 100,
|
|
unit: 'Mbps',
|
|
icon: <Network className="h-5 w-5" />,
|
|
thresholds: { warning: 50, critical: 80 },
|
|
description: 'Inbound traffic'
|
|
}
|
|
]
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-100">
|
|
<Activity className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">Live Stats</h2>
|
|
<p className="text-xs text-gray-500">
|
|
Last updated: {formatTimeAgo(lastUpdated)} • Next refresh in {countdown}s
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleManualRefresh}
|
|
disabled={isRefreshing}
|
|
className={cn(
|
|
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all",
|
|
"bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50",
|
|
isRefreshing && "cursor-not-allowed"
|
|
)}
|
|
>
|
|
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
|
{isRefreshing ? 'Refreshing...' : 'Refresh Now'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Gauges */}
|
|
<div className="p-6">
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{gauges.map((gauge) => (
|
|
<CircularGauge
|
|
key={gauge.label}
|
|
{...gauge}
|
|
isRefreshing={isRefreshing}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Additional Stats */}
|
|
<div className="px-6 pb-6">
|
|
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Additional Metrics</h3>
|
|
|
|
<CompactStatBar
|
|
label="Disk Write"
|
|
value={data?.diskWriteMbps ?? null}
|
|
max={100}
|
|
unit="MB/s"
|
|
color="#f59e0b"
|
|
/>
|
|
|
|
<CompactStatBar
|
|
label="Network Out"
|
|
value={data?.networkOutMbps ?? null}
|
|
max={100}
|
|
unit="Mbps"
|
|
color="#ec4899"
|
|
/>
|
|
|
|
{/* Container counts */}
|
|
{(data?.containersRunning !== null || data?.containersStopped !== null) && (
|
|
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
|
<span className="text-xs text-gray-600">
|
|
{data?.containersRunning ?? 0} running
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
|
<span className="text-xs text-gray-600">
|
|
{data?.containersStopped ?? 0} stopped
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-gray-500">
|
|
{(data?.containersRunning ?? 0) + (data?.containersStopped ?? 0)} total containers
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default LiveStatsPanel
|