643 lines
24 KiB
TypeScript
643 lines
24 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
});
|