2026-06-05 19:22:07 +02:00
"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
2026-06-06 11:37:54 +02:00
/ * * P a r e n t p r o c e s s P I D — e q u a l t o ` p i d ` f o r p r o c e s s r o w s , d i f f e r e n t
* for thread rows ( CPU sort enumerates per - thread ) . The detail modal
* always loads the parent , since / proc / < pid > / cmdline etc . only exist
* at the process level . * /
parent_pid? : number
2026-06-05 19:22:07 +02:00
user : string
cpu : number
2026-06-28 15:17:30 +02:00
/ * * C P U % a v e r a g e d o v e r t h e p r o c e s s ' s w h o l e l i f e t i m e . S u r f a c e s
* long - running idle processes that consume a steady baseline but
* don ' t spike during the 1 - s sample window ( an orphan ` bash -s `
* in a sleep - loop , a polling daemon , etc . ) . Only meaningful on
* the CPU sort response . * /
cpu_avg? : number
2026-06-05 19:22:07 +02:00
mem : number
rss_kb : number
command : string
2026-06-06 11:37:54 +02:00
/ * * F u l l c o m m a n d l i n e . U s e d f o r f i l t e r m a t c h i n g a n d h o v e r t o o l t i p s s o
* searching e . g . "proxmenux" finds a process whose short name is just
* "python3" but whose cmdline is ` python3 /.../proxmenux.py ` . * /
cmdline? : string
2026-06-05 19:22:07 +02:00
}
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"
}
2026-06-06 11:37:54 +02:00
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
2026-06-05 19:22:07 +02:00
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 < ProcessesResponse | null > ( null )
const [ error , setError ] = useState < string | null > ( null )
const [ loading , setLoading ] = useState ( false )
const [ filter , setFilter ] = useState ( "" )
const [ selectedPid , setSelectedPid ] = useState < number | null > ( null )
const fetchProcesses = async ( silent = false ) = > {
if ( ! silent ) setLoading ( true )
setError ( null )
try {
2026-06-06 11:37:54 +02:00
const res = await fetchApi < ProcessesResponse > ( ` /api/processes?sort= ${ sort } &limit= ${ FETCH_LIMIT } ` )
2026-06-05 19:22:07 +02:00
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 ] )
2026-06-06 11:37:54 +02:00
// 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 ) = > {
2026-06-05 19:22:07 +02:00
if ( ! filter ) return true
const q = filter . toLowerCase ( )
return (
p . command . toLowerCase ( ) . includes ( q ) ||
2026-06-06 11:37:54 +02:00
( p . cmdline ? . toLowerCase ( ) . includes ( q ) ? ? false ) ||
2026-06-05 19:22:07 +02:00
p . user . toLowerCase ( ) . includes ( q ) ||
String ( p . pid ) . includes ( q )
)
} )
2026-06-06 11:37:54 +02:00
const filtered = filter ? allMatches : allMatches.slice ( 0 , DISPLAY_LIMIT )
2026-06-05 19:22:07 +02:00
const Icon = sort === "cpu" ? Cpu : MemoryStick
const title = sort === "cpu" ? "Top processes by CPU" : "Top processes by Memory"
const description =
sort === "cpu"
2026-06-06 11:37:54 +02:00
? "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."
2026-06-05 19:22:07 +02:00
// 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 (
< >
< Dialog open = { open } onOpenChange = { onOpenChange } >
2026-06-07 11:31:50 +02:00
< DialogContent
className = "max-w-3xl"
/ * P r e v e n t R a d i x f r o m f o c u s i n g t h e s e a r c h I n p u t o n o p e n — t h e
auto - focus pops the on - screen keyboard on touch devices and
covers half the modal . The user can still tap the field to
start filtering . * /
onOpenAutoFocus = { ( e ) = > e . preventDefault ( ) }
>
2026-06-05 19:22:07 +02:00
< DialogHeader >
< DialogTitle className = "flex items-center gap-2" >
< Icon className = { ` h-5 w-5 ${ accent . text } ` } / >
{ title }
< / DialogTitle >
< DialogDescription className = "text-xs" > { description } < / DialogDescription >
< / DialogHeader >
< div className = "relative mb-2" >
< Search className = "absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" / >
< Input
2026-06-06 11:37:54 +02:00
placeholder = "Filter by command line, user or PID..."
2026-06-05 19:22:07 +02:00
value = { filter }
onChange = { ( e ) = > setFilter ( e . target . value ) }
className = "pl-8 h-8 text-sm"
/ >
< / div >
{ error ? (
< div className = "text-sm text-red-500 py-4" > { error } < / div >
) : (
< ScrollArea className = "h-[440px] border border-border rounded-md" >
< div className = "min-w-full" >
{ /* Sticky solid header so scrolled rows don't bleed through */ }
< div
className = { ` grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground border-b border-border bg-card sticky top-0 z-10 ${ gridCols } ` }
>
< div className = "hidden sm:block" > PID < / div >
< div className = "hidden sm:block truncate" > User < / div >
< div > Command < / div >
< div className = { ` text-right ${ sort === "cpu" ? accent . text : "" } ` } > CPU % < / div >
< div className = { ` text-right ${ sort === "mem" ? accent . text : "" } ` } > { sort === "mem" ? "Memory" : "Mem %" } < / div >
< / div >
{ filtered . length === 0 && ! loading ? (
< div className = "text-center py-8 text-sm text-muted-foreground" >
No processes match the filter
< / div >
) : (
filtered . map ( ( p ) = > {
const primary = sort === "cpu" ? p.cpu : p.mem
const barPct = Math . min ( 100 , ( primary / maxPrimary ) * 100 )
return (
< button
key = { p . pid }
type = "button"
2026-06-06 11:37:54 +02:00
onClick = { ( ) = > setSelectedPid ( p . parent_pid ? ? p . pid ) }
2026-06-05 19:22:07 +02:00
className = { ` w-full text-left grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 border-b border-border/40 hover:bg-white/5 transition-colors ${ gridCols } ` }
>
< div className = "hidden sm:flex font-mono text-xs items-center gap-1.5 min-w-0" >
< span
className = "w-1.5 h-1.5 rounded-full flex-shrink-0"
style = { { background : accent.dot } }
/ >
< span className = "truncate" > { p . pid } < / span >
< / div >
< div className = "hidden sm:block font-mono text-xs truncate" title = { p . user } > { p . user } < / div >
2026-06-06 11:37:54 +02:00
< div className = "font-mono text-xs truncate min-w-0 flex items-center gap-1.5" title = { p . cmdline || p . command } >
2026-06-05 19:22:07 +02:00
{ /* Mobile only: keep the accent dot since PID column is gone */ }
< span
className = "sm:hidden w-1.5 h-1.5 rounded-full flex-shrink-0"
style = { { background : accent.dot } }
/ >
< span className = "truncate" > { p . command } < / span >
< / div >
{ /* Primary metric: value + sized progress bar in the accent colour */ }
{ sort === "cpu" ? (
< div className = "flex flex-col items-end gap-1 min-w-0" >
< span className = { ` font-mono text-sm font-semibold ${ accent . text } ` } > { p . cpu . toFixed ( 1 ) } < / span >
2026-06-28 15:17:30 +02:00
{ / * S h o w l i f e t i m e a v e r a g e o n l y w h e n i t
materially differs from the live
sample ( process consumes a steady
baseline but was idle at sample time
— orphaned bash loop , polling daemon ) .
The 0.5 / 1.5 x thresholds skip cases
where avg and now match within sampler
noise . * / }
{ typeof p . cpu_avg === "number" && p . cpu_avg >= 0.5 && p . cpu_avg > p . cpu * 1.5 && (
< span className = "font-mono text-[10px] text-amber-400" title = "Average CPU% across this process's lifetime — useful for finding long-running idle baselines" >
avg { p . cpu_avg . toFixed ( 1 ) }
< / span >
) }
2026-06-05 19:22:07 +02:00
< div className = "w-full h-1 bg-muted rounded-full overflow-hidden" >
< div className = "h-full rounded-full" style = { { width : ` ${ barPct } % ` , background : accent.bar } } / >
< / div >
< / div >
) : (
< div className = "font-mono text-xs text-right text-muted-foreground" > { p . cpu . toFixed ( 1 ) } < / div >
) }
{ /* Secondary column: mem % when CPU is primary, RSS when memory is primary */ }
{ sort === "cpu" ? (
< div className = "font-mono text-xs text-right text-muted-foreground" > { p . mem . toFixed ( 1 ) } < / div >
) : (
< div className = "flex flex-col items-end gap-1 min-w-0" >
< span className = { ` font-mono text-sm font-semibold ${ accent . text } ` } > { formatRss ( p . rss_kb ) } < / span >
< div className = "w-full h-1 bg-muted rounded-full overflow-hidden" >
< div className = "h-full rounded-full" style = { { width : ` ${ barPct } % ` , background : accent.bar } } / >
< / div >
< / div >
) }
< / button >
)
} )
) }
< / div >
< / ScrollArea >
) }
{ data ? . captured_at && (
< div className = "text-[10px] text-muted-foreground text-right mt-1" >
2026-06-06 11:37:54 +02:00
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 ` }
2026-06-05 19:22:07 +02:00
< / div >
) }
< / DialogContent >
< / Dialog >
< ProcessInfoModal
pid = { selectedPid }
accent = { accent }
onClose = { ( ) = > setSelectedPid ( null ) }
/ >
< / >
)
}