"use client" import { useState, useEffect } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts" import { Loader2, TrendingUp, MemoryStick } from "lucide-react" import { useIsMobile } from "../hooks/use-mobile" import { fetchApi } from "@/lib/api-config" const TIMEFRAME_OPTIONS = [ { value: "hour", label: "1 Hour" }, { value: "day", label: "24 Hours" }, { value: "week", label: "7 Days" }, { value: "month", label: "30 Days" }, ] interface NodeMetricsData { time: string timestamp: number cpu: number load: number memoryTotal: number memoryUsed: number memoryFree: number memoryZfsArc: number } const CustomCpuTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { return (

{label}

{payload.map((entry: any, index: number) => (
{entry.name}: {entry.value}
))}
) } return null } const CustomMemoryTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { return (

{label}

{payload.map((entry: any, index: number) => (
{entry.name}: {entry.value} GB
))}
) } return null } interface MetricsError { headline: string details?: string suggestion?: string } // AVG / MAX / MIN chip row for the chart card headers. Values come // from the backend `period_stats` (calculated over the raw RRD points // BEFORE downsampling), not from the displayed chart points — that's // what makes a 1-minute CPU spike still appear in the 24h MAX even // though the chart shows 5-min bucket averages. // // Colour choice: all three values render in the same foreground tone. // The previous red(max)/green(min) scheme misread as severity (a // healthy 10 % CPU max showed in red and looked like an alert). // // Responsive: on ≥sm the chips sit to the right of the title; on // mobile they wrap below in their own row (the parent CardHeader uses // `flex-col sm:flex-row`). Smaller text + tabular-nums keeps the // chips compact enough that they don't crowd long titles. type PeriodStat = { avg: number; max: number; min: number } | null function ChartStatsHeader({ stats, suffix = "", }: { stats: PeriodStat suffix?: string }) { if (!stats) return null const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1)) return (
{fmt(stats.avg)}{suffix} avg {fmt(stats.max)}{suffix} max {fmt(stats.min)}{suffix} min
) } export function NodeMetricsCharts() { const [timeframe, setTimeframe] = useState("day") const [data, setData] = useState([]) // period_stats from the backend — computed over the raw RRD points // BEFORE the 5-min downsampling so the chart header's MAX/MIN // captures real per-minute extremes (a 1-min CPU spike still shows // up on the 24h view's MAX). const [periodStats, setPeriodStats] = useState<{ cpu?: PeriodStat memory_used?: PeriodStat }>({}) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const isMobile = useIsMobile() const [visibleLines, setVisibleLines] = useState({ cpu: { cpu: true, load: true }, memory: { memoryTotal: true, memoryUsed: true, memoryZfsArc: true, memoryFree: true }, }) // Check if ZFS ARC or Free memory have any non-zero values to decide if we should show them const hasZfsArc = data.some(d => d.memoryZfsArc > 0) const hasMemoryFree = data.some(d => d.memoryFree > 0) useEffect(() => { fetchMetrics() }, [timeframe]) const fetchMetrics = async () => { setLoading(true) setError(null) try { const result = await fetchApi(`/api/node/metrics?timeframe=${timeframe}`) if (!result.data || !Array.isArray(result.data)) { console.error("Invalid data format - data is not an array:", result) throw new Error("Invalid data format received from server") } if (result.data.length === 0) { console.warn("No data points received") setData([]) setLoading(false) return } if (result.data[0]?.loadavg) { } const transformedData = result.data.map((item: any) => { const date = new Date(item.time * 1000) let timeLabel = "" if (timeframe === "hour") { timeLabel = date.toLocaleString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }) } else if (timeframe === "day") { timeLabel = date.toLocaleString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, }) } else if (timeframe === "week") { timeLabel = date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", hour12: false, }) } else { timeLabel = date.toLocaleString("en-US", { month: "short", day: "numeric", }) } return { time: timeLabel, timestamp: item.time, cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0, load: item.loadavg ? typeof item.loadavg === "number" ? Number(item.loadavg.toFixed(2)) : Array.isArray(item.loadavg) && item.loadavg.length > 0 ? Number(item.loadavg[0].toFixed(2)) : 0 : 0, memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0, memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0, memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0, memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0, } }) setData(transformedData) setPeriodStats(result.period_stats || {}) } catch (err: any) { console.error("Error fetching node metrics:", err) // fetchApi attaches the parsed JSON body to err.body. The metrics // endpoint enriches 503 responses with `details` (Proxmox-side // diagnostic) and `suggestion` (how to fix). Pull them through so // the user sees actionable text instead of a bare "503". const body = err?.body setError({ headline: body?.error || err?.message || "Error loading metrics", details: body?.details, suggestion: body?.suggestion, }) } finally { setLoading(false) } } const tickInterval = Math.ceil(data.length / 8) const handleLegendClick = (chartType: "cpu" | "memory", dataKey: string) => { setVisibleLines((prev) => ({ ...prev, [chartType]: { ...prev[chartType], [dataKey as keyof (typeof prev)[typeof chartType]]: !prev[chartType][dataKey as keyof (typeof prev)[typeof chartType]], }, })) } const renderLegend = (chartType: "cpu" | "memory") => (props: any) => { const { payload } = props return (
{payload.map((entry: any, index: number) => { // For memory chart, hide ZFS ARC and Free from legend if they have no data if (chartType === "memory") { if (entry.dataKey === "memoryZfsArc" && !hasZfsArc) return null if (entry.dataKey === "memoryFree" && !hasMemoryFree) return null } const isVisible = visibleLines[chartType][entry.dataKey as keyof (typeof visibleLines)[typeof chartType]] return (
handleLegendClick(chartType, entry.dataKey)} style={{ opacity: isVisible ? 1 : 0.4 }} >
{entry.value}
) })}
) } if (loading) { return (
) } if (error) { // Both panels carry the same error — render an identical card on // each side. The headline is the short cause, the details block // explains it's a Proxmox-host issue (not a Monitor bug), and the // suggestion is the exact command the operator should run. const errorCard = (

{error.headline}

{error.details && (

{error.details}

)} {error.suggestion && (

Suggested fix on the Proxmox host

{error.suggestion}
)}
) return (
{errorCard} {errorCard}
) } if (data.length === 0) { return (

No metrics data available

No metrics data available

) } return (
{/* Timeframe Selector */}
{/* Charts Grid */}
{/* CPU Usage + Load Average Chart */}
CPU Usage & Load Average
} />
{/* Memory Usage Chart */}
Memory Usage
} /> {/* Only show ZFS ARC if there's data */} {hasZfsArc && ( )} {/* Only show Free memory if there's data */} {hasMemoryFree && ( )}
) }