waveterm/frontend/app/view/processviewer/processviewer.tsx
Mike Sawka 158e404d80
Lots of fixes, big and small for processviewer (frontend and backend) (#3224)
The big fix is not spawning a goroutine per process. other fixes are
more minor, but improve the quality and clean up some edge cases.
2026-04-15 22:36:28 -07:00

1030 lines
40 KiB
TypeScript

// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Tooltip } from "@/app/element/tooltip";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { globalStore } from "@/app/store/jotaiStore";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { MetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
import * as keyutil from "@/util/keyutil";
import { isMacOS } from "@/util/platformutil";
import { isBlank, makeConnRoute } from "@/util/util";
import * as jotai from "jotai";
import * as React from "react";
// ---- types ----
type ActionStatus = {
pid: number;
message: string;
isError: boolean;
};
type ProcessViewerEnv = WaveEnvSubset<{
rpc: {
RemoteProcessListCommand: WaveEnv["rpc"]["RemoteProcessListCommand"];
RemoteProcessSignalCommand: WaveEnv["rpc"]["RemoteProcessSignalCommand"];
};
getConnStatusAtom: WaveEnv["getConnStatusAtom"];
getBlockMetaKeyAtom: MetaKeyAtomFnType<"connection">;
}>;
type SortCol = "pid" | "command" | "user" | "cpu" | "mem" | "status" | "threads";
const RowHeight = 24;
const OverscanRows = 100;
// ---- format helpers ----
function formatNumber4(n: number): string {
if (n < 10) return n.toFixed(2);
if (n < 100) return n.toFixed(1);
return Math.floor(n).toString().padStart(4);
}
function fmtMem(bytes: number): string {
if (bytes == null) return "";
if (bytes === -1) return "-";
if (bytes < 1024) return formatNumber4(bytes) + "B";
if (bytes < 1024 * 1024) return formatNumber4(bytes / 1024) + "K";
if (bytes < 1024 * 1024 * 1024) return formatNumber4(bytes / 1024 / 1024) + "M";
return formatNumber4(bytes / 1024 / 1024 / 1024) + "G";
}
function fmtCpu(cpu: number): string {
if (cpu == null) return "";
if (cpu === -1) return " -";
if (cpu === 0) return " 0.0%";
if (cpu < 0.005) return "~0.0%";
if (cpu < 10) return cpu.toFixed(2) + "%";
if (cpu < 100) return cpu.toFixed(1) + "%";
if (cpu < 1000) return " " + Math.floor(cpu).toString() + "%";
return Math.floor(cpu).toString() + "%";
}
function fmtLoad(load: number): string {
if (load == null) return " ";
return formatNumber4(load);
}
// ---- model ----
export class ProcessViewerViewModel implements ViewModel {
viewType: string;
blockId: string;
env: ProcessViewerEnv;
viewIcon = jotai.atom<string>("microchip");
viewName = jotai.atom<string>("Processes");
manageConnection = jotai.atom<boolean>(true);
filterOutNowsh = jotai.atom<boolean>(true);
noPadding = jotai.atom<boolean>(true);
dataAtom: jotai.PrimitiveAtom<ProcessListResponse>;
dataStartAtom: jotai.PrimitiveAtom<number>;
sortByAtom: jotai.PrimitiveAtom<SortCol>;
sortDescAtom: jotai.PrimitiveAtom<boolean>;
scrollTopAtom: jotai.PrimitiveAtom<number>;
containerHeightAtom: jotai.PrimitiveAtom<number>;
loadingAtom: jotai.PrimitiveAtom<boolean>;
errorAtom: jotai.PrimitiveAtom<string>;
lastSuccessAtom: jotai.PrimitiveAtom<number>;
pausedAtom: jotai.PrimitiveAtom<boolean>;
selectedPidAtom: jotai.PrimitiveAtom<number>;
actionStatusAtom: jotai.PrimitiveAtom<ActionStatus>;
textSearchAtom: jotai.PrimitiveAtom<string>;
searchOpenAtom: jotai.PrimitiveAtom<boolean>;
fetchIntervalAtom: jotai.PrimitiveAtom<number>;
connection: jotai.Atom<string>;
connStatus: jotai.Atom<ConnStatus>;
disposed = false;
cancelPoll: (() => void) | null = null;
fetchEpoch = 0;
constructor({ blockId, waveEnv }: ViewModelInitType) {
this.viewType = "processviewer";
this.blockId = blockId;
this.env = waveEnv;
this.dataAtom = jotai.atom<ProcessListResponse>(null) as jotai.PrimitiveAtom<ProcessListResponse>;
this.dataStartAtom = jotai.atom<number>(0);
this.sortByAtom = jotai.atom<SortCol>("cpu");
this.sortDescAtom = jotai.atom<boolean>(true);
this.scrollTopAtom = jotai.atom<number>(0);
this.containerHeightAtom = jotai.atom<number>(0);
this.loadingAtom = jotai.atom<boolean>(true);
this.errorAtom = jotai.atom<string>(null) as jotai.PrimitiveAtom<string>;
this.lastSuccessAtom = jotai.atom<number>(0) as jotai.PrimitiveAtom<number>;
this.pausedAtom = jotai.atom<boolean>(false) as jotai.PrimitiveAtom<boolean>;
this.selectedPidAtom = jotai.atom<number>(null) as jotai.PrimitiveAtom<number>;
this.actionStatusAtom = jotai.atom<ActionStatus>(null) as jotai.PrimitiveAtom<ActionStatus>;
this.textSearchAtom = jotai.atom<string>("") as jotai.PrimitiveAtom<string>;
this.searchOpenAtom = jotai.atom<boolean>(false) as jotai.PrimitiveAtom<boolean>;
this.fetchIntervalAtom = jotai.atom<number>(2000) as jotai.PrimitiveAtom<number>;
this.connection = jotai.atom((get) => {
const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection"));
if (isBlank(connValue)) {
return "local";
}
return connValue;
});
this.connStatus = jotai.atom((get) => {
const connName = get(this.env.getBlockMetaKeyAtom(blockId, "connection"));
const connAtom = this.env.getConnStatusAtom(connName);
return get(connAtom);
});
this.startPolling();
}
get viewComponent(): ViewComponent {
return ProcessViewerView;
}
async doOneFetch(lastPidOrder: boolean, cancelledFn?: () => boolean) {
if (this.disposed) return;
const epoch = ++this.fetchEpoch;
const sortBy = globalStore.get(this.sortByAtom);
const sortDesc = globalStore.get(this.sortDescAtom);
const scrollTop = globalStore.get(this.scrollTopAtom);
const containerHeight = globalStore.get(this.containerHeightAtom);
const conn = globalStore.get(this.connection);
const textSearch = globalStore.get(this.textSearchAtom);
const connStatus = globalStore.get(this.connStatus);
if (!connStatus?.connected) {
return;
}
const start = Math.max(0, Math.floor(scrollTop / RowHeight) - OverscanRows);
const visibleRows = containerHeight > 0 ? Math.ceil(containerHeight / RowHeight) : 50;
const limit = visibleRows + OverscanRows * 2;
const route = makeConnRoute(conn);
try {
const resp = await this.env.rpc.RemoteProcessListCommand(
TabRpcClient,
{
widgetid: this.blockId,
sortby: sortBy,
sortdesc: sortDesc,
start,
limit,
textsearch: textSearch || undefined,
lastpidorder: lastPidOrder,
},
{ route }
);
if (!this.disposed && !cancelledFn?.() && this.fetchEpoch === epoch) {
globalStore.set(this.dataAtom, resp);
globalStore.set(this.dataStartAtom, start);
globalStore.set(this.loadingAtom, false);
globalStore.set(this.errorAtom, null);
globalStore.set(this.lastSuccessAtom, Date.now());
}
} catch (e) {
if (!this.disposed && !cancelledFn?.() && this.fetchEpoch === epoch) {
globalStore.set(this.loadingAtom, false);
globalStore.set(this.errorAtom, String(e));
}
}
}
async doKeepAlive() {
if (this.disposed) return;
const connStatus = globalStore.get(this.connStatus);
if (!connStatus?.connected) {
return;
}
const conn = globalStore.get(this.connection);
const route = makeConnRoute(conn);
try {
await this.env.rpc.RemoteProcessListCommand(
TabRpcClient,
{ widgetid: this.blockId, keepalive: true },
{ route }
);
} catch (_) {
// keepalive failures are silent
}
}
startPolling() {
let cancelled = false;
this.cancelPoll = () => {
cancelled = true;
};
const poll = async () => {
while (!cancelled && !this.disposed) {
await this.doOneFetch(false, () => cancelled);
if (cancelled || this.disposed) break;
const interval = globalStore.get(this.fetchIntervalAtom);
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, interval);
this.cancelPoll = () => {
clearTimeout(timer);
cancelled = true;
resolve();
};
});
if (!cancelled) {
this.cancelPoll = () => {
cancelled = true;
};
}
}
};
poll();
}
startKeepAlive() {
let cancelled = false;
this.cancelPoll = () => {
cancelled = true;
};
const keepAliveLoop = async () => {
while (!cancelled && !this.disposed) {
await this.doKeepAlive();
if (cancelled || this.disposed) break;
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 10000);
this.cancelPoll = () => {
clearTimeout(timer);
cancelled = true;
resolve();
};
});
if (!cancelled) {
this.cancelPoll = () => {
cancelled = true;
};
}
}
};
keepAliveLoop();
}
triggerRefresh() {
if (this.cancelPoll) {
this.cancelPoll();
}
this.cancelPoll = null;
if (!globalStore.get(this.pausedAtom)) {
this.startPolling();
}
}
forceRefreshOnConnectionChange() {
if (this.cancelPoll) {
this.cancelPoll();
}
this.cancelPoll = null;
globalStore.set(this.dataAtom, null);
globalStore.set(this.loadingAtom, true);
globalStore.set(this.errorAtom, null);
if (globalStore.get(this.pausedAtom)) {
this.doOneFetch(false);
this.startKeepAlive();
} else {
this.startPolling();
}
}
setPaused(paused: boolean) {
globalStore.set(this.pausedAtom, paused);
if (paused) {
if (this.cancelPoll) {
this.cancelPoll();
}
this.cancelPoll = null;
this.startKeepAlive();
} else {
if (this.cancelPoll) {
this.cancelPoll();
}
this.cancelPoll = null;
this.startPolling();
}
}
setTextSearch(text: string) {
globalStore.set(this.textSearchAtom, text);
if (globalStore.get(this.pausedAtom)) {
this.doOneFetch(false);
} else {
this.triggerRefresh();
}
}
openSearch() {
globalStore.set(this.searchOpenAtom, true);
}
closeSearch() {
globalStore.set(this.searchOpenAtom, false);
globalStore.set(this.textSearchAtom, "");
this.triggerRefresh();
}
keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {
if (keyutil.checkKeyPressed(waveEvent, "Cmd:f")) {
this.openSearch();
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Space") && !globalStore.get(this.searchOpenAtom)) {
this.setPaused(!globalStore.get(this.pausedAtom));
return true;
}
return false;
}
setSort(col: SortCol) {
const curSort = globalStore.get(this.sortByAtom);
const curDesc = globalStore.get(this.sortDescAtom);
const numericCols: SortCol[] = ["cpu", "mem", "threads"];
if (curSort === col) {
globalStore.set(this.sortDescAtom, !curDesc);
} else {
globalStore.set(this.sortByAtom, col);
globalStore.set(this.sortDescAtom, numericCols.includes(col));
}
if (globalStore.get(this.pausedAtom)) {
this.doOneFetch(false);
} else {
this.triggerRefresh();
}
}
setScrollTop(scrollTop: number) {
const cur = globalStore.get(this.scrollTopAtom);
if (Math.abs(cur - scrollTop) < RowHeight) return;
globalStore.set(this.scrollTopAtom, scrollTop);
if (globalStore.get(this.pausedAtom)) {
this.doOneFetch(true);
}
}
setContainerHeight(height: number) {
const cur = globalStore.get(this.containerHeightAtom);
if (cur === height) return;
globalStore.set(this.containerHeightAtom, height);
if (globalStore.get(this.pausedAtom)) {
this.doOneFetch(true);
} else {
this.triggerRefresh();
}
}
async sendSignal(pid: number, signal: string, killLabel?: boolean) {
const conn = globalStore.get(this.connection);
const route = makeConnRoute(conn);
const label = killLabel ? "Killed" : `sent ${signal}`;
try {
await this.env.rpc.RemoteProcessSignalCommand(TabRpcClient, { pid, signal }, { route });
this.setActionStatus({ pid, message: `Process #${pid} ${label}`, isError: false });
} catch (e) {
this.setActionStatus({ pid, message: String(e), isError: true });
}
}
setActionStatus(status: ActionStatus) {
globalStore.set(this.actionStatusAtom, status);
if (!status.isError) {
setTimeout(() => {
const cur = globalStore.get(this.actionStatusAtom);
if (cur === status) {
globalStore.set(this.actionStatusAtom, null);
}
}, 3000);
}
}
clearActionStatus() {
globalStore.set(this.actionStatusAtom, null);
}
setFetchInterval(ms: number) {
globalStore.set(this.fetchIntervalAtom, ms);
this.triggerRefresh();
}
getSettingsMenuItems(): ContextMenuItem[] {
const currentInterval = globalStore.get(this.fetchIntervalAtom);
return [
{
label: "Refresh Interval",
type: "submenu",
submenu: [
{
label: "1 second",
type: "checkbox",
checked: currentInterval === 1000,
click: () => this.setFetchInterval(1000),
},
{
label: "2 seconds",
type: "checkbox",
checked: currentInterval === 2000,
click: () => this.setFetchInterval(2000),
},
{
label: "5 seconds",
type: "checkbox",
checked: currentInterval === 5000,
click: () => this.setFetchInterval(5000),
},
],
},
];
}
dispose() {
this.disposed = true;
if (this.cancelPoll) {
this.cancelPoll();
this.cancelPoll = null;
}
}
}
// ---- column definitions ----
type ColDef = {
key: SortCol;
label: string;
tooltip?: string;
width: string;
align?: "right";
hideOnPlatform?: string[];
};
const Columns: ColDef[] = [
{ key: "pid", label: "PID", width: "70px", align: "right" },
{ key: "command", label: "Command", width: "minmax(120px, 4fr)" },
{ key: "status", label: "Status", width: "75px", hideOnPlatform: ["windows", "darwin"] },
{ key: "user", label: "User", width: "80px", hideOnPlatform: ["windows"] },
{ key: "threads", label: "NT", tooltip: "Num Threads", width: "40px", align: "right", hideOnPlatform: ["windows"] },
{ key: "cpu", label: "CPU%", width: "70px", align: "right" },
{ key: "mem", label: "Memory", width: "90px", align: "right" },
];
function getColumns(platform: string): ColDef[] {
return Columns.filter((c) => !c.hideOnPlatform?.includes(platform));
}
function getGridTemplate(platform: string): string {
return getColumns(platform)
.map((c) => c.width)
.join(" ");
}
// ---- components ----
const SortIndicator = React.memo(function SortIndicator({ active, desc }: { active: boolean; desc: boolean }) {
if (!active) return null;
return <span className="ml-1 text-[10px]">{desc ? "↓" : "↑"}</span>;
});
SortIndicator.displayName = "SortIndicator";
const StatusIndicator = React.memo(function StatusIndicator({ model }: { model: ProcessViewerViewModel }) {
const paused = jotai.useAtomValue(model.pausedAtom);
const error = jotai.useAtomValue(model.errorAtom);
const lastSuccess = jotai.useAtomValue(model.lastSuccessAtom);
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
if (paused) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [paused]);
if (paused) {
const tooltipContent = (
<div className="flex flex-col gap-0.5">
<span>Paused</span>
<span className="text-muted">Click to resume</span>
</div>
);
return (
<Tooltip content={tooltipContent} placement="bottom">
<div
className="flex items-center justify-center w-4 h-4 cursor-pointer text-warning hover:opacity-80 transition-opacity"
onClick={() => model.setPaused(false)}
>
<svg viewBox="0 0 16 16" fill="currentColor" className="w-3.5 h-3.5">
<rect x="2" y="2" width="4" height="12" rx="1" />
<rect x="10" y="2" width="4" height="12" rx="1" />
</svg>
</div>
</Tooltip>
);
}
const stalled = lastSuccess > 0 && now - lastSuccess > 5000;
const circleColor = error != null ? "text-error" : stalled ? "text-warning" : "text-success";
const statusLabel = error != null ? "Error" : stalled ? "Stalled" : "Updating";
const tooltipContent = (
<div className="flex flex-col gap-0.5">
<span>{statusLabel}</span>
<span className="text-muted">Click to pause</span>
</div>
);
return (
<Tooltip content={tooltipContent} placement="bottom">
<div
className={`flex items-center justify-center w-4 h-4 cursor-pointer ${circleColor} hover:opacity-80 transition-opacity`}
onClick={() => model.setPaused(true)}
>
<svg viewBox="0 0 16 16" fill="currentColor" className="w-3 h-3">
<circle cx="8" cy="8" r="6" />
</svg>
</div>
</Tooltip>
);
});
StatusIndicator.displayName = "StatusIndicator";
const TableHeader = React.memo(function TableHeader({
model,
sortBy,
sortDesc,
platform,
}: {
model: ProcessViewerViewModel;
sortBy: SortCol;
sortDesc: boolean;
platform: string;
}) {
const cols = getColumns(platform);
const gridTemplate = getGridTemplate(platform);
return (
<div
className="grid w-full shrink-0 border-b border-white/10 bg-panel text-xs text-secondary font-medium select-none"
style={{ gridTemplateColumns: gridTemplate }}
>
{cols.map((col) => (
<Tooltip
key={col.key}
content={col.tooltip}
disable={!col.tooltip}
divClassName={`px-2 py-1 cursor-pointer hover:text-primary hover:bg-white/5 transition-colors truncate flex items-center${col.align === "right" ? " justify-end" : ""}`}
divOnClick={() => model.setSort(col.key)}
>
<span className="truncate">{col.label}</span>
<SortIndicator active={sortBy === col.key} desc={sortDesc} />
</Tooltip>
))}
</div>
);
});
TableHeader.displayName = "TableHeader";
const ProcessRow = React.memo(function ProcessRow({
proc,
hasCpu,
platform,
selected,
onSelect,
onContextMenu,
}: {
proc: ProcessInfo;
hasCpu: boolean;
platform: string;
selected: boolean;
onSelect: (pid: number) => void;
onContextMenu: (pid: number, e: React.MouseEvent) => void;
}) {
const cols = getColumns(platform);
const visibleKeys = new Set(cols.map((c) => c.key));
const gridTemplate = getGridTemplate(platform);
if (proc.gone) {
return (
<div
className={`grid w-full text-xs transition-colors cursor-pointer ${selected ? "bg-accentbg" : "hover:bg-white/5"}`}
style={{ gridTemplateColumns: gridTemplate, height: RowHeight }}
onClick={() => onSelect(proc.pid)}
onContextMenu={(e) => onContextMenu(proc.pid, e)}
>
<div className="px-2 flex items-center truncate justify-end text-secondary font-mono text-[11px]">
{proc.pid}
</div>
<div className="px-2 flex items-center truncate text-muted italic">(gone)</div>
{visibleKeys.has("status") && <div className="px-2 flex items-center truncate" />}
{visibleKeys.has("user") && <div className="px-2 flex items-center truncate" />}
{visibleKeys.has("threads") && <div className="px-2 flex items-center truncate" />}
<div className="px-2 flex items-center truncate" />
<div className="px-2 flex items-center truncate" />
</div>
);
}
return (
<div
className={`grid w-full text-xs transition-colors cursor-pointer ${selected ? "bg-accentbg" : "hover:bg-white/5"}`}
style={{ gridTemplateColumns: gridTemplate, height: RowHeight }}
onClick={() => onSelect(proc.pid)}
onContextMenu={(e) => onContextMenu(proc.pid, e)}
>
<div className="px-2 flex items-center truncate justify-end text-secondary font-mono text-[11px]">
{proc.pid}
</div>
<div className="px-2 flex items-center truncate">{proc.command}</div>
{visibleKeys.has("status") && (
<div className="px-2 flex items-center truncate text-secondary text-[11px]">{proc.status}</div>
)}
{visibleKeys.has("user") && (
<div className="px-2 flex items-center truncate text-secondary">{proc.user}</div>
)}
{visibleKeys.has("threads") && (
<div className="px-2 flex items-center truncate justify-end text-secondary font-mono text-[11px]">
{proc.numthreads === -1 ? "-" : proc.numthreads >= 1 ? proc.numthreads : ""}
</div>
)}
<div className="px-2 flex items-center truncate justify-end font-mono text-[11px] whitespace-pre">
{hasCpu ? fmtCpu(proc.cpu) : ""}
</div>
<div className="px-2 flex items-center truncate justify-end font-mono text-[11px]">{fmtMem(proc.mem)}</div>
</div>
);
});
ProcessRow.displayName = "ProcessRow";
const ActionStatusBar = React.memo(function ActionStatusBar({ model }: { model: ProcessViewerViewModel }) {
const actionStatus = jotai.useAtomValue(model.actionStatusAtom);
if (actionStatus == null) return null;
return (
<div
className={`shrink-0 flex items-center px-3 py-1 text-xs border-t border-white/10 ${actionStatus.isError ? "text-error" : "text-secondary"}`}
>
<span className="flex-1 truncate">
{actionStatus.isError ? `Error: ${actionStatus.message}` : actionStatus.message}
</span>
{actionStatus.isError && (
<button
className="ml-2 shrink-0 flex items-center justify-center w-4 h-4 rounded hover:bg-white/10 transition-colors cursor-pointer text-secondary hover:text-primary"
onClick={() => model.clearActionStatus()}
>
<i className="fa-sharp fa-solid fa-xmark text-[10px]" />
</button>
)}
</div>
);
});
ActionStatusBar.displayName = "ActionStatusBar";
type StatusBarProps = {
model: ProcessViewerViewModel;
data: ProcessListResponse;
loading: boolean;
error: string;
wide: boolean;
};
const StatusBar = React.memo(function StatusBar({ model, data, loading, error, wide }: StatusBarProps) {
const searchOpen = jotai.useAtomValue(model.searchOpenAtom);
const totalCount = data?.totalcount ?? 0;
const filteredCount = data?.filteredcount ?? 0;
const summary = data?.summary;
const memUsedFmt = summary?.memused != null ? fmtMem(summary.memused) : null;
const memTotalFmt = summary?.memtotal != null ? fmtMem(summary.memtotal) : null;
const cpuPct =
summary?.cpusum != null && summary?.numcpu != null && summary.numcpu > 0
? (summary.cpusum / summary.numcpu).toFixed(1).padStart(6, " ")
: null;
const procCountValue =
totalCount > 0
? filteredCount < totalCount
? `${filteredCount}/${totalCount}`
: String(totalCount).padStart(5, " ")
: loading
? "…"
: error
? "Err"
: "";
const hasSummaryLoad = summary != null && summary.load1 != null;
const hasSummaryMem = summary != null && memUsedFmt != null;
const hasSummaryCpu = summary != null && cpuPct != null;
const searchTooltip = isMacOS() ? "Search (Cmd-F)" : "Search (Alt-F)";
if (wide) {
return (
<div className="shrink-0 text-xs text-secondary border-b border-white/10 bg-panel flex items-center gap-2 px-2 py-1">
<div className="shrink-0 flex items-center">
<StatusIndicator model={model} />
</div>
{hasSummaryLoad && (
<span className="shrink-0 whitespace-pre">
Load{" "}
<span className="font-mono text-[11px]">
{fmtLoad(summary.load1)} {fmtLoad(summary.load5)} {fmtLoad(summary.load15)}
</span>
</span>
)}
{hasSummaryMem && (
<>
<div className="w-px self-stretch bg-white/10 shrink-0" />
<span className="shrink-0 whitespace-pre">
Mem{" "}
<span className="font-mono text-[11px]">
{memUsedFmt} / {memTotalFmt}
</span>
</span>
</>
)}
{hasSummaryCpu && (
<>
<div className="w-px self-stretch bg-white/10 shrink-0" />
<Tooltip
content={`100% per core · ${summary.numcpu} ${summary.numcpu === 1 ? "core" : "cores"} = ${summary.numcpu * 100}% max`}
placement="bottom"
>
<span className="shrink-0 cursor-default whitespace-pre">
CPU<span className="font-mono text-[11px]">x{summary.numcpu}</span>{" "}
<span className="font-mono text-[11px]">{cpuPct}%</span>
</span>
</Tooltip>
</>
)}
<span className="ml-auto whitespace-pre">
Procs <span className="font-mono text-[11px]">{procCountValue}</span>
</span>
<Tooltip content={searchTooltip} placement="bottom">
<button
className={`shrink-0 flex items-center justify-center w-4 h-4 rounded hover:bg-white/10 transition-colors cursor-pointer hover:text-primary ${searchOpen ? "text-primary" : "text-secondary"}`}
onClick={() => (searchOpen ? model.closeSearch() : model.openSearch())}
>
<i className="fa-sharp fa-solid fa-magnifying-glass text-[10px]" />
</button>
</Tooltip>
</div>
);
}
return (
<div className="shrink-0 text-xs text-secondary border-b border-white/10 bg-panel flex items-center px-2 py-1">
<div className="shrink-0 flex items-center mr-1">
<StatusIndicator model={model} />
</div>
<div className="flex-1 max-w-3" />
<div className="flex flex-row flex-1 min-w-0 items-center">
{hasSummaryLoad && (
<div className="flex flex-col shrink-0 w-[100px] mr-1">
<div>Load</div>
<div className="font-mono text-[11px] whitespace-pre">
{fmtLoad(summary.load1)} {fmtLoad(summary.load5)} {fmtLoad(summary.load15)}
</div>
</div>
)}
{hasSummaryLoad && <div className="flex-1 max-w-3" />}
{hasSummaryMem && (
<div className="flex flex-col shrink-0 w-[95px] mr-1">
<div>Mem</div>
<div className="font-mono text-[11px] whitespace-pre">
{memUsedFmt} / {memTotalFmt}
</div>
</div>
)}
{hasSummaryMem && <div className="flex-1 max-w-3" />}
{hasSummaryCpu && (
<div className="flex flex-col shrink-0 w-[55px] mr-1">
<Tooltip
content={`100% per core · ${summary.numcpu} ${summary.numcpu === 1 ? "core" : "cores"} = ${summary.numcpu * 100}% max`}
placement="bottom"
>
<div className="cursor-default">
CPU<span className="font-mono text-[11px]">x{summary.numcpu}</span>
</div>
</Tooltip>
<div className="font-mono text-[11px] whitespace-pre">{cpuPct}%</div>
</div>
)}
{hasSummaryCpu && <div className="flex-1 max-w-3" />}
<div className="flex-1" />
<div className="flex flex-col w-[38px] shrink-0">
<div>Procs</div>
<div className="font-mono text-[11px] whitespace-pre">{procCountValue}</div>
</div>
<Tooltip content={searchTooltip} placement="bottom">
<button
className={`shrink-0 ml-1 flex items-center justify-center w-4 h-4 rounded hover:bg-white/10 transition-colors cursor-pointer hover:text-primary ${searchOpen ? "text-primary" : "text-secondary"}`}
onClick={() => (searchOpen ? model.closeSearch() : model.openSearch())}
>
<i className="fa-sharp fa-solid fa-magnifying-glass text-[10px]" />
</button>
</Tooltip>
</div>
</div>
);
});
StatusBar.displayName = "StatusBar";
const SearchBar = React.memo(function SearchBar({ model }: { model: ProcessViewerViewModel }) {
const searchOpen = jotai.useAtomValue(model.searchOpenAtom);
const textSearch = jotai.useAtomValue(model.textSearchAtom);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (searchOpen && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [searchOpen]);
if (!searchOpen) return null;
return (
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-white/10 bg-panel">
<input
ref={inputRef}
type="text"
value={textSearch}
placeholder="Filter processes…"
className="flex-1 bg-transparent text-xs text-primary placeholder-secondary outline-none min-w-0"
onChange={(e) => model.setTextSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
model.closeSearch();
}
}}
/>
<button
className="shrink-0 flex items-center justify-center w-4 h-4 rounded hover:bg-white/10 transition-colors cursor-pointer text-secondary hover:text-primary"
onClick={() => model.closeSearch()}
>
<i className="fa-sharp fa-solid fa-xmark text-[10px]" />
</button>
</div>
);
});
SearchBar.displayName = "SearchBar";
export const ProcessViewerView: React.FC<ViewComponentProps<ProcessViewerViewModel>> = React.memo(
function ProcessViewerView({ blockId: _blockId, blockRef: _blockRef, contentRef: _contentRef, model }) {
const data = jotai.useAtomValue(model.dataAtom);
const sortBy = jotai.useAtomValue(model.sortByAtom);
const sortDesc = jotai.useAtomValue(model.sortDescAtom);
const loading = jotai.useAtomValue(model.loadingAtom);
const error = jotai.useAtomValue(model.errorAtom);
const [selectedPid, setSelectedPid] = jotai.useAtom(model.selectedPidAtom);
const dataStart = jotai.useAtomValue(model.dataStartAtom);
const connection = jotai.useAtomValue(model.connection);
const connStatus = jotai.useAtomValue(model.connStatus);
const bodyScrollRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [wide, setWide] = React.useState(false);
const isFirstRender = React.useRef(true);
React.useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
model.forceRefreshOnConnectionChange();
}, [connection]);
const handleSelectPid = React.useCallback(
(pid: number) => {
setSelectedPid((cur) => (cur === pid ? null : pid));
},
[setSelectedPid]
);
const handleContextMenu = React.useCallback(
(pid: number, e: React.MouseEvent) => {
e.preventDefault();
model.setPaused(true);
setSelectedPid(pid);
const platform = globalStore.get(model.dataAtom)?.platform ?? "";
const isWindows = platform === "windows";
const menu: ContextMenuItem[] = [
{
label: "Copy PID",
click: () => navigator.clipboard.writeText(String(pid)),
},
{ type: "separator" },
];
if (!isWindows) {
menu.push({
label: "Signal",
type: "submenu",
submenu: [
{ label: "SIGTERM", click: () => model.sendSignal(pid, "SIGTERM") },
{ label: "SIGINT", click: () => model.sendSignal(pid, "SIGINT") },
{ label: "SIGHUP", click: () => model.sendSignal(pid, "SIGHUP") },
{ label: "SIGKILL", click: () => model.sendSignal(pid, "SIGKILL") },
{ label: "SIGUSR1", click: () => model.sendSignal(pid, "SIGUSR1") },
{ label: "SIGUSR2", click: () => model.sendSignal(pid, "SIGUSR2") },
],
});
menu.push({ type: "separator" });
menu.push({
label: "Kill Process",
click: () => model.sendSignal(pid, "SIGTERM", true),
});
}
menu.push({ type: "separator" });
menu.push(...model.getSettingsMenuItems());
ContextMenuModel.getInstance().showContextMenu(menu, e);
},
[model, setSelectedPid]
);
const platform = data?.platform ?? "";
const totalCount = data?.totalcount ?? 0;
const filteredCount = data?.filteredcount ?? totalCount;
const processes = data?.processes ?? [];
const hasCpu = data?.hascpu ?? false;
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
model.setContainerHeight(entry.contentRect.height);
setWide(entry.contentRect.width >= 600);
}
});
ro.observe(el);
model.setContainerHeight(el.clientHeight);
setWide(el.clientWidth >= 600);
return () => ro.disconnect();
}, [model]);
const handleScroll = React.useCallback(() => {
const el = bodyScrollRef.current;
if (!el) return;
model.setScrollTop(el.scrollTop);
}, [model]);
const totalHeight = filteredCount * RowHeight;
const paddingTop = dataStart * RowHeight;
return (
<div className="flex flex-col w-full h-full overflow-hidden" ref={containerRef}>
<StatusBar model={model} data={data} loading={loading} error={error} wide={wide} />
<SearchBar model={model} />
{/* error */}
{error != null && <div className="px-3 py-2 text-xs text-error shrink-0">{error}</div>}
{/* outer h-scroll wrapper */}
<div className="flex-1 overflow-x-auto overflow-y-hidden">
{!connStatus?.connected ? (
<div className="flex items-center justify-center h-full text-secondary text-sm">
Waiting for connection
</div>
) : (
<div className="flex flex-col h-full min-w-full w-max">
<TableHeader model={model} sortBy={sortBy} sortDesc={sortDesc} platform={platform} />
<div
ref={bodyScrollRef}
className="flex-1 overflow-y-auto overflow-x-hidden w-full wide-scrollbar"
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: "relative" }}>
<div style={{ position: "absolute", top: paddingTop, left: 0, right: 0 }}>
{processes.map((proc) => (
<ProcessRow
key={proc.pid}
proc={proc}
hasCpu={hasCpu}
platform={platform}
selected={selectedPid === proc.pid}
onSelect={handleSelectPid}
onContextMenu={handleContextMenu}
/>
))}
</div>
</div>
</div>
</div>
)}
</div>
<ActionStatusBar model={model} />
</div>
);
}
);
ProcessViewerView.displayName = "ProcessViewerView";