feat(dashboard): redesign in VMmanager style with multi-node disk chart
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 7s

- Three-panel top row: cluster overview, tasks-today, node gauges
- Cluster overview: IPv4 available in stats row alongside nodes/VM count,
  RAM/disk progress bars, RAM overselling ratio
- Tasks panel: completed/running/queued/errors/total from useAllTasks
- Node panel: SVG circular CPU & RAM gauges
- Bottom: nodes table with top-10 tabs (by CPU or disk%) + Grafana-style
  disk I/O chart with parallel useQueries for top-5 nodes, CartesianGrid,
  per-node colored lines and legend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 03:26:41 +05:00
parent 28fa6eb5b4
commit d59d787bed

View File

@@ -1,364 +1,497 @@
import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react'
import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query'
import { ExternalLink, ChevronDown } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Server,
Monitor,
Container,
AlertTriangle,
Cpu,
MemoryStick,
HardDrive,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { apiClient } from '@/lib/api-client';
import { useTranslation } from 'react-i18next';
import { getTasksWebSocket } from '@/lib/websocket';
import { useInstancesMetricsSync } from '@/hooks/use-instances';
AreaChart, Area, XAxis, YAxis, Tooltip, CartesianGrid, Legend, ResponsiveContainer,
} from 'recharts'
import { apiClient } from '@/lib/api-client'
import { getTasksWebSocket } from '@/lib/websocket'
import { useInstancesMetricsSync } from '@/hooks/use-instances'
import { useAllTasks, useActiveTaskCount } from '@/hooks/use-tasks'
import { useIPAMNetworks } from '@/hooks/use-ipam'
import { useNodeRrddata } from '@/hooks/use-nodes'
// ==================== Stat Card ====================
interface StatCardProps {
label: string;
value: number | string;
subtitle?: string;
icon: React.ComponentType<{ className?: string }>;
color?: string;
const CHART_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a855f7', '#06b6d4', '#ec4899', '#84cc16']
type Rec = Record<string, unknown>
// ==================== Helpers ====================
function barColor(pct: number) {
return pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-green-500'
}
function StatCard({ label, value, subtitle, icon: Icon, color = 'text-primary' }: StatCardProps) {
function gaugeStroke(pct: number) {
return pct >= 90 ? '#ef4444' : pct >= 70 ? '#f59e0b' : '#22c55e'
}
// ==================== Resource Bar ====================
function ResourceBar({ label, percent }: { label: string; percent: number }) {
return (
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 ${color}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="text-2xl font-bold tabular-nums">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
</CardContent>
</Card>
);
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums font-medium">{percent.toFixed(1)}%</span>
</div>
<div className="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${barColor(percent)}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
</div>
)
}
// ==================== Resource Card ====================
interface ResourceCardProps {
label: string;
percent: number;
used: string;
total: string;
icon: React.ComponentType<{ className?: string }>;
}
function ResourceCard({ label, percent, used, total, icon: Icon }: ResourceCardProps) {
const barColor =
percent >= 90 ? 'bg-red-500' : percent >= 70 ? 'bg-amber-500' : 'bg-green-500';
// ==================== SVG Circular Gauge ====================
function CircularGauge({ label, value }: { label: string; value: number }) {
const r = 26
const circ = 2 * Math.PI * r
const clamped = Math.min(Math.max(value, 0), 100)
const offset = circ * (1 - clamped / 100)
return (
<Card>
<CardContent className="p-5">
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
<div className="flex flex-col items-center gap-1.5">
<div className="relative w-[68px] h-[68px]">
<svg viewBox="0 0 72 72" className="w-full h-full -rotate-90">
<circle cx="36" cy="36" r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="7" />
<circle
cx="36" cy="36" r={r}
fill="none" stroke={gaugeStroke(value)} strokeWidth="7"
strokeLinecap="round"
strokeDasharray={circ}
strokeDashoffset={offset}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[13px] font-bold tabular-nums">{Math.round(clamped)}</span>
</div>
</div>
<span className="text-[10px] text-muted-foreground tracking-wide">{label}</span>
</div>
)
}
// ==================== Cluster Overview Card ====================
function ClusterOverviewCard({ serverList, nodeStats, allVMs, memPercent, diskPercent, availableIPs }: {
serverList: Rec[]; nodeStats: Rec[]; allVMs: Rec[]
memPercent: number; diskPercent: number; availableIPs: number
}) {
const clusterName =
(serverList[0]?.cluster_name as string) ||
(serverList[0]?.server_name as string) ||
'Кластер'
const totalVMs = allVMs.filter(v => v.type === 'qemu').length
const totalCTs = allVMs.filter(v => v.type === 'lxc').length
const totalNodeMem = nodeStats.reduce((s, n) => s + (Number(n.maxmem) || 0), 0)
const totalVMMem = allVMs.reduce((s, v) => s + (Number(v.maxmem) || 0), 0)
const overselling = (totalNodeMem > 0 ? totalVMMem / totalNodeMem : 1).toFixed(1)
const cols = availableIPs > 0 ? 'grid-cols-3' : 'grid-cols-2'
return (
<Card className="h-full">
<CardContent className="p-5 flex flex-col gap-4 h-full">
<div className="flex items-center gap-1.5">
<button className="flex items-center gap-1.5 group text-left">
<span className="text-base font-semibold group-hover:text-primary transition-colors">{clusterName}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</div>
<div className={`grid ${cols} gap-4`}>
{availableIPs > 0 && (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">IPv4 доступно</p>
<p className="text-3xl font-bold tabular-nums text-green-400">{availableIPs.toLocaleString()}</p>
</div>
)}
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Узлов</p>
<p className="text-3xl font-bold tabular-nums">{nodeStats.length}</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">VM</p>
<p className="text-3xl font-bold tabular-nums">{totalVMs + totalCTs}</p>
</div>
</div>
<div className="space-y-2.5 flex-1">
<ResourceBar label="RAM" percent={memPercent} />
<ResourceBar label="Storage" percent={diskPercent} />
</div>
<div className="flex items-center justify-between text-xs pt-1">
<span className="text-muted-foreground">
Оверселлинг RAM = <span className="text-foreground font-medium">{overselling}</span>
</span>
<span className="flex items-center gap-1 text-muted-foreground">
<ExternalLink className="h-3 w-3" /> Grafana
</span>
</div>
<p className="mb-2 text-2xl font-bold tabular-nums">{percent.toFixed(1)}%</p>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{used} / {total}
</p>
</CardContent>
</Card>
);
)
}
// ==================== Activity Item ====================
interface ActivityItem {
time: string;
user?: string;
action: string;
resource?: string;
type?: string;
}
// ==================== Tasks Today Card ====================
function TasksTodayCard() {
const { data: tasksData } = useAllTasks({ limit: 500 })
const { data: activeData } = useActiveTaskCount()
function ActivityFeed({ items }: { items: ActivityItem[] }) {
const { t } = useTranslation();
const typeColor: Record<string, string> = {
success: 'bg-green-500/20 text-green-500',
error: 'bg-red-500/20 text-red-500',
warning: 'bg-amber-500/20 text-amber-500',
info: 'bg-blue-500/20 text-blue-500',
};
const startOfDay = new Date()
startOfDay.setHours(0, 0, 0, 0)
const startTs = startOfDay.getTime() / 1000
const all = tasksData?.tasks ?? []
const today = all.filter(t => {
if (t.created_at) return new Date(t.created_at) >= startOfDay
if (t.starttime != null) return Number(t.starttime) >= startTs
return false
})
const completed = today.filter(t =>
t.exitstatus === 'OK' || t.status === 'ok' || t.status === 'completed'
).length
const running = activeData?.count ?? today.filter(t => t.started_at && !t.completed_at && !t.endtime).length
const queued = today.filter(t => t.status === 'queued' || t.status === 'pending').length
const errors = today.filter(t =>
t.exitstatus === 'ERROR' || t.status === 'error' || t.status === 'failed'
).length
const rows = [
{ label: 'Выполнено:', value: completed, red: false },
{ label: 'Выполняются:', value: running, red: false },
{ label: 'В очереди:', value: queued, red: false },
{ label: 'Ошибки:', value: errors, red: errors > 0 },
]
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t('dashboard.recent_activity', 'Recent Activity')}</CardTitle>
<Card className="h-full">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-semibold">Задачи за сутки</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data', 'No recent activity')}</p>
) : (
items.map((item, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground font-mono tabular-nums">
{item.time}
</span>
<div className="min-w-0 flex-1">
<span className="text-muted-foreground">{item.user && `${item.user} `}</span>
<span>{item.action}</span>
{item.resource && (
<span className="font-medium"> {item.resource}</span>
)}
</div>
{item.type && (
<Badge variant="secondary" className={`shrink-0 text-[10px] ${typeColor[item.type] ?? ''}`}>
{item.type}
</Badge>
)}
</div>
))
)}
<CardContent className="px-5 pb-5 space-y-2.5">
{rows.map(({ label, value, red }) => (
<div key={label} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{label}</span>
<span className={`text-sm font-semibold tabular-nums ${red ? 'text-red-500' : ''}`}>{value}</span>
</div>
))}
<div className="border-t border-border pt-2.5 flex items-center justify-between">
<span className="text-sm font-medium">Всего</span>
<span className="text-sm font-bold tabular-nums">{today.length}</span>
</div>
</CardContent>
</Card>
);
)
}
// ==================== Format Helpers ====================
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
// ==================== Node Stats Card ====================
function NodeStatsCard({ cpuPercent, memPercent, serverName, serverCount }: {
cpuPercent: number; memPercent: number; serverName: string; serverCount: number
}) {
return (
<Card className="h-full">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-semibold truncate">{serverName || 'Сервер'}</CardTitle>
</CardHeader>
<CardContent className="px-5 pb-5 space-y-4">
<div className="flex justify-around">
<CircularGauge label="CPU, %" value={cpuPercent} />
<CircularGauge label="RAM" value={memPercent} />
</div>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Серверов:</span>
<span className="font-medium">{serverCount}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Платформа:</span>
<span className="font-medium">Proxmox VE</span>
</div>
</div>
</CardContent>
</Card>
)
}
// ==================== Cluster Nodes Section ====================
function ClusterNodesSection({ serverList, nodeStats }: { serverList: Rec[]; nodeStats: Rec[] }) {
const [tab, setTab] = useState<'now' | 'day'>('now')
const timeframe = tab === 'now' ? 'hour' : 'day'
// Top-5 nodes by disk% for chart data
const chartNodes = [...nodeStats]
.sort((a, b) => (Number(b.disk_pct) || 0) - (Number(a.disk_pct) || 0))
.slice(0, 5)
// Fetch RRD for each of those nodes in parallel
const rrdResults = useQueries({
queries: chartNodes.map(n => ({
queryKey: ['node-rrd', Number(n.server_id) || 0, String(n.node || ''), timeframe],
queryFn: () =>
apiClient.get<Rec[]>(
`/proxmox/api/${n.server_id}/node/rrddata?node=${n.node}&timeframe=${timeframe}`
),
enabled: !!n.server_id && !!n.node,
})),
})
// 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]
.sort((a, b) =>
tab === 'now'
? (Number(b.cpu_pct) || 0) - (Number(a.cpu_pct) || 0)
: (Number(b.disk_pct) || 0) - (Number(a.disk_pct) || 0)
)
.slice(0, 10)
return (
<div className="grid gap-4 lg:grid-cols-[1fr_380px]">
{/* Node table */}
<Card>
<CardHeader className="pb-3 pt-4 px-5">
<div className="flex items-center justify-between gap-4 flex-wrap">
<CardTitle className="text-sm font-semibold">Узлы кластера</CardTitle>
<div className="flex gap-1">
{(['now', 'day'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
tab === t
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
>
{t === 'now' ? 'Топ-10 сейчас' : 'Топ-10 за сутки'}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="px-5 pb-4">
<div className="grid grid-cols-[1fr_56px_56px_56px] gap-2 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border mb-1">
<span>Название узлов</span>
<span className="text-right">CPU, %</span>
<span className="text-right">RAM</span>
<span className="text-right">Disk</span>
</div>
{sorted.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Нет данных</p>
) : (
sorted.map((n, i) => {
const cpu = Number(n.cpu_pct) || 0
const ram = Number(n.ram_pct) || 0
const disk = Number(n.disk_pct) || 0
const online = n.status !== 'offline'
return (
<div
key={i}
className="grid grid-cols-[1fr_56px_56px_56px] gap-2 items-center px-2 py-2 rounded hover:bg-muted/40 text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<span className={`h-2 w-2 shrink-0 rounded-full ${online ? 'bg-green-500' : 'bg-red-400'}`} />
<span className="truncate">{String(n.node || n.name || `node-${i + 1}`)}</span>
{n.server_name && (
<span className="text-[10px] text-muted-foreground shrink-0">
#{String(n.server_name)}
</span>
)}
</div>
<span className={`text-right tabular-nums text-xs font-medium ${cpu >= 90 ? 'text-red-400' : cpu >= 70 ? 'text-amber-400' : ''}`}>
{cpu.toFixed(0)}
</span>
<span className={`text-right tabular-nums text-xs ${ram >= 90 ? 'text-red-400' : ram >= 70 ? 'text-amber-400' : ''}`}>
{ram.toFixed(0)}
</span>
<span className="text-right tabular-nums text-xs text-muted-foreground">{disk.toFixed(0)}</span>
</div>
)
})
)}
</CardContent>
</Card>
{/* Disk stats chart — Grafana style, one line per node */}
<Card>
<CardHeader className="pb-2 pt-4 px-5">
<CardTitle className="text-sm font-semibold">ТОП статистика по диску</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-4">
{chartData.length === 0 ? (
<div className="h-52 flex items-center justify-center text-sm text-muted-foreground">
{chartNodes.length > 0 ? 'Загрузка...' : 'Нет данных'}
</div>
) : (
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<defs>
{chartNodes.map((_, j) => (
<linearGradient key={j} id={`g${j}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={CHART_COLORS[j % CHART_COLORS.length]} stopOpacity={0.2} />
<stop offset="95%" stopColor={CHART_COLORS[j % CHART_COLORS.length]} stopOpacity={0} />
</linearGradient>
))}
</defs>
<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
contentStyle={{
background: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v: number, _: string, props: { dataKey?: string }) => {
const idx = Number(String(props.dataKey ?? '').replace('n', ''))
const name = String(chartNodes[idx]?.node ?? `node-${idx}`)
return [`${v} MB/s`, name]
}}
/>
<Legend
formatter={(_, entry) => {
const idx = Number(String((entry as { dataKey?: string }).dataKey ?? '').replace('n', ''))
return <span style={{ fontSize: 11 }}>{String(chartNodes[idx]?.node ?? `node-${idx}`)}</span>
}}
iconType="line"
iconSize={10}
/>
{chartNodes.map((_, j) => (
<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>
)}
</CardContent>
</Card>
</div>
)
}
// ==================== Dashboard Page ====================
export default function DashboardPage() {
const { t } = useTranslation();
const qc = useQueryClient();
// Live per-VM metrics (realtime ~2 s) — overlaid onto the top-loaded list below
const liveMetrics = useInstancesMetricsSync();
const qc = useQueryClient()
const liveMetrics = useInstancesMetricsSync()
const { data: ipamNetworks } = useIPAMNetworks()
// Fetch all resources
const { data: resources } = useQuery({
queryKey: ['resources'],
queryFn: () => apiClient.get<Record<string, unknown>>('/proxmox/api/resources/all'),
queryFn: () => apiClient.get<Rec>('/proxmox/api/resources/all'),
refetchInterval: 15000,
});
})
// Fetch alerts
const { data: alerts } = useQuery({
queryKey: ['dashboard-alerts'],
queryFn: () => apiClient.get<{ alerts: unknown[]; count: number }>('/api/dashboard/alerts'),
refetchInterval: 60000,
});
// Fetch recent activity from audit logs
const { data: recentLogs } = useQuery({
queryKey: ['dashboard-activity'],
queryFn: () => apiClient.get<{ logs: Array<{ created_at: string; username?: string; message: string; category?: string; level?: string }> }>('/logs/api/logs?limit=10'),
refetchInterval: 30000,
});
// Subscribe to WebSocket 'dashboard_metrics' for real-time updates
useEffect(() => {
const ws = getTasksWebSocket();
if (!ws.isConnected) ws.connect();
const ws = getTasksWebSocket()
if (!ws.isConnected) ws.connect()
const unsub = ws.subscribe('dashboard_metrics', (data: unknown) => {
const payload = data as { data?: Record<string, unknown> };
if (payload?.data) {
qc.setQueryData(['resources'], payload.data);
}
});
return unsub;
}, [qc]);
const payload = data as { data?: Rec }
if (payload?.data) qc.setQueryData(['resources'], payload.data)
})
return unsub
}, [qc])
// Parse resource data — API returns { servers: [{vms, containers, nodes}], total_vms, total_containers }
const serverList = ((resources as Record<string, unknown>)?.servers as Array<Record<string, unknown>>) ?? [];
const allVMs = serverList.flatMap((s) => [
...((s.vms as Array<Record<string, unknown>>) ?? []),
...((s.containers as Array<Record<string, unknown>>) ?? []),
].map((v) => {
// Overlay realtime cpu/mem from the live instances channel (falls back to WS snapshot)
const live = liveMetrics.get(`${s.id}:${v.vmid}`);
return live ? { ...v, cpu: live.cpu ?? v.cpu, mem: live.mem ?? v.mem, status: live.status ?? v.status } : v;
}));
const serverList = ((resources as Rec)?.servers as Rec[]) ?? []
const nodesOnline = serverList.filter((s) => !s.error).length;
const qemuVMs = allVMs.filter((v) => v.type === 'qemu');
const lxcCTs = allVMs.filter((v) => v.type === 'lxc');
const runningVMs = qemuVMs.filter((v) => v.status === 'running').length;
const runningCTs = lxcCTs.filter((v) => v.status === 'running').length;
const allVMs = serverList.flatMap(s =>
[...((s.vms as Rec[]) ?? []), ...((s.containers as Rec[]) ?? [])].map(v => {
const live = liveMetrics.get(`${s.id}:${v.vmid}`)
return live
? { ...v, cpu: live.cpu ?? v.cpu, mem: live.mem ?? v.mem, status: live.status ?? v.status }
: v
})
)
// Aggregate resource metrics from node-level data (WS) or fallback to VM-level
const nodeStats = serverList.flatMap((s) => (s.nodes as Array<Record<string, unknown>>) ?? []);
let cpuPercent = 0;
let cpuUsedLabel = '';
let cpuTotalLabel = '';
let memPercent = 0;
let memUsedLabel = '';
let memTotalLabel = '';
let diskPercent = 0;
let diskUsedLabel = '';
let diskTotalLabel = '';
const nodeStats = serverList.flatMap(s =>
((s.nodes as Rec[]) ?? []).map(n => ({
...n,
server_name: s.server_name,
server_id: s.id,
}))
)
let cpuPercent = 0, memPercent = 0, diskPercent = 0
if (nodeStats.length > 0) {
// Use precise node-level stats from WebSocket metrics
cpuPercent = nodeStats.reduce((s, n) => s + (Number(n.cpu_pct) || 0), 0) / nodeStats.length;
cpuUsedLabel = `${cpuPercent.toFixed(1)}%`;
cpuTotalLabel = `${nodeStats.length} nodes`;
memPercent = nodeStats.reduce((s, n) => s + (Number(n.ram_pct) || 0), 0) / nodeStats.length;
memUsedLabel = `${memPercent.toFixed(1)}%`;
memTotalLabel = `${nodeStats.length} nodes`;
diskPercent = nodeStats.reduce((s, n) => s + (Number(n.disk_pct) || 0), 0) / nodeStats.length;
diskUsedLabel = `${diskPercent.toFixed(1)}%`;
diskTotalLabel = `${nodeStats.length} nodes`;
cpuPercent = nodeStats.reduce((s, n) => s + (Number(n.cpu_pct) || 0), 0) / nodeStats.length
memPercent = nodeStats.reduce((s, n) => s + (Number(n.ram_pct) || 0), 0) / nodeStats.length
diskPercent = nodeStats.reduce((s, n) => s + (Number(n.disk_pct) || 0), 0) / nodeStats.length
} else {
// Fallback: aggregate from VM data
const totalCPU = allVMs.reduce((s, v) => s + (Number(v.maxcpu) || 0), 0);
const usedCPU = allVMs.reduce((s, v) => s + (Number(v.cpu) || 0) * (Number(v.maxcpu) || 0), 0);
cpuPercent = totalCPU > 0 ? (usedCPU / totalCPU) * 100 : 0;
cpuUsedLabel = `${usedCPU.toFixed(1)} cores`;
cpuTotalLabel = `${totalCPU} cores`;
const totalMem = allVMs.reduce((s, v) => s + (Number(v.maxmem) || 0), 0);
const usedMem = allVMs.reduce((s, v) => s + (Number(v.mem) || 0), 0);
memPercent = totalMem > 0 ? (usedMem / totalMem) * 100 : 0;
memUsedLabel = formatBytes(usedMem);
memTotalLabel = formatBytes(totalMem);
const totalDisk = allVMs.reduce((s, v) => s + (Number(v.maxdisk) || 0), 0);
const usedDisk = allVMs.reduce((s, v) => s + (Number(v.disk) || 0), 0);
diskPercent = totalDisk > 0 ? (usedDisk / totalDisk) * 100 : 0;
diskUsedLabel = formatBytes(usedDisk);
diskTotalLabel = formatBytes(totalDisk);
const tm = allVMs.reduce((s, v) => s + (Number(v.maxmem) || 0), 0)
const um = allVMs.reduce((s, v) => s + (Number(v.mem) || 0), 0)
memPercent = tm > 0 ? (um / tm) * 100 : 0
const td = allVMs.reduce((s, v) => s + (Number(v.maxdisk) || 0), 0)
const ud = allVMs.reduce((s, v) => s + (Number(v.disk) || 0), 0)
diskPercent = td > 0 ? (ud / td) * 100 : 0
const tc = allVMs.reduce((s, v) => s + (Number(v.maxcpu) || 0), 0)
const uc = allVMs.reduce((s, v) => s + (Number(v.cpu) || 0) * (Number(v.maxcpu) || 0), 0)
cpuPercent = tc > 0 ? (uc / tc) * 100 : 0
}
const availableIPs = (ipamNetworks ?? []).reduce((s, n) => s + (n.available_ips ?? 0), 0)
const serverName =
(serverList[0]?.cluster_name as string) ||
(serverList[0]?.server_name as string) ||
''
return (
<div className="space-y-6">
<h1 className="text-[22px] font-semibold">{t('dashboard.title', 'Dashboard')}</h1>
{/* Stat Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('dashboard.nodes', 'Nodes')}
value={serverList.length}
subtitle={`${nodesOnline} online`}
icon={Server}
<div className="space-y-4">
{/* Top row: cluster overview | tasks | node stats */}
<div className="grid gap-4 lg:grid-cols-[1fr_220px_210px]">
<ClusterOverviewCard
serverList={serverList}
nodeStats={nodeStats}
allVMs={allVMs}
memPercent={memPercent}
diskPercent={diskPercent}
availableIPs={availableIPs}
/>
<StatCard
label={t('dashboard.virtual_machines', 'Virtual Machines')}
value={qemuVMs.length}
subtitle={`${runningVMs} running`}
icon={Monitor}
color="text-blue"
/>
<StatCard
label={t('dashboard.containers', 'Containers')}
value={lxcCTs.length}
subtitle={`${runningCTs} running`}
icon={Container}
color="text-green"
/>
<StatCard
label={t('dashboard.alerts', 'Alerts')}
value={alerts?.count ?? 0}
icon={AlertTriangle}
color="text-amber"
<TasksTodayCard />
<NodeStatsCard
cpuPercent={cpuPercent}
memPercent={memPercent}
serverName={serverName}
serverCount={serverList.length}
/>
</div>
{/* Resource Cards */}
<div className="grid gap-4 sm:grid-cols-3">
<ResourceCard
label={t('common.cpu')}
percent={cpuPercent}
used={cpuUsedLabel}
total={cpuTotalLabel}
icon={Cpu}
/>
<ResourceCard
label={t('dashboard.memory', 'Memory')}
percent={memPercent}
used={memUsedLabel}
total={memTotalLabel}
icon={MemoryStick}
/>
<ResourceCard
label={t('dashboard.storage', 'Storage')}
percent={diskPercent}
used={diskUsedLabel}
total={diskTotalLabel}
icon={HardDrive}
/>
</div>
{/* Bottom row */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Top loaded instances */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t('dashboard.instances', 'Instances')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{(() => {
const topInstances = allVMs
.filter((v) => v.status === 'running' && Number(v.cpu) > 0)
.sort((a, b) => Number(b.cpu) - Number(a.cpu))
.slice(0, 10);
if (topInstances.length === 0) {
return <p className="py-4 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>;
}
return topInstances.map((vm, i) => {
const cpuPct = Math.min(Number(vm.cpu) * 100, 100);
const barColor = cpuPct >= 90 ? 'bg-red-500' : cpuPct >= 70 ? 'bg-amber-500' : 'bg-green-500';
return (
<div key={i} className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50">
<span className="h-2 w-2 shrink-0 rounded-full bg-green-500" />
<span className="min-w-0 flex-1 truncate font-medium">
{String(vm.name ?? `VM ${vm.vmid}`)}
</span>
<div className="flex w-20 shrink-0 items-center gap-1.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${cpuPct}%` }} />
</div>
<span className="w-9 text-right text-[11px] tabular-nums text-muted-foreground">
{cpuPct.toFixed(1)}%
</span>
</div>
<Badge variant="secondary" className="text-[10px]">
{String(vm.type ?? 'vm').toUpperCase()}
</Badge>
</div>
);
});
})()}
</div>
</CardContent>
</Card>
{/* Activity */}
<ActivityFeed items={(recentLogs?.logs ?? []).map((log) => {
const d = new Date(log.created_at);
return {
time: `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`,
user: log.username,
action: log.message,
type: log.level === 'ERROR' ? 'error' : log.level === 'WARNING' ? 'warning' : log.category === 'auth' ? 'info' : 'success',
};
})} />
</div>
{/* Bottom row: nodes table + disk chart */}
<ClusterNodesSection serverList={serverList} nodeStats={nodeStats} />
</div>
);
)
}