Update 1.2.2.1 beta

This commit is contained in:
MacRimi
2026-06-05 19:22:07 +02:00
parent 9656b04a3e
commit 3191f5250d
14 changed files with 892 additions and 26 deletions

View File

@@ -0,0 +1,230 @@
"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
user: string
cpu: number
mem: number
rss_kb: number
command: 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 = 5000
const 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<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 {
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${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])
const filtered = (data?.processes ?? []).filter((p) => {
if (!filter) return true
const q = filter.toLowerCase()
return (
p.command.toLowerCase().includes(q) ||
p.user.toLowerCase().includes(q) ||
String(p.pid).includes(q)
)
})
const Icon = sort === "cpu" ? Cpu : MemoryStick
const title = sort === "cpu" ? "Top processes by CPU" : "Top processes by Memory"
const description =
sort === "cpu"
? "Snapshot from `ps` sorted by CPU usage. Auto-refreshes every 5 s while this dialog is open."
: "Snapshot from `ps` sorted by resident memory. Auto-refreshes every 5 s while this dialog is 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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<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
placeholder="Filter by command, user or PID..."
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"
onClick={() => setSelectedPid(p.pid)}
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>
<div className="font-mono text-xs truncate min-w-0 flex items-center gap-1.5" title={p.command}>
{/* 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>
<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">
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filtered.length} of {data.processes.length} shown
</div>
)}
</DialogContent>
</Dialog>
<ProcessInfoModal
pid={selectedPid}
accent={accent}
onClose={() => setSelectedPid(null)}
/>
</>
)
}

View File

@@ -0,0 +1,224 @@
"use client"
import { useEffect, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { ScrollArea } from "./ui/scroll-area"
import { Activity, FileText, HardDrive, Clock } 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 open = pid != 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 means the process exited while the modal was open — surface a
// clear message instead of stale data, but don't auto-close (user may
// want to read the last snapshot).
setError(e?.message?.includes("404") ? "Process exited" : (e?.message || "Failed to fetch process"))
if (e?.message?.includes("404")) setData(null)
} finally {
if (!silent) setLoading(false)
}
}
useEffect(() => {
if (pid == null) {
setData(null)
setError(null)
return
}
fetchDetail()
const id = setInterval(() => fetchDetail(true), REFRESH_MS)
return () => clearInterval(id)
// 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">
Live snapshot from <span className="font-mono">/proc/{pid}</span>. Auto-refreshes every {REFRESH_MS / 1000} s while open.
</DialogDescription>
</DialogHeader>
{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">
<div className="space-y-4">
{/* Overview */}
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title="Overview">
<Row label="State" value={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">
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>
)
}

View File

@@ -4,10 +4,11 @@ import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
import { ProcessDetailModal } from "./process-detail-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
@@ -187,6 +188,8 @@ export function SystemOverview() {
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
const [tempModalOpen, setTempModalOpen] = useState(false)
const [cpuProcModalOpen, setCpuProcModalOpen] = useState(false)
const [memProcModalOpen, setMemProcModalOpen] = useState(false)
useEffect(() => {
const fetchAllData = async () => {
@@ -400,10 +403,17 @@ export function SystemOverview() {
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
{/* ── CPU Usage (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
<Card className="bg-card border-border">
<Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => setCpuProcModalOpen(true)}
title="View top processes by CPU"
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-1 text-muted-foreground">
<Cpu className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
@@ -443,10 +453,17 @@ export function SystemOverview() {
</Card>
{/* ── Memory (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
<Card className="bg-card border-border">
<Card
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => setMemProcModalOpen(true)}
title="View top processes by memory"
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-1 text-muted-foreground">
<MemoryStick className="h-4 w-4" />
<ChevronRight className="h-4 w-4 opacity-60" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
@@ -566,12 +583,24 @@ export function SystemOverview() {
</Card>
</div>
<TemperatureDetailModal
open={tempModalOpen}
<TemperatureDetailModal
open={tempModalOpen}
onOpenChange={setTempModalOpen}
liveTemperature={systemData.temperature}
/>
<ProcessDetailModal
open={cpuProcModalOpen}
onOpenChange={setCpuProcModalOpen}
sort="cpu"
/>
<ProcessDetailModal
open={memProcModalOpen}
onOpenChange={setMemProcModalOpen}
sort="mem"
/>
<NodeMetricsCharts />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">

View File

@@ -1129,7 +1129,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1)
}, [safeVMData])
const { data: systemData } = useSWR<{ memory_total: number; memory_used: number; memory_usage: number }>(
const { data: systemData } = useSWR<{ memory_total: number; memory_used: number; memory_usage: number; cpu_cores?: number; cpu_threads?: number }>(
"/api/system",
fetcher,
{
@@ -1346,6 +1346,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const inUseVCPU = safeVMData
.filter((vm) => vm.status === "running")
.reduce((sum, vm) => sum + (vm.maxcpu || 0), 0)
const hostThreads = systemData?.cpu_threads ?? systemData?.cpu_cores ?? 0
const stroke = allocPct >= 90 ? '#ef4444' : allocPct >= 75 ? '#f59e0b' : '#3b82f6'
return (
<Card className="bg-card border-border">
@@ -1374,11 +1375,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Configured</span>
<span className="font-medium font-mono whitespace-nowrap">{configuredVCPU || '—'} vCPU</span>
<span className="font-medium font-mono whitespace-nowrap">{configuredVCPU || '—'}{hostThreads ? ` / ${hostThreads}` : ''} vCPU</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">In use</span>
<span className="font-medium font-mono whitespace-nowrap">{inUseVCPU || '—'} vCPU</span>
<span className="font-medium font-mono whitespace-nowrap">{inUseVCPU || '—'}{hostThreads ? ` / ${hostThreads}` : ''} vCPU</span>
</div>
</div>
</div>