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
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:
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user