Fix TypeScript type error in GuestDrawer metricsResource

Add explicit ResourceType typing to metricsResource function
to fix type mismatch with HistoryChart/UnifiedHistoryChart props.
This commit is contained in:
rcourtman
2026-01-22 00:30:23 +00:00
parent 222c88f33c
commit d50ee66604
2 changed files with 329 additions and 179 deletions

View File

@@ -48,7 +48,7 @@ export interface ChartsResponse {
// Persistent metrics history types (SQLite-backed, longer retention)
export type HistoryTimeRange = '1h' | '6h' | '12h' | '24h' | '7d' | '30d' | '90d';
export type ResourceType = 'node' | 'guest' | 'storage' | 'docker' | 'dockerHost';
export type ResourceType = 'node' | 'guest' | 'vm' | 'container' | 'storage' | 'docker' | 'dockerHost';
export interface MetricsHistoryParams {
resourceType: ResourceType;

View File

@@ -1,7 +1,10 @@
import { Component, Show, For } from 'solid-js';
import { Component, Show, For, createSignal } from 'solid-js';
import { VM, Container } from '@/types/api';
import { formatBytes, formatUptime } from '@/utils/format';
import { DiskList } from './DiskList';
import { HistoryChart } from '../shared/HistoryChart';
import { UnifiedHistoryChart } from '../shared/UnifiedHistoryChart';
import { HistoryTimeRange, ResourceType } from '@/api/charts';
type Guest = VM | Container;
@@ -73,187 +76,334 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
return networkInterfaces().length > 0;
};
const fallbackGuestId = () => {
return props.guest.id || `${props.guest.instance}:${props.guest.node}:${props.guest.vmid}`;
};
const metricsResource = (): { type: ResourceType; id: string } => {
const key = props.metricsKey || '';
const separatorIndex = key.indexOf(':');
const fallbackType: ResourceType = isVM(props.guest) ? 'vm' : 'container';
if (separatorIndex === -1) {
return { type: fallbackType, id: fallbackGuestId() };
}
const kind = key.slice(0, separatorIndex);
const id = key.slice(separatorIndex + 1) || fallbackGuestId();
const type: ResourceType = kind === 'vm' || kind === 'container' ? kind : fallbackType;
return { type, id };
};
const [activeTab, setActiveTab] = createSignal<'overview' | 'history'>('overview');
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>('24h');
const [viewMode, setViewMode] = createSignal<'unified' | 'split'>('unified');
return (
<div class="space-y-3">
{/* Flex layout - items grow to fill space, max ~4 per row */}
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
{/* System Info - always show */}
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.cpus}</span>
</div>
</Show>
<Show when={props.guest.uptime > 0}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.guest.uptime)}</span>
</div>
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Node</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.node}</span>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2" title={isVM(props.guest) ? `QEMU guest agent ${agentVersion()}` : agentVersion()}>
{isVM(props.guest) ? `QEMU ${agentVersion()}` : agentVersion()}
</span>
</div>
</Show>
</div>
</div>
{/* Guest Info - OS and IPs */}
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Guest Info</div>
<div class="space-y-2">
<Show when={hasOsInfo()}>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium">{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500 mx-1"></span>
</Show>
<Show when={osVersion().length > 0}>
<span>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
{/* Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Memory</div>
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</div>
</Show>
{/* Backup Info */}
<Show when={props.guest.lastBackup}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Backup</div>
<div class="space-y-1 text-[11px]">
{(() => {
const backupDate = new Date(props.guest.lastBackup);
const now = new Date();
const daysSince = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
const isOld = daysSince > 7;
const isCritical = daysSince > 30;
return (
<>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Last Backup</span>
<span class={`font-medium ${isCritical ? 'text-red-600 dark:text-red-400' : isOld ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'}`}>
{daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`}
</span>
</div>
<div class="text-[10px] text-gray-400 dark:text-gray-500">
{backupDate.toLocaleDateString()}
</div>
</>
);
})()}
</div>
</div>
</Show>
{/* Tags */}
<Show when={props.guest.tags && (Array.isArray(props.guest.tags) ? props.guest.tags.length > 0 : props.guest.tags.length > 0)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Tags</div>
<div class="flex flex-wrap gap-1">
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
{(tag) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{tag.trim()}
</span>
)}
</For>
</div>
</div>
</Show>
{/* Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Filesystems</div>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
</div>
</div>
</Show>
{/* Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Network</div>
<div class="space-y-2">
<For each={networkInterfaces().slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
<div class="flex items-center gap-2 text-[11px] font-medium text-gray-700 dark:text-gray-200">
<span class="truncate">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal">{iface.mac}</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
{/* Tabs */}
<div class="flex items-center gap-6 border-b border-gray-200 dark:border-gray-700 px-1 mb-1">
<button
onClick={() => setActiveTab('overview')}
class={`pb-2 text-sm font-medium transition-colors relative ${activeTab() === 'overview'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Overview
{activeTab() === 'overview' && (
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
<button
onClick={() => setActiveTab('history')}
class={`pb-2 text-sm font-medium transition-colors relative ${activeTab() === 'history'
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
History
{activeTab() === 'history' && (
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
</div>
<Show when={activeTab() === 'overview'}>
{/* Flex layout - items grow to fill space, max ~4 per row */}
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
{/* System Info - always show */}
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.cpus}</span>
</div>
</Show>
<Show when={props.guest.uptime > 0}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.guest.uptime)}</span>
</div>
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Node</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.node}</span>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2" title={isVM(props.guest) ? `QEMU guest agent ${agentVersion()}` : agentVersion()}>
{isVM(props.guest) ? `QEMU ${agentVersion()}` : agentVersion()}
</span>
</div>
</Show>
</div>
</div>
{/* Guest Info - OS and IPs */}
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Guest Info</div>
<div class="space-y-2">
<Show when={hasOsInfo()}>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium">{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500 mx-1"></span>
</Show>
<Show when={osVersion().length > 0}>
<span>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
{/* Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Memory</div>
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</div>
</Show>
{/* Backup Info */}
<Show when={props.guest.lastBackup}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Backup</div>
<div class="space-y-1 text-[11px]">
{(() => {
const backupDate = new Date(props.guest.lastBackup);
const now = new Date();
const daysSince = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
const isOld = daysSince > 7;
const isCritical = daysSince > 30;
return (
<>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Last Backup</span>
<span class={`font-medium ${isCritical ? 'text-red-600 dark:text-red-400' : isOld ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'}`}>
{daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`}
</span>
</div>
<div class="text-[10px] text-gray-400 dark:text-gray-500">
{backupDate.toLocaleDateString()}
</div>
</>
);
})()}
</div>
</div>
</Show>
{/* Tags */}
<Show when={props.guest.tags && (Array.isArray(props.guest.tags) ? props.guest.tags.length > 0 : props.guest.tags.length > 0)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Tags</div>
<div class="flex flex-wrap gap-1">
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
{(tag) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{tag.trim()}
</span>
)}
</For>
</div>
</div>
</Show>
{/* Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Filesystems</div>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
</div>
</div>
</Show>
{/* Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Network</div>
<div class="space-y-2">
<For each={networkInterfaces().slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
<div class="flex items-center gap-2 text-[11px] font-medium text-gray-700 dark:text-gray-200">
<span class="truncate">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal">{iface.mac}</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
</div>
</Show>
<Show when={activeTab() === 'history'}>
<div class="space-y-6">
{/* Toolbar: Range and View Toggle */}
<div class="flex flex-col gap-4 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-xl border border-gray-100 dark:border-gray-700/50 shadow-sm">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-gray-400 uppercase tracking-widest">Controls</span>
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
<button
onClick={() => setViewMode('unified')}
class={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${viewMode() === 'unified'
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Unified
</button>
<button
onClick={() => setViewMode('split')}
class={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${viewMode() === 'split'
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Split
</button>
</div>
</div>
<div class="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-700/30">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Range</span>
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
{(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => (
<button
onClick={() => setHistoryRange(r)}
class={`px-4 py-1.5 text-xs font-medium rounded-md transition-all ${historyRange() === r
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{r}
</button>
))}
</div>
</div>
</div>
<Show when={viewMode() === 'unified'}>
<UnifiedHistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
label="Resource Performance"
height={280}
range={historyRange()}
hideSelector={true}
/>
</Show>
<Show when={viewMode() === 'split'}>
<div class="grid grid-cols-1 gap-4">
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="cpu"
label="CPU Usage"
unit="%"
height={160}
range={historyRange()}
hideSelector={true}
/>
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="memory"
label="Memory Usage"
unit="%"
height={160}
range={historyRange()}
hideSelector={true}
/>
<HistoryChart
resourceType={metricsResource().type}
resourceId={metricsResource().id}
metric="disk"
label="Disk Usage"
unit="%"
height={160}
range={historyRange()}
hideSelector={true}
/>
</div>
</Show>
</div>
</Show>
</div>
);
};