letsbe-hub/src/components/admin/live-stats-panel.tsx

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