From c2aecae8673d0a2a191d23088e6acd9917939749 Mon Sep 17 00:00:00 2001 From: Jonathan Atta Date: Wed, 11 Mar 2026 17:26:51 +0100 Subject: [PATCH] feat: add GetManPage function and integrate parent process name in ProcessDetail --- app.go | 5 + backend/sysinfo.go | 34 +++++ frontend/src/App.tsx | 215 +++++++++++++++++++++++++----- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 2 + 6 files changed, 227 insertions(+), 35 deletions(-) diff --git a/app.go b/app.go index b5e21ff..e104662 100644 --- a/app.go +++ b/app.go @@ -56,3 +56,8 @@ func (a *App) GetProcessDetail(pid int32) (*backend.ProcessDetail, error) { func (a *App) KillProcess(pid int32, force bool) error { return backend.KillProcess(pid, force) } + +// GetManPage returns the plain-text manual page for the given command. +func (a *App) GetManPage(name string) string { + return backend.GetManPage(name) +} diff --git a/backend/sysinfo.go b/backend/sysinfo.go index a05e3a0..c65b812 100644 --- a/backend/sysinfo.go +++ b/backend/sysinfo.go @@ -211,6 +211,7 @@ type ProcessDetail struct { Username string `json:"username"` CreatedAt int64 `json:"created_at"` // unix ms ParentPID int32 `json:"parent_pid"` + ParentName string `json:"parent_name"` Nice int32 `json:"nice"` NumThreads int32 `json:"num_threads"` NumFDs int32 `json:"num_fds"` @@ -258,6 +259,13 @@ func FetchProcessDetail(pid int32) (*ProcessDetail, error) { } if v, e := p.Ppid(); e == nil { d.ParentPID = v + if d.ParentPID > 0 { + if pp, err2 := ps.NewProcess(d.ParentPID); err2 == nil { + if pname, err2 := pp.Name(); err2 == nil { + d.ParentName = pname + } + } + } } if v, e := p.Nice(); e == nil { d.Nice = v @@ -304,4 +312,30 @@ func KillProcess(pid int32, force bool) error { return p.Kill() } return p.Terminate() +} +// GetManPage returns the plain-text man page for the given command name. +// Returns an empty string if man is not installed or the page does not exist. +func GetManPage(name string) string { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "man", "-P", "cat", name) + out, err := cmd.Output() + if err != nil { + return "" + } + // Strip nroff backspace-based bold/underline formatting (char + backspace + char) + raw := []byte(out) + cleaned := make([]byte, 0, len(raw)) + for i := 0; i < len(raw); i++ { + if i+1 < len(raw) && raw[i+1] == '\b' { + i++ // skip formatting char and backspace; the real char follows + } else { + cleaned = append(cleaned, raw[i]) + } + } + text := strings.TrimSpace(string(cleaned)) + if len(text) > 12000 { + text = text[:12000] + "\n…[truncated]" + } + return text } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95f4321..4ee6180 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -47,6 +47,7 @@ type ProcessDetail = { username: string created_at: number parent_pid: number + parent_name: string nice: number num_threads: number num_fds: number @@ -64,6 +65,7 @@ type ProcessDetail = { } type SortField = 'cpu' | 'mem' +type NavItem = { pid: number; name: string } // --------------------------------------------------------------------------- // Helpers @@ -119,10 +121,10 @@ function MetricValue({ value, pct }: { value: string; pct: number }) { ) } -function DetailRow({ label, value, mono = false }: { label: string; value: React.ReactNode; mono?: boolean }) { +function DetailRow({ label, value, mono = false, tooltip }: { label: string; value: React.ReactNode; mono?: boolean; tooltip?: string }) { return ( -
- {label} +
+ {label} {value || '—'}
) @@ -136,9 +138,9 @@ function SectionTitle({ children }: { children: React.ReactNode }) { ) } -function StatBlock({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) { +function StatBlock({ label, value, sub, tooltip }: { label: string; value: React.ReactNode; sub?: string; tooltip?: string }) { return ( -
+

{label}

{value}

{sub &&

{sub}

} @@ -153,16 +155,28 @@ function StatBlock({ label, value, sub }: { label: string; value: React.ReactNod function ProcessDetailPanel({ pid, onClose, + onNavigate, + navStack, + onNavBack, + gpuType, }: { pid: number onClose: () => void + onNavigate: (pid: number, fromName: string) => void + navStack: NavItem[] + onNavBack: (index: number) => void + gpuType?: string }) { const [detail, setDetail] = useState(null) const [loading, setLoading] = useState(true) const [killing, setKilling] = useState(false) const [killConfirm, setKillConfirm] = useState(false) const [killError, setKillError] = useState(null) + const [manPage, setManPage] = useState(null) + const [manPageLoading, setManPageLoading] = useState(false) + const [manOpen, setManOpen] = useState(false) const intervalRef = useRef | null>(null) + const lastManNameRef = useRef('') const load = useCallback(async () => { try { @@ -204,6 +218,17 @@ function ProcessDetailPanel({ return () => window.removeEventListener('keydown', onKey) }, [onClose]) + useEffect(() => { + if (!detail?.name || detail.name === lastManNameRef.current) return + lastManNameRef.current = detail.name + setManPageLoading(true) + setManPage(null) + ;(window as any)['go']['main']['App']['GetManPage'](detail.name) + .then((text: string) => { setManPage(text || null) }) + .catch(() => setManPage(null)) + .finally(() => setManPageLoading(false)) + }, [detail?.name]) + return ( <> {/* Backdrop */} @@ -217,10 +242,40 @@ function ProcessDetailPanel({ {/* Panel header */}
+ {navStack.length > 0 && ( +
+ {navStack.map((item, i) => ( + + + + + ))} + {detail?.name ?? `PID ${pid}`} +
+ )}
{detail?.name ?? `PID ${pid}`} + {gpuType && ( + + GPU·{gpuType} + + )} {detail?.status && ( Identity
- 0 ? detail.parent_pid : '—'} /> - + {detail.parent_pid > 0 ? ( +
onNavigate(detail.parent_pid, detail.name)} + > +

Parent PID

+

{detail.parent_pid}

+ {detail.parent_name &&

{detail.parent_name}

} +
+ ) : ( + + )} +
{/* Paths */} Executable
- + - {detail.cwd && } + {detail.cwd && }
{/* CPU & Memory */} @@ -342,26 +411,31 @@ function ProcessDetailPanel({
@@ -374,11 +448,13 @@ function ProcessDetailPanel({ label="Read" value={fmtBytes(detail.read_bytes)} sub={`${detail.read_ops.toLocaleString()} ops`} + tooltip="Total bytes read from disk since process start. ops = number of read syscalls issued" />
@@ -390,12 +466,39 @@ function ProcessDetailPanel({
+ + {/* Man page */} + {(manPage !== null || manPageLoading) && ( + <> + Manual page + {manPageLoading ? ( +

Loading…

+ ) : ( +
+ + {manOpen && ( +
+                          {manPage}
+                        
+ )} +
+ )} + + )} )}
@@ -421,9 +524,15 @@ export default function App() { const [procOpen, setProcOpen] = useState(true) const [gpuOpen, setGpuOpen] = useState(true) const [gpuSortBy, setGpuSortBy] = useState<'vram' | 'ram'>('vram') + const [navStack, setNavStack] = useState([]) - const PAGE_SIZE = 20 - const GPU_PAGE_SIZE = 4 + const openDetail = useCallback((pid: number) => { + setSelectedPid(pid) + setNavStack([]) + }, []) + + const [pageSize, setPageSize] = useState(20) + const [gpuPageSize, setGpuPageSize] = useState(4) useEffect(() => { const handler = (m: Metrics) => setMetrics(m) @@ -448,16 +557,16 @@ export default function App() { const gpuProcesses = [...(metrics?.gpu_processes ?? [])].sort((a, b) => gpuSortBy === 'vram' ? b.vram_mb - a.vram_mb : b.ram - a.ram ) - const gpuTotalPages = Math.ceil(gpuProcesses.length / GPU_PAGE_SIZE) + const gpuTotalPages = Math.ceil(gpuProcesses.length / gpuPageSize) const safeGpuPage = Math.min(gpuPage, Math.max(0, gpuTotalPages - 1)) - const pagedGpuProcesses = gpuProcesses.slice(safeGpuPage * GPU_PAGE_SIZE, (safeGpuPage + 1) * GPU_PAGE_SIZE) + const pagedGpuProcesses = gpuProcesses.slice(safeGpuPage * gpuPageSize, (safeGpuPage + 1) * gpuPageSize) const sortedProcesses = metrics ? [...metrics.processes].sort((a, b) => b[sortBy] - a[sortBy]) : [] - const totalPages = Math.ceil(sortedProcesses.length / PAGE_SIZE) + const totalPages = Math.ceil(sortedProcesses.length / pageSize) const safePage = Math.min(page, Math.max(0, totalPages - 1)) - const pagedProcesses = sortedProcesses.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE) + const pagedProcesses = sortedProcesses.slice(safePage * pageSize, (safePage + 1) * pageSize) return (
@@ -554,17 +663,17 @@ export default function App() { - - - - + + + + {pagedProcesses.map((p) => ( setSelectedPid(p.pid)} + onClick={() => openDetail(p.pid)} className={cn( 'border-b border-zinc-900 transition-colors cursor-pointer', selectedPid === p.pid @@ -602,11 +711,21 @@ export default function App() {
PIDNameCPUMemoryPIDNameCPUMemory
{/* Pagination */} - {totalPages > 1 && ( + {totalPages > 0 && (
- - {safePage * PAGE_SIZE + 1}–{Math.min((safePage + 1) * PAGE_SIZE, sortedProcesses.length)} of {sortedProcesses.length} - +
+ + {safePage * pageSize + 1}–{Math.min((safePage + 1) * pageSize, sortedProcesses.length)} of {sortedProcesses.length} + + +