Files
ProxMenux/AppImage/components/process-info-modal.tsx
2026-06-05 19:45:46 +02:00

260 lines
9.5 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { ScrollArea } from "./ui/scroll-area"
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
import { fetchApi } from "@/lib/api-config"
interface ProcessDetail {
pid: number
comm: string
cmdline: string
exe: string | null
cwd: string | null
state: string
ppid: number
parent_name: string | null
threads: number
vm_rss_kb: number
vm_size_kb: number
vm_swap_kb: number
user: string
group: string
uid: number
gid: number
start_time: string | null
elapsed: string | null
cpu: number
mem: number
io_read_bytes: number | null
io_write_bytes: number | null
fd_count: number | null
captured_at: number
}
interface ProcessInfoModalProps {
pid: number | null
accent: { dot: string; bar: string; text: string }
onClose: () => void
}
const REFRESH_MS = 3000
const formatKb = (kb: number | null | undefined): string => {
if (kb == null) return "—"
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
return `${kb} KB`
}
const formatBytes = (b: number | null | undefined): string => {
if (b == null) return "—"
if (b >= 1024 * 1024 * 1024) return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`
if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`
return `${b} B`
}
// Linux process states from /proc/<pid>/status. The first char of `State:`
// is the canonical letter — the rest of the field is a human label like
// "(running)". We expand the bare letter to something readable.
const stateLabel = (state: string): string => {
const letter = (state || "").trim().charAt(0).toUpperCase()
const map: Record<string, string> = {
R: "Running",
S: "Sleeping",
D: "Disk wait",
Z: "Zombie",
T: "Stopped",
t: "Tracing stop",
X: "Dead",
I: "Idle",
}
return map[letter] || state || "—"
}
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
const [data, setData] = useState<ProcessDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [exited, setExited] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const open = pid != null
const stopPolling = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
const fetchDetail = async (silent = false) => {
if (pid == null) return
if (!silent) setLoading(true)
setError(null)
try {
const res = await fetchApi<ProcessDetail>(`/api/processes/${pid}`)
setData(res)
} catch (e: any) {
// 404 = the process exited while the modal was open. Expected for
// short-lived helpers (pct exec, backup subprocesses, the `ps` snapshot
// itself). Keep the last good snapshot on screen, stop polling, and
// surface an info banner — NOT an error — so it doesn't look like a bug.
if (e?.message?.includes("404")) {
setExited(true)
stopPolling()
} else {
setError(e?.message || "Failed to fetch process")
}
} finally {
if (!silent) setLoading(false)
}
}
useEffect(() => {
if (pid == null) {
setData(null)
setError(null)
setExited(false)
stopPolling()
return
}
setExited(false)
fetchDetail()
intervalRef.current = setInterval(() => fetchDetail(true), REFRESH_MS)
return () => stopPolling()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pid])
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: accent.dot }}
/>
<span className="truncate font-mono text-base">{data?.comm || "Process"}</span>
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
</DialogTitle>
<DialogDescription className="text-xs">
{exited ? (
<>Last snapshot from <span className="font-mono">/proc/{pid}</span> before the process finished.</>
) : (
<>Live snapshot from <span className="font-mono">/proc/{pid}</span>. Auto-refreshes every {REFRESH_MS / 1000} s while open.</>
)}
</DialogDescription>
</DialogHeader>
{/* Info banner when the process has finished. Amber, not red — this is
expected behavior for short-lived processes, not an error. */}
{exited && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/10 text-xs text-amber-300">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div>
<div className="font-medium text-amber-200">This process has finished</div>
<div className="text-amber-300/80 mt-0.5">
It was likely a short-lived helper (a script, a <span className="font-mono">pct exec</span>, or a one-shot command) that completed while the modal was open. The data below is the last snapshot captured before it exited not a stale or broken read.
</div>
</div>
</div>
)}
{error && !data ? (
<div className="text-sm text-red-500 py-4">{error}</div>
) : !data ? (
<div className="text-sm text-muted-foreground py-8 text-center">
{loading ? "Loading…" : "—"}
</div>
) : (
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
<div className="space-y-4">
{/* Overview */}
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title="Overview">
<Row label="State" value={exited ? "Exited" : stateLabel(data.state)} />
<Row label="Parent" value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
<Row label="Threads" value={String(data.threads)} mono />
<Row label="Open FDs" value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
<Row label="User" value={`${data.user} (${data.uid})`} mono />
<Row label="Group" value={`${data.group} (${data.gid})`} mono />
</Section>
{/* Resources */}
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title="Resources">
<Row label="CPU" value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Memory" value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
<Row label="Resident (RSS)" value={formatKb(data.vm_rss_kb)} mono />
<Row label="Virtual size" value={formatKb(data.vm_size_kb)} mono />
<Row label="Swap" value={formatKb(data.vm_swap_kb)} mono />
<Row label="I/O read" value={formatBytes(data.io_read_bytes)} mono />
<Row label="I/O write" value={formatBytes(data.io_write_bytes)} mono />
</Section>
{/* Command */}
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title="Command">
<Row label="Name" value={data.comm} mono />
<Row label="Command line" value={data.cmdline || data.comm} mono wrap />
<Row label="Executable" value={data.exe || "—"} mono wrap />
<Row label="Working dir" value={data.cwd || "—"} mono wrap />
</Section>
{/* Times */}
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title="Lifetime">
<Row label="Started" value={data.start_time || "—"} mono />
<Row label="Running for" value={data.elapsed || "—"} mono />
</Section>
</div>
</ScrollArea>
)}
{data?.captured_at && (
<div className="text-[10px] text-muted-foreground text-right mt-1">
{exited ? "Last seen" : "Captured"} {new Date(data.captured_at * 1000).toLocaleTimeString()}
{error ? ` · ${error}` : ""}
</div>
)}
</DialogContent>
</Dialog>
)
}
function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-md overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-card text-xs font-medium uppercase tracking-wider text-muted-foreground border-b border-border">
{icon}
{title}
</div>
<div className="divide-y divide-border/40">{children}</div>
</div>
)
}
function Row({
label,
value,
mono,
wrap,
valueClass,
}: {
label: string
value: string
mono?: boolean
wrap?: boolean
valueClass?: string
}) {
return (
<div className="grid grid-cols-[110px_minmax(0,1fr)] gap-2 px-3 py-1.5 text-xs">
<div className="text-muted-foreground">{label}</div>
<div
className={`${mono ? "font-mono" : ""} ${wrap ? "break-all" : "truncate"} ${valueClass || ""}`}
title={value}
>
{value}
</div>
</div>
)
}