Pulse/frontend-modern/src/components/shared/NodeSummaryTable.tsx
courtmanr@gmail.com 3ed190e8bc Add EnhancedCPUBar component with Load Average visualization
- Create EnhancedCPUBar component to show CPU usage with Load Average overlay
- Add vertical marker indicating 1-minute Load Average relative to core count
- Purple glow indicator when Load > Cores (system overload)
- Detailed tooltip showing Usage, Load, and Capacity metrics
- Full sparkline mode support for historical view
- Integrate into NodeSummaryTable for Proxmox nodes
- Integrate into DockerHostSummaryTable for Docker hosts
- Integrate into HostsOverview for standalone hosts
2025-11-29 15:15:38 +00:00

863 lines
34 KiB
TypeScript

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 }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
const CpuIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
);
const MemoryIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
const DiskIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
);
const TempIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2a2 2 0 00-2 2v9.5a4 4 0 104 0V4a2 2 0 00-2-2z" />
<circle cx="12" cy="17" r="2" fill="currentColor" />
</svg>
);
const VMIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
const ContainerIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
);
const BackupIcon = (props: { class?: string }) => (
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
);
// 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<string, number>;
currentTab: 'dashboard' | 'storage' | 'backups';
selectedNode: string | null;
globalTemperatureMonitoringEnabled?: boolean;
onNodeClick: (nodeId: string, nodeType: 'pve' | 'pbs') => void;
}
export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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<SortKey>('default');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const countColumns = createMemo<CountColumn[]>(() => {
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<ColumnDef[]>(() => {
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<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(props.vms ?? []).forEach((vm) => {
const key = nodeKey(vm.instance, vm.node);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
});
const containerCountsByNode = createMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(props.containers ?? []).forEach((ct) => {
const key = nodeKey(ct.instance, ct.node);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
});
const storageCountsByNode = createMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(props.storage ?? []).forEach((storage) => {
const key = nodeKey(storage.instance, storage.node);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
});
const diskCountsByNode = createMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(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<SortKey, 'default'>) => {
switch (key) {
case 'name':
return 'asc';
default:
return 'desc';
}
};
const handleSort = (key: Exclude<SortKey, 'default'>) => {
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 (
<div
class={`${baseClass} ${nameClass}`}
onClick={() => handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
onKeyDown={(e) => e.key === 'Enter' && handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
tabindex="0"
role="button"
aria-label={`Sort by ${cellProps.column.label} ${isSorted() ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
<Show when={Icon !== undefined && isMobile()}>
<span class="md:hidden" title={cellProps.column.label}>
{Icon && <Icon class="w-4 h-4" />}
</span>
</Show>
<span class={Icon !== undefined && isMobile() ? 'hidden md:inline' : ''}>
{cellProps.column.id === 'name' && props.currentTab === 'backups' ? 'Node / PBS' : cellProps.column.label}
</span>
<Show when={sortIndicator()}>
<span class="ml-0.5">{sortIndicator()}</span>
</Show>
</div>
);
};
return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div>
{/* Header */}
<div
class="grid items-center bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20"
style={{ 'grid-template-columns': gridTemplate() }}
>
<For each={visibleColumns()}>
{(column) => (
<HeaderCell column={column} />
)}
</For>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedItems()}>
{(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<string, string> = {};
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 (
<div class={`${baseCellClass} ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<div class="flex items-center gap-1.5">
<StatusDot
variant={statusIndicator().variant}
title={statusIndicator().label}
ariaLabel={statusIndicator().label}
size="xs"
/>
<a
href={
isPVEItem
? node!.guestURL || node!.host || `https://${node!.name}:8006`
: pbs!.host || `https://${pbs!.name}:8007`
}
target="_blank"
onClick={(e) => 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()}
</a>
<Show when={showActualName()}>
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate">
({(node as Node).name})
</span>
</Show>
<div class="hidden xl:flex items-center gap-1.5 ml-1">
<Show when={isPVEItem}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
PVE
</span>
</Show>
<Show when={isPVEItem && node!.pveVersion}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
</span>
</Show>
<Show when={isPVEItem && node!.isClusterMember !== undefined}>
<span
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
</span>
</Show>
<Show when={isPBSItem}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
PBS
</span>
</Show>
<Show when={isPBSItem && pbs!.version}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{pbs!.version}
</span>
</Show>
</div>
</div>
</div>
);
case 'uptime':
return (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
<span
class={`text-xs ${isPVEItem && (node?.uptime ?? 0) < 3600
? 'text-orange-500'
: 'text-gray-600 dark:text-gray-400'
}`}
>
<Show when={online && uptimeValue} fallback="-">
<Show when={isMobile()} fallback={formatUptime(uptimeValue)}>
{formatUptime(uptimeValue, true)}
</Show>
</Show>
</span>
</div>
);
case 'cpu':
return (
<div class={`${baseCellClass} ${alignClass}`}>
<Show when={isMobile()}>
<div class="md:hidden w-full">
<MetricText value={cpuPercentValue} type="cpu" />
</div>
</Show>
<div class="hidden md:block w-full">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={cpuPercentValue}
type="cpu"
resourceId={metricsKey}
isRunning={online}
showMobile={false}
class="w-full"
/>
}>
<EnhancedCPUBar
usage={cpuPercentValue}
loadAverage={node!.loadAverage?.[0]}
cores={node!.cpuInfo?.cores}
model={node!.cpuInfo?.model}
resourceId={metricsKey}
/>
</Show>
</div>
</div>
);
case 'memory':
return (
<div class={`${baseCellClass} ${alignClass}`}>
<Show when={isMobile()}>
<div class="md:hidden w-full">
<MetricText value={memoryPercentValue} type="memory" />
</div>
</Show>
<div class="hidden md:block w-full">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={memoryPercentValue}
type="memory"
resourceId={metricsKey}
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined}
isRunning={online}
showMobile={false}
class="w-full"
/>
}>
<StackedMemoryBar
used={node!.memory?.used || 0}
total={node!.memory?.total || 0}
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}
/>
</Show>
</div>
</div>
);
case 'disk':
return (
<div class={`${baseCellClass} ${alignClass}`}>
<ResponsiveMetricCell
value={diskPercentValue}
type="disk"
resourceId={metricsKey}
sublabel={diskSublabel}
isRunning={online}
showMobile={isMobile()}
class="w-full"
/>
</div>
);
case 'temperature':
return (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
<Show
when={
online &&
isPVEItem &&
cpuTemperatureValue !== null &&
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
isTemperatureMonitoringEnabled(node!)
}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">-</span>}
>
{(() => {
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 (
<div title={`Min: ${min ?? '-'}°C, Max: ${max ?? '-'}°C${hasGPU ? `\nGPU: ${gpus.map(g => `${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}>
<TemperatureGauge
value={value}
min={min}
max={max}
/>
</div>
);
}
return (
<TemperatureGauge value={value} />
);
})()}
</Show>
</div>
);
// 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 (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
<span class={textClass}>{display}</span>
</div>
);
}
return null;
}
};
return (
<div
class={`${rowClass()} grid items-center`}
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
<For each={visibleColumns()}>
{(column) => renderCell(column)}
</For>
</div>
);
}}
</For >
</div >
</div >
</Card >
);
};