"use client" import { useEffect, useState } from "react" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" import { Input } from "./ui/input" import { ScrollArea } from "./ui/scroll-area" import { Cpu, MemoryStick, Search } from "lucide-react" import { fetchApi } from "@/lib/api-config" import { ProcessInfoModal } from "./process-info-modal" interface ProcessInfo { pid: number /** Parent process PID — equal to `pid` for process rows, different * for thread rows (CPU sort enumerates per-thread). The detail modal * always loads the parent, since /proc//cmdline etc. only exist * at the process level. */ parent_pid?: number user: string cpu: number mem: number rss_kb: number command: string /** Full command line. Used for filter matching and hover tooltips so * searching e.g. "proxmenux" finds a process whose short name is just * "python3" but whose cmdline is `python3 /.../proxmenux.py`. */ cmdline?: string } interface ProcessesResponse { processes: ProcessInfo[] sort: "cpu" | "mem" captured_at: number } interface ProcessDetailModalProps { open: boolean onOpenChange: (open: boolean) => void /** Which metric the parent card represents (drives default sort + emphasis) */ sort: "cpu" | "mem" } const REFRESH_MS = 3000 // FETCH_LIMIT is how many rows the server returns. DISPLAY_LIMIT is what // the user actually sees when no filter is set. We over-fetch so the // filter can find processes that aren't in the top-N by metric — e.g., // searching "proxmenux" in the Memory modal should find it even though // it's nowhere near the top 25 by RSS. const FETCH_LIMIT = 200 const DISPLAY_LIMIT = 25 const formatRss = (kb: number): string => { if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB` if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB` return `${kb} KB` } export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) { const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [filter, setFilter] = useState("") const [selectedPid, setSelectedPid] = useState(null) const fetchProcesses = async (silent = false) => { if (!silent) setLoading(true) setError(null) try { const res = await fetchApi(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`) setData(res) } catch (e: any) { setError(e?.message || "Failed to fetch processes") } finally { if (!silent) setLoading(false) } } useEffect(() => { if (!open) return fetchProcesses() const id = setInterval(() => fetchProcesses(true), REFRESH_MS) return () => clearInterval(id) // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, sort]) // Reset filter when dialog closes useEffect(() => { if (!open) setFilter("") }, [open]) // When no filter is set, the user just wants the top N by metric // (CPU usage or memory). When they type a query, they want EVERY // match — including processes that aren't in the top N — which is // why we over-fetch on the server. const allMatches = (data?.processes ?? []).filter((p) => { if (!filter) return true const q = filter.toLowerCase() return ( p.command.toLowerCase().includes(q) || (p.cmdline?.toLowerCase().includes(q) ?? false) || p.user.toLowerCase().includes(q) || String(p.pid).includes(q) ) }) const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT) const Icon = sort === "cpu" ? Cpu : MemoryStick const title = sort === "cpu" ? "Top processes by CPU" : "Top processes by Memory" const description = sort === "cpu" ? "Current CPU usage per process, as a fraction of the host's total CPU — same scale as the CPU Usage card above. Refreshes every 3 s while open." : "Current resident memory per process. Refreshes every 3 s while open." // Accent palette matched to the Overview cards: CPU Usage donut uses // blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep // the same hues so the modal feels like a continuation of the card. const accent = sort === "cpu" ? { dot: "#3b82f6", bar: "#3b82f6", text: "text-blue-500" } : { dot: "#6366f1", bar: "#6366f1", text: "text-indigo-400" } // Scale bars to the largest value in the (filtered) list so the visual // ranking is preserved even when no process is near 100 %. CPU can // exceed 100 % on multi-threaded apps — falling back to max=1 prevents // a divide-by-zero when the list is empty. const maxPrimary = Math.max( 1, ...filtered.map((p) => (sort === "cpu" ? p.cpu : p.mem)) ) // Mobile drops PID + USER; desktop keeps the full 5-column layout. // CPU and MEM columns are wider on desktop with a real gap between // them so the two metrics don't feel glued together. const gridCols = "grid-cols-[minmax(0,1fr)_70px_90px] sm:grid-cols-[60px_96px_minmax(140px,1fr)_110px_120px]" return ( <> e.preventDefault()} > {title} {description}
setFilter(e.target.value)} className="pl-8 h-8 text-sm" />
{error ? (
{error}
) : (
{/* Sticky solid header so scrolled rows don't bleed through */}
PID
User
Command
CPU %
{sort === "mem" ? "Memory" : "Mem %"}
{filtered.length === 0 && !loading ? (
No processes match the filter
) : ( filtered.map((p) => { const primary = sort === "cpu" ? p.cpu : p.mem const barPct = Math.min(100, (primary / maxPrimary) * 100) return ( ) }) )}
)} {data?.captured_at && (
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filter ? `${allMatches.length} match${allMatches.length === 1 ? '' : 'es'} of ${data.processes.length} processes` : `Top ${filtered.length} of ${data.processes.length} processes`}
)}
setSelectedPid(null)} /> ) }