From dc129cd3a403e9fd2f9177ddbfcf18e5042445e5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 23 Aug 2026 17:01:49 +0100 Subject: [PATCH] Improve Proxmox node search coverage Contract-Neutral: Behavioral search fix; no public subsystem contract delta --- frontend-modern/browser-verification.json | 49 +- .../features/proxmox/ProxmoxNodesTable.tsx | 946 +++++++++--------- .../features/proxmox/ProxmoxPageSurface.tsx | 1 + .../__tests__/ProxmoxNodesTable.test.tsx | 18 + .../ProxmoxPageSurface.contract.test.tsx | 17 + .../__tests__/proxmoxPageModel.test.ts | 77 ++ .../src/features/proxmox/proxmoxPageModel.ts | 53 + 7 files changed, 672 insertions(+), 489 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index fe02243e9..156b2b482 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,23 +1,17 @@ { "version": 1, - "base_sha": "4040a79f4f5ffc465131fb75c31146d93e45c8d5", - "verified_at": "2026-08-23T15:31:26Z", + "base_sha": "d8fe2a935875675b22a87c5471ffc923f7a0ab51", + "verified_at": "2026-08-23T16:50:00Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", - "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", - "frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts", - "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts", - "frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts" + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", + "frontend-modern/src/features/proxmox/proxmoxPageModel.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "eb8ddd4c5f03efa629ba480fa9cbe4bbd8293834e6d0e6bf05e3fbb81d247edf", - "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "765e4185fea10dd2e6da0c9e3de53ee53293ae6e1e0e19d6171fb01748fa5b8d", - "frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts": "792a66e9f01fa1ab731413527255c631ccd20bc26bcef24a74208f1e61f76d58", - "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts": "b12b4a1df25c88a5468e867aa2b58f10a452d65be846dcfd1bcaa4942e506a96", - "frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts": "ecfc505a901c65b7ed8e7d17d828961d1409c948c6f78bf2528ea907cf484343", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "d67a6b5d5f2de1ff3c0fe100652cf2e3d8ed2f74cda767a1bdb7462dea243b28" + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "5b2dce1ff4cae9624fa106ad9c01f29e0025b33e0def87cee30798e74fe31329", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "27d2077c659d40e2c51472086e745311221d771129455a4bae2be816ab116dbe", + "frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "f80b8e01fd31e7f2de55cda5a2f5e58005068b07daa77c3356811ad7f5919051" }, "routes": [ "/proxmox/overview" @@ -28,25 +22,22 @@ "height": 720 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "The Proxmox overview renders the large local mock estate with grouped workload virtualization active", - "At 1280x720 the app scroll height remains exactly 29470 pixels at scroll positions 0, 1800, and 5000", - "At 375x812 the app scroll height remains exactly 36372 pixels at scroll positions 0, 2800, and 7000", - "All mounted phone guest rows measure 37 pixels and grouped node headers remain a single 28-pixel line", - "Back to top is absent near the top and appears after the 640-pixel threshold", - "The phone control is a 44-pixel fixed button positioned above the bottom navigation", - "The verified desktop and phone journeys reported no browser console errors" + "Default desktop Proxmox overview renders the 50-node mock estate with 8 clusters and 2 standalone nodes", + "Desktop search for pve31 renders one matching node as 1 of 50 and keeps the matching guest inventory visible", + "Narrow search for pve31 renders one matching node with the native pve31 identifier beside its friendly name", + "Narrow filtered layout has no horizontal overflow and retains the responsive node and workload columns", + "The browser verification run reported no console errors" ], "interactions": [ - "Scrolled repeatedly through the desktop mock estate and compared the app-owned scroll shell height at multiple virtual windows", - "Activated Back to top on desktop and confirmed the smooth scroll completed at zero and the control disappeared", - "Applied a temporary 375x812 viewport override and repeated the multi-position scroll-height measurements", - "Captured and visually inspected the phone viewport with the control above the mobile navigation", - "Activated Back to top on phone and confirmed the app scroll shell returned to zero while retaining the same total height", - "Reset the temporary viewport override and closed the temporary test tab after verification" + "Loaded the Proxmox overview and inspected the default 50-node desktop table", + "Entered pve31 in the shared workload search and verified the node table reduced to 1 of 50", + "Verified the same query left the matching Analytics A host and its pve31 workload rows visible", + "Applied a temporary 390x844 viewport override, reloaded the overview, entered pve31, and verified the narrow DOM and screenshot", + "Reset the temporary viewport override after verification" ] } diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index 9607f7fcb..ed91d572d 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -54,6 +54,7 @@ import type { Resource } from '@/types/resource'; import { nodeFromResource } from '@/utils/resourceStateAdapters'; import { getResourceClusterLabel, + filterProxmoxNodes, getResourceVersion, isProxmoxChildOfNode, } from './proxmoxPageModel'; @@ -192,6 +193,7 @@ const getHostSortValue = ( export const ProxmoxNodesTable: Component<{ nodes: Resource[]; guests: Resource[]; + search?: Accessor; metricDisplayMode?: Accessor; metricHistoryRange?: Accessor; layoutWidth?: Accessor; @@ -217,13 +219,16 @@ export const ProxmoxNodesTable: Component<{ const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id)); const displayMode = () => props.metricDisplayMode?.() ?? 'bars'; const isSparklineMode = () => displayMode() === 'sparklines'; + const filteredNodes = createMemo(() => + filterProxmoxNodes(props.nodes, props.guests, props.search?.() ?? ''), + ); const sort = createPlatformTableSortState({ storageKey: 'proxmoxNodes', sortKeys: PROXMOX_HOST_SORT_KEYS, descendingFirst: ['uptime', 'cpu', 'memory', 'disk', 'temp', 'vms', 'cts'], }); const sortedNodes = createMemo(() => - sort.sortRows(props.nodes, (node, key) => getHostSortValue(node, props.guests, key)), + sort.sortRows(filteredNodes(), (node, key) => getHostSortValue(node, props.guests, key)), ); const previewLimit = createMemo(() => layoutMode() === 'narrow' || layoutMode() === 'phone' || layoutMode() === 'mobile' ? 4 : 8, @@ -253,483 +258,504 @@ export const ProxmoxNodesTable: Component<{ /> } > - - Nodes - - - {formatPlatformTableIntegerValue(props.nodes.length)} - - - + 0} + fallback={ + } - actions={ - - 0 || props.topology.standalone > 0) - } - > - - 0}> - {formatPlatformTableIntegerValue(props.topology!.clusters)}{' '} - {props.topology!.clusters === 1 ? 'cluster' : 'clusters'} - - 0 && props.topology!.standalone > 0}> - {' / '} - - 0}> - {formatPlatformTableIntegerValue(props.topology!.standalone)} standalone - - - - - - } - cardClass="proxmox-nodes-card" - tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`} - colgroup={ - + > + + Nodes + + + {formatPlatformTableIntegerValue(filteredNodes().length)} + + + of {formatPlatformTableIntegerValue(props.nodes.length)} + + + + + + } + actions={ + + 0 || props.topology.standalone > 0) + } + > + + 0}> + {formatPlatformTableIntegerValue(props.topology!.clusters)}{' '} + {props.topology!.clusters === 1 ? 'cluster' : 'clusters'} + + 0 && props.topology!.standalone > 0}> + {' / '} + + 0}> + {formatPlatformTableIntegerValue(props.topology!.standalone)} standalone + + + + + + } + cardClass="proxmox-nodes-card" + tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`} + colgroup={ + + + {(column) => ( + + )} + + + } + header={ {(column) => ( - + + {(layoutMode() === 'narrow' || + layoutMode() === 'phone' || + layoutMode() === 'mobile') && + column.id === 'memory' + ? 'Mem' + : (layoutMode() === 'narrow' || + layoutMode() === 'phone' || + layoutMode() === 'mobile') && + column.id === 'uptime' + ? 'Age' + : column.label} + )} - - } - header={ - - {(column) => ( - - {(layoutMode() === 'narrow' || + } + body={ + + {(node) => { + const name = () => asTrimmedString(node.name) || node.id; + const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? ''; + const visibleNodeLabel = () => + layoutMode() === 'narrow' && nativeNodeName().length > 0 + ? nativeNodeName() + : name(); + const showNativeNodeName = () => + layoutMode() === 'phone' && + nativeNodeName().length > 0 && + nativeNodeName() !== name(); + const drawerNode = createMemo(() => nodeFromResource(node)); + const detailRowId = () => `proxmox-host-drawer-${node.id}`; + const isSelected = () => selectedNodeId() === node.id; + const toggleNodeDrawer = () => + setSelectedNodeId((current) => (current === node.id ? null : node.id)); + const handleActivationKey: JSX.EventHandler = ( + event, + ) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + toggleNodeDrawer(); + }; + const version = () => asTrimmedString(getResourceVersion(node)); + const cluster = () => getResourceClusterLabel(node); + const counts = () => countGuestsForNode(props.guests, node); + const indicator = () => getSimpleStatusIndicator(node.status); + const connectionHealth = createMemo(() => + (asTrimmedString(node.proxmox?.connectionHealth) ?? '').toLowerCase(), + ); + const availabilityLabel = createMemo(() => { + if (node.status === 'offline') return 'Offline'; + // A linked Pulse agent can still prove that a host is online + // while the Proxmox provider path is unavailable. In that + // hybrid case the provider metrics are stale, not evidence + // that the host itself is powered off. + if (connectionHealth() === 'error' && node.status === 'online') return 'Stale'; + if (connectionHealth() === 'error') return 'Offline'; + if ( + node.status === 'unknown' || + connectionHealth() === 'degraded' || + connectionHealth() === 'stale' + ) { + return 'Stale'; + } + if (node.status === 'degraded' || node.status === 'warning') return 'Degraded'; + return 'Online'; + }); + const isOnline = createMemo(() => availabilityLabel() === 'Online'); + const usesCompactMetrics = () => + layoutMode() === 'narrow' || layoutMode() === 'phone' || - layoutMode() === 'mobile') && - column.id === 'memory' - ? 'Mem' - : (layoutMode() === 'narrow' || - layoutMode() === 'phone' || - layoutMode() === 'mobile') && - column.id === 'uptime' - ? 'Age' - : column.label} - - )} - - } - body={ - - {(node) => { - const name = () => asTrimmedString(node.name) || node.id; - const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? ''; - const visibleNodeLabel = () => - layoutMode() === 'narrow' && nativeNodeName().length > 0 - ? nativeNodeName() - : name(); - const showNativeNodeName = () => - layoutMode() === 'phone' && - nativeNodeName().length > 0 && - nativeNodeName() !== name(); - const drawerNode = createMemo(() => nodeFromResource(node)); - const detailRowId = () => `proxmox-host-drawer-${node.id}`; - const isSelected = () => selectedNodeId() === node.id; - const toggleNodeDrawer = () => - setSelectedNodeId((current) => (current === node.id ? null : node.id)); - const handleActivationKey: JSX.EventHandler = ( - event, - ) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - toggleNodeDrawer(); - }; - const version = () => asTrimmedString(getResourceVersion(node)); - const cluster = () => getResourceClusterLabel(node); - const counts = () => countGuestsForNode(props.guests, node); - const indicator = () => getSimpleStatusIndicator(node.status); - const connectionHealth = createMemo(() => - (asTrimmedString(node.proxmox?.connectionHealth) ?? '').toLowerCase(), - ); - const availabilityLabel = createMemo(() => { - if (node.status === 'offline') return 'Offline'; - // A linked Pulse agent can still prove that a host is online - // while the Proxmox provider path is unavailable. In that - // hybrid case the provider metrics are stale, not evidence - // that the host itself is powered off. - if (connectionHealth() === 'error' && node.status === 'online') return 'Stale'; - if (connectionHealth() === 'error') return 'Offline'; - if ( - node.status === 'unknown' || - connectionHealth() === 'degraded' || - connectionHealth() === 'stale' - ) { - return 'Stale'; - } - if (node.status === 'degraded' || node.status === 'warning') return 'Degraded'; - return 'Online'; - }); - const isOnline = createMemo(() => availabilityLabel() === 'Online'); - const usesCompactMetrics = () => - layoutMode() === 'narrow' || layoutMode() === 'phone' || layoutMode() === 'mobile'; - const usesCondensedIdentity = () => - layoutMode() === 'narrow' || layoutMode() === 'phone'; - const uptime = () => - formatNodeUptime(isOnline() ? node.uptime : undefined, usesCompactMetrics()); - const fullUptime = () => formatNodeUptime(isOnline() ? node.uptime : undefined).label; - const metricsKey = () => buildMetricKeyForUnifiedResource(node); - const temperature = () => node.temperature; - const alertResourceIds = () => hostOverrideIdCandidates(node); - const temperatureThresholds = () => - alertsActivation.getMetricThresholds('node', 'temperature', alertResourceIds()); - const cpuThresholds = () => - alertsActivation.getMetricThresholds('node', 'cpu', alertResourceIds()); - const memoryThresholds = () => - alertsActivation.getMetricThresholds('node', 'memory', alertResourceIds()); - const diskThresholds = () => - alertsActivation.getMetricThresholds('node', 'disk', alertResourceIds()); - const cpuPercent = () => getPlatformTableFiniteMetric(node.cpu?.current) ?? 0; - const memoryUsed = () => getPlatformTableFiniteMetric(node.memory?.used) ?? 0; - const memoryTotal = () => getPlatformTableFiniteMetric(node.memory?.total) ?? 0; - const memoryPercent = () => - memoryTotal() > 0 - ? (memoryUsed() / memoryTotal()) * 100 - : (getPlatformTableFiniteMetric(node.memory?.current) ?? 0); - const memoryPercentOnly = () => - !memoryTotal() && getPlatformTableFiniteMetric(node.memory?.current) !== undefined - ? getPlatformTableFiniteMetric(node.memory?.current) - : undefined; - const diskPercent = () => getPlatformTableFiniteMetric(node.disk?.current) ?? 0; - const aggregateDisk = (): Disk | undefined => { - if (!node.disk) return undefined; - const total = getPlatformTableFiniteMetric(node.disk.total) ?? 0; - const used = getPlatformTableFiniteMetric(node.disk.used) ?? 0; - const free = getPlatformTableFiniteMetric(node.disk.free) ?? 0; - const usage = getPlatformTableFiniteMetric(node.disk.current) ?? 0; - return { total, used, free, usage } as Disk; - }; - const legacyNode = () => projectResourceToLegacyNode(node); - const externalUrl = () => { - const shimmed = drawerNode(); - return shimmed ? getNodeExternalUrl(shimmed) : ''; - }; - const pendingUpdates = () => drawerNode()?.pendingUpdates ?? 0; - const alertStyles = createMemo(() => - getAlertStyles(node.id, activeAlerts, alertsEnabled(), node.name), - ); - const alertAccentTone = createMemo< - 'critical' | 'warning' | 'acknowledged' | undefined - >(() => { - const styles = alertStyles(); - if (styles.hasUnacknowledgedAlert) { - return styles.severity === 'critical' ? 'critical' : 'warning'; - } - return styles.hasAcknowledgedOnlyAlert ? 'acknowledged' : undefined; - }); - const rowAlertBg = () => { - const styles = alertStyles(); - if (!styles.hasUnacknowledgedAlert) return ''; - return styles.severity === 'critical' - ? 'bg-red-50 dark:bg-red-950' - : 'bg-yellow-50 dark:bg-yellow-950'; - }; - const cpuSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'cpu'); - const memorySeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'memory'); - const diskSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'disk'); - const renderColumnCell = (column: ProxmoxHostTableColumn): JSX.Element => { - switch (column.id) { - case 'node': - return ( - -
- - -
- a]:hidden' : ''}`} - nameClass="truncate font-semibold text-base-content" - title={`Open ${name()} web interface`} - > - {visibleNodeLabel()} - - - - {nativeNodeName()} + layoutMode() === 'mobile'; + const usesCondensedIdentity = () => + layoutMode() === 'narrow' || layoutMode() === 'phone'; + const uptime = () => + formatNodeUptime(isOnline() ? node.uptime : undefined, usesCompactMetrics()); + const fullUptime = () => + formatNodeUptime(isOnline() ? node.uptime : undefined).label; + const metricsKey = () => buildMetricKeyForUnifiedResource(node); + const temperature = () => node.temperature; + const alertResourceIds = () => hostOverrideIdCandidates(node); + const temperatureThresholds = () => + alertsActivation.getMetricThresholds('node', 'temperature', alertResourceIds()); + const cpuThresholds = () => + alertsActivation.getMetricThresholds('node', 'cpu', alertResourceIds()); + const memoryThresholds = () => + alertsActivation.getMetricThresholds('node', 'memory', alertResourceIds()); + const diskThresholds = () => + alertsActivation.getMetricThresholds('node', 'disk', alertResourceIds()); + const cpuPercent = () => getPlatformTableFiniteMetric(node.cpu?.current) ?? 0; + const memoryUsed = () => getPlatformTableFiniteMetric(node.memory?.used) ?? 0; + const memoryTotal = () => getPlatformTableFiniteMetric(node.memory?.total) ?? 0; + const memoryPercent = () => + memoryTotal() > 0 + ? (memoryUsed() / memoryTotal()) * 100 + : (getPlatformTableFiniteMetric(node.memory?.current) ?? 0); + const memoryPercentOnly = () => + !memoryTotal() && getPlatformTableFiniteMetric(node.memory?.current) !== undefined + ? getPlatformTableFiniteMetric(node.memory?.current) + : undefined; + const diskPercent = () => getPlatformTableFiniteMetric(node.disk?.current) ?? 0; + const aggregateDisk = (): Disk | undefined => { + if (!node.disk) return undefined; + const total = getPlatformTableFiniteMetric(node.disk.total) ?? 0; + const used = getPlatformTableFiniteMetric(node.disk.used) ?? 0; + const free = getPlatformTableFiniteMetric(node.disk.free) ?? 0; + const usage = getPlatformTableFiniteMetric(node.disk.current) ?? 0; + return { total, used, free, usage } as Disk; + }; + const legacyNode = () => projectResourceToLegacyNode(node); + const externalUrl = () => { + const shimmed = drawerNode(); + return shimmed ? getNodeExternalUrl(shimmed) : ''; + }; + const pendingUpdates = () => drawerNode()?.pendingUpdates ?? 0; + const alertStyles = createMemo(() => + getAlertStyles(node.id, activeAlerts, alertsEnabled(), node.name), + ); + const alertAccentTone = createMemo< + 'critical' | 'warning' | 'acknowledged' | undefined + >(() => { + const styles = alertStyles(); + if (styles.hasUnacknowledgedAlert) { + return styles.severity === 'critical' ? 'critical' : 'warning'; + } + return styles.hasAcknowledgedOnlyAlert ? 'acknowledged' : undefined; + }); + const rowAlertBg = () => { + const styles = alertStyles(); + if (!styles.hasUnacknowledgedAlert) return ''; + return styles.severity === 'critical' + ? 'bg-red-50 dark:bg-red-950' + : 'bg-yellow-50 dark:bg-yellow-950'; + }; + const cpuSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'cpu'); + const memorySeries = () => + metricHistory.getNodeMetricSeries(legacyNode(), 'memory'); + const diskSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'disk'); + const renderColumnCell = (column: ProxmoxHostTableColumn): JSX.Element => { + switch (column.id) { + case 'node': + return ( + +
+ + +
+ a]:hidden' : ''}`} + nameClass="truncate font-semibold text-base-content" + title={`Open ${name()} web interface`} + > + {visibleNodeLabel()} + + + + {nativeNodeName()} + + +
+ + + + 0}> + = 10 + ? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400' + : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-400' + }`} + title={`${pendingUpdates()} pending apt update${pendingUpdates() !== 1 ? 's' : ''}`} + > + {pendingUpdates()} updates
- -
+ ); + case 'version': + return ( + +
}> + + {version()}
- 0}> - = 10 - ? 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400' - : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-400' - }`} - title={`${pendingUpdates()} pending apt update${pendingUpdates() !== 1 ? 's' : ''}`} - > - {pendingUpdates()} updates - - -
- - ); - case 'version': - return ( - - —}> - - {version()} - - - - ); - case 'uptime': - return ( - - {uptime().label} - - ); - case 'cpu': - return ( - - }> - - } - > - - - - - ); - case 'memory': - return ( - - 0 || memoryPercentOnly() != null)} - fallback={} + + ); + case 'uptime': + return ( + - - } - > - + ); + case 'cpu': + return ( + + }> + } - title={`${name()} memory history`} - /> - - - - ); - case 'disk': - return ( - - } - > - 1 - ? 'vertical-bars' - : undefined - } - disks={normalizeDiskArray(node.agent?.disks)} - aggregateDisk={aggregateDisk()} - thresholds={diskThresholds()} + > + - } + + + + ); + case 'memory': + return ( + + 0 || memoryPercentOnly() != null)} + fallback={} > - + } + > + + + + + ); + case 'disk': + return ( + + } + > + 1 + ? 'vertical-bars' + : undefined + } + disks={normalizeDiskArray(node.agent?.disks)} + aggregateDisk={aggregateDisk()} + thresholds={diskThresholds()} + /> + } + > + + + + + ); + case 'temp': + return ( + + 0 + } + fallback={} + > + - - - ); - case 'temp': - return ( - - 0 - } - fallback={} - > - - - - ); - case 'vms': - return ( - - 0 ? VMS_BADGE : ZERO_BADGE} - data-proxmox-vm-count - > - {counts().vms} - - - ); - case 'cts': - return ( - - 0 ? CTS_BADGE : ZERO_BADGE} - data-proxmox-container-count - > - {counts().containers} - - - ); - case 'cluster': - return ( - - - {cluster()} - - - ); - default: - column.id satisfies never; - return <>; - } - }; + + ); + case 'vms': + return ( + + 0 ? VMS_BADGE : ZERO_BADGE} + data-proxmox-vm-count + > + {counts().vms} + + + ); + case 'cts': + return ( + + 0 ? CTS_BADGE : ZERO_BADGE} + data-proxmox-container-count + > + {counts().containers} + + + ); + case 'cluster': + return ( + + + {cluster()} + + + ); + default: + column.id satisfies never; + return <>; + } + }; - return ( - <> - - {(column) => renderColumnCell(column)} - - - {(selectedNode) => ( - - { - const config = toDiscoveryConfig(node); - return config - ? { agentId: config.agentId, hostname: config.hostname } - : undefined; - })()} - /> - - )} - - - ); - }} - - } - /> + return ( + <> + + {(column) => renderColumnCell(column)} + + + {(selectedNode) => ( + + { + const config = toDiscoveryConfig(node); + return config + ? { agentId: config.agentId, hostname: config.hostname } + : undefined; + })()} + /> + + )} + + + ); + }} + + } + /> + ); }; diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index e0093d64e..5884e5be6 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -333,6 +333,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { { }); describe('ProxmoxNodesTable', () => { + it('uses the shared workload search to narrow the host preview and count', () => { + render(() => ( + 'pve-node-9'} + emptyIcon={} + emptyTitle="No Proxmox VE nodes" + emptyDescription="No nodes" + /> + )); + + expect(screen.getByText('Nodes').parentElement).toHaveTextContent('Nodes1of 10'); + expect(screen.getByText('pve-node-9')).toBeInTheDocument(); + expect(screen.queryByText('pve-node-1')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Show all 10 nodes' })).not.toBeInTheDocument(); + }); + it('keeps large estates bounded until the operator expands the node preview', () => { render(() => ( { ); }); + it('passes the shared workload search into the node table', () => { + mockWorkloadSearch.mockReturnValue('pve-1'); + setResources([ + makeResource({ + id: 'agent:pve-1', + type: 'agent', + proxmox: { nodeName: 'pve-1', clusterName: 'homelab' }, + }), + ]); + + renderSurface(); + + expect(mockNodesTableProps).toHaveBeenLastCalledWith( + expect.objectContaining({ search: mockWorkloadSearch }), + ); + }); + it('keeps Proxmox workload and backup filters free of a saved-views affordance', () => { // Saved views persisted the page's URL query string to localStorage. The // browser's own bookmarks already do that and survive a cleared cache, so diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts index 442b8eb1f..5cea78c80 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts @@ -4,6 +4,7 @@ import { PROXMOX_TAB_SPECS, buildProxmoxPageModel, buildVisibleProxmoxTabSpecs, + filterProxmoxNodes, getResourceVersion, resolveProxmoxPlatformScope, } from '../proxmoxPageModel'; @@ -218,4 +219,80 @@ describe('proxmoxPageModel', () => { ), ).toBe('8.3.3'); }); + + it('filters nodes by native identity, cluster, aliases, and matching guests', () => { + const nodes = [ + makeResource({ + id: 'friendly-east', + type: 'agent', + displayName: 'East estate', + platformId: 'site-east', + proxmox: { + nodeName: 'pve-east-01', + nodeAliases: ['old-east-name'], + clusterName: 'production', + }, + }), + makeResource({ + id: 'friendly-west', + type: 'agent', + displayName: 'West estate', + platformId: 'site-west', + proxmox: { nodeName: 'pve-west-01', clusterName: 'production' }, + }), + ]; + const guests = [ + makeResource({ + id: 'vm-database', + type: 'vm', + displayName: 'database-primary', + parentName: 'pve-east-01', + proxmox: { nodeName: 'pve-east-01', instance: 'site-east', vmid: 101 }, + }), + ]; + + expect(filterProxmoxNodes(nodes, guests, 'pve-east-01').map((node) => node.id)).toEqual([ + 'friendly-east', + ]); + expect(filterProxmoxNodes(nodes, guests, 'old-east-name').map((node) => node.id)).toEqual([ + 'friendly-east', + ]); + expect(filterProxmoxNodes(nodes, guests, 'database-primary').map((node) => node.id)).toEqual([ + 'friendly-east', + ]); + expect(filterProxmoxNodes(nodes, guests, 'pve-east-01, pve-west-01')).toHaveLength(2); + }); + + it('preserves exclusion search semantics and does not cross provider boundaries', () => { + const nodes = [ + makeResource({ + id: 'site-a-node', + type: 'agent', + platformId: 'site-a', + proxmox: { instance: 'site-a', nodeName: 'pve-1', clusterName: 'production' }, + }), + makeResource({ + id: 'site-b-node', + type: 'agent', + platformId: 'site-b', + proxmox: { instance: 'site-b', nodeName: 'pve-1', clusterName: 'production' }, + }), + ]; + const guests = [ + makeResource({ + id: 'site-b-vm', + type: 'vm', + displayName: 'unique-west-guest', + platformId: 'site-b', + proxmox: { instance: 'site-b', nodeName: 'pve-1', vmid: 202 }, + }), + ]; + + expect( + filterProxmoxNodes(nodes, guests, 'pve-1 -unique-west-guest').map((node) => node.id), + ).toEqual(['site-a-node']); + expect(filterProxmoxNodes(nodes, guests, 'unique-west-guest').map((node) => node.id)).toEqual([ + 'site-b-node', + ]); + }); }); diff --git a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts index c3afd8e2a..99e6fb5ad 100644 --- a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts @@ -1,5 +1,6 @@ import type { Resource, ResourceMetric, ResourceType } from '@/types/resource'; import { formatProxmoxVersion } from '@/utils/proxmoxVersion'; +import { matchesSearchTermSplit, splitSearchExclusions } from '@/utils/searchQuery'; export type ProxmoxPageTabId = 'overview' | 'storage' | 'replication' | 'backups' | 'ceph' | 'mail'; @@ -209,6 +210,58 @@ export function getResourceVmid(resource: Resource): string { return ''; } +const getProxmoxSearchValues = (resource: Resource): Array => [ + resource.name, + resource.displayName, + resource.id, + resource.parentName, + resource.platformId, + resource.status, + resource.identity?.hostname, + ...(resource.identity?.ips ?? []), + ...(resource.tags ?? []), + resource.proxmox?.node, + resource.proxmox?.nodeName, + resource.proxmox?.nodeIdentity, + resource.proxmox?.nodeDisplayName, + ...(resource.proxmox?.nodeAliases ?? []), + resource.proxmox?.clusterName, + resource.proxmox?.instance, + resource.proxmox?.host, + resource.proxmox?.pveVersion, + getResourceVmid(resource), +]; + +/** + * Keeps the overview's host inventory aligned with its workload search. + * A matching guest makes its parent node searchable too, so searching for a + * VM/LXC does not leave the operator looking at an unrelated host preview. + */ +export const filterProxmoxNodes = ( + nodes: Resource[], + guests: Resource[], + search: string, +): Resource[] => { + const split = splitSearchExclusions(search); + if (!split.needle && split.excludes.length === 0) return nodes; + + return nodes.filter((node) => { + const values = getProxmoxSearchValues(node); + for (const guest of guests) { + if (isProxmoxChildOfNode(guest, node)) values.push(...getProxmoxSearchValues(guest)); + } + + const haystack = values + .filter( + (value): value is string | number => typeof value === 'string' || typeof value === 'number', + ) + .map(String) + .join(' ') + .toLowerCase(); + return matchesSearchTermSplit(haystack, split); + }); +}; + export function getResourceLastBackup(resource: Resource): string | number | null { const platformProxmox = getPlatformData(resource).proxmox; if (!isRecord(platformProxmox)) return null;