mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
560 lines
21 KiB
TypeScript
560 lines
21 KiB
TypeScript
"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"
|
|
import { useI18n } from "../lib/i18n/provider"
|
|
|
|
const TIMEFRAME_OPTIONS = [
|
|
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
|
{ value: "day", labelKey: "overview.timeframes.day" },
|
|
{ value: "week", labelKey: "overview.timeframes.week" },
|
|
{ value: "month", labelKey: "overview.timeframes.month" },
|
|
]
|
|
|
|
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 (
|
|
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
|
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
|
<div className="space-y-1.5">
|
|
{payload.map((entry: any, index: number) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
|
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
|
<span className="text-sm font-semibold text-white">{entry.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
const CustomMemoryTooltip = ({ active, payload, label }: any) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
|
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
|
<div className="space-y-1.5">
|
|
{payload.map((entry: any, index: number) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
|
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
|
<span className="text-sm font-semibold text-white">{entry.value} GB</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
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 = "",
|
|
labels,
|
|
}: {
|
|
stats: PeriodStat
|
|
suffix?: string
|
|
labels: { avg: string; max: string; min: string }
|
|
}) {
|
|
if (!stats) return null
|
|
const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
|
|
return (
|
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums">
|
|
<span>
|
|
<span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span>
|
|
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.avg}</span>
|
|
</span>
|
|
<span>
|
|
<span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span>
|
|
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.max}</span>
|
|
</span>
|
|
<span>
|
|
<span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span>
|
|
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.min}</span>
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
export function NodeMetricsCharts() {
|
|
const { language, t } = useI18n()
|
|
const [timeframe, setTimeframe] = useState("day")
|
|
const [data, setData] = useState<NodeMetricsData[]>([])
|
|
// 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<MetricsError | null>(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, language])
|
|
|
|
const fetchMetrics = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const result = await fetchApi<any>(`/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(t("overview.invalidMetricsData"))
|
|
}
|
|
|
|
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(language, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
})
|
|
} else if (timeframe === "day") {
|
|
timeLabel = date.toLocaleString(language, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
})
|
|
} else if (timeframe === "week") {
|
|
timeLabel = date.toLocaleString(language, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
hour12: false,
|
|
})
|
|
} else {
|
|
timeLabel = date.toLocaleString(language, {
|
|
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 || t("overview.metricsLoadError"),
|
|
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 (
|
|
<div className="flex justify-center gap-4 pb-2 flex-wrap">
|
|
{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 (
|
|
<div
|
|
key={`legend-${index}`}
|
|
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={() => handleLegendClick(chartType, entry.dataKey)}
|
|
style={{ opacity: isVisible ? 1 : 0.4 }}
|
|
>
|
|
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: entry.color }} />
|
|
<span className="text-sm text-foreground">{entry.value}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Card className="bg-card border-border">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-center h-[300px]">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-card border-border">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-center h-[300px]">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 = (
|
|
<Card className="bg-card border-border">
|
|
<CardContent className="p-6">
|
|
<div className="flex flex-col items-start justify-center h-[300px] gap-2 px-2 overflow-auto">
|
|
<p className="text-sm font-semibold text-red-400">{error.headline}</p>
|
|
{error.details && (
|
|
<p className="text-xs text-muted-foreground leading-relaxed">{error.details}</p>
|
|
)}
|
|
{error.suggestion && (
|
|
<div className="w-full mt-2">
|
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
|
{t("overview.suggestedFix")}
|
|
</p>
|
|
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
|
|
{error.suggestion}
|
|
</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{errorCard}
|
|
{errorCard}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Card className="bg-card border-border">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-center h-[300px]">
|
|
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-card border-border">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-center h-[300px]">
|
|
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Timeframe Selector */}
|
|
<div className="flex justify-end">
|
|
<Select value={timeframe} onValueChange={setTimeframe}>
|
|
<SelectTrigger className="w-32">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TIMEFRAME_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{t(option.labelKey)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Charts Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* CPU Usage + Load Average Chart */}
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="px-4 md:px-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
<CardTitle className="text-foreground flex items-center">
|
|
<TrendingUp className="h-5 w-5 mr-2" />
|
|
{t("overview.cpuUsageLoadAverage")}
|
|
</CardTitle>
|
|
<ChartStatsHeader
|
|
stats={periodStats.cpu ?? null}
|
|
suffix="%"
|
|
labels={{
|
|
avg: t("overview.stats.avg"),
|
|
max: t("overview.stats.max"),
|
|
min: t("overview.stats.min"),
|
|
}}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-0 md:px-6">
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
|
<XAxis
|
|
dataKey="time"
|
|
stroke="currentColor"
|
|
className="text-foreground"
|
|
tick={{ fill: "currentColor", fontSize: 12 }}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={60}
|
|
interval={tickInterval}
|
|
/>
|
|
<YAxis
|
|
yAxisId="left"
|
|
stroke="currentColor"
|
|
className="text-foreground"
|
|
tick={{ fill: "currentColor", fontSize: 12 }}
|
|
label={
|
|
isMobile ? undefined : { value: "CPU %", angle: -90, position: "insideLeft", fill: "currentColor" }
|
|
}
|
|
domain={[0, "dataMax"]}
|
|
/>
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
stroke="currentColor"
|
|
className="text-foreground"
|
|
tick={{ fill: "currentColor", fontSize: 12 }}
|
|
label={
|
|
isMobile ? undefined : { value: t("overview.loadAxis"), angle: 90, position: "insideRight", fill: "currentColor" }
|
|
}
|
|
domain={[0, "dataMax"]}
|
|
/>
|
|
<Tooltip content={<CustomCpuTooltip />} />
|
|
<Legend verticalAlign="top" height={36} content={renderLegend("cpu")} />
|
|
<Area
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="cpu"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
fill="#3b82f6"
|
|
fillOpacity={0.3}
|
|
name={t("overview.cpuPercent")}
|
|
hide={!visibleLines.cpu.cpu}
|
|
/>
|
|
<Area
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="load"
|
|
stroke="#10b981"
|
|
strokeWidth={2}
|
|
fill="#10b981"
|
|
fillOpacity={0.3}
|
|
name={t("overview.loadAverage")}
|
|
hide={!visibleLines.cpu.load}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Memory Usage Chart */}
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="px-4 md:px-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
<CardTitle className="text-foreground flex items-center">
|
|
<MemoryStick className="h-5 w-5 mr-2" />
|
|
{t("overview.memoryUsage")}
|
|
</CardTitle>
|
|
<ChartStatsHeader
|
|
stats={periodStats.memory_used ?? null}
|
|
suffix=" GB"
|
|
labels={{
|
|
avg: t("overview.stats.avg"),
|
|
max: t("overview.stats.max"),
|
|
min: t("overview.stats.min"),
|
|
}}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="px-0 pr-2 md:px-6">
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
|
<XAxis
|
|
dataKey="time"
|
|
stroke="currentColor"
|
|
className="text-foreground"
|
|
tick={{ fill: "currentColor", fontSize: 12 }}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={60}
|
|
interval={tickInterval}
|
|
/>
|
|
<YAxis
|
|
stroke="currentColor"
|
|
className="text-foreground"
|
|
tick={{ fill: "currentColor", fontSize: 12 }}
|
|
label={
|
|
isMobile ? undefined : { value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }
|
|
}
|
|
domain={[0, "dataMax"]}
|
|
/>
|
|
<Tooltip content={<CustomMemoryTooltip />} />
|
|
<Legend verticalAlign="top" height={36} content={renderLegend("memory")} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="memoryTotal"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
fill="#3b82f6"
|
|
fillOpacity={0.1}
|
|
name={t("overview.total")}
|
|
hide={!visibleLines.memory.memoryTotal}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="memoryUsed"
|
|
stroke="#10b981"
|
|
strokeWidth={2}
|
|
fill="#10b981"
|
|
fillOpacity={0.3}
|
|
name={t("overview.used")}
|
|
hide={!visibleLines.memory.memoryUsed}
|
|
/>
|
|
{/* Only show ZFS ARC if there's data */}
|
|
{hasZfsArc && (
|
|
<Area
|
|
type="monotone"
|
|
dataKey="memoryZfsArc"
|
|
stroke="#f59e0b"
|
|
strokeWidth={2}
|
|
fill="#f59e0b"
|
|
fillOpacity={0.3}
|
|
name="ZFS ARC"
|
|
hide={!visibleLines.memory.memoryZfsArc}
|
|
/>
|
|
)}
|
|
{/* Only show Free memory if there's data */}
|
|
{hasMemoryFree && (
|
|
<Area
|
|
type="monotone"
|
|
dataKey="memoryFree"
|
|
stroke="#06b6d4"
|
|
strokeWidth={2}
|
|
fill="#06b6d4"
|
|
fillOpacity={0.3}
|
|
name={t("overview.free")}
|
|
hide={!visibleLines.memory.memoryFree}
|
|
/>
|
|
)}
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|