import { Activity, Clock3, Cpu, HardDrive, MemoryStick, Network, } from 'lucide-react'; import React, { memo, useEffect, useMemo, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage'; import { cn } from '../../lib/utils'; import { useServerStats } from '../../application/state/useServerStats'; import { ResourceBar } from './ResourceBar'; import { SystemPanelEmpty, SystemPanelError, SystemPanelInlineError, SystemPanelLoading, SystemPanelShell, } from './SystemPanelUi'; interface SystemOverviewTabProps { sessionId: string; isVisible: boolean; isSupportedOs: boolean; refreshIntervalSec: number; } interface OverviewSample { at: number; cpu: number; memory: number; disk: number; network: number; } function clampPercent(value: number | null | undefined): number | null { if (!Number.isFinite(value)) return null; return Math.max(0, Math.min(100, Number(value))); } function ratioPercent(used: number | null | undefined, total: number | null | undefined): number | null { if (!Number.isFinite(used) || !Number.isFinite(total) || Number(total) <= 0) return null; return clampPercent((Number(used) / Number(total)) * 100); } function formatPercent(value: number | null | undefined, digits = 0): string { if (!Number.isFinite(value)) return '--'; return `${Number(value).toFixed(digits)}%`; } function formatBytes(bytes: number): string { const value = Number(bytes); if (!Number.isFinite(value) || value <= 0) return '0 B'; if (value >= 1024 ** 4) return `${(value / 1024 ** 4).toFixed(1)} TB`; if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} GB`; if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`; if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`; return `${Math.round(value)} B`; } function formatThroughput(bytesPerSecond: number): string { return `${formatBytes(bytesPerSecond)}/s`; } function formatStorageGb(gb: number | null | undefined): string { if (!Number.isFinite(gb)) return '--'; const value = Number(gb); if (value >= 1024) return `${(value / 1024).toFixed(1)} TB`; return `${value.toFixed(value >= 10 ? 0 : 1)} GB`; } function formatMemoryMb(mb: number | null | undefined): string { if (!Number.isFinite(mb)) return '--'; const value = Number(mb); if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`; return `${Math.round(value)} MB`; } function formatDuration(seconds: number | null | undefined, t: ReturnType['t']): string { if (!Number.isFinite(seconds) || Number(seconds) < 0) return '--'; const totalHours = Math.floor(Number(seconds) / 3600); const days = Math.floor(totalHours / 24); const hours = totalHours % 24; const minutes = Math.floor((Number(seconds) % 3600) / 60); if (days > 0) return t('systemManager.overview.duration.daysHours', { days, hours }); if (hours > 0) return t('systemManager.overview.duration.hoursMinutes', { hours, minutes }); return t('systemManager.overview.duration.minutes', { minutes }); } function formatLoad(loadAverage: number[] | undefined): string { if (!loadAverage || loadAverage.length === 0) return '--'; return loadAverage.map((load) => load.toFixed(2)).join(' / '); } function MetricTrend({ values, max, className, gradientId, }: { values: number[]; max?: number; className?: string; /** Unique id for the gradient def (needed if multiple charts on same page) */ gradientId?: string; }) { const width = 120; const height = 34; const finite = values.filter((value) => Number.isFinite(value)); const computedMax = max ?? Math.max(1, ...finite); const safeValues = values.length > 1 ? values : [0, values[0] ?? 0]; // Compute point coordinates const points = safeValues.map((value, index) => { const x = safeValues.length === 1 ? width : (index / (safeValues.length - 1)) * width; const clamped = Math.max(0, Math.min(computedMax, Number.isFinite(value) ? value : 0)); const y = height - (clamped / computedMax) * (height - 4) - 2; return { x, y }; }); // Build smooth bezier curve path let smoothPath = ''; if (points.length === 1) { smoothPath = `M 0 ${height} L ${width} ${height}`; } else if (points.length === 2) { smoothPath = `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`; } else { smoothPath = `M ${points[0].x} ${points[0].y}`; for (let i = 1; i < points.length - 1; i++) { const prev = points[i - 1]; const curr = points[i]; const next = points[i + 1]; const cpx1 = prev.x + (curr.x - prev.x) * 0.6; const cpy1 = prev.y + (curr.y - prev.y) * 0.2; const cpx2 = curr.x - (next.x - prev.x) * 0.2; const cpy2 = curr.y - (next.y - prev.y) * 0.1; smoothPath += ` C ${cpx1.toFixed(1)} ${cpy1.toFixed(1)}, ${cpx2.toFixed(1)} ${cpy2.toFixed(1)}, ${curr.x.toFixed(1)} ${curr.y.toFixed(1)}`; } // Last segment const last = points[points.length - 1]; const secondLast = points[points.length - 2]; smoothPath += ` S ${last.x.toFixed(1)} ${last.y.toFixed(1)}, ${last.x.toFixed(1)} ${last.y.toFixed(1)}`; } // Area path for gradient fill const areaPath = `${smoothPath} L ${width} ${height} L 0 ${height} Z`; const gradId = gradientId || `metric-trend-grad-${Math.random().toString(36).slice(2, 8)}`; return ( {/* End point dot */} {points.length >= 2 && ( )} ); } function RadialGauge({ value, className, gradientId, }: { value: number | null; className?: string; /** Unique gradient id */ gradientId?: string; }) { const clamped = clampPercent(value) ?? 0; const gradId = gradientId || `radial-gauge-${Math.random().toString(36).slice(2, 8)}`; // Determine color intensity based on value const isHigh = clamped > 85; const isMedium = clamped > 60 && clamped <= 85; return (
{/* Glow filter */} {/* Track */} {/* Progress with gradient */}
{formatPercent(value)}
); } function MetricCard({ label, value, detail, icon: Icon, gaugeValue, trendValues, trendMax, tone, toneBg, gradientId, }: { label: string; value: string; detail: string; icon: React.ComponentType<{ size?: number; className?: string }>; gaugeValue: number | null; trendValues: number[]; trendMax?: number; tone: string; /** Background glow color class (e.g. "from-sky-500/5") */ toneBg?: string; gradientId?: string; }) { const cardGradId = gradientId || `card-${Math.random().toString(36).slice(2, 8)}`; return (
{/* Subtle top accent line */}
{label}
{value}
{detail}
); } function InfoPill({ label, value, icon: Icon, tone, }: { label: string; value: string; icon?: React.ComponentType<{ size?: number; className?: string }>; tone?: string; }) { return (
{Icon && }
{label}
{value || '--'}
); } export const SystemOverviewTab = memo(function SystemOverviewTab({ sessionId, isVisible, isSupportedOs, refreshIntervalSec, }: SystemOverviewTabProps) { const { t } = useI18n(); const [history, setHistory] = useState([]); const { stats, error, isLoading: loading, refresh, } = useServerStats({ sessionId, enabled: isVisible, refreshInterval: refreshIntervalSec, isSupportedOs, isConnected: true, }); const hasStats = Boolean(stats.lastUpdated); const memoryPercent = ratioPercent(stats?.memUsed, stats?.memTotal); const mountedDiskUsage = aggregateMountedDiskUsage(stats.disks); const diskUsed = mountedDiskUsage?.used ?? stats.diskUsed; const diskTotal = mountedDiskUsage?.total ?? stats.diskTotal; const diskPercent = mountedDiskUsage?.percent ?? clampPercent(stats.diskPercent); const networkSpeed = (stats?.netRxSpeed ?? 0) + (stats?.netTxSpeed ?? 0); const networkGauge = Math.min(100, Math.log10(networkSpeed + 1) * 14); const loadOne = stats?.loadAverage?.[0] ?? null; const loadPercent = ratioPercent(loadOne, stats?.cpuCores); useEffect(() => { setHistory([]); }, [sessionId]); useEffect(() => { if (!isVisible || !hasStats) return; setHistory((prev) => { const next = [ ...prev, { at: Date.now(), cpu: clampPercent(stats.cpu) ?? 0, memory: memoryPercent ?? 0, disk: diskPercent ?? 0, network: networkSpeed, }, ]; return next.slice(-24); }); }, [diskPercent, hasStats, isVisible, memoryPercent, networkSpeed, stats.cpu]); const trends = useMemo(() => ({ cpu: history.map((sample) => sample.cpu), memory: history.map((sample) => sample.memory), disk: history.map((sample) => sample.disk), network: history.map((sample) => sample.network), }), [history]); // Prefer cached stats over empty/loading so tab switches never flash the // empty placeholder when we already have a successful sample. const showBlockingError = Boolean(error && !hasStats && !loading); const showInitialLoading = Boolean(loading && !hasStats); const showEmpty = Boolean(!hasStats && !loading && !error); return ( {error && hasStats && !loading && ( void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> )} {showBlockingError && error ? ( void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> ) : showInitialLoading ? ( ) : showEmpty ? ( ) : hasStats ? (
{/* Main metric cards */}
{/* Info pills grid */}
{/* CPU Cores section */}
{t('systemManager.overview.cpuCores')}
{loadPercent !== null ? `${t('systemManager.overview.load')} ${formatPercent(loadPercent)}` : t('systemManager.overview.noData')}
{stats.cpuPerCore.length > 0 ? (
{stats.cpuPerCore.slice(0, 12).map((core, index) => ( ))}
) : (
{t('systemManager.overview.noData')}
)}
{/* Disks section */}
{t('systemManager.overview.disks')}
{stats.disks.length > 0 ? (
{stats.disks.map((disk) => (
{disk.mountPoint} {formatStorageGb(disk.used)} / {formatStorageGb(disk.total)}
))}
) : (
{t('systemManager.overview.noDisks')}
)}
{/* Network interfaces section */}
{t('systemManager.overview.interfaces')}
{stats.netInterfaces.length > 0 ? (
{stats.netInterfaces.slice(0, 5).map((iface) => (
{iface.name} {formatBytes(iface.rxBytes)} ↓ · {formatBytes(iface.txBytes)} ↑
{t('systemManager.overview.rx')} {formatThroughput(iface.rxSpeed)} {t('systemManager.overview.tx')} {formatThroughput(iface.txSpeed)}
))}
) : (
{t('systemManager.overview.noInterfaces')}
)}
{/* Top processes section */}
{t('systemManager.overview.topProcesses')}
{stats.topProcesses.length > 0 ? (
{stats.topProcesses.slice(0, 5).map((proc) => (
{proc.command} PID {proc.pid}
))}
) : (
{t('systemManager.overview.noTopProcesses')}
)}
) : null}
); });