Merge branch 'feature/metrics-history'
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useVMRrddata } from '@/hooks/use-instances';
|
||||
import { useVMMetrics } from '@/hooks/use-instances';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
@@ -22,17 +22,31 @@ interface Props {
|
||||
node: string;
|
||||
}
|
||||
|
||||
const TIMEFRAMES = [
|
||||
{ value: 'hour', label: '1h' },
|
||||
{ value: 'day', label: '24h' },
|
||||
{ value: 'week', label: '7d' },
|
||||
{ value: 'month', label: '30d' },
|
||||
{ value: 'year', label: '1y' },
|
||||
const PRESETS = [
|
||||
{ tf: 'hour', label: '1h' },
|
||||
{ tf: 'day', label: '24h' },
|
||||
{ tf: 'week', label: '7d' },
|
||||
{ tf: 'month', label: '30d' },
|
||||
];
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const MAX_SPAN = 30 * 86400;
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Convert a datetime-local string to unix seconds
|
||||
function localToUnix(val: string): number | undefined {
|
||||
if (!val) return undefined;
|
||||
return Math.floor(new Date(val).getTime() / 1000);
|
||||
}
|
||||
|
||||
// Convert unix seconds to datetime-local value string
|
||||
function unixToLocal(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
interface ChartCardProps {
|
||||
@@ -42,13 +56,17 @@ interface ChartCardProps {
|
||||
color: string;
|
||||
formatValue?: (v: number) => string;
|
||||
unit?: string;
|
||||
headerRight?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCardProps) {
|
||||
function ChartCard({ title, data, dataKey, color, formatValue, unit, headerRight }: ChartCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
{headerRight}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
@@ -62,7 +80,7 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={formatTime}
|
||||
tickFormatter={fmtTime}
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
@@ -70,17 +88,22 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
tick={{ fontSize: 10 }}
|
||||
tickFormatter={(v) => (formatValue ? formatValue(v) : String(v))}
|
||||
width={50}
|
||||
width={55}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
}}
|
||||
formatter={(val: any) => [
|
||||
val != null && typeof val === 'number' && formatValue ? formatValue(val) : `${val ?? ''}${unit || ''}`,
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
formatter={(val: unknown) => [
|
||||
val != null && typeof val === 'number' && formatValue
|
||||
? formatValue(val)
|
||||
: `${val ?? ''}${unit || ''}`,
|
||||
title,
|
||||
]}
|
||||
labelFormatter={(ts) => new Date((ts as number) * 1000).toLocaleString()}
|
||||
@@ -91,6 +114,7 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#grad-${dataKey})`}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -102,45 +126,115 @@ function ChartCard({ title, data, dataKey, color, formatValue, unit }: ChartCard
|
||||
export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [timeframe, setTimeframe] = useState('hour');
|
||||
const { data: rrdData, isLoading } = useVMRrddata(serverId, vmid, type, node, timeframe);
|
||||
// custom range: raw datetime-local strings
|
||||
const [fromLocal, setFromLocal] = useState('');
|
||||
const [toLocal, setToLocal] = useState('');
|
||||
const [nic, setNic] = useState('all');
|
||||
|
||||
// Parse RRD data into chart-friendly format
|
||||
const chartData = (rrdData as Array<Record<string, unknown>> | undefined)?.map((point) => ({
|
||||
time: Number(point.time),
|
||||
cpu: (Number(point.cpu) || 0) * 100,
|
||||
mem: Number(point.mem) || Number(point.memused) || 0,
|
||||
maxmem: Number(point.maxmem) || 0,
|
||||
netin: Number(point.netin) || 0,
|
||||
netout: Number(point.netout) || 0,
|
||||
diskread: Number(point.diskread) || 0,
|
||||
diskwrite: Number(point.diskwrite) || 0,
|
||||
})) || [];
|
||||
// Compute unix-second from/to with 30-day clamp
|
||||
const customRange = useMemo<{ from?: number; to?: number }>(() => {
|
||||
const f = localToUnix(fromLocal);
|
||||
const to = localToUnix(toLocal);
|
||||
if (f == null || to == null) return {};
|
||||
let from = f;
|
||||
if (to - from > MAX_SPAN) from = to - MAX_SPAN;
|
||||
return { from, to };
|
||||
}, [fromLocal, toLocal]);
|
||||
|
||||
const hasCustom = customRange.from != null && customRange.to != null;
|
||||
|
||||
const { data, isLoading } = useVMMetrics(serverId, vmid, type, node, {
|
||||
timeframe: hasCustom ? undefined : timeframe,
|
||||
from: customRange.from,
|
||||
to: customRange.to,
|
||||
nic,
|
||||
});
|
||||
|
||||
const points = (data?.data ?? []) as Array<Record<string, unknown>>;
|
||||
const nics = data?.meta?.nics ?? [];
|
||||
|
||||
function resetCustom() {
|
||||
setFromLocal('');
|
||||
setToLocal('');
|
||||
}
|
||||
|
||||
// When the clamped from differs from the input, reflect it back
|
||||
const displayFrom =
|
||||
hasCustom && customRange.from != null ? unixToLocal(customRange.from) : fromLocal;
|
||||
|
||||
const nicSelector = (
|
||||
<select
|
||||
value={nic}
|
||||
onChange={(e) => setNic(e.target.value)}
|
||||
className="ml-1 rounded border border-border bg-background px-1 py-0.5 text-xs text-foreground focus:outline-none"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{nics.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Timeframe selector */}
|
||||
<div className="flex gap-1">
|
||||
{TIMEFRAMES.map((tf) => (
|
||||
<Button
|
||||
key={tf.value}
|
||||
variant={timeframe === tf.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeframe(tf.value)}
|
||||
>
|
||||
{tf.label}
|
||||
</Button>
|
||||
))}
|
||||
{/* Time controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Preset buttons */}
|
||||
<div className="flex gap-1">
|
||||
{PRESETS.map((p) => (
|
||||
<Button
|
||||
key={p.tf}
|
||||
variant={!hasCustom && timeframe === p.tf ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTimeframe(p.tf);
|
||||
resetCustom();
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom range inputs */}
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={displayFrom}
|
||||
onChange={(e) => setFromLocal(e.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none"
|
||||
/>
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocal}
|
||||
onChange={(e) => setToLocal(e.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none"
|
||||
/>
|
||||
{hasCustom && (
|
||||
<Button variant="outline" size="sm" onClick={resetCustom}>
|
||||
Сбросить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : points.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20 text-sm text-muted-foreground">
|
||||
Нет данных
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ChartCard
|
||||
title={t('graphs.cpu_usage')}
|
||||
data={chartData}
|
||||
data={points}
|
||||
dataKey="cpu"
|
||||
color="hsl(217, 91%, 60%)"
|
||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||
@@ -148,38 +242,64 @@ export default function GraphsTab({ serverId, vmid, type, node }: Props) {
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.memory_usage')}
|
||||
data={chartData}
|
||||
data={points}
|
||||
dataKey="mem"
|
||||
color="hsl(142, 76%, 36%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.network_in')}
|
||||
data={chartData}
|
||||
dataKey="netin"
|
||||
color="hsl(262, 83%, 58%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.network_out')}
|
||||
data={chartData}
|
||||
dataKey="netout"
|
||||
color="hsl(25, 95%, 53%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
title={t('graphs.disk_write')}
|
||||
data={points}
|
||||
dataKey="diskwrite"
|
||||
color="hsl(0, 84%, 60%)"
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.disk_read')}
|
||||
data={chartData}
|
||||
data={points}
|
||||
dataKey="diskread"
|
||||
color="hsl(199, 89%, 48%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.disk_write')}
|
||||
data={chartData}
|
||||
dataKey="diskwrite"
|
||||
color="hsl(0, 84%, 60%)"
|
||||
formatValue={(v) => formatBytes(v)}
|
||||
title={t('graphs.network_in')}
|
||||
data={points}
|
||||
dataKey="netin"
|
||||
color="hsl(262, 83%, 58%)"
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
headerRight={nicSelector}
|
||||
/>
|
||||
<ChartCard
|
||||
title={t('graphs.network_out')}
|
||||
data={points}
|
||||
dataKey="netout"
|
||||
color="hsl(25, 95%, 53%)"
|
||||
formatValue={(v) => `${formatBytes(v)}/s`}
|
||||
headerRight={nicSelector}
|
||||
/>
|
||||
<ChartCard
|
||||
title="IOPS чтение"
|
||||
data={points}
|
||||
dataKey="iops_read"
|
||||
color="hsl(180, 70%, 40%)"
|
||||
formatValue={(v) => `${v.toFixed(0)} ops/s`}
|
||||
unit=" ops/s"
|
||||
/>
|
||||
<ChartCard
|
||||
title="IOPS запись"
|
||||
data={points}
|
||||
dataKey="iops_write"
|
||||
color="hsl(300, 60%, 50%)"
|
||||
formatValue={(v) => `${v.toFixed(0)} ops/s`}
|
||||
unit=" ops/s"
|
||||
/>
|
||||
<ChartCard
|
||||
title="Заполненность диска"
|
||||
data={points}
|
||||
dataKey="diskpct"
|
||||
color="hsl(45, 93%, 47%)"
|
||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||
unit="%"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server, ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useVMLiveMetrics, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
import { formatBytes, formatUptime, formatPercent, vmVcpuCount } from '@/lib/format';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DiskInfo } from '@/types';
|
||||
|
||||
@@ -165,7 +165,7 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
<MetricCard
|
||||
label={t('common.cpu')}
|
||||
value={formatPercent(cpuPercent)}
|
||||
subtitle={config?.cores ? `${config.cores} ${t('common.cores')}` : undefined}
|
||||
subtitle={vmVcpuCount(config) ? `${vmVcpuCount(config)} ${t('common.cores')}` : undefined}
|
||||
percent={cpuPercent}
|
||||
icon={Cpu}
|
||||
/>
|
||||
@@ -258,7 +258,7 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
<InfoRow label={t('common.vmid')} value={vmid} />
|
||||
<InfoRow label={t('common.node')} value={node} />
|
||||
<InfoRow label={t('common.type')} value={type === 'qemu' ? t('common.qemu_virtual_machine') : t('common.lxc_container')} />
|
||||
<InfoRow label={t('common.vcpu')} value={config?.cores} />
|
||||
<InfoRow label={t('common.vcpu')} value={vmVcpuCount(config)} />
|
||||
<InfoRow label={t('common.memory')} value={config?.memory ? `${config.memory} MB` : undefined} />
|
||||
<InfoRow label={t('common.os_type')} value={config?.ostype as string} />
|
||||
<InfoRow label={t('common.boot_order')} value={config?.boot as string} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { getTasksWebSocket } from '@/lib/websocket';
|
||||
@@ -12,7 +12,6 @@ export const vmKeys = {
|
||||
detail: (serverId: number, vmid: number) => ['vm', serverId, vmid] as const,
|
||||
config: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'config'] as const,
|
||||
status: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'status'] as const,
|
||||
rrddata: (serverId: number, vmid: number, tf: string) => ['vm', serverId, vmid, 'rrddata', tf] as const,
|
||||
snapshots: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'snapshots'] as const,
|
||||
interfaces: (serverId: number, vmid: number) => ['vm', serverId, vmid, 'interfaces'] as const,
|
||||
};
|
||||
@@ -178,14 +177,39 @@ export function useVMConfig(serverId: number, vmid: number, type: string, node:
|
||||
});
|
||||
}
|
||||
|
||||
export function useVMRrddata(serverId: number, vmid: number, type: string, node: string, timeframe = 'hour') {
|
||||
|
||||
export interface MetricPoint {
|
||||
time: number;
|
||||
cpu: number | null; mem: number | null; maxmem: number | null;
|
||||
netin: number | null; netout: number | null;
|
||||
diskread: number | null; diskwrite: number | null;
|
||||
diskpct: number | null; iops_read: number | null; iops_write: number | null;
|
||||
}
|
||||
export interface MetricsResponse {
|
||||
data: MetricPoint[]; from: number; to: number; meta: { nics: string[] };
|
||||
}
|
||||
|
||||
export function useVMMetrics(
|
||||
serverId: number, vmid: number, type: string, node: string,
|
||||
opts: { timeframe?: string; from?: number; to?: number; nic?: string },
|
||||
) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useQuery({
|
||||
queryKey: vmKeys.rrddata(serverId, vmid, timeframe),
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<{ data: unknown[]; timeframe: string }>(`/proxmox/api/${serverId}/${prefix}/${vmid}/rrddata?node=${node}&timeframe=${timeframe}`);
|
||||
return res.data ?? [];
|
||||
const { timeframe = 'hour', from, to, nic = 'all' } = opts;
|
||||
return useQuery<MetricsResponse>({
|
||||
queryKey: ['vm', serverId, vmid, 'metrics', { timeframe, from, to, nic }],
|
||||
queryFn: () => {
|
||||
const qs = new URLSearchParams({ node, nic });
|
||||
if (from != null && to != null) {
|
||||
qs.set('from_ts', String(from));
|
||||
qs.set('to_ts', String(to));
|
||||
} else {
|
||||
qs.set('timeframe', timeframe);
|
||||
}
|
||||
return apiClient.get(`/proxmox/api/${serverId}/${prefix}/${vmid}/metrics?${qs}`);
|
||||
},
|
||||
// Keep showing the previous series while a new range/NIC loads, so switching
|
||||
// the interface selector doesn't blank the whole grid into a spinner.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,7 +298,11 @@ export function useSetVMOwner(serverId: number, vmid: number) {
|
||||
return useMutation({
|
||||
mutationFn: (user_id: number | null) =>
|
||||
apiClient.put(`/proxmox/api/${serverId}/vm/${vmid}/owner`, { user_id }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['vm-owner', serverId, vmid] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['vm-owner', serverId, vmid] });
|
||||
// Refresh the instances list so the owner column updates without a manual reload.
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,24 @@ export function useGlobalRealtimeSync() {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
});
|
||||
|
||||
const unsubVmOwnerChanged = ws.onType('vm_owner_changed', (data: unknown) => {
|
||||
const msg = data as {
|
||||
vmid: number;
|
||||
server_id: number;
|
||||
owner_id: number | null;
|
||||
owner_user: { username: string; email?: string; full_name?: string } | null;
|
||||
};
|
||||
qc.setQueryData<Array<Record<string, unknown>>>(vmKeys.all, (old) =>
|
||||
old?.map((vm) =>
|
||||
vm.server_id === msg.server_id && vm.vmid === msg.vmid
|
||||
? { ...vm, owner_id: msg.owner_id, owner_user: msg.owner_user }
|
||||
: vm
|
||||
)
|
||||
);
|
||||
// Keep the admin owner panel (settings tab) in sync as well.
|
||||
qc.invalidateQueries({ queryKey: ['vm-owner', msg.server_id, msg.vmid] });
|
||||
});
|
||||
|
||||
// ── Server events ──────────────────────────────────────────────────────
|
||||
const invalidateServerCaches = () => {
|
||||
qc.invalidateQueries({ queryKey: nodeKeys.servers });
|
||||
@@ -74,6 +92,7 @@ export function useGlobalRealtimeSync() {
|
||||
unsubVmStatus();
|
||||
unsubVmDeleted();
|
||||
unsubVmCreated();
|
||||
unsubVmOwnerChanged();
|
||||
unsubServerAdded();
|
||||
unsubServerUpdated();
|
||||
unsubServerDeleted();
|
||||
|
||||
@@ -28,19 +28,32 @@ export function vmTypeLabel(type: string): string {
|
||||
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Total vCPU count from a live Proxmox config.
|
||||
* QEMU reports `cores` *per socket*, so the real total is `sockets * cores`
|
||||
* (LXC has no sockets and reports the total in `cores`).
|
||||
*/
|
||||
export function vmVcpuCount(config?: { cores?: number; sockets?: number } | null): number | undefined {
|
||||
if (!config?.cores) return undefined;
|
||||
return config.cores * (config.sockets ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a VM/LXC hardware configuration as "N vCPU / X GB RAM / Y GB".
|
||||
* `memory` is expected in MB; disk in GB (falls back to `maxdisk` in bytes).
|
||||
* `memory` and `maxdisk` come from the cached instances list in bytes;
|
||||
* `disk_size` (when present) is already in GB.
|
||||
*/
|
||||
export function formatVmConfig(vm: {
|
||||
cores?: number;
|
||||
memory?: number;
|
||||
maxmem?: number;
|
||||
disk_size?: number;
|
||||
maxdisk?: number;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (vm.cores) parts.push(`${vm.cores} vCPU`);
|
||||
if (vm.memory) parts.push(`${Math.round(vm.memory / 1024)} GB RAM`);
|
||||
const memBytes = vm.memory || vm.maxmem;
|
||||
if (memBytes) parts.push(`${formatBytes(memBytes, 0)} RAM`);
|
||||
const diskGb = vm.disk_size ?? (vm.maxdisk ? Math.round(vm.maxdisk / 1024 ** 3) : 0);
|
||||
if (diskGb) parts.push(`${diskGb} GB`);
|
||||
return parts.join(' / ');
|
||||
|
||||
@@ -120,6 +120,10 @@ export interface VMInstance {
|
||||
|
||||
export interface VMConfig {
|
||||
cores?: number;
|
||||
/** QEMU socket count; total vCPU = sockets * cores (defaults to 1) */
|
||||
sockets?: number;
|
||||
/** QEMU hotplugged vCPU count, when fewer than sockets * cores are online */
|
||||
vcpus?: number;
|
||||
memory?: number;
|
||||
disk?: string;
|
||||
name?: string;
|
||||
|
||||
Reference in New Issue
Block a user