mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { ListTree, X, Skull } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { managerPost } from "@/main-axios";
|
|
import { useManagerData, extractError } from "./useManagerData";
|
|
import { ManagerCardShell } from "./ManagerCardShell";
|
|
import { ManagerSearch } from "./ManagerToolbar";
|
|
import { Select2 } from "@/components/select2";
|
|
|
|
interface ProcessRow {
|
|
pid: number;
|
|
ppid: number;
|
|
user: string;
|
|
cpu: number;
|
|
mem: number;
|
|
command: string;
|
|
args: string;
|
|
}
|
|
|
|
type SortKey = "cpu" | "mem" | "pid";
|
|
type Signal = "TERM" | "KILL";
|
|
|
|
export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
|
|
const { t } = useTranslation();
|
|
const { data, loading, error, refresh } = useManagerData<{
|
|
processes: ProcessRow[];
|
|
}>(hostId, "processes");
|
|
const [filter, setFilter] = useState("");
|
|
const [sort, setSort] = useState<SortKey>("cpu");
|
|
const [tree, setTree] = useState(false);
|
|
const [busy, setBusy] = useState<number | null>(null);
|
|
|
|
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = filter.toLowerCase();
|
|
const list = all.filter(
|
|
(p) =>
|
|
!q ||
|
|
p.args.toLowerCase().includes(q) ||
|
|
p.command.toLowerCase().includes(q) ||
|
|
String(p.pid).includes(q) ||
|
|
p.user.toLowerCase().includes(q),
|
|
);
|
|
return [...list].sort((a, b) => {
|
|
if (sort === "pid") return a.pid - b.pid;
|
|
return b[sort] - a[sort];
|
|
});
|
|
}, [all, filter, sort]);
|
|
|
|
// Build a depth map for the tree view from ppid links (only among visible).
|
|
const treeRows = useMemo(() => {
|
|
if (!tree) return null;
|
|
const byId = new Map(filtered.map((p) => [p.pid, p]));
|
|
const children = new Map<number, ProcessRow[]>();
|
|
const roots: ProcessRow[] = [];
|
|
for (const p of filtered) {
|
|
if (p.ppid && byId.has(p.ppid)) {
|
|
const arr = children.get(p.ppid) ?? [];
|
|
arr.push(p);
|
|
children.set(p.ppid, arr);
|
|
} else {
|
|
roots.push(p);
|
|
}
|
|
}
|
|
const out: Array<{ proc: ProcessRow; depth: number }> = [];
|
|
const walk = (p: ProcessRow, depth: number) => {
|
|
out.push({ proc: p, depth });
|
|
for (const c of children.get(p.pid) ?? []) walk(c, depth + 1);
|
|
};
|
|
roots.forEach((r) => walk(r, 0));
|
|
return out;
|
|
}, [tree, filtered]);
|
|
|
|
const kill = async (pid: number, signal: Signal) => {
|
|
if (hostId == null) return;
|
|
setBusy(pid);
|
|
try {
|
|
const res = await managerPost<{ success: boolean; output: string }>(
|
|
hostId,
|
|
"processes",
|
|
{ pid, signal },
|
|
"signal",
|
|
);
|
|
if (res.success) {
|
|
toast.success(t("hostMetrics.managers.signalSent", { pid }));
|
|
refresh();
|
|
} else {
|
|
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
|
|
}
|
|
} catch (e) {
|
|
toast.error(extractError(e).message);
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const rows = treeRows
|
|
? treeRows.slice(0, 200)
|
|
: filtered.slice(0, 200).map((proc) => ({ proc, depth: 0 }));
|
|
|
|
return (
|
|
<ManagerCardShell
|
|
title={t("hostMetrics.managers.processInspector")}
|
|
icon={<ListTree className="size-3.5" />}
|
|
loading={loading}
|
|
error={error}
|
|
onRefresh={refresh}
|
|
empty={!loading && filtered.length === 0}
|
|
headerExtra={
|
|
<>
|
|
<button
|
|
onClick={() => setTree((v) => !v)}
|
|
className={`flex h-6 items-center gap-1 border px-1.5 text-[10px] transition-colors ${
|
|
tree
|
|
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
|
: "border-border text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<ListTree className="size-3" />
|
|
{t("hostMetrics.managers.tree")}
|
|
</button>
|
|
<Select2
|
|
value={sort}
|
|
onChange={(e) => setSort(e.target.value as SortKey)}
|
|
disabled={tree}
|
|
className="h-6 border border-border bg-background px-1 text-[10px] disabled:opacity-40"
|
|
>
|
|
<option value="cpu">CPU</option>
|
|
<option value="mem">MEM</option>
|
|
<option value="pid">PID</option>
|
|
</Select2>
|
|
</>
|
|
}
|
|
>
|
|
<ManagerSearch
|
|
value={filter}
|
|
onChange={setFilter}
|
|
count={filtered.length}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<div className="grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
|
|
<span>PID</span>
|
|
<span>CPU</span>
|
|
<span>MEM</span>
|
|
<span>CMD</span>
|
|
<span />
|
|
</div>
|
|
{rows.map(({ proc: p, depth }) => (
|
|
<div
|
|
key={p.pid}
|
|
className="relative grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] items-center gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
|
|
>
|
|
<span className="text-muted-foreground">{p.pid}</span>
|
|
<span className="font-bold text-accent-brand">
|
|
{p.cpu.toFixed(0)}%
|
|
</span>
|
|
<span>{p.mem.toFixed(0)}%</span>
|
|
<span
|
|
className="truncate"
|
|
title={`${p.user} · ${p.args}`}
|
|
style={depth ? { paddingLeft: depth * 12 } : undefined}
|
|
>
|
|
{depth > 0 && (
|
|
<span className="text-muted-foreground/40">└ </span>
|
|
)}
|
|
{/* `comm` is capped at 15 chars by the kernel; the full command line is in args. */}
|
|
{p.args || p.command}
|
|
</span>
|
|
<button
|
|
onClick={() => kill(p.pid, "TERM")}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
kill(p.pid, "KILL");
|
|
}}
|
|
disabled={busy === p.pid}
|
|
title={t("hostMetrics.managers.killHint")}
|
|
className="flex size-5 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
|
>
|
|
{busy === p.pid ? (
|
|
<Skull className="size-3" />
|
|
) : (
|
|
<X className="size-3" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ManagerCardShell>
|
|
);
|
|
}
|