import { Component, For, Show, createMemo, createSignal } from 'solid-js'; import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api'; import { formatBytes, formatUptime } from '@/utils/format'; 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'; import { buildMetricKey } from '@/utils/metricsKeys'; import { StatusDot } from '@/components/shared/StatusDot'; import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status'; import { ResponsiveMetricCell, MetricText } from '@/components/shared/responsive'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; import { useBreakpoint } from '@/hooks/useBreakpoint'; 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 { isMobile } = useBreakpoint(); const isTemperatureMonitoringEnabled = (node: Node): boolean => { const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true; if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) { return node.temperatureMonitoringEnabled; } return globalEnabled; }; type TableItem = Node | PBSInstance; const isPVE = (item: TableItem): item is Node => { return (item as Node).pveVersion !== undefined; }; type CountSortKey = 'vmCount' | 'containerCount' | 'storageCount' | 'diskCount' | 'backupCount'; type SortKey = | 'default' | 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'temperature' | CountSortKey; const [sortKey, setSortKey] = createSignal('default'); const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); const hasAnyTemperatureData = createMemo(() => { 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: TableItem) => { if (!isPVE(item)) return null; const node = item; 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: TableItem) => { if (isPVE(item)) { const node = item; return node.status === 'online' && (node.uptime || 0) > 0; } const pbs = item; 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: TableItem) => { if (isPVE(item)) { const node = item; return Math.round((node.cpu || 0) * 100); } const pbs = item; return Math.round(pbs.cpu || 0); }; const getMemoryPercent = (item: TableItem) => { if (isPVE(item)) { const node = item; return Math.round(node.memory?.usage || 0); } const pbs = item; if (!pbs.memoryTotal) return 0; return Math.round((pbs.memoryUsed / pbs.memoryTotal) * 100); }; const getDiskPercent = (item: TableItem) => { if (isPVE(item)) { const node = item; if (!node.disk || node.disk.total === 0) return 0; return Math.round((node.disk.used / node.disk.total) * 100); } const pbs = item; const totals = getPbsTotals(pbs); if (totals.total === 0) return 0; return Math.round((totals.used / totals.total) * 100); }; const getDiskSublabel = (item: TableItem) => { if (isPVE(item)) { const node = item; if (!node.disk) return undefined; return `${formatBytes(node.disk.used, 0)}/${formatBytes(node.disk.total, 0)}`; } const pbs = item; 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: TableItem) => { return getCpuTemperatureValue(item); }; const getCountValue = (item: TableItem, key: CountSortKey): number | null => { if (!isPVE(item)) { const pbs = item; if (key === 'backupCount') { return props.backupCounts?.[pbs.name] ?? 0; } return null; } const node = item; 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: TableItem, key: SortKey): number | string | null => { switch (key) { case 'name': return isPVE(item) ? getNodeDisplayName(item) : item.name; case 'uptime': return isPVE(item) ? item.uptime ?? 0 : item.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: TableItem, b: TableItem) => { const aIsPVE = isPVE(a); const bIsPVE = isPVE(b); if (aIsPVE !== bIsPVE) return aIsPVE ? -1 : 1; const aOnline = isItemOnline(a); const bOnline = isItemOnline(b); if (aOnline !== bOnline) return aOnline ? -1 : 1; const aName = aIsPVE ? getNodeDisplayName(a) : a.name; const bName = bIsPVE ? getNodeDisplayName(b) : b.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; }; const sortedItems = createMemo(() => { const items: TableItem[] = []; if (props.nodes) items.push(...props.nodes); if (props.pbsInstances) items.push(...props.pbsInstances); 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); }); }); const renderSortIndicator = (key: SortKey) => { if (sortKey() !== key) return null; return sortDirection() === 'asc' ? '▲' : '▼'; }; const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; return (
{(item) => { const isPVEItem = isPVE(item); const isPBSItem = !isPVEItem; const node = isPVEItem ? (item as Node) : null; const pbs = isPBSItem ? (item as PBSInstance) : null; const online = isItemOnline(item); const statusIndicator = createMemo(() => isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance), ); const cpuPercentValue = getCpuPercent(item); const memoryPercentValue = getMemoryPercent(item); const diskPercentValue = getDiskPercent(item); const diskSublabel = getDiskSublabel(item); const cpuTemperatureValue = getCpuTemperatureValue(item); const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0; const displayName = () => { if (isPVEItem) return getNodeDisplayName(node as Node); return (pbs as PBSInstance).name; }; const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node); const nodeId = isPVEItem ? node!.id : pbs!.name; const isSelected = () => props.selectedNode === nodeId; const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name; const metricsKey = buildMetricKey('node', resourceId); 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:shadow-sm group'; if (isSelected()) { return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group bg-blue-50 dark:bg-blue-900/20`; } if (showAlertHighlight()) { const alertBg = alertStyles().severity === 'critical' ? 'bg-red-50 dark:bg-red-950/30' : 'bg-yellow-50 dark:bg-yellow-950/20'; return `cursor-pointer transition-all duration-200 relative hover:shadow-sm group ${alertBg}`; } 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, isPVEItem ? 'pve' : 'pbs')} > {/* Name */} {/* Uptime */} {/* CPU */} {/* Memory */} {/* Disk */} {/* Temperature */} {/* Dashboard tab: VMs and CTs */} {/* Storage tab: Storage and Disks */} {/* Backups tab: Backups */} ); }}
handleSort('name')} > {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')} handleSort('uptime')}> Uptime {renderSortIndicator('uptime')} handleSort('cpu')}> CPU {renderSortIndicator('cpu')} handleSort('memory')}> Memory {renderSortIndicator('memory')} handleSort('disk')}> Disk {renderSortIndicator('disk')} handleSort('temperature')}> Temp {renderSortIndicator('temperature')} handleSort('vmCount')}> VMs {renderSortIndicator('vmCount')} handleSort('containerCount')}> CTs {renderSortIndicator('containerCount')} handleSort('storageCount')}> Storage {renderSortIndicator('storageCount')} handleSort('diskCount')}> Disks {renderSortIndicator('diskCount')} handleSort('backupCount')}> Backups {renderSortIndicator('backupCount')}
e.stopPropagation()} class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 whitespace-nowrap" title={displayName()} > {displayName()} ({(node as Node).name})
{formatUptime(uptimeValue, true)}
-} > {(() => { const value = cpuTemperatureValue as number; const temp = node!.temperature; const cpuMinValue = typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null; const cpuMaxValue = typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0 ? temp.cpuMaxRecord : null; const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null; const gpus = temp?.gpu ?? []; const hasGPU = gpus.length > 0; if (hasMinMax || hasGPU) { const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined; const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined; return (
`${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}>
); } return ( ); })()}
{online ? getCountValue(item, 'vmCount') ?? '-' : '-'}
{online ? getCountValue(item, 'containerCount') ?? '-' : '-'}
{online ? getCountValue(item, 'storageCount') ?? '-' : '-'}
{online ? getCountValue(item, 'diskCount') ?? '-' : '-'}
{online ? getCountValue(item, 'backupCount') ?? '-' : '-'}
); };