From 158e404d8039a0d9c74d6bd9f34e16db562b1fa1 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 22:36:28 -0700 Subject: [PATCH] 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. --- Taskfile.yml | 3 + docs/docs/keybindings.mdx | 8 ++ docs/docs/releasenotes.mdx | 2 +- docs/docs/widgets.mdx | 7 + .../onboarding/onboarding-upgrade-v0145.tsx | 32 ++--- .../app/view/processviewer/processviewer.tsx | 24 ++-- package-lock.json | 4 +- pkg/wconfig/defaultconfig/widgets.json | 10 ++ pkg/wshrpc/wshremote/processviewer.go | 134 +++++++++--------- 9 files changed, 125 insertions(+), 99 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 106ac99e0..bf37a83e4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -156,6 +156,7 @@ tasks: - tsunami/go.mod - tsunami/go.sum - tsunami/**/*.go + - package.json build:schema: desc: Build the schema for configuration. @@ -185,6 +186,7 @@ tasks: - "pkg/**/*.json" - "pkg/**/*.sh" - tsunami/**/*.go + - package.json generates: - dist/bin/wavesrv.* @@ -289,6 +291,7 @@ tasks: sources: - "cmd/wsh/**/*.go" - "pkg/**/*.go" + - package.json generates: - "dist/bin/wsh*" diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index 36ca33a9c..d5d88856e 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -107,6 +107,14 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch | | Scroll up one page | | | Scroll down one page | +## Process Viewer Keybindings + +| Key | Function | +| ----------------------- | ------------------------------------- | +| | Pause / resume live updates | +| | Open process filter / search | +| | Close search bar | + ## Customizeable Systemwide Global Hotkey Wave allows setting a custom global hotkey to focus your most recent window from anywhere in your computer. For more information on this, see [the config docs](./config#customizable-systemwide-global-hotkey). diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index 352af2da7..987be8153 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -11,7 +11,7 @@ sidebar_position: 200 Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for global hotkey, and several quality-of-life improvements. - **Process Viewer** - New widget that displays running processes on local and remote machines, with CPU and memory usage, sortable columns, and the ability to send signals to processes -- **Quake Mode** - The global hotkey (`app:globalhotkey`) now triggers a dedicated quake mode that drops a Wave window down from the top of the screen, similar to classic quake-style terminals +- **Quake Mode** - The global hotkey (`app:globalhotkey`) now toggles a Wave window visible and invisible - **[bugfix] Settings Widget** - Fixed a bug where config files that didn't exist yet couldn't be created or edited from the Settings widget UI - **Drag & Drop Files into Terminal** - Drag files from Finder (macOS) or your file manager into a terminal block to paste their quoted path ([#746](https://github.com/wavetermdev/waveterm/issues/746)) - New opt-in `app:showsplitbuttons` setting adds horizontal/vertical split buttons to block headers diff --git a/docs/docs/widgets.mdx b/docs/docs/widgets.mdx index d8795ca4e..52257619d 100644 --- a/docs/docs/widgets.mdx +++ b/docs/docs/widgets.mdx @@ -6,6 +6,7 @@ title: "Widgets" import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; +import { VersionBadge } from "@site/src/components/versionbadge"; @@ -138,4 +139,10 @@ You can also save by pressing . To exit **edit mode** without saving, click the cancel button to the right of the header. You can also exit without saving by pressing . +### Process Viewer + +The Process Viewer shows a live list of running processes on any connected host. It is similar to `top` or `htop`, displaying PID, command, CPU%, and memory usage. On Linux it also shows process status and thread count. + +Columns are sortable by clicking their headers. Right-clicking a row lets you send Unix signals (SIGTERM, SIGKILL, etc.) or copy the PID. You can filter the list by pressing and typing a search term. Press to pause live updates (useful when inspecting a specific process); press it again to resume. + diff --git a/frontend/app/onboarding/onboarding-upgrade-v0145.tsx b/frontend/app/onboarding/onboarding-upgrade-v0145.tsx index 88408ef82..be0b43cf4 100644 --- a/frontend/app/onboarding/onboarding-upgrade-v0145.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-v0145.tsx @@ -6,8 +6,8 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {

- Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for the global hotkey, and several - quality-of-life improvements. + Wave v0.14.5 introduces a new Process Viewer widget, several quality-of-life improvements, and a + fix for creating new config files from the Settings widget.

@@ -24,19 +24,6 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {
-
-
- -
-
-
Quake Mode
-
- The global hotkey (app:globalhotkey) now triggers a dedicated quake mode that - drops a Wave window down from the top of the screen, similar to classic quake-style terminals. -
-
-
-
@@ -46,18 +33,21 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {
  • - Drag & Drop Files into Terminal - Drag files from Finder or your - file manager into a terminal to paste their quoted path + Quake Mode — global hotkey ( + app:globalhotkey) now toggles a Wave window visible and invisible
  • - New opt-in app:showsplitbuttons setting adds split buttons to block - headers + Drag & Drop Files into Terminal + to paste their quoted path
  • -
  • Toggle the widgets sidebar from the View menu
  • +
  • + New app:showsplitbuttons setting adds split buttons to block headers +
  • +
  • Toggle the widgets sidebar on and off from the View menu
  • F2 to rename the active tab
  • Mouse back/forward buttons now navigate in web widgets
  • - [bugfix]{" "}Config files that didn't exist yet couldn't be + [bugfix] Config files that didn't exist yet couldn't be created or edited from the Settings widget
diff --git a/frontend/app/view/processviewer/processviewer.tsx b/frontend/app/view/processviewer/processviewer.tsx index a9d79c6ee..d7f284bcf 100644 --- a/frontend/app/view/processviewer/processviewer.tsx +++ b/frontend/app/view/processviewer/processviewer.tsx @@ -311,6 +311,10 @@ export class ProcessViewerViewModel implements ViewModel { this.cancelPoll = null; this.startKeepAlive(); } else { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; this.startPolling(); } } @@ -470,7 +474,7 @@ 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: "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" }, @@ -603,9 +607,9 @@ const ProcessRow = React.memo(function ProcessRow({ 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); - const showStatus = platform !== "windows" && platform !== "darwin"; - const showThreads = platform !== "windows"; if (proc.gone) { return (
(gone)
- {showStatus &&
} -
- {showThreads &&
} + {visibleKeys.has("status") &&
} + {visibleKeys.has("user") &&
} + {visibleKeys.has("threads") &&
}
@@ -637,11 +641,13 @@ const ProcessRow = React.memo(function ProcessRow({ {proc.pid}
{proc.command}
- {showStatus && ( + {visibleKeys.has("status") && (
{proc.status}
)} -
{proc.user}
- {showThreads && ( + {visibleKeys.has("user") && ( +
{proc.user}
+ )} + {visibleKeys.has("threads") && (
{proc.numthreads === -1 ? "-" : proc.numthreads >= 1 ? proc.numthreads : ""}
diff --git a/package-lock.json b/package-lock.json index 667722b00..9e4a67a93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.5-beta.0", + "version": "0.14.5-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.5-beta.0", + "version": "0.14.5-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 2d0524b7d..eb978d644 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -40,5 +40,15 @@ "view": "sysinfo" } } + }, + "defwidget@processviewer": { + "display:order": -1, + "icon": "list-tree", + "label": "processes", + "blockdef": { + "meta": { + "view": "processviewer" + } + } } } diff --git a/pkg/wshrpc/wshremote/processviewer.go b/pkg/wshrpc/wshremote/processviewer.go index f4248d51f..dc6bb0ee6 100644 --- a/pkg/wshrpc/wshremote/processviewer.go +++ b/pkg/wshrpc/wshremote/processviewer.go @@ -27,6 +27,7 @@ import ( const ( ProcCacheIdleTimeout = 60 * time.Second ProcCachePollInterval = 1 * time.Second + ProcCacheMinSleep = 500 * time.Millisecond ProcViewerMaxLimit = 500 ) @@ -153,17 +154,48 @@ func (s *procCacheState) getWidgetPidOrder(widgetId string) ([]int32, int) { return entry.pids, entry.totalCount } +// updateCacheAndCheckIdle stores the latest snapshot, signals the first-ready channel if needed, +// and checks whether the loop has been idle long enough to shut down. +// Returns true if the loop should exit (idle timeout reached), false to continue. +func (s *procCacheState) updateCacheAndCheckIdle(result *wshrpc.ProcessListResponse, firstDone *bool, firstReadyCh chan struct{}) bool { + s.lock.Lock() + defer s.lock.Unlock() + if result != nil { + s.cached = result + } + if !*firstDone { + *firstDone = true + close(firstReadyCh) + s.ready = nil + } + if time.Since(s.lastRequest) < ProcCacheIdleTimeout { + return false + } + s.cached = nil + s.running = false + s.lastCPUSamples = nil + s.lastCPUEpoch = 0 + s.uidCache = nil + s.widgetPidOrders = nil + return true +} + func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { + firstDone := false defer func() { - panichandler.PanicHandler("procCache.runLoop", recover()) + if panichandler.PanicHandler("procCache.runLoop", recover()) == nil { + return + } + s.lock.Lock() + defer s.lock.Unlock() + s.running = false + if !firstDone { + close(firstReadyCh) + s.ready = nil + } }() - numCPU := runtime.NumCPU() - if numCPU < 1 { - numCPU = 1 - } - - firstDone := false + numCPU := max(runtime.NumCPU(), 1) for { iterStart := time.Now() @@ -178,36 +210,21 @@ func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { } } - 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.widgetPidOrders = nil - s.lock.Unlock() + if s.updateCacheAndCheckIdle(result, &firstDone, firstReadyCh) { return } - s.lock.Unlock() elapsed := time.Since(iterStart) - if sleep := ProcCachePollInterval - elapsed; sleep > 0 { - time.Sleep(sleep) - } + time.Sleep(max(ProcCacheMinSleep, ProcCachePollInterval-elapsed)) } } // 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 runtime.GOOS == "windows" { + return "" + } if s.uidCache == nil { s.uidCache = make(map[uint32]string) } @@ -239,33 +256,25 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse s.lastCPUSamples = make(map[int32]cpuSample, len(procs)) } - snap, _ := procinfo.MakeGlobalSnapshot() + snap, err := procinfo.MakeGlobalSnapshot() + if err != nil { + return nil + } 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} - }() + 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)) @@ -295,10 +304,11 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse } } - // Compute total memory for MemPct. + // Compute total memory for MemPct and summary. + vmStat, _ := mem.VirtualMemoryWithContext(ctx) var totalMem uint64 - if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { - totalMem = vm.Total + if vmStat != nil { + totalMem = vmStat.Total } var cpuSum float64 @@ -331,16 +341,7 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse 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 + summary := buildProcessSummary(ctx, len(procs), numCPU, cpuSum, vmStat) return &wshrpc.ProcessListResponse{ Processes: infos, @@ -351,6 +352,10 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse } } +func bound(v, lo, hi int) int { + return max(lo, min(v, hi)) +} + func computeCPUPct(t1, t2, elapsedSec float64) float64 { delta := (t2 - t1) / elapsedSec * 100 if delta < 0 { @@ -359,17 +364,17 @@ func computeCPUPct(t1, t2, elapsedSec float64) float64 { return delta } -func buildProcessSummary(ctx context.Context, total int, numCPU int, cpuSum float64) wshrpc.ProcessSummary { +func buildProcessSummary(ctx context.Context, total int, numCPU int, cpuSum float64, vmStat *mem.VirtualMemoryStat) 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 + if vmStat != nil { + summary.MemTotal = vmStat.Total + summary.MemUsed = vmStat.Used + summary.MemFree = vmStat.Free } return summary } @@ -544,10 +549,7 @@ func (impl *ServerImpl) RemoteProcessListCommand(ctx context.Context, data wshrp for _, p := range raw.Processes { pidMap[p.Pid] = p } - start := data.Start - if start >= len(pidOrder) { - start = len(pidOrder) - } + start := bound(data.Start, 0, len(pidOrder)) window := pidOrder[start:] if limit > 0 && len(window) > limit { window = window[:limit]