0f17efe3b4
- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc, .credentials.json…) is always refused, but a word like token/secret/ password/credential only in a data file's name — design-tokens.md or count_tokens.py is skill content, not a key. - System Health: off Linux the History, CPU threads and Busiest processes cards (all read from /proc) are left out instead of shown empty; the CPU bar without a sample and Windows' always-zero Load row go too. - Users pick their own pool account per tool (/api/me/pool-accounts, Settings → AI tools), within their groups, refused while pinned. - Admin user detail, 9router update, session replay and Antigravity catalog changes from parallel sessions.
722 lines
27 KiB
TypeScript
722 lines
27 KiB
TypeScript
/**
|
|
* @file HostHealth.tsx
|
|
* @description The machine AgentHub runs on, as part of the Dashboard's
|
|
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
|
|
* signed-in user sees all of it). In the tab's own card style:
|
|
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
|
|
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
|
|
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
|
|
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
|
* server started); then CPU (every thread as a compact cell, temperatures,
|
|
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
|
* busiest processes with who each counts for (history, CPU threads and
|
|
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
|
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
|
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
|
* Administration users table shares for each home's share of the homes disk.
|
|
* Read-only.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Activity,
|
|
ArrowDown,
|
|
ArrowUp,
|
|
Cpu,
|
|
HardDrive,
|
|
ListTree,
|
|
Network,
|
|
Server,
|
|
Thermometer,
|
|
Users,
|
|
} from "lucide-react";
|
|
import {
|
|
api,
|
|
type HostHistory,
|
|
type HostHistoryRange,
|
|
type HostStats,
|
|
type HostUsageGroup,
|
|
} from "../../lib/api";
|
|
import { formatBytes, formatDateTime } from "../../lib/format";
|
|
import { Tip } from "../Tip";
|
|
import { MiniChart } from "../admin/MiniChart";
|
|
import { UserAvatar } from "../users/UserAvatar";
|
|
|
|
const POLL_MS = 5000;
|
|
const HISTORY_POLL_MS = 30000;
|
|
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
|
|
|
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
|
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
|
|
|
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
|
|
export type HostState = {
|
|
stats: HostStats | null;
|
|
error: string | null;
|
|
reload: () => Promise<void>;
|
|
};
|
|
|
|
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
|
|
const [stats, setStats] = useState<HostStats | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const reload = useCallback(
|
|
() =>
|
|
api.host
|
|
.stats()
|
|
.then((s) => {
|
|
setStats(s);
|
|
setError(null);
|
|
})
|
|
.catch((e: Error) => setError(e.message)),
|
|
[]
|
|
);
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
void reload();
|
|
if (!pollMs) return;
|
|
const id = setInterval(() => {
|
|
if (document.visibilityState === "visible") void reload();
|
|
}, pollMs);
|
|
return () => clearInterval(id);
|
|
}, [enabled, pollMs, reload]);
|
|
return { stats, error, reload };
|
|
}
|
|
|
|
/** A thin bar, amber past 75 %, red past 90 %. */
|
|
export function Meter({ value, label }: { value: number; label: string }) {
|
|
const v = Math.max(0, Math.min(100, value));
|
|
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
|
return (
|
|
<div
|
|
role="meter"
|
|
aria-label={label}
|
|
aria-valuenow={Math.round(v)}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
|
>
|
|
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The System Health tab's own bar: h-2, green / amber / red by level. */
|
|
function HealthBar({
|
|
label,
|
|
value,
|
|
tip,
|
|
warn = 70,
|
|
bad = 90,
|
|
}: {
|
|
label: string;
|
|
value: number | null;
|
|
tip?: string;
|
|
warn?: number;
|
|
bad?: number;
|
|
}) {
|
|
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
|
|
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
|
|
return (
|
|
<Tip block raw={tip}>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-[10px]">
|
|
<span className="text-fg-muted">{label}</span>
|
|
<span className="text-fg-secondary font-mono">
|
|
{value == null ? "—" : `${Math.round(v)}%`}
|
|
</span>
|
|
</div>
|
|
<div
|
|
role="meter"
|
|
aria-label={label}
|
|
aria-valuenow={Math.round(v)}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
className="w-full bg-surface-3 rounded-full h-2"
|
|
>
|
|
<div
|
|
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
|
|
style={{ width: `${v}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Tip>
|
|
);
|
|
}
|
|
|
|
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
|
|
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
|
|
const v = Math.max(0, Math.min(100, value));
|
|
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
|
|
return (
|
|
<div className="space-y-0.5">
|
|
<div className="flex justify-between text-[10px] font-mono">
|
|
<span className="text-fg-muted">{n}</span>
|
|
<span className={tone}>{Math.round(v)}%</span>
|
|
</div>
|
|
<Meter value={v} label={label} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CardHead({
|
|
icon: Icon,
|
|
tone,
|
|
title,
|
|
right,
|
|
}: {
|
|
icon: ComponentType<{ className?: string }>;
|
|
tone: string;
|
|
title: string;
|
|
right?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
|
|
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
|
|
</div>
|
|
{right}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
|
|
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
|
|
{children}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
|
const d = Math.floor(sec / 86400);
|
|
const h = Math.floor((sec % 86400) / 3600);
|
|
const m = Math.floor((sec % 3600) / 60);
|
|
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
|
|
}
|
|
|
|
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
|
|
export type HeapInfo = {
|
|
heapUsed: number;
|
|
heapTotal: number;
|
|
heapLimit?: number;
|
|
external: number;
|
|
};
|
|
|
|
/**
|
|
* The tab's first card: the machine and the AgentHub process on it.
|
|
* `heap` comes from the tab's settings read.
|
|
*/
|
|
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
|
const { t } = useTranslation("dashboard");
|
|
const s = host.stats;
|
|
const mem = s?.memory;
|
|
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
|
|
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
|
|
return (
|
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
|
|
<CardHead
|
|
icon={Server}
|
|
tone="text-status-success"
|
|
title={t("host.title")}
|
|
right={
|
|
s && (
|
|
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
|
|
{s.hostname}
|
|
</span>
|
|
)
|
|
}
|
|
/>
|
|
{!s ? (
|
|
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2.5">
|
|
<Row label={t("host.os")}>
|
|
{s.os.distro ?? s.os.platform} · {s.os.release}
|
|
</Row>
|
|
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
|
|
<Row label={t("host.cpuModel")}>
|
|
<Tip raw={s.cpu.model ?? undefined}>
|
|
<span>
|
|
{s.cpu.physicalCores
|
|
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
|
|
: ""}
|
|
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
|
|
</span>
|
|
</Tip>
|
|
</Row>
|
|
{s.os.platform !== "win32" && (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
|
{t("host.load")}
|
|
</span>
|
|
<div className="flex gap-1 ml-auto">
|
|
{s.cpu.load.slice(0, 3).map((load, i) => (
|
|
<span
|
|
key={i}
|
|
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
|
>
|
|
{load.toFixed(2)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{s.process && (
|
|
<Row label={t("host.agenthub")}>
|
|
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
|
<span>
|
|
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
|
|
</span>
|
|
</Tip>
|
|
</Row>
|
|
)}
|
|
</div>
|
|
<div className="border-t border-border/40 pt-3 space-y-3">
|
|
{s.usage && (
|
|
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
|
|
)}
|
|
<HealthBar
|
|
label={t("host.memory")}
|
|
value={mem ? pct(mem.used, mem.total) : null}
|
|
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
|
/>
|
|
{mem && mem.swapTotal > 0 && (
|
|
<HealthBar
|
|
label={t("host.swap")}
|
|
value={pct(mem.swapUsed, mem.swapTotal)}
|
|
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
|
|
/>
|
|
)}
|
|
{heap && (
|
|
<HealthBar
|
|
label={t("health.heap")}
|
|
value={heapPct}
|
|
warn={60}
|
|
bad={85}
|
|
tip={t("health.heapTip", {
|
|
pct: (heapPct ?? 0).toFixed(1),
|
|
used: formatBytes(heap.heapUsed),
|
|
limit: formatBytes(heapMax),
|
|
allocated: formatBytes(heap.heapTotal),
|
|
external: formatBytes(heap.external),
|
|
})}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The tab's machine group: host + history, CPU / disks / processes, users. */
|
|
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
|
const { t } = useTranslation("dashboard");
|
|
const { stats } = host;
|
|
const [range, setRange] = useState<HostHistoryRange>("1h");
|
|
const [history, setHistory] = useState<HostHistory | null>(null);
|
|
// The user tile whose busiest processes are shown.
|
|
const [open, setOpen] = useState<string | null>(null);
|
|
|
|
const loadHistory = useCallback(
|
|
() =>
|
|
api.host
|
|
.history(range)
|
|
.then(setHistory)
|
|
.catch(() => setHistory(null)),
|
|
[range]
|
|
);
|
|
useEffect(() => {
|
|
void loadHistory();
|
|
const id = setInterval(() => {
|
|
if (document.visibilityState === "visible") void loadHistory();
|
|
}, HISTORY_POLL_MS);
|
|
return () => clearInterval(id);
|
|
}, [loadHistory]);
|
|
|
|
// Same tree as the full render below, so the card is not remounted once stats arrive.
|
|
if (!stats)
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<HostCard host={host} heap={heap} />
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const groups = stats.usage?.groups ?? [];
|
|
const groupName = (g: HostUsageGroup) => {
|
|
if (g.kind === "host") return t("host.hostGroup");
|
|
if (g.kind === "system") return t("host.systemGroup");
|
|
return g.displayName || g.username || "";
|
|
};
|
|
const groupByKey = new Map(groups.map((g) => [g.key, g]));
|
|
const ownerOf = (key: string) => {
|
|
const g = groupByKey.get(key);
|
|
if (g) return groupName(g);
|
|
if (key === "host") return t("host.hostGroup");
|
|
if (key === "system") return t("host.systemGroup");
|
|
return key;
|
|
};
|
|
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
|
const timeLabel = (iso: string) =>
|
|
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
const histPoints = (key: "cpu" | "memory") =>
|
|
(history?.points ?? [])
|
|
.filter((p) => p[key] != null)
|
|
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
|
|
const perCore = stats.cpu.perCore ?? [];
|
|
const network = stats.network ?? [];
|
|
const temps = stats.temperatures ?? [];
|
|
const topProcs = stats.topProcesses ?? [];
|
|
const others = groups.filter((g) => g.kind !== "user");
|
|
const people = groups.filter((g) => g.kind === "user");
|
|
// History, per-thread CPU and processes are read from /proc: on Windows and
|
|
// macOS their cards would only ever be empty, so they are left out.
|
|
const linux = stats.os.platform === "linux";
|
|
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
|
|
const showProcs = !!stats.usage || others.length > 0;
|
|
|
|
// A whole disk's I/O goes on the first mounted file system that lives on it
|
|
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
|
// mounted gets a row of its own.
|
|
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
|
|
const diskRows = stats.disks.map((d) => {
|
|
const base = d.device.split("/").pop() ?? "";
|
|
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
|
|
const io = key ? ioLeft.get(key) : undefined;
|
|
if (key) ioLeft.delete(key);
|
|
return { disk: d, io };
|
|
});
|
|
const loneIo = [...ioLeft.values()];
|
|
|
|
const rate = (down?: number, up?: number) => (
|
|
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
|
|
<span className="inline-flex items-center gap-0.5">
|
|
<ArrowDown className="w-2.5 h-2.5" />
|
|
{down == null ? "—" : perSec(down)}
|
|
</span>
|
|
<span className="inline-flex items-center gap-0.5">
|
|
<ArrowUp className="w-2.5 h-2.5" />
|
|
{up == null ? "—" : perSec(up)}
|
|
</span>
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<HostCard host={host} heap={heap} />
|
|
|
|
{/* CPU and memory over time. */}
|
|
{linux && (
|
|
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
|
<CardHead
|
|
icon={Activity}
|
|
tone="text-accent"
|
|
title={t("host.historyTitle")}
|
|
right={
|
|
<div
|
|
role="group"
|
|
aria-label={t("host.range")}
|
|
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
|
>
|
|
{RANGES.map((r) => (
|
|
<button
|
|
key={r}
|
|
type="button"
|
|
onClick={() => setRange(r)}
|
|
aria-pressed={range === r}
|
|
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
|
range === r
|
|
? "bg-surface-4 text-fg-secondary"
|
|
: "text-fg-muted hover:text-fg-secondary"
|
|
}`}
|
|
>
|
|
{t(`host.range${r}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
<div>
|
|
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
|
<MiniChart
|
|
kind="line"
|
|
points={histPoints("cpu")}
|
|
max={100}
|
|
format={fmtPct}
|
|
label={t("host.historyCpu")}
|
|
emptyText={t("host.historyNone")}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
|
<MiniChart
|
|
kind="line"
|
|
points={histPoints("memory")}
|
|
max={100}
|
|
format={fmtPct}
|
|
label={t("host.historyMemory")}
|
|
emptyText={t("host.historyNone")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{history?.startedAt && (
|
|
<p className="text-[10px] text-fg-muted">
|
|
{t("host.historyHelp", {
|
|
s: history.intervalMs / 1000,
|
|
at: formatDateTime(history.startedAt),
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
|
|
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
|
{showCores && (
|
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
|
<CardHead
|
|
icon={Cpu}
|
|
tone="text-syntax-keyword"
|
|
title={t("host.cpu")}
|
|
right={
|
|
<span className="text-[10px] font-mono text-fg-muted">
|
|
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
|
</span>
|
|
}
|
|
/>
|
|
{perCore.length > 0 && (
|
|
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
|
{perCore.map((v, i) => (
|
|
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{temps.length > 0 && (
|
|
<div
|
|
className="border-t border-border/40 pt-3 space-y-2"
|
|
data-testid="server-temperatures"
|
|
>
|
|
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
|
<Thermometer className="w-3 h-3 text-status-warning" />
|
|
{t("host.temperatures")}
|
|
</div>
|
|
{temps.map((s, i) => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
|
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
|
</span>
|
|
<span
|
|
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
|
s.celsius >= 90
|
|
? "bg-status-danger/20 text-status-danger"
|
|
: s.celsius >= 75
|
|
? "bg-status-warning/20 text-status-warning"
|
|
: "bg-surface-3 text-fg-secondary"
|
|
}`}
|
|
>
|
|
{s.celsius.toFixed(0)} °C
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{stats.gpus.length > 0 && (
|
|
<div className="border-t border-border/40 pt-3 space-y-3">
|
|
{stats.gpus.map((g, i) => (
|
|
<HealthBar
|
|
key={i}
|
|
label={g.name}
|
|
value={g.utilization}
|
|
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Disks (with their I/O) and network interfaces. */}
|
|
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
|
|
<CardHead
|
|
icon={HardDrive}
|
|
tone="text-syntax-builtin"
|
|
title={t("host.storageNetwork")}
|
|
/>
|
|
<div className="space-y-3" data-testid="server-disk-io">
|
|
{diskRows.map(({ disk: d, io }) => (
|
|
<div key={d.device} className="space-y-1">
|
|
<HealthBar
|
|
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
|
|
value={pct(d.used, d.total)}
|
|
warn={75}
|
|
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
|
|
/>
|
|
<div className="flex justify-between gap-2">
|
|
<span className="text-[10px] font-mono text-fg-muted">
|
|
{formatBytes(d.free)} {t("host.free")}
|
|
</span>
|
|
{io && rate(io.read, io.write)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{loneIo.map((io) => (
|
|
<div key={io.name} className="flex items-center gap-3">
|
|
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
|
|
<span className="ml-auto">{rate(io.read, io.write)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{network.length > 0 && (
|
|
<div
|
|
className="border-t border-border/40 pt-3 space-y-2"
|
|
data-testid="server-network"
|
|
>
|
|
{network.map((n) => (
|
|
<div key={n.name} className="flex items-center gap-3">
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
|
|
<Network className="w-3 h-3 text-fg-muted" />
|
|
<span className="font-mono">{n.name}</span>
|
|
</span>
|
|
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* The busiest processes, and what belongs to no user. */}
|
|
{showProcs && (
|
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
|
<CardHead
|
|
icon={ListTree}
|
|
tone="text-syntax-function"
|
|
title={t("host.topProcesses")}
|
|
right={
|
|
<span className="text-[10px] font-mono text-fg-muted">
|
|
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
|
</span>
|
|
}
|
|
/>
|
|
{!stats.usage ? (
|
|
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{topProcs.map((p) => (
|
|
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
|
<div className="flex items-center gap-3">
|
|
<span className="min-w-0 flex-1 truncate text-xs">
|
|
<span className="font-mono text-fg-secondary">{p.name}</span>
|
|
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
|
</span>
|
|
<span className="text-[10px] font-mono text-fg-muted">
|
|
{formatBytes(p.memory)}
|
|
</span>
|
|
<span
|
|
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
|
>
|
|
{fmtPct(p.cpu)}
|
|
</span>
|
|
</div>
|
|
</Tip>
|
|
))}
|
|
</div>
|
|
)}
|
|
{others.length > 0 && (
|
|
<div
|
|
className="border-t border-border/40 pt-3 space-y-1.5"
|
|
data-testid="server-usage-table"
|
|
>
|
|
{others.map((g) => (
|
|
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
|
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
|
{groupName(g)}
|
|
</span>
|
|
<span className="font-mono text-fg-secondary">
|
|
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
|
{t("host.processCount", { n: g.processes })}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Each user's live share of the machine (server mode). */}
|
|
{people.length > 0 && (
|
|
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
|
|
<CardHead
|
|
icon={Users}
|
|
tone="text-syntax-string"
|
|
title={t("host.usersTitle")}
|
|
right={
|
|
<span className="text-[10px] font-mono text-fg-muted">
|
|
{t("host.usersCount", { n: people.length })}
|
|
</span>
|
|
}
|
|
/>
|
|
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{people.map((g) => {
|
|
const name = groupName(g);
|
|
const isOpen = open === g.key;
|
|
return (
|
|
<div key={g.key} className="space-y-2" data-testid="server-user">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(isOpen ? null : g.key)}
|
|
disabled={!g.top.length}
|
|
aria-expanded={isOpen}
|
|
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
|
>
|
|
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
|
|
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
|
{name}
|
|
</span>
|
|
<span className="text-[10px] font-mono text-fg-muted">
|
|
{t("host.processCount", { n: g.processes })}
|
|
</span>
|
|
</button>
|
|
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
|
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
|
{isOpen && (
|
|
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
|
{g.top.map((p) => (
|
|
<li
|
|
key={p.pid}
|
|
className="flex justify-between gap-2 text-[10px] font-mono"
|
|
>
|
|
<span className="truncate text-fg-secondary">
|
|
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
|
</span>
|
|
<span className="flex-shrink-0 text-fg-muted">
|
|
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|