feat(dashboard): show top disk stats as a donut chart
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s

Replace the disk I/O area chart with a donut where each slice is a node
sized by its disk usage % (disk_pct). More meaningful than I/O, which is
near-zero most of the time. Use real node names (name|node) in the legend
instead of node-0/node-1, and drop the now-unused RRD fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 19:41:54 +05:00
parent 381e7ae821
commit daa75e1502

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ExternalLink, ChevronDown } from 'lucide-react' import { ExternalLink, ChevronDown } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid, Legend, ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { apiClient } from '@/lib/api-client' import { apiClient } from '@/lib/api-client'
import { getTasksWebSocket } from '@/lib/websocket' import { getTasksWebSocket } from '@/lib/websocket'
@@ -217,51 +217,20 @@ function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: {
} }
// ==================== Cluster Nodes Section ==================== // ==================== Cluster Nodes Section ====================
function ClusterNodesSection({ serverList, nodeStats }: { serverList: Rec[]; nodeStats: Rec[] }) { function ClusterNodesSection({ nodeStats }: { nodeStats: Rec[] }) {
const [tab, setTab] = useState<'now' | 'day'>('now') const [tab, setTab] = useState<'now' | 'day'>('now')
const timeframe = tab === 'now' ? 'hour' : 'day'
// Top-5 nodes by disk% for chart data // Top-10 nodes by disk usage % — one donut slice per node.
const chartNodes = [...nodeStats] // Node name lives in `name` (dashboard_metrics WS payload) or `node`
.sort((a, b) => (Number(b.disk_pct) || 0) - (Number(a.disk_pct) || 0)) // (REST resources/all); fall back gracefully.
.slice(0, 5) const pieData = [...nodeStats]
.map(n => ({
// Fetch RRD for each of those nodes in parallel. name: String(n.node || n.name || ''),
// Node name comes from the `dashboard_metrics` WS payload, where the field is value: Number(n.disk_pct) || 0,
// `name` (not `node`); fall back to `node` for the REST `resources/all` shape. }))
const rrdResults = useQueries({ .filter(d => d.name && d.value > 0)
queries: chartNodes.map(n => { .sort((a, b) => b.value - a.value)
const nodeName = String(n.node || n.name || '') .slice(0, 10)
return {
queryKey: ['node-rrd', Number(n.server_id) || 0, nodeName, timeframe],
queryFn: () =>
apiClient
.get<{ data?: Rec[] } | Rec[]>(
`/proxmox/api/${n.server_id}/node/rrddata?node=${encodeURIComponent(nodeName)}&timeframe=${timeframe}`
)
// Endpoint wraps the series in `{ data: [...] }`; tolerate both shapes.
.then(r => (Array.isArray(r) ? r : (r?.data ?? []))),
enabled: !!n.server_id && !!nodeName,
}
}),
})
// Align series by index (all PVE nodes produce same-length RRD arrays)
const baseData = (rrdResults[0]?.data as Rec[]) ?? []
const chartData = baseData.map((d, i) => {
const point: Rec = {
t: new Date((Number(d.time) || 0) * 1000).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
}
chartNodes.forEach((n, j) => {
const row = ((rrdResults[j]?.data as Rec[]) ?? [])[i]
if (row) {
point[`n${j}`] = +(
((Number(row.diskread) || 0) + (Number(row.diskwrite) || 0)) / 1_048_576
).toFixed(2)
}
})
return point
})
const sorted = [...nodeStats] const sorted = [...nodeStats]
.sort((a, b) => .sort((a, b) =>
@@ -338,39 +307,34 @@ function ClusterNodesSection({ serverList, nodeStats }: { serverList: Rec[]; nod
</CardContent> </CardContent>
</Card> </Card>
{/* Disk stats chart — Grafana style, one line per node */} {/* Disk stats — donut, one slice per node by disk usage % */}
<Card> <Card>
<CardHeader className="pb-2 pt-4 px-5"> <CardHeader className="pb-2 pt-4 px-5">
<CardTitle className="text-sm font-semibold">ТОП статистика по диску</CardTitle> <CardTitle className="text-sm font-semibold">ТОП статистика по диску</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="px-3 pb-4"> <CardContent className="px-3 pb-4">
{chartData.length === 0 ? ( {pieData.length === 0 ? (
<div className="h-52 flex items-center justify-center text-sm text-muted-foreground"> <div className="h-52 flex items-center justify-center text-sm text-muted-foreground">
{chartNodes.length > 0 ? 'Загрузка...' : 'Нет данных'} Нет данных
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}> <PieChart>
<defs> <Pie
{chartNodes.map((_, j) => ( data={pieData}
<linearGradient key={j} id={`g${j}`} x1="0" y1="0" x2="0" y2="1"> dataKey="value"
<stop offset="5%" stopColor={CHART_COLORS[j % CHART_COLORS.length]} stopOpacity={0.2} /> nameKey="name"
<stop offset="95%" stopColor={CHART_COLORS[j % CHART_COLORS.length]} stopOpacity={0} /> cx="50%"
</linearGradient> cy="50%"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
strokeWidth={0}
>
{pieData.map((_, j) => (
<Cell key={j} fill={CHART_COLORS[j % CHART_COLORS.length]} />
))} ))}
</defs> </Pie>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" vertical={false} />
<XAxis
dataKey="t"
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false} axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false} axisLine={false}
unit=" MB"
/>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
background: 'hsl(var(--card))', background: 'hsl(var(--card))',
@@ -378,33 +342,14 @@ function ClusterNodesSection({ serverList, nodeStats }: { serverList: Rec[]; nod
borderRadius: 8, borderRadius: 8,
fontSize: 11, fontSize: 11,
}} }}
formatter={(v: number, _: string, props: { dataKey?: string }) => { formatter={(v: number, name: string) => [`${v}%`, name]}
const idx = Number(String(props.dataKey ?? '').replace('n', ''))
const name = String(chartNodes[idx]?.node ?? `node-${idx}`)
return [`${v} MB/s`, name]
}}
/> />
<Legend <Legend
formatter={(_, entry) => { formatter={(value) => <span style={{ fontSize: 11 }}>{value}</span>}
const idx = Number(String((entry as { dataKey?: string }).dataKey ?? '').replace('n', '')) iconType="circle"
return <span style={{ fontSize: 11 }}>{String(chartNodes[idx]?.node ?? `node-${idx}`)}</span> iconSize={8}
}}
iconType="line"
iconSize={10}
/> />
{chartNodes.map((_, j) => ( </PieChart>
<Area
key={j}
type="monotone"
dataKey={`n${j}`}
stroke={CHART_COLORS[j % CHART_COLORS.length]}
fill={`url(#g${j})`}
strokeWidth={1.5}
dot={false}
connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
</CardContent> </CardContent>
@@ -499,7 +444,7 @@ export default function DashboardPage() {
</div> </div>
{/* Bottom row: nodes table + disk chart */} {/* Bottom row: nodes table + disk chart */}
<ClusterNodesSection serverList={serverList} nodeStats={nodeStats} /> <ClusterNodesSection nodeStats={nodeStats} />
</div> </div>
) )
} }