feat(metrics): real-time VM disk fill, IOPS and network I/O
Add live per-disk usage, disk read/write and network in/out rates to the instance Overview tab, the instances list and the dashboard top-loaded list. Backend: - get_vm_fsinfo() pulls per-filesystem usage via QEMU guest agent - metrics_broadcaster computes I/O rates (bytes/sec) from cumulative counters - instances-list loop now refreshes each running VM via status/current (1s-granular) instead of cluster/resources (10s pvestatd), with bounded concurrency, and paces both realtime loops to a 1s cadence Frontend: - OverviewTab: per-disk cards, Network I/O and I/O-rate cards via WS - InstancesPage: Net and Disk I/O columns; Disk% shown for LXC only - DashboardPage: top-loaded list overlaid with realtime metrics Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ 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';
|
||||
|
||||
// ==================== Stat Card ====================
|
||||
interface StatCardProps {
|
||||
@@ -145,6 +146,8 @@ function formatBytes(bytes: number): string {
|
||||
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();
|
||||
|
||||
// Fetch all resources
|
||||
const { data: resources } = useQuery({
|
||||
@@ -185,7 +188,11 @@ export default function DashboardPage() {
|
||||
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 nodesOnline = serverList.filter((s) => !s.error).length;
|
||||
const qemuVMs = allVMs.filter((v) => v.type === 'qemu');
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
KeyRound,
|
||||
FileText,
|
||||
Disc,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -349,6 +352,12 @@ export default function InstancesPage() {
|
||||
uptime: live.uptime ?? vm.uptime,
|
||||
netin: live.netin ?? vm.netin,
|
||||
netout: live.netout ?? vm.netout,
|
||||
diskread: live.diskread ?? vm.diskread,
|
||||
diskwrite: live.diskwrite ?? vm.diskwrite,
|
||||
diskread_rate: live.diskread_rate,
|
||||
diskwrite_rate: live.diskwrite_rate,
|
||||
netin_rate: live.netin_rate,
|
||||
netout_rate: live.netout_rate,
|
||||
};
|
||||
});
|
||||
}, [allRows, metricsMap]);
|
||||
@@ -594,7 +603,9 @@ export default function InstancesPage() {
|
||||
header: 'Disk',
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId || (!vm.disk && !vm.maxdisk)) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
if (vm._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
// For QEMU, cluster/resources 'disk' = disk I/O bytes (not usage) → not meaningful as %
|
||||
if (vm.type !== 'lxc' || (!vm.disk && !vm.maxdisk)) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -606,6 +617,67 @@ export default function InstancesPage() {
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'net_io',
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowDown className="h-3 w-3 text-blue-500" />
|
||||
<ArrowUp className="h-3 w-3 text-violet-500" />
|
||||
Net
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId || vm.status !== 'running') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const inRate = vm.netin_rate;
|
||||
const outRate = vm.netout_rate;
|
||||
if (inRate == null && outRate == null) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const fmt = (v?: number) => v != null ? `${formatBytes(v)}/s` : '—';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowDown className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-xs tabular-nums">{fmt(inRate)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUp className="h-3 w-3 shrink-0 text-violet-500" />
|
||||
<span className="text-xs tabular-nums">{fmt(outRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
id: 'disk_io',
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
Disk I/O
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId || vm.status !== 'running') return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const readRate = vm.diskread_rate;
|
||||
const writeRate = vm.diskwrite_rate;
|
||||
if (readRate == null && writeRate == null) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const fmt = (v?: number) => v != null ? `${formatBytes(v)}/s` : '—';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-cyan-500 font-medium w-3">R</span>
|
||||
<span className="text-xs tabular-nums">{fmt(readRate)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-orange-500 font-medium w-3">W</span>
|
||||
<span className="text-xs tabular-nums">{fmt(writeRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
id: 'uptime',
|
||||
header: 'Uptime',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server } from 'lucide-react';
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server, ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useVMStatus, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
||||
import { useVMLiveMetrics, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DiskInfo } from '@/types';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
@@ -55,6 +56,84 @@ function MetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkCard({ label, inRate, outRate }: { label: string; inRate?: number; outRate?: number }) {
|
||||
const fmt = (v?: number) => (v != null ? `${formatBytes(v)}/s` : '—');
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowDown className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="text-base font-bold tabular-nums">{fmt(inRate)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUp className="h-3.5 w-3.5 text-violet-500" />
|
||||
<span className="text-base font-bold tabular-nums">{fmt(outRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DiskCard({ disk }: { disk: DiskInfo }) {
|
||||
const pct = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
|
||||
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">{disk.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono">{disk.mountpoint}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold tabular-nums">{pct.toFixed(1)}%</p>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatBytes(disk.used)} / {formatBytes(disk.total)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IoRateCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value?: number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold tabular-nums">
|
||||
{value != null ? `${formatBytes(value)}/s` : '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-border/50 last:border-0">
|
||||
@@ -66,21 +145,22 @@ function InfoRow({ label, value }: { label: string; value?: string | number | nu
|
||||
|
||||
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||
const status = useVMLiveMetrics(serverId, vmid, type, node);
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
||||
|
||||
const cpuPercent = status ? (status.cpu || 0) * 100 : 0;
|
||||
const memPercent = status && status.maxmem ? (status.mem / status.maxmem) * 100 : 0;
|
||||
const diskPercent = status && status.maxdisk ? (status.disk / status.maxdisk) * 100 : 0;
|
||||
|
||||
const primaryIP = ifaces?.interfaces
|
||||
?.flatMap((i) => i.ips || [])
|
||||
.find((ip) => ip.type === 'ipv4' && !ip.address.startsWith('127.'));
|
||||
|
||||
const disks: DiskInfo[] = status?.disks ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Metric cards */}
|
||||
{/* Row 1: Main metric cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label={t('common.cpu')}
|
||||
@@ -96,22 +176,77 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
percent={memPercent}
|
||||
icon={MemoryStick}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t('common.disk')}
|
||||
value={status ? formatBytes(status.disk) : '—'}
|
||||
subtitle={status ? `${t('common.of')} ${formatBytes(status.maxdisk)}` : undefined}
|
||||
percent={diskPercent}
|
||||
icon={HardDrive}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t('common.uptime')}
|
||||
value={status?.uptime ? formatUptime(status.uptime) : '—'}
|
||||
icon={Clock}
|
||||
/>
|
||||
<NetworkCard
|
||||
label={t('common.network_io')}
|
||||
inRate={status?.netin_rate}
|
||||
outRate={status?.netout_rate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Storage section — per-disk breakdown */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
{t('common.storage')}
|
||||
</h3>
|
||||
{disks.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{disks.map((disk) => (
|
||||
<DiskCard key={`${disk.name}-${disk.mountpoint}`} disk={disk} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-4 text-sm text-muted-foreground">
|
||||
{type === 'qemu'
|
||||
? t('common.guest_agent_required')
|
||||
: '—'}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* I/O Rates section */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowDown className="h-4 w-4 text-muted-foreground" />
|
||||
{t('common.io_rates')}
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<IoRateCard
|
||||
label={t('common.disk_read_rate')}
|
||||
value={status?.diskread_rate}
|
||||
icon={HardDrive}
|
||||
color="text-cyan-500"
|
||||
/>
|
||||
<IoRateCard
|
||||
label={t('common.disk_write_rate')}
|
||||
value={status?.diskwrite_rate}
|
||||
icon={HardDrive}
|
||||
color="text-orange-500"
|
||||
/>
|
||||
<IoRateCard
|
||||
label={t('common.net_in_rate')}
|
||||
value={status?.netin_rate}
|
||||
icon={ArrowDown}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
<IoRateCard
|
||||
label={t('common.net_out_rate')}
|
||||
value={status?.netout_rate}
|
||||
icon={ArrowUp}
|
||||
color="text-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info + Network details */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Info card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
@@ -132,7 +267,6 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { getTasksWebSocket } from '@/lib/websocket';
|
||||
import type { VMInstance, Snapshot, VMConfig } from '@/types';
|
||||
import type { VMInstance, Snapshot, VMConfig, DiskInfo } from '@/types';
|
||||
|
||||
// ==================== Query Keys ====================
|
||||
export const vmKeys = {
|
||||
@@ -68,9 +68,12 @@ export function useVMStatusSync() {
|
||||
}
|
||||
|
||||
export type LiveInstanceMetrics = {
|
||||
server_id: number; vmid: number; node?: string; status?: string;
|
||||
server_id: number; vmid: number; node?: string; status?: string; type?: string;
|
||||
cpu?: number; mem?: number; maxmem?: number; disk?: number; maxdisk?: number;
|
||||
uptime?: number; netin?: number; netout?: number;
|
||||
diskread?: number; diskwrite?: number;
|
||||
diskread_rate?: number; diskwrite_rate?: number;
|
||||
netin_rate?: number; netout_rate?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -120,6 +123,14 @@ interface VMStatusResponse {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VMLiveStatus extends VMStatusResponse {
|
||||
diskread_rate?: number;
|
||||
diskwrite_rate?: number;
|
||||
netin_rate?: number;
|
||||
netout_rate?: number;
|
||||
disks?: DiskInfo[];
|
||||
}
|
||||
|
||||
export function useVMStatus(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery<VMStatusResponse>({
|
||||
@@ -130,6 +141,34 @@ export function useVMStatus(serverId: number, vmid: number, type: string, node:
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the live per-instance WebSocket channel and returns real-time
|
||||
* VM/LXC metrics including I/O rates and per-disk info.
|
||||
* Falls back to the HTTP-polled status until the first WS message arrives.
|
||||
*/
|
||||
export function useVMLiveMetrics(
|
||||
serverId: number,
|
||||
vmid: number,
|
||||
type: string,
|
||||
node: string,
|
||||
): VMLiveStatus | null {
|
||||
const [live, setLive] = useState<VMLiveStatus | null>(null);
|
||||
const { data: http } = useVMStatus(serverId, vmid, type, node);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = getTasksWebSocket();
|
||||
ws.connect();
|
||||
const channel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
|
||||
const unsub = ws.subscribe(channel, (msg: unknown) => {
|
||||
const payload = msg as { data?: VMLiveStatus };
|
||||
if (payload?.data) setLive(payload.data);
|
||||
});
|
||||
return () => unsub();
|
||||
}, [serverId, vmid, type, node]);
|
||||
|
||||
return live ?? (http as VMLiveStatus) ?? null;
|
||||
}
|
||||
|
||||
export function useVMConfig(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery<VMConfig>({
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
"primary_ip": "Primary IP",
|
||||
"network_in": "Network In",
|
||||
"network_out": "Network Out",
|
||||
"network_io": "Network I/O",
|
||||
"storage": "Storage",
|
||||
"disk_read_rate": "Disk Read",
|
||||
"disk_write_rate": "Disk Write",
|
||||
"net_in_rate": "Net In",
|
||||
"net_out_rate": "Net Out",
|
||||
"io_rates": "I/O Rates",
|
||||
"guest_agent_required": "Install qemu-guest-agent for disk usage details",
|
||||
"used_of": "used of",
|
||||
"rollback": "Rollback",
|
||||
"description": "Description",
|
||||
"os_type": "OS Type",
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
"primary_ip": "Основной IP",
|
||||
"network_in": "Входящий трафик",
|
||||
"network_out": "Исходящий трафик",
|
||||
"network_io": "Сеть I/O",
|
||||
"storage": "Хранилище",
|
||||
"disk_read_rate": "Чтение диска",
|
||||
"disk_write_rate": "Запись диска",
|
||||
"net_in_rate": "Вх. трафик",
|
||||
"net_out_rate": "Исх. трафик",
|
||||
"io_rates": "Скорость I/O",
|
||||
"guest_agent_required": "Установите qemu-guest-agent для данных об использовании диска",
|
||||
"used_of": "из",
|
||||
"rollback": "Откатить",
|
||||
"description": "Описание",
|
||||
"os_type": "Тип ОС",
|
||||
|
||||
@@ -63,6 +63,13 @@ export interface ProxmoxServerCreate {
|
||||
|
||||
// ==================== VM / Container Types ====================
|
||||
|
||||
export interface DiskInfo {
|
||||
name: string;
|
||||
mountpoint: string;
|
||||
used: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type VMType = 'qemu' | 'lxc';
|
||||
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown' | 'creating';
|
||||
|
||||
@@ -82,6 +89,12 @@ export interface VMInstance {
|
||||
uptime?: number;
|
||||
netin?: number;
|
||||
netout?: number;
|
||||
diskread?: number;
|
||||
diskwrite?: number;
|
||||
diskread_rate?: number;
|
||||
diskwrite_rate?: number;
|
||||
netin_rate?: number;
|
||||
netout_rate?: number;
|
||||
ip_address?: string;
|
||||
tags?: string;
|
||||
template?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user