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 { type ColumnPriority } from '@/hooks/useBreakpoint'; import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive'; import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; // Icons for mobile headers const ClockIcon = (props: { class?: string }) => ( ); const CpuIcon = (props: { class?: string }) => ( ); const MemoryIcon = (props: { class?: string }) => ( ); const DiskIcon = (props: { class?: string }) => ( ); const TempIcon = (props: { class?: string }) => ( ); const VMIcon = (props: { class?: string }) => ( ); const ContainerIcon = (props: { class?: string }) => ( ); const BackupIcon = (props: { class?: string }) => ( ); // Column configuration using the new priority system interface ColumnDef { id: string; label: string; priority: ColumnPriority; icon?: Component<{ class?: string }>; minWidth?: string; maxWidth?: string; flex?: number; align?: 'left' | 'center' | 'right'; } const BASE_COLUMNS: ColumnDef[] = [ { id: 'name', label: 'Node', priority: 'essential', minWidth: '100px', maxWidth: '300px', align: 'left' }, { id: 'uptime', label: 'Uptime', priority: 'essential', icon: ClockIcon, minWidth: '35px', flex: 1, align: 'center' }, // Metric columns - fixed width to match progress bar max-width (140px + padding) { id: 'cpu', label: 'CPU', priority: 'essential', icon: CpuIcon, minWidth: '40px', maxWidth: '156px', align: 'center' }, { id: 'memory', label: 'Memory', priority: 'essential', icon: MemoryIcon, minWidth: '40px', maxWidth: '156px', align: 'center' }, { id: 'disk', label: 'Disk', priority: 'essential', icon: DiskIcon, minWidth: '40px', maxWidth: '156px', align: 'center' }, ]; const TEMP_COLUMN: ColumnDef = { id: 'temperature', label: 'Temp', priority: 'essential', icon: TempIcon, minWidth: '35px', flex: 1, align: 'center' }; 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; 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; interface CountColumn { header: string; key: CountSortKey; icon: Component<{ class?: string }>; minWidth?: string; maxWidth?: string; } 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', icon: VMIcon, minWidth: '40px' }, { header: 'CTs', key: 'containerCount', icon: ContainerIcon, minWidth: '40px' }, ]; case 'storage': return [ { header: 'Storage', key: 'storageCount', icon: DiskIcon, minWidth: '50px' }, { header: 'Disks', key: 'diskCount', icon: DiskIcon, minWidth: '45px' }, ]; case 'backups': return [{ header: 'Backups', key: 'backupCount', icon: BackupIcon, minWidth: '50px' }]; default: return []; } }); const hasAnyTemperatureData = createMemo(() => { return ( props.nodes?.some( (node) => node.temperature?.available || isTemperatureMonitoringEnabled(node), ) || false ); }); // Build dynamic columns list const columns = createMemo(() => { const cols = [...BASE_COLUMNS]; if (hasAnyTemperatureData()) { cols.push(TEMP_COLUMN); } // Add count columns based on tab - these share extra space evenly with other flex columns countColumns().forEach((cc) => { cols.push({ id: cc.key, label: cc.header, priority: 'essential' as ColumnPriority, icon: cc.icon, minWidth: cc.minWidth || '40px', flex: 1, align: 'center', }); }); return cols; }); // Use the responsive grid template hook for dynamic column visibility // Pass the columns accessor to support reactive column changes const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns }); 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); }); }); // Header cell component const HeaderCell: Component<{ column: ColumnDef }> = (cellProps) => { const Icon = cellProps.column.icon; const isSorted = () => sortKey() === cellProps.column.id; const sortIndicator = () => isSorted() ? (sortDirection() === 'asc' ? '▲' : '▼') : ''; const alignClass = () => { if (cellProps.column.align === 'center') return 'justify-center text-center'; if (cellProps.column.align === 'right') return 'justify-end text-right'; return 'justify-start text-left'; }; const baseClass = `px-0.5 md:px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center h-full ${alignClass()}`; const nameClass = cellProps.column.id === 'name' ? 'pl-3' : ''; return (
handleSort(cellProps.column.id as Exclude)} onKeyDown={(e) => e.key === 'Enter' && handleSort(cellProps.column.id as Exclude)} tabindex="0" role="button" aria-label={`Sort by ${cellProps.column.label} ${isSorted() ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} > {Icon && } {cellProps.column.id === 'name' && props.currentTab === 'backups' ? 'Node / PBS' : cellProps.column.label} {sortIndicator()}
); }; return (
{/* Header */}
{(column) => ( )}
{(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`; } if (showAlertHighlight()) { return 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'; } let className = baseHover; if (props.selectedNode && props.selectedNode !== nodeId) { className += ' opacity-50 hover:opacity-80'; } if (!online) { className += ' opacity-60'; } return className; }); const cellBgClass = createMemo(() => { if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30'; if (showAlertHighlight()) { return alertStyles().severity === 'critical' ? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40' : 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30'; } return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50'; }); // Render cell content based on column type const renderCell = (column: ColumnDef) => { const baseCellClass = `${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center h-full`; const alignClass = column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : 'justify-start'; switch (column.id) { case 'name': return (
e.stopPropagation()} class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate" title={displayName()} > {displayName()} ({(node as Node).name})
); case 'uptime': return (
{formatUptime(uptimeValue, true)}
); case 'cpu': return (
); case 'memory': return (
); case 'disk': return (
); case 'temperature': return (
-} > {(() => { 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 ( ); })()}
); // Count columns (vmCount, containerCount, storageCount, diskCount, backupCount) default: if (column.id.endsWith('Count')) { const value = getCountValue(item, column.id as CountSortKey); 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 (
{display}
); } return null; } }; return (
props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')} > {(column) => renderCell(column)}
); }}
); };