mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
262 lines
10 KiB
TypeScript
262 lines
10 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"
|
|
import { useI18n } from "../lib/i18n/provider"
|
|
|
|
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, t: (key: string) => string): string => {
|
|
const rawLetter = (state || "").trim().charAt(0)
|
|
const letter = rawLetter.toUpperCase()
|
|
const map: Record<string, string> = {
|
|
R: "running",
|
|
S: "sleeping",
|
|
D: "diskWait",
|
|
Z: "zombie",
|
|
T: rawLetter === "t" ? "tracingStop" : "stopped",
|
|
X: "dead",
|
|
I: "idle",
|
|
}
|
|
const key = map[letter]
|
|
return key ? t(`details.processInfo.states.${key}`) : state || "—"
|
|
}
|
|
|
|
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
|
|
const { language, t } = useI18n()
|
|
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(t("details.processInfo.fetchFailed"))
|
|
}
|
|
} 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 || t("details.processInfo.titleFallback")}</span>
|
|
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{exited
|
|
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
|
|
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
|
|
</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">{t("details.processInfo.finishedTitle")}</div>
|
|
<div className="text-amber-300/80 mt-0.5">
|
|
{t("details.processInfo.finishedDescription")}
|
|
</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 ? t("details.processInfo.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={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 />
|
|
</Section>
|
|
|
|
{/* Resources */}
|
|
<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 />
|
|
</Section>
|
|
|
|
{/* Command */}
|
|
<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 />
|
|
</Section>
|
|
|
|
{/* Times */}
|
|
<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 />
|
|
</Section>
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
|
|
{data?.captured_at && (
|
|
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
|
{exited ? t("details.processInfo.lastSeen") : t("details.processInfo.captured")}{" "}
|
|
{new Date(data.captured_at * 1000).toLocaleTimeString(language)}
|
|
{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>
|
|
)
|
|
}
|