Files
NetMesh/components/systemManager/SystemOverviewTab.tsx

643 lines
24 KiB
TypeScript
Raw Permalink Normal View History

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<typeof useI18n>['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 (
<svg className={cn('h-9 w-full overflow-visible', className)} viewBox={`0 0 ${width} ${height}`} role="img">
<defs>
<linearGradient id={gradId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.35" />
<stop offset="60%" stopColor="currentColor" stopOpacity="0.12" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradId})`} />
<path
d={smoothPath}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* End point dot */}
{points.length >= 2 && (
<circle
cx={points[points.length - 1].x}
cy={points[points.length - 1].y}
r="2.5"
fill="currentColor"
className="drop-shadow-sm"
/>
)}
</svg>
);
}
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 (
<div className={cn('relative h-16 w-16 shrink-0', className)}>
<svg viewBox="0 0 44 44" className="h-full w-full -rotate-90">
<defs>
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.7" />
</linearGradient>
{/* Glow filter */}
<filter id={`${gradId}-glow`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{/* Track */}
<circle
cx="22"
cy="22"
r="18"
fill="none"
stroke="currentColor"
strokeWidth="5"
className="text-muted/50"
/>
{/* Progress with gradient */}
<circle
cx="22"
cy="22"
r="18"
fill="none"
stroke={`url(#${gradId})`}
strokeWidth="5"
strokeLinecap="round"
pathLength={100}
strokeDasharray={`${clamped} ${100 - clamped}`}
style={{ transition: 'stroke-dasharray 0.4s ease-out' }}
filter={isHigh || isMedium ? `url(#${gradId}-glow)` : undefined}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[12px] font-bold tabular-nums text-foreground leading-tight">
{formatPercent(value)}
</span>
</div>
</div>
);
}
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 (
<section
className={cn(
'relative overflow-hidden rounded-lg border bg-card p-3',
'transition-all duration-300 hover:shadow-md hover:-translate-y-0.5',
toneBg ? `bg-gradient-to-br ${toneBg}` : '',
)}
style={{ borderColor: 'hsl(var(--border) / 0.7)' }}
>
{/* Subtle top accent line */}
<div
className={cn('absolute top-0 left-0 right-0 h-0.5 opacity-60', tone)}
style={{
background: 'currentColor',
}}
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: 'hsl(var(--muted-foreground))' }}>
<Icon size={13} className={tone} />
<span>{label}</span>
</div>
<div
className="truncate text-lg font-bold tabular-nums leading-tight"
style={{ color: 'hsl(var(--foreground))' }}
>
{value}
</div>
<div className="mt-1 truncate text-[10px]" style={{ color: 'hsl(var(--muted-foreground))' }}>
{detail}
</div>
</div>
<RadialGauge value={gaugeValue} className={tone} gradientId={`${cardGradId}-radial`} />
</div>
<MetricTrend values={trendValues} max={trendMax} className={cn('mt-2', tone)} gradientId={`${cardGradId}-trend`} />
</section>
);
}
function InfoPill({
label,
value,
icon: Icon,
tone,
}: {
label: string;
value: string;
icon?: React.ComponentType<{ size?: number; className?: string }>;
tone?: string;
}) {
return (
<div
className="min-w-0 rounded-lg border px-3 py-2.5 transition-all duration-200 hover:shadow-sm"
style={{
borderColor: 'hsl(var(--border) / 0.6)',
background: 'hsl(var(--background))',
}}
>
<div className="flex items-center gap-1.5 mb-0.5">
{Icon && <Icon size={11} className={cn(tone || 'text-muted-foreground')} />}
<div className="text-[10px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{label}</div>
</div>
<div className="truncate text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
{value || '--'}
</div>
</div>
);
}
export const SystemOverviewTab = memo(function SystemOverviewTab({
sessionId,
isVisible,
isSupportedOs,
refreshIntervalSec,
}: SystemOverviewTabProps) {
const { t } = useI18n();
const [history, setHistory] = useState<OverviewSample[]>([]);
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 (
<SystemPanelShell section="system-manager-overview">
{error && hasStats && !loading && (
<SystemPanelInlineError
message={error}
onRetry={() => void refresh()}
retryLabel={t('history.action.retry')}
loading={loading}
/>
)}
{showBlockingError && error ? (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
) : showInitialLoading ? (
<SystemPanelLoading message={t('systemManager.overview.loading')} />
) : showEmpty ? (
<SystemPanelEmpty icon={Activity} message={t('systemManager.overview.empty')} />
) : hasStats ? (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
{/* Main metric cards */}
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="CPU"
value={formatPercent(stats.cpu)}
detail={stats.cpuCores ? t('systemManager.overview.cores', { count: String(stats.cpuCores) }) : '--'}
icon={Cpu}
gaugeValue={stats.cpu}
trendValues={trends.cpu}
trendMax={100}
tone="text-sky-500"
gradientId="cpu-card"
/>
<MetricCard
label={t('systemManager.overview.memory')}
value={formatPercent(memoryPercent)}
detail={`${formatMemoryMb(stats.memUsed)} / ${formatMemoryMb(stats.memTotal)}`}
icon={MemoryStick}
gaugeValue={memoryPercent}
trendValues={trends.memory}
trendMax={100}
tone="text-emerald-500"
gradientId="mem-card"
/>
<MetricCard
label={t('systemManager.overview.disk')}
value={formatPercent(diskPercent)}
detail={`${formatStorageGb(diskUsed)} / ${formatStorageGb(diskTotal)}`}
icon={HardDrive}
gaugeValue={diskPercent}
trendValues={trends.disk}
trendMax={100}
tone="text-amber-500"
gradientId="disk-card"
/>
<MetricCard
label={t('systemManager.overview.network')}
value={formatThroughput(networkSpeed)}
detail={`${t('systemManager.overview.rx')} ${formatThroughput(stats.netRxSpeed)} · ${t('systemManager.overview.tx')} ${formatThroughput(stats.netTxSpeed)}`}
icon={Network}
gaugeValue={networkGauge}
trendValues={trends.network}
tone="text-cyan-500"
gradientId="net-card"
/>
</div>
{/* Info pills grid */}
<div className="grid grid-cols-3 gap-2">
<InfoPill
label={t('systemManager.overview.load')}
value={formatLoad(stats.loadAverage)}
icon={Activity}
tone="text-sky-500"
/>
<InfoPill
label={t('systemManager.overview.uptime')}
value={formatDuration(stats.uptimeSeconds, t)}
icon={Clock3}
tone="text-emerald-500"
/>
<InfoPill
label={t('systemManager.overview.latency')}
value={Number.isFinite(stats.latencyMs) ? `${Math.round(stats.latencyMs ?? 0)} ms` : '--'}
icon={Network}
tone="text-cyan-500"
/>
<InfoPill
label={t('systemManager.overview.system')}
value={stats.osName || '--'}
icon={HardDrive}
tone="text-amber-500"
/>
<InfoPill
label={t('systemManager.overview.kernel')}
value={stats.kernelRelease || '--'}
icon={Cpu}
tone="text-sky-500"
/>
<InfoPill
label={t('systemManager.overview.swap')}
value={`${formatMemoryMb(stats.swapUsed)} / ${formatMemoryMb(stats.swapTotal)}`}
icon={MemoryStick}
tone="text-emerald-500"
/>
</div>
{/* CPU Cores section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<Cpu size={13} className="text-sky-500" />
{t('systemManager.overview.cpuCores')}
</div>
<span className="text-[10px]" style={{ color: 'hsl(var(--muted-foreground))' }}>
{loadPercent !== null ? `${t('systemManager.overview.load')} ${formatPercent(loadPercent)}` : t('systemManager.overview.noData')}
</span>
</div>
{stats.cpuPerCore.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{stats.cpuPerCore.slice(0, 12).map((core, index) => (
<ResourceBar key={`core-${index}`} label={`C${index + 1}`} value={core} />
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noData')}</div>
)}
</section>
{/* Disks section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<HardDrive size={13} className="text-amber-500" />
{t('systemManager.overview.disks')}
</div>
{stats.disks.length > 0 ? (
<div className="space-y-2.5">
{stats.disks.map((disk) => (
<div key={disk.mountPoint} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate font-medium" style={{ color: 'hsl(var(--foreground))' }}>{disk.mountPoint}</span>
<span className="shrink-0 tabular-nums" style={{ color: 'hsl(var(--muted-foreground))' }}>
{formatStorageGb(disk.used)} / {formatStorageGb(disk.total)}
</span>
</div>
<ResourceBar label="" value={disk.percent} />
</div>
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noDisks')}</div>
)}
</section>
{/* Network interfaces section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<Network size={13} className="text-cyan-500" />
{t('systemManager.overview.interfaces')}
</div>
{stats.netInterfaces.length > 0 ? (
<div className="space-y-2.5">
{stats.netInterfaces.slice(0, 5).map((iface) => (
<div key={iface.name} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate font-medium" style={{ color: 'hsl(var(--foreground))' }}>{iface.name}</span>
<span className="shrink-0 tabular-nums" style={{ color: 'hsl(var(--muted-foreground))' }}>
{formatBytes(iface.rxBytes)} · {formatBytes(iface.txBytes)}
</span>
</div>
<div className="flex items-center justify-between text-[10px] tabular-nums" style={{ color: 'hsl(var(--muted-foreground) / 0.8)' }}>
<span>{t('systemManager.overview.rx')} {formatThroughput(iface.rxSpeed)}</span>
<span>{t('systemManager.overview.tx')} {formatThroughput(iface.txSpeed)}</span>
</div>
</div>
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noInterfaces')}</div>
)}
</section>
{/* Top processes section */}
<section
className="rounded-lg border p-3"
style={{
borderColor: 'hsl(var(--border) / 0.7)',
background: 'hsl(var(--background))',
}}
>
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<Clock3 size={13} className="text-rose-500" />
{t('systemManager.overview.topProcesses')}
</div>
{stats.topProcesses.length > 0 ? (
<div className="space-y-2">
{stats.topProcesses.slice(0, 5).map((proc) => (
<div key={`${proc.pid}-${proc.command}`} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate font-medium" style={{ color: 'hsl(var(--foreground))' }}>{proc.command}</span>
<span className="shrink-0 tabular-nums" style={{ color: 'hsl(var(--muted-foreground))' }}>PID {proc.pid}</span>
</div>
<ResourceBar label="MEM" value={proc.memPercent} />
</div>
))}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>{t('systemManager.overview.noTopProcesses')}</div>
)}
</section>
</div>
) : null}
</SystemPanelShell>
);
});