diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index b71363993..5ec5d7602 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -124,6 +124,7 @@ import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw' import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw'; import guestRowStateSource from '@/components/Workloads/useGuestRowState.ts?raw'; import workloadSelectionStateSource from '@/components/Workloads/useWorkloadSelectionState.ts?raw'; +import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw'; import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw'; import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw'; import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw'; @@ -348,6 +349,7 @@ describe('shared primitive guardrails', () => { expect(summaryTableCardHeaderSource).not.toContain('border-b border-border bg-surface-hover'); for (const source of [ workloadsTableSource, + proxmoxNodesTableSource, unifiedResourceHostTableCardSource, unifiedResourceServiceInfrastructureCardSource, storageContentCardSource, @@ -370,6 +372,7 @@ describe('shared primitive guardrails', () => { for (const source of [ workloadsTableSource, + proxmoxNodesTableSource, unifiedResourceHostTableCardSource, unifiedResourceServiceInfrastructureCardSource, storageContentCardSource, diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index e8abe65c1..87a922911 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -1,12 +1,14 @@ -import { For, Show, type Accessor, type Component, type JSX } from 'solid-js'; -import { Card } from '@/components/shared/Card'; +import { For, Show, createMemo, type Accessor, type Component, type JSX } from 'solid-js'; import { EmptyState } from '@/components/shared/EmptyState'; import { StatusDot } from '@/components/shared/StatusDot'; import { ResponsiveMetricCell } from '@/components/shared/responsive'; +import { TableCard } from '@/components/shared/TableCard'; +import { TableCardHeader } from '@/components/shared/TableCardHeader'; import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar'; import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar'; import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline'; import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; import { Table, TableBody, @@ -20,6 +22,7 @@ import { asTrimmedString } from '@/utils/stringUtils'; import { normalizeDiskArray } from '@/utils/format'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { useWorkloadTableMetricHistory } from '@/components/Workloads/useWorkloadTableMetricHistory'; +import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel'; import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel'; import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel'; import type { Disk, Node as LegacyNode } from '@/types/api'; @@ -29,6 +32,12 @@ import { getResourceNodeName, getResourceVersion, } from './proxmoxPageModel'; +import { + getProxmoxHostColumnWidthStyle, + getProxmoxHostTableMinWidthClass, + getProxmoxHostVisibleColumnsForLayout, + type ProxmoxHostTableColumn, +} from './proxmoxHostTableModel'; // Proxmox Overview mirrors the v5 Dashboard layout: a dedicated nodes table on // top, the canonical Workloads filter + guest table below. The nodes table @@ -103,6 +112,18 @@ const formatPercentLabel = (value: number | null | undefined): string => { return `${Math.round(Math.max(0, normalized))}%`; }; +const getHostHeaderCellClass = (column: ProxmoxHostTableColumn): string => { + const align = + column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : ''; + return `px-1.5 sm:px-2 py-0.5 font-medium ${align}`.trim(); +}; + +const getHostBodyCellClass = (column: ProxmoxHostTableColumn): string => { + const align = + column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : ''; + return `px-1.5 sm:px-2 py-1 ${align}`.trim(); +}; + export const ProxmoxNodesTable: Component<{ nodes: Resource[]; guests: Resource[]; @@ -112,6 +133,10 @@ export const ProxmoxNodesTable: Component<{ emptyTitle: string; emptyDescription: string; }> = (props) => { + const breakpoint = useBreakpoint(); + const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width())); + const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode())); + const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id)); const displayMode = () => props.metricDisplayMode?.() ?? 'bars'; const isSparklineMode = () => displayMode() === 'sparklines'; @@ -127,38 +152,43 @@ export const ProxmoxNodesTable: Component<{ 0} fallback={ - - - + +
+ +
+
} > - - - - - Node - Version - Uptime - - CPU - - - Memory - - - Disk - - Temp - VMs - CTs - Cluster + + +
+ + + {(column) => ( + + )} + + + + + + {(column) => ( + {column.label} + )} + - + {(node) => { const name = () => asTrimmedString(node.name) || node.id; @@ -194,151 +224,192 @@ export const ProxmoxNodesTable: Component<{ } as Disk) : undefined; const legacyNode = () => projectResourceToLegacyNode(node); - const cpuSeries = () => - metricHistory.getNodeMetricSeries(legacyNode(), 'cpu'); + const cpuSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'cpu'); const memorySeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'memory'); - const diskSeries = () => - metricHistory.getNodeMetricSeries(legacyNode(), 'disk'); + const diskSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'disk'); + const renderColumnCell = (column: ProxmoxHostTableColumn): JSX.Element => { + switch (column.id) { + case 'node': + return ( + +
+ + + {name()} + +
+
+ ); + case 'version': + return ( + + —}> + + {version()} + + + + ); + case 'uptime': + return ( + + {uptime().label} + + ); + case 'cpu': + return ( + + + } + > + + + + ); + case 'memory': + return ( + + 0 || memoryPercentOnly() != null) + } + fallback={ +
+ +
+ } + > + +
+ } + > + + +
+ ); + case 'disk': + return ( + + + + + } + > + + + } + > + + + + ); + case 'temp': + return ( + + 0 + } + fallback={} + > + + + + ); + case 'vms': + return ( + + 0 ? VMS_BADGE : ZERO_BADGE}> + {counts().vms} + + + ); + case 'cts': + return ( + + 0 ? CTS_BADGE : ZERO_BADGE}> + {counts().containers} + + + ); + case 'cluster': + return ( + + + {cluster()} + + + ); + default: + column.id satisfies never; + return <>; + } + }; + return ( - - -
- - - {name()} - -
-
- - —}> - - {version()} - - - - - {uptime().label} - - - - } - > - - - - - 0 || memoryPercentOnly() != null)} - fallback={ -
- -
- } - > - -
- } - > - - -
- - - - - } - > - - - } - > - - - - - 0} - fallback={} - > - - - - - 0 ? VMS_BADGE : ZERO_BADGE}> - {counts().vms} - - - - 0 ? CTS_BADGE : ZERO_BADGE}> - {counts().containers} - - - - - {cluster()} - - + + {(column) => renderColumnCell(column)} ); }}
-
+
); }; diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts new file mode 100644 index 000000000..6ea6455da --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + getProxmoxHostColumnWidthStyle, + getProxmoxHostTableMinWidthClass, + getProxmoxHostVisibleColumnsForLayout, +} from '../proxmoxHostTableModel'; + +describe('proxmoxHostTableModel', () => { + it('prioritizes live utilization columns in the mobile host table', () => { + const columns = getProxmoxHostVisibleColumnsForLayout('mobile'); + const ids = columns.map((column) => column.id); + + expect(ids).toEqual(['node', 'cpu', 'memory', 'disk']); + expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-full'); + expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '38%' }); + expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '20%' }); + expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '21%' }); + expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '21%' }); + }); + + it('adds temperature and guest counts before slower-changing metadata on tablet', () => { + expect(getProxmoxHostVisibleColumnsForLayout('tablet').map((column) => column.id)).toEqual([ + 'node', + 'cpu', + 'memory', + 'disk', + 'temp', + 'vms', + 'cts', + ]); + expect(getProxmoxHostTableMinWidthClass('tablet')).toBe('min-w-full'); + }); + + it('keeps the full host inventory table on compact and wide layouts', () => { + const compactIds = getProxmoxHostVisibleColumnsForLayout('compact').map((column) => column.id); + + expect(compactIds).toEqual([ + 'node', + 'version', + 'uptime', + 'cpu', + 'memory', + 'disk', + 'temp', + 'vms', + 'cts', + 'cluster', + ]); + expect(getProxmoxHostTableMinWidthClass('compact')).toBe('min-w-[1080px]'); + expect(getProxmoxHostTableMinWidthClass('wide')).toBe('min-w-[1080px]'); + expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({ + width: '12%', + }); + }); +}); diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts new file mode 100644 index 000000000..42dfa9ece --- /dev/null +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -0,0 +1,119 @@ +import type { JSX } from 'solid-js'; + +import type { WorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel'; + +export type ProxmoxHostTableColumnId = + | 'node' + | 'version' + | 'uptime' + | 'cpu' + | 'memory' + | 'disk' + | 'temp' + | 'vms' + | 'cts' + | 'cluster'; + +export type ProxmoxHostTableColumn = { + id: ProxmoxHostTableColumnId; + label: string; + align?: 'left' | 'right' | 'center'; +}; + +const HOST_TABLE_LAYOUT_ORDER: Record = { + mobile: 0, + tablet: 1, + compact: 2, + wide: 3, +}; + +const HOST_COLUMN_MIN_LAYOUT: Record = { + node: 'mobile', + cpu: 'mobile', + memory: 'mobile', + disk: 'mobile', + temp: 'tablet', + vms: 'tablet', + cts: 'tablet', + version: 'compact', + uptime: 'compact', + cluster: 'compact', +}; + +const HOST_COLUMN_DESKTOP_WIDTHS: Record = { + node: 15, + version: 8, + uptime: 7, + cpu: 13, + memory: 13, + disk: 15, + temp: 7, + vms: 5, + cts: 5, + cluster: 12, +}; + +const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< + Exclude, + Partial> +> = { + mobile: { + node: 38, + cpu: 20, + memory: 21, + disk: 21, + }, + tablet: { + node: 26, + cpu: 17, + memory: 17, + disk: 19, + temp: 8, + vms: 6, + cts: 6, + }, + compact: HOST_COLUMN_DESKTOP_WIDTHS, +}; + +const formatPercentage = (value: number): string => `${Number(value.toFixed(4))}%`; + +export const PROXMOX_HOST_TABLE_COLUMNS: ProxmoxHostTableColumn[] = [ + { id: 'node', label: 'Node' }, + { id: 'version', label: 'Version' }, + { id: 'uptime', label: 'Uptime', align: 'right' }, + { id: 'cpu', label: 'CPU' }, + { id: 'memory', label: 'Memory' }, + { id: 'disk', label: 'Disk' }, + { id: 'temp', label: 'Temp', align: 'right' }, + { id: 'vms', label: 'VMs', align: 'center' }, + { id: 'cts', label: 'CTs', align: 'center' }, + { id: 'cluster', label: 'Cluster' }, +]; + +export const getProxmoxHostVisibleColumnsForLayout = ( + layoutMode: WorkloadTableLayoutMode, +): ProxmoxHostTableColumn[] => { + const layoutRank = HOST_TABLE_LAYOUT_ORDER[layoutMode]; + return PROXMOX_HOST_TABLE_COLUMNS.filter( + (column) => HOST_TABLE_LAYOUT_ORDER[HOST_COLUMN_MIN_LAYOUT[column.id]] <= layoutRank, + ); +}; + +export const getProxmoxHostColumnWidthStyle = ( + columnId: ProxmoxHostTableColumnId, + layoutMode: WorkloadTableLayoutMode, + visibleColumnIds: readonly ProxmoxHostTableColumnId[], +): JSX.CSSProperties => { + const weights = + layoutMode === 'wide' ? HOST_COLUMN_DESKTOP_WIDTHS : HOST_COLUMN_RESPONSIVE_WEIGHTS[layoutMode]; + const columnWeight = weights[columnId] ?? 0; + const totalWeight = visibleColumnIds.reduce((total, id) => total + (weights[id] ?? 0), 0); + const width = totalWeight > 0 ? (columnWeight / totalWeight) * 100 : 0; + + return { width: formatPercentage(width) }; +}; + +export const getProxmoxHostTableMinWidthClass = ( + layoutMode: WorkloadTableLayoutMode, +): 'min-w-full' | 'min-w-[1080px]' => + layoutMode === 'mobile' || layoutMode === 'tablet' ? 'min-w-full' : 'min-w-[1080px]';