)
}
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 (
)
}
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 (
)
}
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 && (
)}