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.
This commit is contained in:
Mike Sawka 2026-04-15 22:36:28 -07:00 committed by GitHub
parent 4a55b7ed0f
commit 158e404d80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 125 additions and 99 deletions

View file

@ -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*"

View file

@ -107,6 +107,14 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch
| <Kbd k="Shift:PageUp"/> | Scroll up one page |
| <Kbd k="Shift:PageDown"/>| Scroll down one page |
## Process Viewer Keybindings
| Key | Function |
| ----------------------- | ------------------------------------- |
| <Kbd k="Space"/> | Pause / resume live updates |
| <Kbd k="Cmd:f"/> | Open process filter / search |
| <Kbd k="Escape"/> | 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).

View file

@ -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

View file

@ -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";
<PlatformProvider>
@ -138,4 +139,10 @@ You can also save by pressing <Kbd k="Cmd:s" />.
To exit **edit mode** without saving, click the cancel button to the right of the header.
You can also exit without saving by pressing <Kbd k="Cmd:r" />.
### Process Viewer <VersionBadge version="v0.14.5" />
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 <Kbd k="Cmd:f"/> and typing a search term. Press <Kbd k="Space"/> to pause live updates (useful when inspecting a specific process); press it again to resume.
</PlatformProvider>

View file

@ -6,8 +6,8 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {
<div className="flex flex-col items-start gap-6 w-full mb-4 unselectable">
<div className="text-secondary leading-relaxed">
<p className="mb-0">
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.
</p>
</div>
@ -24,19 +24,6 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {
</div>
</div>
<div className="flex w-full items-start gap-4">
<div className="flex-shrink-0">
<i className="text-[24px] text-accent fa-solid fa-terminal"></i>
</div>
<div className="flex flex-col items-start gap-2 flex-1">
<div className="text-foreground text-base font-semibold leading-[18px]">Quake Mode</div>
<div className="text-secondary leading-5">
The global hotkey (<code>app:globalhotkey</code>) now triggers a dedicated quake mode that
drops a Wave window down from the top of the screen, similar to classic quake-style terminals.
</div>
</div>
</div>
<div className="flex w-full items-start gap-4">
<div className="flex-shrink-0">
<i className="text-[24px] text-accent fa-solid fa-wrench"></i>
@ -46,18 +33,21 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {
<div className="text-secondary leading-5">
<ul className="list-disc list-outside space-y-1 pl-5">
<li>
<strong>Drag &amp; Drop Files into Terminal</strong> - Drag files from Finder or your
file manager into a terminal to paste their quoted path
<strong>Quake Mode</strong> &mdash; global hotkey (
<code>app:globalhotkey</code>) now toggles a Wave window visible and invisible
</li>
<li>
New opt-in <code>app:showsplitbuttons</code> setting adds split buttons to block
headers
<strong>Drag &amp; Drop Files into Terminal</strong>
to paste their quoted path
</li>
<li>Toggle the widgets sidebar from the View menu</li>
<li>
New <code>app:showsplitbuttons</code> setting adds split buttons to block headers
</li>
<li>Toggle the widgets sidebar on and off from the View menu</li>
<li>F2 to rename the active tab</li>
<li>Mouse back/forward buttons now navigate in web widgets</li>
<li>
<strong>[bugfix]</strong>{" "}Config files that didn&apos;t exist yet couldn&apos;t be
<strong>[bugfix]</strong> Config files that didn&apos;t exist yet couldn&apos;t be
created or edited from the Settings widget
</li>
</ul>

View file

@ -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 (
<div
@ -618,9 +622,9 @@ const ProcessRow = React.memo(function ProcessRow({
{proc.pid}
</div>
<div className="px-2 flex items-center truncate text-muted italic">(gone)</div>
{showStatus && <div className="px-2 flex items-center truncate" />}
<div className="px-2 flex items-center truncate" />
{showThreads && <div className="px-2 flex items-center truncate" />}
{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>
@ -637,11 +641,13 @@ const ProcessRow = React.memo(function ProcessRow({
{proc.pid}
</div>
<div className="px-2 flex items-center truncate">{proc.command}</div>
{showStatus && (
{visibleKeys.has("status") && (
<div className="px-2 flex items-center truncate text-secondary text-[11px]">{proc.status}</div>
)}
<div className="px-2 flex items-center truncate text-secondary">{proc.user}</div>
{showThreads && (
{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>

4
package-lock.json generated
View file

@ -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": [

View file

@ -40,5 +40,15 @@
"view": "sysinfo"
}
}
},
"defwidget@processviewer": {
"display:order": -1,
"icon": "list-tree",
"label": "processes",
"blockdef": {
"meta": {
"view": "processviewer"
}
}
}
}

View file

@ -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]