|
|
|
@@ -0,0 +1,204 @@
|
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
|
import { useClient } from 'hooks/useClient';
|
|
|
|
|
import { Cpu, MemoryStick, HardDrive, Server, Activity } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
type Stats = {
|
|
|
|
|
hostname: string;
|
|
|
|
|
platform: string;
|
|
|
|
|
uptimeSec: number;
|
|
|
|
|
loadavg: number[];
|
|
|
|
|
cpu: { model: string; cores: number; usagePct: number; perCore: number[] };
|
|
|
|
|
mem: {
|
|
|
|
|
totalBytes: number;
|
|
|
|
|
usedBytes: number;
|
|
|
|
|
freeBytes: number;
|
|
|
|
|
usedPct: number;
|
|
|
|
|
swapTotalBytes: number;
|
|
|
|
|
swapUsedBytes: number;
|
|
|
|
|
} | null;
|
|
|
|
|
disks: { mount: string; fsType: string; totalBytes: number; usedBytes: number; usedPct: number }[];
|
|
|
|
|
processes: { pid: number; user: string; cpuPct: number; memPct: number; command: string }[];
|
|
|
|
|
timestamp: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const POLL_MS = 2000;
|
|
|
|
|
|
|
|
|
|
function fmtBytes(n: number): string {
|
|
|
|
|
if (!n) return '0 B';
|
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
|
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
|
|
|
return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fmtUptime(sec: number): string {
|
|
|
|
|
const d = Math.floor(sec / 86400);
|
|
|
|
|
const h = Math.floor((sec % 86400) / 3600);
|
|
|
|
|
const m = Math.floor((sec % 3600) / 60);
|
|
|
|
|
return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const barColor = (pct: number) =>
|
|
|
|
|
pct >= 85 ? 'bg-red-500' : pct >= 60 ? 'bg-amber-500' : 'bg-emerald-500';
|
|
|
|
|
|
|
|
|
|
const Bar = ({ pct }: { pct: number }) => (
|
|
|
|
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
|
|
|
|
<div className={`h-full rounded-full transition-all ${barColor(pct)}`} style={{ width: `${Math.min(100, pct)}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const Card = ({ title, icon: Icon, children }: { title: string; icon: typeof Cpu; children: React.ReactNode }) => (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
|
|
|
|
<Icon size={16} className="text-primary" />
|
|
|
|
|
{title}
|
|
|
|
|
</div>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const SystemMonitorScreen = () => {
|
|
|
|
|
const { get } = useClient();
|
|
|
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const alive = useRef(true);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
alive.current = true;
|
|
|
|
|
const tick = () => {
|
|
|
|
|
get<Stats>('/system-monitor/stats')
|
|
|
|
|
.then((s) => {
|
|
|
|
|
if (alive.current) {
|
|
|
|
|
setStats(s);
|
|
|
|
|
setError(null);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => alive.current && setError('Failed to read system stats'));
|
|
|
|
|
};
|
|
|
|
|
tick();
|
|
|
|
|
const id = setInterval(tick, POLL_MS);
|
|
|
|
|
return () => {
|
|
|
|
|
alive.current = false;
|
|
|
|
|
clearInterval(id);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full w-full overflow-y-auto p-4 md:p-6">
|
|
|
|
|
<div className="mx-auto flex max-w-6xl flex-col gap-5">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Activity className="text-primary" />
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-lg font-semibold text-foreground">System Monitor</h1>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{stats ? `${stats.hostname} · ${stats.platform}` : 'Loading…'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{stats && (
|
|
|
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
|
|
|
<span>up {fmtUptime(stats.uptimeSec)}</span>
|
|
|
|
|
<span>load {stats.loadavg.map((l) => l.toFixed(2)).join(' ')}</span>
|
|
|
|
|
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-emerald-500" title="live" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-500">{error}</div>}
|
|
|
|
|
|
|
|
|
|
{stats && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
|
|
|
{/* CPU */}
|
|
|
|
|
<Card title="CPU" icon={Cpu}>
|
|
|
|
|
<div className="flex items-end justify-between">
|
|
|
|
|
<span className="text-3xl font-semibold text-foreground">{stats.cpu.usagePct.toFixed(0)}%</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">{stats.cpu.cores} cores</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Bar pct={stats.cpu.usagePct} />
|
|
|
|
|
<p className="truncate text-xs text-muted-foreground" title={stats.cpu.model}>{stats.cpu.model}</p>
|
|
|
|
|
<div className="grid grid-cols-8 gap-1 pt-1">
|
|
|
|
|
{stats.cpu.perCore.map((c, i) => (
|
|
|
|
|
<div key={i} className="flex h-10 items-end rounded bg-muted" title={`core ${i}: ${c}%`}>
|
|
|
|
|
<div className={`w-full rounded ${barColor(c)}`} style={{ height: `${Math.max(4, c)}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Memory */}
|
|
|
|
|
<Card title="Memory" icon={MemoryStick}>
|
|
|
|
|
{stats.mem ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex items-end justify-between">
|
|
|
|
|
<span className="text-3xl font-semibold text-foreground">{stats.mem.usedPct.toFixed(0)}%</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{fmtBytes(stats.mem.usedBytes)} / {fmtBytes(stats.mem.totalBytes)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Bar pct={stats.mem.usedPct} />
|
|
|
|
|
{stats.mem.swapTotalBytes > 0 && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
swap {fmtBytes(stats.mem.swapUsedBytes)} / {fmtBytes(stats.mem.swapTotalBytes)}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-sm text-muted-foreground">unavailable</p>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Disks */}
|
|
|
|
|
<Card title="Disks" icon={HardDrive}>
|
|
|
|
|
{stats.disks.length ? (
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
{stats.disks.map((d) => (
|
|
|
|
|
<div key={d.mount} className="flex flex-col gap-1">
|
|
|
|
|
<div className="flex items-center justify-between text-xs">
|
|
|
|
|
<span className="truncate font-mono text-foreground" title={`${d.mount} (${d.fsType})`}>{d.mount}</span>
|
|
|
|
|
<span className="text-muted-foreground">{fmtBytes(d.usedBytes)} / {fmtBytes(d.totalBytes)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Bar pct={d.usedPct} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-sm text-muted-foreground">no disks</p>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Processes */}
|
|
|
|
|
<Card title="Top processes" icon={Server}>
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
<table className="w-full text-left text-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
<th className="pb-2 pr-4 font-medium">PID</th>
|
|
|
|
|
<th className="pb-2 pr-4 font-medium">User</th>
|
|
|
|
|
<th className="pb-2 pr-4 text-right font-medium">CPU%</th>
|
|
|
|
|
<th className="pb-2 pr-4 text-right font-medium">MEM%</th>
|
|
|
|
|
<th className="pb-2 font-medium">Command</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{stats.processes.map((p) => (
|
|
|
|
|
<tr key={p.pid} className="border-t border-border/60">
|
|
|
|
|
<td className="py-1.5 pr-4 font-mono text-muted-foreground">{p.pid}</td>
|
|
|
|
|
<td className="py-1.5 pr-4 text-muted-foreground">{p.user}</td>
|
|
|
|
|
<td className="py-1.5 pr-4 text-right font-mono text-foreground">{p.cpuPct.toFixed(1)}</td>
|
|
|
|
|
<td className="py-1.5 pr-4 text-right font-mono text-muted-foreground">{p.memPct.toFixed(1)}</td>
|
|
|
|
|
<td className="max-w-0 truncate py-1.5 font-mono text-foreground" title={p.command}>{p.command}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|