2026-06-05 19:22:07 +02:00
|
|
|
"use client"
|
|
|
|
|
|
2026-06-05 19:45:46 +02:00
|
|
|
import { useEffect, useRef, useState } from "react"
|
2026-06-05 19:22:07 +02:00
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
|
|
|
|
import { ScrollArea } from "./ui/scroll-area"
|
2026-06-05 19:45:46 +02:00
|
|
|
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
|
2026-06-05 19:22:07 +02:00
|
|
|
import { fetchApi } from "@/lib/api-config"
|
2026-08-04 17:01:19 +02:00
|
|
|
import { useI18n } from "../lib/i18n/provider"
|
2026-06-05 19:22:07 +02:00
|
|
|
|
|
|
|
|
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.
|
2026-08-04 17:01:19 +02:00
|
|
|
const stateLabel = (state: string, t: (key: string) => string): string => {
|
|
|
|
|
const rawLetter = (state || "").trim().charAt(0)
|
|
|
|
|
const letter = rawLetter.toUpperCase()
|
2026-06-05 19:22:07 +02:00
|
|
|
const map: Record<string, string> = {
|
2026-08-04 17:01:19 +02:00
|
|
|
R: "running",
|
|
|
|
|
S: "sleeping",
|
|
|
|
|
D: "diskWait",
|
|
|
|
|
Z: "zombie",
|
|
|
|
|
T: rawLetter === "t" ? "tracingStop" : "stopped",
|
|
|
|
|
X: "dead",
|
|
|
|
|
I: "idle",
|
2026-06-05 19:22:07 +02:00
|
|
|
}
|
2026-08-04 17:01:19 +02:00
|
|
|
const key = map[letter]
|
|
|
|
|
return key ? t(`details.processInfo.states.${key}`) : state || "—"
|
2026-06-05 19:22:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
|
2026-08-04 17:01:19 +02:00
|
|
|
const { language, t } = useI18n()
|
2026-06-05 19:22:07 +02:00
|
|
|
const [data, setData] = useState<ProcessDetail | null>(null)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
2026-06-05 19:45:46 +02:00
|
|
|
const [exited, setExited] = useState(false)
|
|
|
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
2026-06-05 19:22:07 +02:00
|
|
|
const open = pid != null
|
|
|
|
|
|
2026-06-05 19:45:46 +02:00
|
|
|
const stopPolling = () => {
|
|
|
|
|
if (intervalRef.current) {
|
|
|
|
|
clearInterval(intervalRef.current)
|
|
|
|
|
intervalRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 19:22:07 +02:00
|
|
|
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) {
|
2026-06-05 19:45:46 +02:00
|
|
|
// 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 {
|
2026-08-04 17:01:19 +02:00
|
|
|
setError(t("details.processInfo.fetchFailed"))
|
2026-06-05 19:45:46 +02:00
|
|
|
}
|
2026-06-05 19:22:07 +02:00
|
|
|
} finally {
|
|
|
|
|
if (!silent) setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (pid == null) {
|
|
|
|
|
setData(null)
|
|
|
|
|
setError(null)
|
2026-06-05 19:45:46 +02:00
|
|
|
setExited(false)
|
|
|
|
|
stopPolling()
|
2026-06-05 19:22:07 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-06-05 19:45:46 +02:00
|
|
|
setExited(false)
|
2026-06-05 19:22:07 +02:00
|
|
|
fetchDetail()
|
2026-06-05 19:45:46 +02:00
|
|
|
intervalRef.current = setInterval(() => fetchDetail(true), REFRESH_MS)
|
|
|
|
|
return () => stopPolling()
|
2026-06-05 19:22:07 +02:00
|
|
|
// 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 }}
|
|
|
|
|
/>
|
2026-08-04 17:01:19 +02:00
|
|
|
<span className="truncate font-mono text-base">{data?.comm || t("details.processInfo.titleFallback")}</span>
|
2026-06-05 19:22:07 +02:00
|
|
|
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs">
|
2026-08-04 17:01:19 +02:00
|
|
|
{exited
|
|
|
|
|
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
|
|
|
|
|
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
|
2026-06-05 19:22:07 +02:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
2026-06-05 19:45:46 +02:00
|
|
|
{/* 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>
|
2026-08-04 17:01:19 +02:00
|
|
|
<div className="font-medium text-amber-200">{t("details.processInfo.finishedTitle")}</div>
|
2026-06-05 19:45:46 +02:00
|
|
|
<div className="text-amber-300/80 mt-0.5">
|
2026-08-04 17:01:19 +02:00
|
|
|
{t("details.processInfo.finishedDescription")}
|
2026-06-05 19:45:46 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-05 19:22:07 +02:00
|
|
|
{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">
|
2026-08-04 17:01:19 +02:00
|
|
|
{loading ? t("details.processInfo.loading") : "—"}
|
2026-06-05 19:22:07 +02:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-06-05 19:45:46 +02:00
|
|
|
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
|
2026-06-05 19:22:07 +02:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* Overview */}
|
2026-08-04 17:01:19 +02:00
|
|
|
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title={t("details.processInfo.sections.overview")}>
|
|
|
|
|
<Row label={t("details.processInfo.labels.state")} value={exited ? t("details.processInfo.states.exited") : stateLabel(data.state, t)} />
|
|
|
|
|
<Row label={t("details.processInfo.labels.parent")} value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.threads")} value={String(data.threads)} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.openFds")} value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.user")} value={`${data.user} (${data.uid})`} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.group")} value={`${data.group} (${data.gid})`} mono />
|
2026-06-05 19:22:07 +02:00
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Resources */}
|
2026-08-04 17:01:19 +02:00
|
|
|
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title={t("details.processInfo.sections.resources")}>
|
|
|
|
|
<Row label={t("details.processInfo.labels.cpu")} value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
|
|
|
|
|
<Row label={t("details.processInfo.labels.memory")} value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
|
|
|
|
|
<Row label={t("details.processInfo.labels.residentRss")} value={formatKb(data.vm_rss_kb)} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.virtualSize")} value={formatKb(data.vm_size_kb)} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.swap")} value={formatKb(data.vm_swap_kb)} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.ioRead")} value={formatBytes(data.io_read_bytes)} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.ioWrite")} value={formatBytes(data.io_write_bytes)} mono />
|
2026-06-05 19:22:07 +02:00
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Command */}
|
2026-08-04 17:01:19 +02:00
|
|
|
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title={t("details.processInfo.sections.command")}>
|
|
|
|
|
<Row label={t("details.processInfo.labels.name")} value={data.comm} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.commandLine")} value={data.cmdline || data.comm} mono wrap />
|
|
|
|
|
<Row label={t("details.processInfo.labels.executable")} value={data.exe || "—"} mono wrap />
|
|
|
|
|
<Row label={t("details.processInfo.labels.workingDir")} value={data.cwd || "—"} mono wrap />
|
2026-06-05 19:22:07 +02:00
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
{/* Times */}
|
2026-08-04 17:01:19 +02:00
|
|
|
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title={t("details.processInfo.sections.lifetime")}>
|
|
|
|
|
<Row label={t("details.processInfo.labels.started")} value={data.start_time || "—"} mono />
|
|
|
|
|
<Row label={t("details.processInfo.labels.runningFor")} value={data.elapsed || "—"} mono />
|
2026-06-05 19:22:07 +02:00
|
|
|
</Section>
|
|
|
|
|
</div>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{data?.captured_at && (
|
|
|
|
|
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
2026-08-04 17:01:19 +02:00
|
|
|
{exited ? t("details.processInfo.lastSeen") : t("details.processInfo.captured")}{" "}
|
|
|
|
|
{new Date(data.captured_at * 1000).toLocaleTimeString(language)}
|
2026-06-05 19:22:07 +02:00
|
|
|
{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>
|
|
|
|
|
)
|
|
|
|
|
}
|