- 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
863 lines
34 KiB
TypeScript
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 >
|
|
);
|
|
};
|