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) => (
+
+ onNavBack(i)}
+ className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors hover:underline truncate max-w-[100px]"
+ title={`Back to ${item.name || `PID ${item.pid}`} (PID ${item.pid})`}
+ >
+ {item.name || `PID ${item.pid}`}
+
+
+
+ ))}
+ {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…
+ ) : (
+
+
setManOpen(o => !o)}
+ className="flex items-center gap-1.5 text-xs text-zinc-600 hover:text-zinc-400 transition-colors mb-2"
+ >
+ {manOpen ? : }
+ {manOpen ? 'Collapse' : 'Show'} man {detail.name}
+
+ {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() {
- PID
- Name
- CPU
- Memory
+ PID
+ Name
+ CPU
+ Memory
{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() {
{/* 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}
+
+ { setPageSize(Number(e.target.value)); setPage(0) }}
+ className="text-xs bg-zinc-900 border border-zinc-800 text-zinc-400 rounded px-1.5 py-0.5 cursor-pointer hover:border-zinc-600 transition-colors"
+ title="Items per page"
+ >
+ {[10, 20, 50, 100].map(n => {n} / page )}
+
+
setPage(0)}
@@ -746,10 +865,10 @@ export default function App() {
- Name
- Type
- VRAM
- RAM
+ Name
+ Type
+ VRAM
+ RAM
@@ -757,13 +876,18 @@ export default function App() {
setSelectedPid(p.pid)}
+ onClick={() => openDetail(p.pid)}
>
{p.name}
{/* GPU pagination */}
- {gpuTotalPages > 1 && (
+ {gpuTotalPages > 0 && (
-
- {safeGpuPage * GPU_PAGE_SIZE + 1}–{Math.min((safeGpuPage + 1) * GPU_PAGE_SIZE, gpuProcesses.length)} of {gpuProcesses.length}
-
+
+
+ {safeGpuPage * gpuPageSize + 1}–{Math.min((safeGpuPage + 1) * gpuPageSize, gpuProcesses.length)} of {gpuProcesses.length}
+
+ { setGpuPageSize(Number(e.target.value)); setGpuPage(0) }}
+ className="text-xs bg-zinc-900 border border-zinc-800 text-zinc-400 rounded px-1.5 py-0.5 cursor-pointer hover:border-zinc-600 transition-colors"
+ title="Items per page"
+ >
+ {[4, 10, 20, 50].map(n => {n} / page )}
+
+
setGpuPage(0)} disabled={safeGpuPage === 0}
className="px-2 py-1 rounded text-xs text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">«
@@ -827,7 +961,18 @@ export default function App() {
{selectedPid !== null && (
setSelectedPid(null)}
+ onClose={() => { setSelectedPid(null); setNavStack([]) }}
+ onNavigate={(targetPid, fromName) => {
+ setNavStack(s => [...s, { pid: selectedPid, name: fromName }])
+ setSelectedPid(targetPid)
+ }}
+ navStack={navStack}
+ onNavBack={(index) => {
+ const target = navStack[index]
+ setNavStack(s => s.slice(0, index))
+ setSelectedPid(target.pid)
+ }}
+ gpuType={metrics?.gpu_processes?.find(gp => gp.pid === selectedPid)?.type}
/>
)}
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 2b2a37f..a0e6e87 100755
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -3,6 +3,8 @@
import {backend} from '../models';
import {context} from '../models';
+export function GetManPage(arg1:string):Promise
;
+
export function GetMetrics():Promise;
export function GetProcessDetail(arg1:number):Promise;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index e1b2519..3824f78 100755
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+export function GetManPage(arg1) {
+ return window['go']['main']['App']['GetManPage'](arg1);
+}
+
export function GetMetrics() {
return window['go']['main']['App']['GetMetrics']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 2c8d73e..294d9b4 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -96,6 +96,7 @@ export namespace backend {
username: string;
created_at: number;
parent_pid: number;
+ parent_name: string;
nice: number;
num_threads: number;
num_fds: number;
@@ -126,6 +127,7 @@ export namespace backend {
this.username = source["username"];
this.created_at = source["created_at"];
this.parent_pid = source["parent_pid"];
+ this.parent_name = source["parent_name"];
this.nice = source["nice"];
this.num_threads = source["num_threads"];
this.num_fds = source["num_fds"];