import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api'; import { formatBytes, formatPercent, formatUptime } from '@/utils/format'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; import { Card } from '@/components/shared/Card'; import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes'; import { getCpuTemperature } from '@/utils/temperature'; import { useAlertsActivation } from '@/stores/alertsActivation'; interface NodeSummaryTableProps { nodes: Node[]; pbsInstances?: PBSInstance[]; vms?: VM[]; containers?: Container[]; storage?: Storage[]; backupCounts?: Record; currentTab: 'dashboard' | 'storage' | 'backups'; selectedNode: string | null; globalTemperatureMonitoringEnabled?: boolean; onNodeClick: (nodeId: string, nodeType: 'pve' | 'pbs') => void; } export const NodeSummaryTable: Component = (props) => { const { activeAlerts, state } = useWebSocket(); const alertsActivation = useAlertsActivation(); const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const isTemperatureMonitoringEnabled = (node: Node): boolean => { const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true; // Check per-node setting first, fall back to global if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) { return node.temperatureMonitoringEnabled; } return globalEnabled; }; type CountSortKey = 'vmCount' | 'containerCount' | 'storageCount' | 'diskCount' | 'backupCount'; type SortKey = | 'default' | 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'temperature' | CountSortKey; interface SortableItem { type: 'pve' | 'pbs'; data: Node | PBSInstance; } interface CountColumn { header: string; key: CountSortKey; } const [sortKey, setSortKey] = createSignal('default'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const countColumns = createMemo(() => { switch (props.currentTab) { case 'dashboard': return [ { header: 'VMs', key: 'vmCount' }, { header: 'Containers', key: 'containerCount' }, ]; case 'storage': return [ { header: 'Storage', key: 'storageCount' }, { header: 'Disks', key: 'diskCount' }, ]; case 'backups': return [{ header: 'Backups', key: 'backupCount' }]; default: return []; } }); const hasAnyTemperatureData = createMemo(() => { // Show temperature column if ANY node has monitoring enabled OR has temperature data return ( props.nodes?.some( (node) => node.temperature?.available || isTemperatureMonitoringEnabled(node), ) || false ); }); const nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`; const vmCountsByNode = createMemo>(() => { const counts: Record = {}; (props.vms ?? []).forEach((vm) => { const key = nodeKey(vm.instance, vm.node); counts[key] = (counts[key] || 0) + 1; }); return counts; }); const containerCountsByNode = createMemo>(() => { const counts: Record = {}; (props.containers ?? []).forEach((ct) => { const key = nodeKey(ct.instance, ct.node); counts[key] = (counts[key] || 0) + 1; }); return counts; }); const storageCountsByNode = createMemo>(() => { const counts: Record = {}; (props.storage ?? []).forEach((storage) => { const key = nodeKey(storage.instance, storage.node); counts[key] = (counts[key] || 0) + 1; }); return counts; }); const diskCountsByNode = createMemo>(() => { const counts: Record = {}; (state.physicalDisks ?? []).forEach((disk) => { const key = nodeKey(disk.instance, disk.node); counts[key] = (counts[key] || 0) + 1; }); return counts; }); const getCpuTemperatureValue = (item: SortableItem) => { if (item.type !== 'pve') return null; const node = item.data as Node; const value = getCpuTemperature(node.temperature); return value !== null ? Math.round(value) : null; }; const getDefaultSortDirection = (key: Exclude) => { switch (key) { case 'name': return 'asc'; default: return 'desc'; } }; const handleSort = (key: Exclude) => { if (sortKey() === key) { if (sortDirection() === 'asc') { setSortDirection('desc'); } else { setSortKey('default'); setSortDirection('asc'); } } else { setSortKey(key); setSortDirection(getDefaultSortDirection(key)); } }; const isItemOnline = (item: SortableItem) => { if (item.type === 'pve') { const node = item.data as Node; return node.status === 'online' && (node.uptime || 0) > 0; } const pbs = item.data as PBSInstance; return pbs.status === 'healthy' || pbs.status === 'online'; }; const getPbsTotals = (pbs: PBSInstance) => { return (pbs.datastores ?? []).reduce( (acc, ds) => { acc.used += ds.used || 0; acc.total += ds.total || 0; return acc; }, { used: 0, total: 0 }, ); }; const getCpuPercent = (item: SortableItem) => { if (item.type === 'pve') { const node = item.data as Node; return Math.round((node.cpu || 0) * 100); } const pbs = item.data as PBSInstance; return Math.round(pbs.cpu || 0); }; const getMemoryPercent = (item: SortableItem) => { if (item.type === 'pve') { const node = item.data as Node; return Math.round(node.memory?.usage || 0); } const pbs = item.data as PBSInstance; if (!pbs.memoryTotal) return 0; return Math.round((pbs.memoryUsed / pbs.memoryTotal) * 100); }; const getDiskPercent = (item: SortableItem) => { if (item.type === 'pve') { const node = item.data as Node; if (!node.disk || node.disk.total === 0) return 0; return Math.round((node.disk.used / node.disk.total) * 100); } const pbs = item.data as PBSInstance; const totals = getPbsTotals(pbs); if (totals.total === 0) return 0; return Math.round((totals.used / totals.total) * 100); }; const getDiskSublabel = (item: SortableItem) => { if (item.type === 'pve') { const node = item.data as Node; if (!node.disk) return undefined; return `${formatBytes(node.disk.used, 0)}/${formatBytes(node.disk.total, 0)}`; } const pbs = item.data as PBSInstance; if (!pbs.datastores || pbs.datastores.length === 0) return undefined; const totals = getPbsTotals(pbs); return `${formatBytes(totals.used, 0)}/${formatBytes(totals.total, 0)}`; }; const getTemperatureValue = (item: SortableItem) => { return getCpuTemperatureValue(item); }; const getCountValue = (item: SortableItem, key: CountSortKey): number | null => { if (item.type === 'pbs') { const pbs = item.data as PBSInstance; if (key === 'backupCount') { return props.backupCounts?.[pbs.name] ?? 0; } return null; } const node = item.data as Node; const keyId = nodeKey(node.instance, node.name); switch (key) { case 'vmCount': return vmCountsByNode()[keyId] ?? 0; case 'containerCount': return containerCountsByNode()[keyId] ?? 0; case 'storageCount': return storageCountsByNode()[keyId] ?? 0; case 'diskCount': return diskCountsByNode()[keyId] ?? 0; case 'backupCount': return props.backupCounts?.[node.id] ?? 0; default: return null; } }; const getSortValue = (item: SortableItem, key: SortKey): number | string | null => { switch (key) { case 'name': return item.type === 'pve' ? getNodeDisplayName(item.data as Node) : (item.data as PBSInstance).name; case 'uptime': return item.type === 'pve' ? (item.data as Node).uptime ?? 0 : (item.data as PBSInstance).uptime ?? 0; case 'cpu': return getCpuPercent(item); case 'memory': return getMemoryPercent(item); case 'disk': return getDiskPercent(item); case 'temperature': return getTemperatureValue(item); case 'vmCount': case 'containerCount': case 'storageCount': case 'diskCount': case 'backupCount': return getCountValue(item, key); default: return null; } }; const defaultComparison = (a: SortableItem, b: SortableItem) => { if (a.type !== b.type) return a.type === 'pve' ? -1 : 1; const aOnline = isItemOnline(a); const bOnline = isItemOnline(b); if (aOnline !== bOnline) return aOnline ? -1 : 1; const aName = a.type === 'pve' ? getNodeDisplayName(a.data as Node) : (a.data as PBSInstance).name; const bName = b.type === 'pve' ? getNodeDisplayName(b.data as Node) : (b.data as PBSInstance).name; return aName.localeCompare(bName); }; const compareValues = (valueA: number | string | null, valueB: number | string | null) => { const aEmpty = valueA === null || valueA === undefined || (typeof valueA === 'number' && Number.isNaN(valueA)); const bEmpty = valueB === null || valueB === undefined || (typeof valueB === 'number' && Number.isNaN(valueB)); if (aEmpty && bEmpty) return 0; if (aEmpty) return 1; if (bEmpty) return -1; if (typeof valueA === 'number' && typeof valueB === 'number') { if (valueA === valueB) return 0; return valueA < valueB ? -1 : 1; } const aStr = String(valueA).toLowerCase(); const bStr = String(valueB).toLowerCase(); if (aStr === bStr) return 0; return aStr < bStr ? -1 : 1; }; // Combine and sort nodes based on current sort selection const sortedItems = createMemo(() => { const items: SortableItem[] = []; props.nodes?.forEach((node) => items.push({ type: 'pve', data: node })); props.pbsInstances?.forEach((pbs) => items.push({ type: 'pbs', data: pbs })); const key = sortKey(); const direction = sortDirection(); return items.sort((a, b) => { if (key === 'default') { return defaultComparison(a, b); } const valueA = getSortValue(a, key); const valueB = getSortValue(b, key); const comparison = compareValues(valueA, valueB); if (comparison !== 0) { return direction === 'asc' ? comparison : -comparison; } return defaultComparison(a, b); }); }); // Don't return null - let the table render even if empty // This prevents the table from disappearing on refresh while data loads return ( <>
{(column) => ( )} {(item) => { const isPVE = item.type === 'pve'; const isPBS = item.type === 'pbs'; const node = isPVE ? (item.data as Node) : null; const pbs = isPBS ? (item.data as PBSInstance) : null; const online = isItemOnline(item); const cpuPercentValue = getCpuPercent(item); const memoryPercentValue = getMemoryPercent(item); const diskPercentValue = getDiskPercent(item); const diskSublabel = getDiskSublabel(item); const cpuTemperatureValue = getCpuTemperatureValue(item); const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0; const displayName = () => { if (isPVE) return getNodeDisplayName(node as Node); return (pbs as PBSInstance).name; }; const showActualName = () => isPVE && hasAlternateDisplayName(node as Node); // Use unique node ID (not hostname) to handle duplicate node names const nodeId = isPVE ? node!.id : pbs!.name; const isSelected = () => props.selectedNode === nodeId; // Use the full resource ID for alert matching const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name; const alertStyles = createMemo(() => getAlertStyles(resourceId, activeAlerts, alertsEnabled()), ); const showAlertHighlight = createMemo( () => alertStyles().hasUnacknowledgedAlert && online, ); const rowStyle = createMemo(() => { const styles: Record = {}; const shadows: string[] = []; if (showAlertHighlight()) { const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308'; shadows.push(`inset 4px 0 0 0 ${color}`); } if (isSelected()) { shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)'); shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)'); } if (shadows.length > 0) { styles['box-shadow'] = shadows.join(', '); } return styles; }); const rowClass = createMemo(() => { const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'; if (isSelected()) { return `cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10`; } if (showAlertHighlight()) { return alertStyles().severity === 'critical' ? 'cursor-pointer transition-all duration-200 relative bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40 hover:shadow-sm' : 'cursor-pointer transition-all duration-200 relative bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30 hover:shadow-sm'; } let className = baseHover; if (props.selectedNode && props.selectedNode !== nodeId) { className += ' opacity-50 hover:opacity-80'; } if (!online) { className += ' opacity-60'; } return className; }); return ( props.onNodeClick(nodeId, item.type)} > {(column) => { const value = getCountValue(item, column.key); const display = online ? value ?? '-' : '-'; const textClass = online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'; return ( ); }} ); }}
handleSort('name')} onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} tabindex="0" role="button" aria-label={`Sort by name ${ sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : '' }`} > {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '} {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('uptime')} > Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('cpu')} > CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('memory')} > Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('disk')} > {props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}{' '} {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort('temperature')} > Temp{' '} {sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')} handleSort(column.key)} > {column.header}{' '} {sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')}
e.stopPropagation()} class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400" > {displayName()} ({(node as Node).name}) PVE v{node!.pveVersion.split('/')[1] || node!.pveVersion} {node!.isClusterMember ? node!.clusterName : 'Standalone'} PBS v{pbs!.version}
{formatUptime(uptimeValue)} -} > -} > -} > - } > {(() => { const value = cpuTemperatureValue as number; const severityClass = value >= 80 ? 'text-red-600 dark:text-red-400' : value >= 70 ? 'text-yellow-600 dark:text-yellow-400' : 'text-green-600 dark:text-green-400'; const temp = node!.temperature; const cpuMin = temp?.cpuMin; const cpuMax = temp?.cpuMaxRecord; const hasMinMax = typeof cpuMin === 'number' && cpuMin > 0 && typeof cpuMax === 'number' && cpuMax > 0; if (hasMinMax) { const min = Math.round(cpuMin); const max = Math.round(cpuMax); const getTooltipColor = (temp: number) => { if (temp >= 80) return 'text-red-400'; if (temp >= 70) return 'text-yellow-400'; return 'text-green-400'; }; return ( {value}°C ); } return {value}°C; })()} {display}
); };