diff --git a/.kilocode/skills/create-view/SKILL.md b/.kilocode/skills/create-view/SKILL.md index f39b1ce0d8..49049ca9e5 100644 --- a/.kilocode/skills/create-view/SKILL.md +++ b/.kilocode/skills/create-view/SKILL.md @@ -203,9 +203,11 @@ export const MyView: React.FC> = ({ ### 3. Register the View -Add your view to the `BlockRegistry` in `frontend/app/block/block.tsx`: +Add your view to the `BlockRegistry` in `frontend/app/block/blockregistry.ts`: ```typescript +import { MyViewModel } from "@/app/view/myview/myview-model"; + const BlockRegistry: Map = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index b09cc1bdcc..f199dc5e9c 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -3,21 +3,13 @@ import { BlockComponentModel2, - BlockNodeModel, BlockProps, FullBlockProps, FullSubBlockProps, SubBlockProps, } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { useTabModel } from "@/app/store/tab-model"; -import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; -import { LauncherViewModel } from "@/app/view/launcher/launcher"; -import { PreviewModel } from "@/app/view/preview/preview-model"; -import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; -import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; -import { VDomModel } from "@/app/view/vdom/vdom-model"; -import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { useDebouncedNodeInnerRect } from "@/layout/index"; @@ -26,48 +18,13 @@ import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockCom import { makeORef } from "@/store/wos"; import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; import { isBlank, useAtomValueSafe } from "@/util/util"; -import { HelpViewModel } from "@/view/helpview/helpview"; -import { TermViewModel } from "@/view/term/term-model"; -import { WaveAiModel } from "@/view/waveai/waveai"; -import { WebViewModel } from "@/view/webview/webview"; import clsx from "clsx"; -import { atom, useAtomValue } from "jotai"; +import { useAtomValue } from "jotai"; import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; -import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import "./block.scss"; import { BlockEnv } from "./blockenv"; import { BlockFrame } from "./blockframe"; -import { blockViewToIcon, blockViewToName } from "./blockutil"; - -const BlockRegistry: Map = new Map(); -BlockRegistry.set("term", TermViewModel); -BlockRegistry.set("preview", PreviewModel); -BlockRegistry.set("web", WebViewModel); -BlockRegistry.set("waveai", WaveAiModel); -BlockRegistry.set("cpuplot", SysinfoViewModel); -BlockRegistry.set("sysinfo", SysinfoViewModel); -BlockRegistry.set("vdom", VDomModel); -BlockRegistry.set("tips", QuickTipsViewModel); -BlockRegistry.set("help", HelpViewModel); -BlockRegistry.set("launcher", LauncherViewModel); -BlockRegistry.set("tsunami", TsunamiViewModel); -BlockRegistry.set("aifilediff", AiFileDiffViewModel); -BlockRegistry.set("waveconfig", WaveConfigViewModel); - -function makeViewModel( - blockId: string, - blockView: string, - nodeModel: BlockNodeModel, - tabModel: TabModel, - waveEnv: WaveEnv -): ViewModel { - const ctor = BlockRegistry.get(blockView); - if (ctor != null) { - return new ctor({ blockId, nodeModel, tabModel, waveEnv }); - } - return makeDefaultViewModel(blockView); -} +import { makeViewModel } from "./blockregistry"; function getViewElem( blockId: string, @@ -86,18 +43,6 @@ function getViewElem( return ; } -function makeDefaultViewModel(viewType: string): ViewModel { - const viewModel: ViewModel = { - viewType: viewType, - viewIcon: atom(blockViewToIcon(viewType)), - viewName: atom(blockViewToName(viewType)), - preIconButton: atom(null), - endIconButtons: atom(null), - viewComponent: null, - }; - return viewModel; -} - const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { const waveEnv = useWaveEnv(); const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); @@ -250,8 +195,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const focusFromPointerEnter = useCallback( (event: React.PointerEvent) => { const focusFollowsCursorEnabled = - focusFollowsCursorMode === "on" || - (focusFollowsCursorMode === "term" && blockView === "term"); + focusFollowsCursorMode === "on" || (focusFollowsCursorMode === "term" && blockView === "term"); if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) { return; } diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts new file mode 100644 index 0000000000..5de7e05bd3 --- /dev/null +++ b/frontend/app/block/blockregistry.ts @@ -0,0 +1,65 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import type { TabModel } from "@/app/store/tab-model"; +import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; +import { LauncherViewModel } from "@/app/view/launcher/launcher"; +import { PreviewModel } from "@/app/view/preview/preview-model"; +import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; +import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; +import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; +import { WaveEnv } from "@/app/waveenv/waveenv"; +import { atom } from "jotai"; +import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; +import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; +import { blockViewToIcon, blockViewToName } from "./blockutil"; +import { HelpViewModel } from "@/view/helpview/helpview"; +import { TermViewModel } from "@/view/term/term-model"; +import { WaveAiModel } from "@/view/waveai/waveai"; +import { WebViewModel } from "@/view/webview/webview"; + +const BlockRegistry: Map = new Map(); +BlockRegistry.set("term", TermViewModel); +BlockRegistry.set("preview", PreviewModel); +BlockRegistry.set("web", WebViewModel); +BlockRegistry.set("waveai", WaveAiModel); +BlockRegistry.set("cpuplot", SysinfoViewModel); +BlockRegistry.set("sysinfo", SysinfoViewModel); +BlockRegistry.set("vdom", VDomModel); +BlockRegistry.set("tips", QuickTipsViewModel); +BlockRegistry.set("help", HelpViewModel); +BlockRegistry.set("launcher", LauncherViewModel); +BlockRegistry.set("tsunami", TsunamiViewModel); +BlockRegistry.set("aifilediff", AiFileDiffViewModel); +BlockRegistry.set("waveconfig", WaveConfigViewModel); +BlockRegistry.set("processviewer", ProcessViewerViewModel); + +function makeDefaultViewModel(viewType: string): ViewModel { + const viewModel: ViewModel = { + viewType: viewType, + viewIcon: atom(blockViewToIcon(viewType)), + viewName: atom(blockViewToName(viewType)), + preIconButton: atom(null), + endIconButtons: atom(null), + viewComponent: null, + }; + return viewModel; +} + +function makeViewModel( + blockId: string, + blockView: string, + nodeModel: BlockNodeModel, + tabModel: TabModel, + waveEnv: WaveEnv +): ViewModel { + const ctor = BlockRegistry.get(blockView); + if (ctor != null) { + return new ctor({ blockId, nodeModel, tabModel, waveEnv }); + } + return makeDefaultViewModel(blockView); +} + +export { makeViewModel }; diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 92d976400f..3ef4d39821 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -42,6 +42,9 @@ export function blockViewToIcon(view: string): string { if (view == "tips") { return "lightbulb"; } + if (view == "processviewer") { + return "microchip"; + } return "square"; } @@ -67,6 +70,9 @@ export function blockViewToName(view: string): string { if (view == "tips") { return "Tips"; } + if (view == "processviewer") { + return "Processes"; + } return view; } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 2f5024f0ef..191877de82 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -756,6 +756,18 @@ export class RpcApiType { return client.wshRpcCall("remotemkdir", data, opts); } + // command "remoteprocesslist" [call] + RemoteProcessListCommand(client: WshClient, data: CommandRemoteProcessListData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteprocesslist", data, opts); + return client.wshRpcCall("remoteprocesslist", data, opts); + } + + // command "remoteprocesssignal" [call] + RemoteProcessSignalCommand(client: WshClient, data: CommandRemoteProcessSignalData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteprocesssignal", data, opts); + return client.wshRpcCall("remoteprocesssignal", data, opts); + } + // command "remotereconnecttojobmanager" [call] RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotereconnecttojobmanager", data, opts); diff --git a/frontend/app/view/processviewer/processviewer.tsx b/frontend/app/view/processviewer/processviewer.tsx new file mode 100644 index 0000000000..d4f9a53920 --- /dev/null +++ b/frontend/app/view/processviewer/processviewer.tsx @@ -0,0 +1,852 @@ +// 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 < 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 ""; + return cpu.toFixed(1) + "%"; +} + +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("microchip"); + viewName = jotai.atom("Processes"); + manageConnection = jotai.atom(true); + filterOutNowsh = jotai.atom(true); + noPadding = jotai.atom(true); + + dataAtom: jotai.PrimitiveAtom; + sortByAtom: jotai.PrimitiveAtom; + sortDescAtom: jotai.PrimitiveAtom; + scrollTopAtom: jotai.PrimitiveAtom; + containerHeightAtom: jotai.PrimitiveAtom; + loadingAtom: jotai.PrimitiveAtom; + errorAtom: jotai.PrimitiveAtom; + lastSuccessAtom: jotai.PrimitiveAtom; + pausedAtom: jotai.PrimitiveAtom; + selectedPidAtom: jotai.PrimitiveAtom; + actionStatusAtom: jotai.PrimitiveAtom; + textSearchAtom: jotai.PrimitiveAtom; + searchOpenAtom: jotai.PrimitiveAtom; + + connection: jotai.Atom; + connStatus: jotai.Atom; + + disposed = false; + cancelPoll: (() => void) | null = null; + + constructor({ blockId, waveEnv }: ViewModelInitType) { + this.viewType = "processviewer"; + this.blockId = blockId; + this.env = waveEnv; + + this.dataAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.sortByAtom = jotai.atom("cpu"); + this.sortDescAtom = jotai.atom(true); + this.scrollTopAtom = jotai.atom(0); + this.containerHeightAtom = jotai.atom(0); + this.loadingAtom = jotai.atom(true); + this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.lastSuccessAtom = jotai.atom(0) as jotai.PrimitiveAtom; + this.pausedAtom = jotai.atom(false) as jotai.PrimitiveAtom; + this.selectedPidAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.actionStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.textSearchAtom = jotai.atom("") as jotai.PrimitiveAtom; + this.searchOpenAtom = jotai.atom(false) as jotai.PrimitiveAtom; + + 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(cancelledFn?: () => boolean) { + if (this.disposed) return; + 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 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, + { sortby: sortBy, sortdesc: sortDesc, start, limit, textsearch: textSearch || undefined }, + { route } + ); + if (!this.disposed && !cancelledFn?.()) { + globalStore.set(this.dataAtom, resp); + globalStore.set(this.loadingAtom, false); + globalStore.set(this.errorAtom, null); + globalStore.set(this.lastSuccessAtom, Date.now()); + } + } catch (e) { + if (!this.disposed && !cancelledFn?.()) { + globalStore.set(this.loadingAtom, false); + globalStore.set(this.errorAtom, String(e)); + } + } + } + + startPolling() { + let cancelled = false; + this.cancelPoll = () => { + cancelled = true; + }; + + const poll = async () => { + while (!cancelled && !this.disposed) { + await this.doOneFetch(() => cancelled); + + if (cancelled || this.disposed) break; + + await new Promise((resolve) => { + const timer = setTimeout(resolve, 1000); + this.cancelPoll = () => { + clearTimeout(timer); + cancelled = true; + resolve(); + }; + }); + + if (!cancelled) { + this.cancelPoll = () => { + cancelled = true; + }; + } + } + }; + + poll(); + } + + triggerRefresh() { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; + if (!globalStore.get(this.pausedAtom)) { + this.startPolling(); + } + } + + setPaused(paused: boolean) { + globalStore.set(this.pausedAtom, paused); + if (paused) { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; + } else { + this.startPolling(); + } + } + + setTextSearch(text: string) { + globalStore.set(this.textSearchAtom, text); + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(); + } 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(); + } else { + this.triggerRefresh(); + } + } + + setScrollTop(scrollTop: number) { + const cur = globalStore.get(this.scrollTopAtom); + if (Math.abs(cur - scrollTop) < RowHeight) return; + globalStore.set(this.scrollTopAtom, scrollTop); + this.triggerRefresh(); + } + + setContainerHeight(height: number) { + const cur = globalStore.get(this.containerHeightAtom); + if (cur === height) return; + globalStore.set(this.containerHeightAtom, height); + 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); + } + + 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" }, + { 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 {desc ? "↓" : "↑"}; +}); +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 = ( +
+ Paused + Click to resume +
+ ); + return ( + +
model.setPaused(false)} + > + + + + +
+
+ ); + } + + 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 = ( +
+ {statusLabel} + Click to pause +
+ ); + + return ( + +
model.setPaused(true)} + > + + + +
+
+ ); +}); +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 ( +
+ {cols.map((col) => ( + model.setSort(col.key)} + > + {col.label} + + + ))} +
+ ); +}); +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 gridTemplate = getGridTemplate(platform); + const showStatus = platform !== "windows" && platform !== "darwin"; + const showThreads = platform !== "windows"; + return ( +
onSelect(proc.pid)} + onContextMenu={(e) => onContextMenu(proc.pid, e)} + > +
+ {proc.pid} +
+
{proc.command}
+ {showStatus && ( +
{proc.status}
+ )} +
{proc.user}
+ {showThreads && ( +
+ {proc.numthreads >= 1 ? proc.numthreads : ""} +
+ )} +
+ {hasCpu && proc.cpu != null ? fmtCpu(proc.cpu) : ""} +
+
{fmtMem(proc.mem)}
+
+ ); +}); +ProcessRow.displayName = "ProcessRow"; + +const ActionStatusBar = React.memo(function ActionStatusBar({ model }: { model: ProcessViewerViewModel }) { + const actionStatus = jotai.useAtomValue(model.actionStatusAtom); + if (actionStatus == null) return null; + + return ( +
+ + {actionStatus.isError ? `Error: ${actionStatus.message}` : actionStatus.message} + + {actionStatus.isError && ( + + )} +
+ ); +}); +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 ( +
+
+ +
+ {hasSummaryLoad && ( + + Load{" "} + + {fmtLoad(summary.load1)} {fmtLoad(summary.load5)} {fmtLoad(summary.load15)} + + + )} + {hasSummaryMem && ( + <> +
+ + Mem{" "} + + {memUsedFmt} / {memTotalFmt} + + + + )} + {hasSummaryCpu && ( + <> +
+ + + CPUx{summary.numcpu}{" "} + {cpuPct}% + + + + )} + + Procs {procCountValue} + + + + +
+ ); + } + + return ( +
+
+ +
+
+
+ {hasSummaryLoad && ( +
+
Load
+
+ {fmtLoad(summary.load1)} {fmtLoad(summary.load5)} {fmtLoad(summary.load15)} +
+
+ )} + {hasSummaryLoad &&
} + {hasSummaryMem && ( +
+
Mem
+
+ {memUsedFmt} / {memTotalFmt} +
+
+ )} + {hasSummaryMem &&
} + {hasSummaryCpu && ( +
+ +
+ CPUx{summary.numcpu} +
+
+
{cpuPct}%
+
+ )} + {hasSummaryCpu &&
} +
+
+
Procs
+
{procCountValue}
+
+ + + +
+
+ ); +}); +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(null); + + React.useEffect(() => { + if (searchOpen && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [searchOpen]); + + if (!searchOpen) return null; + + return ( +
+ model.setTextSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + model.closeSearch(); + } + }} + /> + +
+ ); +}); +SearchBar.displayName = "SearchBar"; + +export const ProcessViewerView: React.FC> = 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 scrollTop = jotai.useAtomValue(model.scrollTopAtom); + const [selectedPid, setSelectedPid] = jotai.useAtom(model.selectedPidAtom); + const bodyScrollRef = React.useRef(null); + const containerRef = React.useRef(null); + const [wide, setWide] = React.useState(false); + + 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), + }); + } + + ContextMenuModel.getInstance().showContextMenu(menu, e); + }, + [model, setSelectedPid] + ); + + const platform = data?.platform ?? ""; + const startIdx = Math.max(0, Math.floor(scrollTop / RowHeight) - OverscanRows); + 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 = startIdx * RowHeight; + + return ( +
+ + + + {/* error */} + {error != null &&
{error}
} + + {/* outer h-scroll wrapper */} +
+ {/* inner column — expands to header's natural width, rows match */} +
+ + + {/* virtualized rows — same width as header, scrolls vertically */} +
+
+
+ {processes.map((proc) => ( + + ))} +
+
+
+
+
+ +
+ ); + } +); +ProcessViewerView.displayName = "ProcessViewerView"; diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index ea5a8b0b90..123b9d3144 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -22,6 +22,7 @@ export const PreviewWorkspaceId = crypto.randomUUID(); export const PreviewClientId = crypto.randomUUID(); export const WebBlockId = crypto.randomUUID(); export const SysinfoBlockId = crypto.randomUUID(); +export const ProcessViewerBlockId = crypto.randomUUID(); // What works "out of the box" in the mock environment (no MockEnv overrides needed): // @@ -388,7 +389,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { oid: PreviewTabId, version: 1, name: "Preview Tab", - blockids: [WebBlockId, SysinfoBlockId], + blockids: [WebBlockId, SysinfoBlockId, ProcessViewerBlockId], meta: {}, } as Tab, [`block:${WebBlockId}`]: { @@ -410,6 +411,14 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { "graph:numpoints": 90, }, } as Block, + [`block:${ProcessViewerBlockId}`]: { + otype: "block", + oid: ProcessViewerBlockId, + version: 1, + meta: { + view: "processviewer", + }, + } as Block, }; const defaultAtoms: Partial = { uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), diff --git a/frontend/preview/previews/processviewer.preview.tsx b/frontend/preview/previews/processviewer.preview.tsx new file mode 100644 index 0000000000..f4ab1d0289 --- /dev/null +++ b/frontend/preview/previews/processviewer.preview.tsx @@ -0,0 +1,96 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; +import { ProcessViewerBlockId } from "../mock/mockwaveenv"; +import { useRpcOverride } from "../mock/use-rpc-override"; + +const PreviewNodeId = "preview-processviewer-node"; + +const MockProcesses: ProcessInfo[] = [ + { pid: 1, ppid: 0, command: "launchd", user: "root", cpu: 0.0, mem: 4096 * 1024 }, + { pid: 123, ppid: 1, command: "kernel_task", user: "root", cpu: 12.3, mem: 2048 * 1024 * 1024 }, + { pid: 456, ppid: 1, command: "WindowServer", user: "_windowserver", cpu: 5.1, mem: 512 * 1024 * 1024 }, + { pid: 789, ppid: 1, command: "node", user: "mike", cpu: 8.7, mem: 256 * 1024 * 1024 }, + { pid: 1001, ppid: 1, command: "Electron", user: "mike", cpu: 3.2, mem: 400 * 1024 * 1024 }, + { pid: 1234, ppid: 1001, command: "waveterm-helper", user: "mike", cpu: 0.5, mem: 64 * 1024 * 1024 }, + { pid: 2001, ppid: 1, command: "sshd", user: "root", cpu: 0.0, mem: 8 * 1024 * 1024 }, + { pid: 2345, ppid: 1, command: "postgres", user: "postgres", cpu: 1.2, mem: 128 * 1024 * 1024 }, + { pid: 3001, ppid: 1, command: "nginx", user: "_www", cpu: 0.3, mem: 32 * 1024 * 1024 }, + { pid: 3456, ppid: 1, command: "python3", user: "mike", cpu: 2.8, mem: 96 * 1024 * 1024 }, + { pid: 4001, ppid: 1, command: "docker", user: "root", cpu: 0.1, mem: 48 * 1024 * 1024 }, + { pid: 4567, ppid: 4001, command: "containerd", user: "root", cpu: 0.2, mem: 80 * 1024 * 1024 }, + { pid: 5001, ppid: 1, command: "zsh", user: "mike", cpu: 0.0, mem: 6 * 1024 * 1024 }, + { pid: 5678, ppid: 5001, command: "vim", user: "mike", cpu: 0.0, mem: 20 * 1024 * 1024 }, + { pid: 6001, ppid: 1, command: "coreaudiod", user: "_coreaudiod", cpu: 0.4, mem: 16 * 1024 * 1024 }, +]; + +const MockSummary: ProcessSummary = { + total: MockProcesses.length, + load1: 1.42, + load5: 1.78, + load15: 2.01, + memtotal: 32 * 1024 * 1024 * 1024, + memused: 18 * 1024 * 1024 * 1024, + memfree: 2 * 1024 * 1024 * 1024, +}; + +function makeMockProcessListResponse(data: CommandRemoteProcessListData): ProcessListResponse { + let procs = [...MockProcesses]; + + const sortBy = (data.sortby as "pid" | "command" | "user" | "cpu" | "mem") ?? "cpu"; + const sortDesc = data.sortdesc ?? false; + + procs.sort((a, b) => { + let cmp = 0; + if (sortBy === "pid") cmp = a.pid - b.pid; + else if (sortBy === "command") cmp = (a.command ?? "").localeCompare(b.command ?? ""); + else if (sortBy === "user") cmp = (a.user ?? "").localeCompare(b.user ?? ""); + else if (sortBy === "cpu") cmp = (a.cpu ?? 0) - (b.cpu ?? 0); + else if (sortBy === "mem") cmp = (a.mem ?? 0) - (b.mem ?? 0); + return sortDesc ? -cmp : cmp; + }); + + const start = data.start ?? 0; + const limit = data.limit ?? procs.length; + const sliced = procs.slice(start, start + limit); + + return { + processes: sliced, + summary: MockSummary, + ts: Date.now(), + hascpu: true, + totalcount: procs.length, + filteredcount: procs.length, + }; +} + +export default function ProcessViewerPreview() { + const nodeModel = React.useMemo( + () => + makeMockNodeModel({ + nodeId: PreviewNodeId, + blockId: ProcessViewerBlockId, + innerRect: { width: "800px", height: "500px" }, + numLeafs: 1, + }), + [] + ); + + useRpcOverride("RemoteProcessListCommand", async (_client, data) => { + return makeMockProcessListResponse(data); + }); + + return ( +
+
processviewer block (mock RPC — RemoteProcessListCommand)
+
+
+ +
+
+
+ ); +} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 7a60b6877d..0e56b7d345 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -557,6 +557,22 @@ declare global { fileinfo?: FileInfo[]; }; + // wshrpc.CommandRemoteProcessListData + type CommandRemoteProcessListData = { + sortby?: string; + sortdesc?: boolean; + start?: number; + limit?: number; + textsearch?: string; + pids?: number[]; + }; + + // wshrpc.CommandRemoteProcessSignalData + type CommandRemoteProcessSignalData = { + pid: number; + signal: string; + }; + // wshrpc.CommandRemoteReconnectToJobManagerData type CommandRemoteReconnectToJobManagerData = { jobid: string; @@ -1244,6 +1260,43 @@ declare global { y: number; }; + // wshrpc.ProcessInfo + type ProcessInfo = { + pid: number; + ppid?: number; + command?: string; + status?: string; + user?: string; + mem?: number; + mempct?: number; + cpu?: number; + numthreads?: number; + }; + + // wshrpc.ProcessListResponse + type ProcessListResponse = { + processes: ProcessInfo[]; + summary: ProcessSummary; + ts: number; + hascpu?: boolean; + platform?: string; + totalcount?: number; + filteredcount?: number; + }; + + // wshrpc.ProcessSummary + type ProcessSummary = { + total: number; + load1?: number; + load5?: number; + load15?: number; + memtotal?: number; + memused?: number; + memfree?: number; + numcpu?: number; + cpusum?: number; + }; + // uctypes.RateLimitInfo type RateLimitInfo = { req: number; diff --git a/go.mod b/go.mod index 1e9e2d3663..e6b811c3bf 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/alexflint/go-filemutex v1.3.0 github.com/creack/pty v1.1.24 + github.com/ebitengine/purego v0.10.0 github.com/emirpasic/gods v1.18.1 github.com/fsnotify/fsnotify v1.9.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -49,7 +50,6 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/package-lock.json b/package-lock.json index 00bae215eb..c6c3fc35eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4-beta.1", + "version": "0.14.4-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4-beta.1", + "version": "0.14.4-beta.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/util/procinfo/procinfo.go b/pkg/util/procinfo/procinfo.go new file mode 100644 index 0000000000..1a1fa549ff --- /dev/null +++ b/pkg/util/procinfo/procinfo.go @@ -0,0 +1,40 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import "errors" + +// ErrNotFound is returned by GetProcInfo when the requested pid does not exist. +var ErrNotFound = errors.New("procinfo: process not found") + +// LinuxStatStatus maps the single-character state from /proc/[pid]/stat to a human-readable name. +var LinuxStatStatus = map[string]string{ + "R": "running", + "S": "sleeping", + "D": "disk-wait", + "Z": "zombie", + "T": "stopped", + "t": "tracing-stop", + "W": "paging", + "X": "dead", + "x": "dead", + "K": "wakekill", + "P": "parked", + "I": "idle", +} + +// ProcInfo holds per-process information read from the OS. +// CpuUser and CpuSys are cumulative CPU seconds since process start; +// callers should diff two samples over a known interval to derive a rate. +type ProcInfo struct { + Pid int32 + Ppid int32 + Command string + Status string + CpuUser float64 // cumulative user CPU seconds + CpuSys float64 // cumulative system CPU seconds + VmRSS uint64 // resident set size in bytes + Uid uint32 + NumThreads int32 +} diff --git a/pkg/util/procinfo/procinfo_darwin.go b/pkg/util/procinfo/procinfo_darwin.go new file mode 100644 index 0000000000..b575d141e2 --- /dev/null +++ b/pkg/util/procinfo/procinfo_darwin.go @@ -0,0 +1,160 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import ( + "context" + "fmt" + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + goproc "github.com/shirou/gopsutil/v4/process" + "golang.org/x/sys/unix" +) + +const ( + systemLibPath = "/usr/lib/libSystem.B.dylib" + procPidInfoSym = "proc_pidinfo" + machTimebaseSym = "mach_timebase_info" + procPidTaskInfo = 4 + kernSuccess = 0 +) + +// From +type machTimebaseInfo struct { + Numer uint32 + Denom uint32 +} + +// From libproc.h / proc_info.h +// This is the struct returned by PROC_PIDTASKINFO. +// Keep field order exact. +type procTaskInfo struct { + VirtualSize uint64 + ResidentSize uint64 + TotalUser uint64 + TotalSystem uint64 + ThreadsUser uint64 + ThreadsSys uint64 + Policy int32 + Faults int32 + Pageins int32 + CowFaults int32 + MessagesSent int32 + MessagesRecv int32 + SyscallsMach int32 + SyscallsUnix int32 + Csw int32 + Threadnum int32 + Numrunning int32 + Priority int32 +} + +var ( + darwinProcOnce sync.Once + darwinProcInitErr error + darwinLibHandle uintptr + darwinProcPidInfo procPidInfoFunc + darwinMachTimebase machTimebaseInfoFunc + darwinTimeScale float64 // mach absolute time units -> nanoseconds +) + +type procPidInfoFunc func(pid, flavor int32, arg uint64, buffer uintptr, bufferSize int32) int32 +type machTimebaseInfoFunc func(info uintptr) int32 + +func MakeGlobalSnapshot() (any, error) { + return nil, nil +} + +// GetProcInfo reads process information for the given pid. +// Core fields come from kern.proc.pid sysctl; CPU times, VmRSS, and NumThreads +// are fetched via proc_pidinfo(PROC_PIDTASKINFO). +func GetProcInfo(ctx context.Context, _ any, pid int32) (*ProcInfo, error) { + k, err := unix.SysctlKinfoProc("kern.proc.pid", int(pid)) + if err != nil { + if err == syscall.ESRCH { + return nil, ErrNotFound + } + return nil, fmt.Errorf("procinfo: SysctlKinfoProc pid %d: %w", pid, err) + } + + info := &ProcInfo{ + Pid: int32(k.Proc.P_pid), + Ppid: k.Eproc.Ppid, + Command: unix.ByteSliceToString(k.Proc.P_comm[:]), + Uid: k.Eproc.Ucred.Uid, + } + + if ti, terr := getDarwinProcTaskInfo(pid); terr == nil { + if darwinTimeScale > 0 { + info.CpuUser = float64(ti.TotalUser) * darwinTimeScale / 1e9 + info.CpuSys = float64(ti.TotalSystem) * darwinTimeScale / 1e9 + } + info.VmRSS = ti.ResidentSize + info.NumThreads = ti.Threadnum + } else { + if p, gerr := goproc.NewProcessWithContext(ctx, pid); gerr == nil { + if mi, merr := p.MemoryInfoWithContext(ctx); merr == nil { + info.VmRSS = mi.RSS + } + if nt, nerr := p.NumThreadsWithContext(ctx); nerr == nil { + info.NumThreads = nt + } + } + } + + return info, nil +} + +func initDarwinProcFuncs() error { + darwinProcOnce.Do(func() { + handle, err := purego.Dlopen(systemLibPath, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + darwinProcInitErr = fmt.Errorf("dlopen %s: %w", systemLibPath, err) + return + } + darwinLibHandle = handle + + purego.RegisterLibFunc(&darwinProcPidInfo, darwinLibHandle, procPidInfoSym) + purego.RegisterLibFunc(&darwinMachTimebase, darwinLibHandle, machTimebaseSym) + + var tb machTimebaseInfo + if rc := darwinMachTimebase(uintptr(unsafe.Pointer(&tb))); rc != kernSuccess { + darwinProcInitErr = fmt.Errorf("mach_timebase_info failed: %d", rc) + return + } + if tb.Denom == 0 { + darwinProcInitErr = fmt.Errorf("mach_timebase_info returned denom=0") + return + } + + darwinTimeScale = float64(tb.Numer) / float64(tb.Denom) + }) + return darwinProcInitErr +} + +func getDarwinProcTaskInfo(pid int32) (*procTaskInfo, error) { + if err := initDarwinProcFuncs(); err != nil { + return nil, err + } + + var ti procTaskInfo + ret := darwinProcPidInfo( + pid, + procPidTaskInfo, + 0, + uintptr(unsafe.Pointer(&ti)), + int32(unsafe.Sizeof(ti)), + ) + if ret <= 0 { + return nil, fmt.Errorf("proc_pidinfo(pid=%d) returned %d", pid, ret) + } + if ret != int32(unsafe.Sizeof(ti)) { + return nil, fmt.Errorf("proc_pidinfo(pid=%d) short read: got=%d want=%d", pid, ret, unsafe.Sizeof(ti)) + } + return &ti, nil +} + diff --git a/pkg/util/procinfo/procinfo_linux.go b/pkg/util/procinfo/procinfo_linux.go new file mode 100644 index 0000000000..c885456fc8 --- /dev/null +++ b/pkg/util/procinfo/procinfo_linux.go @@ -0,0 +1,144 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +// userHz is USER_HZ, the kernel's timer frequency used in /proc/[pid]/stat CPU fields. +// On Linux this is always 100. +const userHz = 100.0 + +// pageSize is cached at init since it never changes at runtime. +var pageSize int64 + +func init() { + pageSize = int64(os.Getpagesize()) + if pageSize <= 0 { + pageSize = 4096 + } +} + +func MakeGlobalSnapshot() (any, error) { + return nil, nil +} + +// GetProcInfo reads process information for the given pid from /proc. +// It reads /proc/[pid]/stat for most fields and /proc/[pid]/status for the UID. +func GetProcInfo(_ context.Context, _ any, pid int32) (*ProcInfo, error) { + info, err := readStat(pid) + if err != nil { + return nil, err + } + if uid, err := readUid(pid); err == nil { + info.Uid = uid + } else if errors.Is(err, ErrNotFound) { + return nil, ErrNotFound + } + return info, nil +} + +// readStat parses /proc/[pid]/stat. +// +// The comm field (field 2) is enclosed in parentheses and may contain spaces +// and even parentheses itself, so we locate the last ')' to find the field +// boundary rather than splitting on whitespace naively. +func readStat(pid int32) (*ProcInfo, error) { + path := fmt.Sprintf("/proc/%d/stat", pid) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("procinfo: read %s: %w", path, err) + } + s := strings.TrimRight(string(data), "\n") + + // Locate comm: everything between first '(' and last ')'. + lp := strings.Index(s, "(") + rp := strings.LastIndex(s, ")") + if lp < 0 || rp < 0 || rp <= lp { + return nil, fmt.Errorf("procinfo: malformed stat for pid %d", pid) + } + + pidStr := strings.TrimSpace(s[:lp]) + comm := s[lp+1 : rp] + rest := strings.Fields(s[rp+1:]) + + // rest[0] = field 3 (state), rest[1] = field 4 (ppid), ... + // Fields after comm are numbered starting at 3, so rest[i] = field (i+3). + // We need: + // rest[0] = field 3 state + // rest[1] = field 4 ppid + // rest[11] = field 14 utime + // rest[12] = field 15 stime + // rest[17] = field 20 num_threads + // rest[21] = field 24 rss (pages) + if len(rest) < 22 { + return nil, fmt.Errorf("procinfo: too few fields in stat for pid %d", pid) + } + + parsedPid, err := strconv.ParseInt(pidStr, 10, 32) + if err != nil { + return nil, fmt.Errorf("procinfo: parse pid: %w", err) + } + + ppid, _ := strconv.ParseInt(rest[1], 10, 32) + utime, _ := strconv.ParseUint(rest[11], 10, 64) + stime, _ := strconv.ParseUint(rest[12], 10, 64) + numThreads, _ := strconv.ParseInt(rest[17], 10, 32) + rssPages, _ := strconv.ParseInt(rest[21], 10, 64) + + statusChar := rest[0] + status, ok := LinuxStatStatus[statusChar] + if !ok { + status = "unknown" + } + + info := &ProcInfo{ + Pid: int32(parsedPid), + Ppid: int32(ppid), + Command: comm, + Status: status, + CpuUser: float64(utime) / userHz, + CpuSys: float64(stime) / userHz, + VmRSS: uint64(rssPages * pageSize), + NumThreads: int32(numThreads), + } + return info, nil +} + +// readUid reads the real UID from /proc/[pid]/status. +// The Uid line looks like: Uid: 1000 1000 1000 1000 +func readUid(pid int32) (uint32, error) { + path := fmt.Sprintf("/proc/%d/status", pid) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, ErrNotFound + } + return 0, fmt.Errorf("procinfo: read %s: %w", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "Uid:") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + break + } + uid, err := strconv.ParseUint(fields[1], 10, 32) + if err != nil { + break + } + return uint32(uid), nil + } + return 0, fmt.Errorf("procinfo: Uid line not found in %s", path) +} diff --git a/pkg/util/procinfo/procinfo_windows.go b/pkg/util/procinfo/procinfo_windows.go new file mode 100644 index 0000000000..39054c6383 --- /dev/null +++ b/pkg/util/procinfo/procinfo_windows.go @@ -0,0 +1,140 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import ( + "context" + "errors" + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var modpsapi = syscall.NewLazyDLL("psapi.dll") +var procGetProcessMemoryInfo = modpsapi.NewProc("GetProcessMemoryInfo") + +// processMemoryCounters mirrors PROCESS_MEMORY_COUNTERS from psapi.h. +type processMemoryCounters struct { + CB uint32 + PageFaultCount uint32 + PeakWorkingSetSize uintptr + WorkingSetSize uintptr + QuotaPeakPagedPoolUsage uintptr + QuotaPagedPoolUsage uintptr + QuotaPeakNonPagedPoolUsage uintptr + QuotaNonPagedPoolUsage uintptr + PagefileUsage uintptr + PeakPagefileUsage uintptr +} + +// snapInfo holds the data collected in a single pass of CreateToolhelp32Snapshot. +type snapInfo struct { + ppid uint32 + numThreads uint32 + exeName string +} + +// windowsSnapshot is the concrete type returned by MakeGlobalSnapshot on Windows. +type windowsSnapshot struct { + procs map[int32]*snapInfo +} + +// MakeGlobalSnapshot enumerates all processes once via CreateToolhelp32Snapshot. +func MakeGlobalSnapshot() (any, error) { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("procinfo: CreateToolhelp32Snapshot: %w", err) + } + defer windows.CloseHandle(snap) + + procs := make(map[int32]*snapInfo) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + if err := windows.Process32First(snap, &entry); err != nil { + return nil, fmt.Errorf("procinfo: Process32First: %w", err) + } + for { + pid := int32(entry.ProcessID) + procs[pid] = &snapInfo{ + ppid: entry.ParentProcessID, + numThreads: entry.Threads, + exeName: windows.UTF16ToString(entry.ExeFile[:]), + } + if err := windows.Process32Next(snap, &entry); err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + break + } + return nil, fmt.Errorf("procinfo: Process32Next: %w", err) + } + } + + return &windowsSnapshot{procs: procs}, nil +} + +// GetProcInfo returns a ProcInfo for the given pid. +// snap must be a non-nil value returned by MakeGlobalSnapshot. +// Returns nil, nil if the pid is not present in the snapshot. +func GetProcInfo(_ context.Context, snap any, pid int32) (*ProcInfo, error) { + if snap == nil { + return nil, fmt.Errorf("procinfo: GetProcInfo requires a snapshot on windows") + } + ws, ok := snap.(*windowsSnapshot) + if !ok { + return nil, fmt.Errorf("procinfo: invalid snapshot type") + } + si, found := ws.procs[pid] + if !found { + return nil, ErrNotFound + } + + info := &ProcInfo{ + Pid: pid, + Ppid: int32(si.ppid), + NumThreads: int32(si.numThreads), + Command: si.exeName, + } + + handle, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION, + false, + uint32(pid), + ) + if err != nil { + // ERROR_INVALID_PARAMETER means the pid no longer exists. + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil, ErrNotFound + } + return info, nil + } + defer windows.CloseHandle(handle) + + var creation, exit, kernel, user windows.Filetime + if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err == nil { + info.CpuUser = filetimeToSeconds(user) + info.CpuSys = filetimeToSeconds(kernel) + } + + var mc processMemoryCounters + mc.CB = uint32(unsafe.Sizeof(mc)) + r, _, _ := procGetProcessMemoryInfo.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&mc)), + uintptr(mc.CB), + ) + if r != 0 { + info.VmRSS = uint64(mc.WorkingSetSize) + } + + return info, nil +} + +// filetimeToSeconds converts a FILETIME (100-ns intervals) to cumulative seconds. +func filetimeToSeconds(ft windows.Filetime) float64 { + ns100 := (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime) + return float64(ns100) / 1e7 +} diff --git a/pkg/util/unixutil/unixutil_unix.go b/pkg/util/unixutil/unixutil_unix.go index f47a0e0f3b..3552f9b194 100644 --- a/pkg/util/unixutil/unixutil_unix.go +++ b/pkg/util/unixutil/unixutil_unix.go @@ -80,3 +80,15 @@ func IsPidRunning(pid int) bool { } return false } + +func SendSignalByName(pid int, sigName string) error { + sig := ParseSignal(sigName) + if sig == nil { + return fmt.Errorf("unsupported or invalid signal %q", sigName) + } + p, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("process %d not found: %w", pid, err) + } + return p.Signal(sig) +} diff --git a/pkg/util/unixutil/unixutil_windows.go b/pkg/util/unixutil/unixutil_windows.go index 5c7f72aba9..9500f198dc 100644 --- a/pkg/util/unixutil/unixutil_windows.go +++ b/pkg/util/unixutil/unixutil_windows.go @@ -44,3 +44,7 @@ func SignalHup(pid int) error { func IsPidRunning(pid int) bool { return false } + +func SendSignalByName(pid int, sigName string) error { + return fmt.Errorf("sending signals is not supported on Windows") +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 2968baa8d7..5c5cd62012 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -753,6 +753,18 @@ func RemoteMkdirCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er return err } +// command "remoteprocesslist", wshserver.RemoteProcessListCommand +func RemoteProcessListCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteProcessListData, opts *wshrpc.RpcOpts) (*wshrpc.ProcessListResponse, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.ProcessListResponse](w, "remoteprocesslist", data, opts) + return resp, err +} + +// command "remoteprocesssignal", wshserver.RemoteProcessSignalCommand +func RemoteProcessSignalCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteProcessSignalData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "remoteprocesssignal", data, opts) + return err +} + // command "remotereconnecttojobmanager", wshserver.RemoteReconnectToJobManagerCommand func RemoteReconnectToJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteReconnectToJobManagerData, opts *wshrpc.RpcOpts) (*wshrpc.CommandRemoteReconnectToJobManagerRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandRemoteReconnectToJobManagerRtnData](w, "remotereconnecttojobmanager", data, opts) diff --git a/pkg/wshrpc/wshremote/processviewer.go b/pkg/wshrpc/wshremote/processviewer.go new file mode 100644 index 0000000000..647c027424 --- /dev/null +++ b/pkg/wshrpc/wshremote/processviewer.go @@ -0,0 +1,470 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshremote + +import ( + "context" + "fmt" + "os" + "os/user" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + goproc "github.com/shirou/gopsutil/v4/process" + "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/util/procinfo" + "github.com/wavetermdev/waveterm/pkg/util/unixutil" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const ( + ProcCacheIdleTimeout = 10 * time.Second + ProcCachePollInterval = 1 * time.Second + ProcViewerMaxLimit = 500 +) + +// cpuSample records a single CPU time measurement for a process. +type cpuSample struct { + CPUSec float64 // user+system cpu seconds at sample time + SampledAt time.Time // when the sample was taken + Epoch int // epoch at which this sample was recorded +} + +// procCacheState is the singleton background cache for process list data. +// lastCPUSamples, lastCPUEpoch, and uidCache are only accessed by the single runLoop goroutine. +type procCacheState struct { + lock sync.Mutex + cached *wshrpc.ProcessListResponse + lastRequest time.Time + running bool + // ready is closed when the first result is placed in cache; set to nil after close. + ready chan struct{} + + lastCPUSamples map[int32]cpuSample + lastCPUEpoch int + uidCache map[uint32]string // uid -> username, populated lazily +} + +// procCache is the singleton background cache for process list data. +var procCache = &procCacheState{} + +// requestAndWait marks the cache as recently requested and returns the current cached +// result. If the background goroutine is not running it starts it and waits for the +// first populate before returning. +func (s *procCacheState) requestAndWait(ctx context.Context) (*wshrpc.ProcessListResponse, error) { + s.lock.Lock() + s.lastRequest = time.Now() + if !s.running { + s.running = true + readyCh := make(chan struct{}) + s.ready = readyCh + go s.runLoop(readyCh) + } + readyCh := s.ready + s.lock.Unlock() + + if readyCh != nil { + select { + case <-readyCh: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + s.lock.Lock() + result := s.cached + s.lock.Unlock() + + if result == nil { + return nil, fmt.Errorf("process list unavailable") + } + return result, nil +} + +func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { + defer func() { + panichandler.PanicHandler("procCache.runLoop", recover()) + }() + + numCPU := runtime.NumCPU() + if numCPU < 1 { + numCPU = 1 + } + + firstDone := false + + for { + iterStart := time.Now() + + s.lastCPUEpoch++ + result := s.collectSnapshot(numCPU) + + // Remove stale entries (pids that weren't seen this epoch). + for pid, sample := range s.lastCPUSamples { + if sample.Epoch < s.lastCPUEpoch { + delete(s.lastCPUSamples, pid) + } + } + + s.lock.Lock() + s.cached = result + idleFor := time.Since(s.lastRequest) + if !firstDone { + firstDone = true + close(firstReadyCh) + s.ready = nil + } + if idleFor >= ProcCacheIdleTimeout { + s.cached = nil + s.running = false + s.lastCPUSamples = nil + s.lastCPUEpoch = 0 + s.uidCache = nil + s.lock.Unlock() + return + } + s.lock.Unlock() + + elapsed := time.Since(iterStart) + if sleep := ProcCachePollInterval - elapsed; sleep > 0 { + time.Sleep(sleep) + } + } +} + +// lookupUID resolves a uid to a username, using the per-run cache to avoid +// repeated syscalls for the same uid. +func (s *procCacheState) lookupUID(uid uint32) string { + if s.uidCache == nil { + s.uidCache = make(map[uint32]string) + } + if name, ok := s.uidCache[uid]; ok { + return name + } + u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)) + if err != nil { + s.uidCache[uid] = "" + return "" + } + name := u.Username + s.uidCache[uid] = name + return name +} + +// collectSnapshot fetches all process info, updates lastCPUSamples with fresh measurements, +// and computes CPU% using each pid's previous sample (if available). +func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + procs, err := goproc.ProcessesWithContext(ctx) + if err != nil { + return nil + } + + if s.lastCPUSamples == nil { + s.lastCPUSamples = make(map[int32]cpuSample, len(procs)) + } + + snap, _ := procinfo.MakeGlobalSnapshot() + + hasCPU := s.lastCPUEpoch > 1 // first epoch has no previous sample to diff against + + // Build per-pid procinfo in parallel, then compute CPU% sequentially. + type pidInfo struct { + pid int32 + info *procinfo.ProcInfo + } + rawInfos := make([]pidInfo, len(procs)) + var wg sync.WaitGroup + for i, p := range procs { + i, p := i, p + wg.Add(1) + go func() { + defer func() { + panichandler.PanicHandler("collectSnapshot:GetProcInfo", recover()) + wg.Done() + }() + pi, err := procinfo.GetProcInfo(ctx, snap, p.Pid) + if err != nil { + pi = nil + } + rawInfos[i] = pidInfo{pid: p.Pid, info: pi} + }() + } + wg.Wait() + + // Sample CPU times and compute CPU% sequentially to keep epoch accounting simple. + cpuPcts := make(map[int32]float64, len(procs)) + sampleTime := time.Now() + for _, ri := range rawInfos { + if ri.info == nil { + continue + } + curCPUSec := ri.info.CpuUser + ri.info.CpuSys + + if hasCPU { + if prev, ok := s.lastCPUSamples[ri.pid]; ok { + elapsed := sampleTime.Sub(prev.SampledAt).Seconds() + if elapsed > 0 { + cpuPcts[ri.pid] = computeCPUPct(prev.CPUSec, curCPUSec, elapsed) + } + } + } + + s.lastCPUSamples[ri.pid] = cpuSample{ + CPUSec: curCPUSec, + SampledAt: sampleTime, + Epoch: s.lastCPUEpoch, + } + } + + // Compute total memory for MemPct. + var totalMem uint64 + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + totalMem = vm.Total + } + + var cpuSum float64 + infos := make([]wshrpc.ProcessInfo, 0, len(rawInfos)) + for _, ri := range rawInfos { + if ri.info == nil { + continue + } + pi := ri.info + info := wshrpc.ProcessInfo{ + Pid: pi.Pid, + Ppid: pi.Ppid, + Command: pi.Command, + Status: pi.Status, + Mem: pi.VmRSS, + NumThreads: pi.NumThreads, + User: s.lookupUID(pi.Uid), + } + if totalMem > 0 { + info.MemPct = float64(pi.VmRSS) / float64(totalMem) * 100 + } + if hasCPU { + if cpu, ok := cpuPcts[pi.Pid]; ok { + v := cpu + info.Cpu = &v + cpuSum += cpu + } + } + infos = append(infos, info) + } + + summaryCh := make(chan wshrpc.ProcessSummary, 1) + go func() { + defer func() { + if err := panichandler.PanicHandler("buildProcessSummary", recover()); err != nil { + summaryCh <- wshrpc.ProcessSummary{Total: len(procs)} + } + }() + summaryCh <- buildProcessSummary(ctx, len(procs), numCPU, cpuSum) + }() + summary := <-summaryCh + + return &wshrpc.ProcessListResponse{ + Processes: infos, + Summary: summary, + Ts: time.Now().UnixMilli(), + HasCPU: hasCPU, + Platform: runtime.GOOS, + } +} + +func computeCPUPct(t1, t2, elapsedSec float64) float64 { + delta := (t2 - t1) / elapsedSec * 100 + if delta < 0 { + delta = 0 + } + return delta +} + +func buildProcessSummary(ctx context.Context, total int, numCPU int, cpuSum float64) wshrpc.ProcessSummary { + summary := wshrpc.ProcessSummary{Total: total, NumCPU: numCPU, CpuSum: cpuSum} + if avg, err := load.AvgWithContext(ctx); err == nil { + summary.Load1 = avg.Load1 + summary.Load5 = avg.Load5 + summary.Load15 = avg.Load15 + } + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + summary.MemTotal = vm.Total + summary.MemUsed = vm.Used + summary.MemFree = vm.Free + } + return summary +} + +func filterProcesses(processes []wshrpc.ProcessInfo, textSearch string) []wshrpc.ProcessInfo { + if textSearch == "" { + return processes + } + search := strings.ToLower(textSearch) + filtered := processes[:0] + for _, p := range processes { + pidStr := strconv.Itoa(int(p.Pid)) + if strings.Contains(strings.ToLower(p.Command), search) || + strings.Contains(strings.ToLower(p.Status), search) || + strings.Contains(strings.ToLower(p.User), search) || + strings.Contains(pidStr, search) { + filtered = append(filtered, p) + } + } + return filtered +} + +func sortAndLimitProcesses(processes []wshrpc.ProcessInfo, sortBy string, sortDesc bool, start int, limit int) []wshrpc.ProcessInfo { + switch sortBy { + case "cpu": + sort.Slice(processes, func(i, j int) bool { + ci, cj := 0.0, 0.0 + if processes[i].Cpu != nil { + ci = *processes[i].Cpu + } + if processes[j].Cpu != nil { + cj = *processes[j].Cpu + } + if sortDesc { + return ci > cj + } + return ci < cj + }) + case "mem": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Mem > processes[j].Mem + } + return processes[i].Mem < processes[j].Mem + }) + case "command": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Command > processes[j].Command + } + return processes[i].Command < processes[j].Command + }) + case "user": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].User > processes[j].User + } + return processes[i].User < processes[j].User + }) + case "status": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Status > processes[j].Status + } + return processes[i].Status < processes[j].Status + }) + case "threads": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].NumThreads > processes[j].NumThreads + } + return processes[i].NumThreads < processes[j].NumThreads + }) + default: // "pid" + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Pid > processes[j].Pid + } + return processes[i].Pid < processes[j].Pid + }) + } + if start > 0 { + if start >= len(processes) { + return nil + } + processes = processes[start:] + } + if limit > 0 && len(processes) > limit { + processes = processes[:limit] + } + return processes +} + +func (impl *ServerImpl) RemoteProcessListCommand(ctx context.Context, data wshrpc.CommandRemoteProcessListData) (*wshrpc.ProcessListResponse, error) { + raw, err := procCache.requestAndWait(ctx) + if err != nil { + return nil, err + } + + // Pids overrides all other request fields; when set we skip sort/limit/start/textsearch + // and return only the exact pids requested. + if len(data.Pids) > 0 { + pidSet := make(map[int32]struct{}, len(data.Pids)) + for _, pid := range data.Pids { + pidSet[pid] = struct{}{} + } + processes := make([]wshrpc.ProcessInfo, 0, len(data.Pids)) + for _, p := range raw.Processes { + if _, ok := pidSet[p.Pid]; ok { + processes = append(processes, p) + } + } + return &wshrpc.ProcessListResponse{ + Processes: processes, + Summary: raw.Summary, + Ts: raw.Ts, + HasCPU: raw.HasCPU, + Platform: raw.Platform, + }, nil + } + + sortBy := data.SortBy + if sortBy == "" { + sortBy = "cpu" + } + limit := data.Limit + if limit <= 0 || limit > ProcViewerMaxLimit { + limit = ProcViewerMaxLimit + } + + totalCount := len(raw.Processes) + + // Copy processes so we can sort/limit without mutating the cache. + processes := make([]wshrpc.ProcessInfo, len(raw.Processes)) + copy(processes, raw.Processes) + processes = filterProcesses(processes, data.TextSearch) + filteredCount := len(processes) + processes = sortAndLimitProcesses(processes, sortBy, data.SortDesc, data.Start, limit) + + return &wshrpc.ProcessListResponse{ + Processes: processes, + Summary: raw.Summary, + Ts: raw.Ts, + HasCPU: raw.HasCPU, + Platform: raw.Platform, + TotalCount: totalCount, + FilteredCount: filteredCount, + }, nil +} + +func (impl *ServerImpl) RemoteProcessSignalCommand(ctx context.Context, data wshrpc.CommandRemoteProcessSignalData) error { + if runtime.GOOS == "windows" { + // special case handling for windows. SIGTERM is mapped to "Kill Process" context menu so will do a proc.Kill() on windows + proc, err := os.FindProcess(int(data.Pid)) + if err != nil { + return fmt.Errorf("process %d not found: %w", data.Pid, err) + } + sig := strings.ToUpper(data.Signal) + if sig == "SIGINT" { + return proc.Signal(os.Interrupt) + } + if sig == "SIGTERM" || sig == "SIGKILL" { + return proc.Kill() + } + return fmt.Errorf("signal %q is not supported on Windows", data.Signal) + } + return unixutil.SendSignalByName(int(data.Pid), data.Signal) +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 2fee3e392e..cc4b793da8 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -128,6 +128,8 @@ type WshRpcInterface interface { RemoteDisconnectFromJobManagerCommand(ctx context.Context, data CommandRemoteDisconnectFromJobManagerData) error RemoteTerminateJobManagerCommand(ctx context.Context, data CommandRemoteTerminateJobManagerData) error BadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error + RemoteProcessListCommand(ctx context.Context, data CommandRemoteProcessListData) (*ProcessListResponse, error) + RemoteProcessSignalCommand(ctx context.Context, data CommandRemoteProcessSignalData) error // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) @@ -908,3 +910,52 @@ type FocusedBlockData struct { TermShellIntegrationStatus string `json:"termshellintegrationstatus,omitempty"` TermLastCommand string `json:"termlastcommand,omitempty"` } + +type ProcessInfo struct { + Pid int32 `json:"pid"` + Ppid int32 `json:"ppid,omitempty"` + Command string `json:"command,omitempty"` + Status string `json:"status,omitempty"` + User string `json:"user,omitempty"` + Mem uint64 `json:"mem,omitempty"` + MemPct float64 `json:"mempct,omitempty"` + Cpu *float64 `json:"cpu,omitempty"` + NumThreads int32 `json:"numthreads,omitempty"` +} + +type ProcessSummary struct { + Total int `json:"total"` + Load1 float64 `json:"load1,omitempty"` + Load5 float64 `json:"load5,omitempty"` + Load15 float64 `json:"load15,omitempty"` + MemTotal uint64 `json:"memtotal,omitempty"` + MemUsed uint64 `json:"memused,omitempty"` + MemFree uint64 `json:"memfree,omitempty"` + NumCPU int `json:"numcpu,omitempty"` + CpuSum float64 `json:"cpusum,omitempty"` +} + +type ProcessListResponse struct { + Processes []ProcessInfo `json:"processes"` + Summary ProcessSummary `json:"summary"` + Ts int64 `json:"ts"` + HasCPU bool `json:"hascpu,omitempty"` + Platform string `json:"platform,omitempty"` + TotalCount int `json:"totalcount,omitempty"` + FilteredCount int `json:"filteredcount,omitempty"` +} + +type CommandRemoteProcessListData struct { + SortBy string `json:"sortby,omitempty"` + SortDesc bool `json:"sortdesc,omitempty"` + Start int `json:"start,omitempty"` + Limit int `json:"limit,omitempty"` + TextSearch string `json:"textsearch,omitempty"` + // Pids overrides all other fields; when set, returns only the specified pids (no sort/limit/start/textsearch). + Pids []int32 `json:"pids,omitempty"` +} + +type CommandRemoteProcessSignalData struct { + Pid int32 `json:"pid"` + Signal string `json:"signal"` +}