diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index e80756ebd..b74e461b8 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -23,7 +23,6 @@ import { getPlatformIcon } from '@/features/platformPage/platformIcon'; import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { STORAGE_KEYS } from '@/utils/localStorage'; -import { resourceMatchesSearch } from '@/utils/resourceSearchMatch'; import { PlatformErrorState, PlatformSectionTabs, @@ -41,6 +40,7 @@ import { PROXMOX_TAB_SPECS, buildProxmoxPageModel, buildVisibleProxmoxTabSpecs, + filterProxmoxNodesForSearch, type ProxmoxPageModel, type ProxmoxPageTabId, } from './proxmoxPageModel'; @@ -247,11 +247,13 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { workloadsState.surfaceInitialDataReceived() && workloadsState.allGuests().length > 0, ); - const filteredNodes = createMemo(() => { - const term = workloadsState.search(); - if (!term.trim()) return props.model().pveNodes; - return props.model().pveNodes.filter((node) => resourceMatchesSearch(node, term)); - }); + const filteredNodes = createMemo(() => + filterProxmoxNodesForSearch( + props.model().pveNodes, + props.model().guests, + workloadsState.search(), + ), + ); return (
diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts index ff9f8664a..5f2fd436d 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, + filterProxmoxNodesForSearch, getResourceVersion, resolveProxmoxPlatformScope, } from '../proxmoxPageModel'; @@ -31,6 +32,44 @@ describe('proxmoxPageModel', () => { ]); }); + describe('filterProxmoxNodesForSearch', () => { + const minipc = makeResource({ + id: 'minipc', + type: 'agent', + proxmox: { nodeName: 'minipc', clusterName: 'homelab' }, + }); + const delly = makeResource({ + id: 'delly', + type: 'agent', + proxmox: { nodeName: 'delly', clusterName: 'homelab' }, + }); + const debianGo = makeResource({ + id: 'system-container-112', + type: 'system-container', + name: 'debian-go', + proxmox: { vmid: 112, nodeName: 'minipc' }, + }); + + it('returns every node when the search term is empty', () => { + expect(filterProxmoxNodesForSearch([minipc, delly], [debianGo], '')).toEqual([ + minipc, + delly, + ]); + }); + + it('keeps the host node of a matching guest so a guest search does not empty the nodes table', () => { + // Regression: searching a guest name used to filter the nodes table to + // nothing, surfacing the "No Proxmox VE nodes" empty state even though the + // guest's host node exists and the guest is listed below. + const result = filterProxmoxNodesForSearch([minipc, delly], [debianGo], 'debian-go'); + expect(result).toEqual([minipc]); + }); + + it('still matches a node by its own name', () => { + expect(filterProxmoxNodesForSearch([minipc, delly], [debianGo], 'delly')).toEqual([delly]); + }); + }); + it('builds a Proxmox-first estate model from canonical v6 resources', () => { const model = buildProxmoxPageModel([ makeResource({ diff --git a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts index a67cdfc71..5212d109a 100644 --- a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts @@ -1,6 +1,7 @@ import type { Resource, ResourceMetric, ResourceType } from '@/types/resource'; import type { ResourceChange } from '@/types/resource'; import { formatProxmoxVersion } from '@/utils/proxmoxVersion'; +import { resourceMatchesSearch } from '@/utils/resourceSearchMatch'; export type ProxmoxPageTabId = 'overview' | 'storage' | 'replication' | 'backups' | 'ceph' | 'mail'; @@ -175,6 +176,32 @@ export function getResourceNodeName(resource: Resource): string { ); } +// filterProxmoxNodesForSearch narrows the nodes table to match the shared +// workload search box. Because that box is a VM/LXC filter, filtering nodes by +// the raw term alone collapses the table to its empty state whenever the term +// matches a guest but not a node name — which misreads as "no Proxmox nodes" +// while the matching guest is listed right below. Keep a node when it matches +// the term directly OR when it hosts a guest that matches, so a guest search +// still shows that guest's host node for context. +export function filterProxmoxNodesForSearch( + nodes: Resource[], + guests: Resource[], + term: string, +): Resource[] { + if (!term.trim()) return nodes; + const matchingGuestNodeNames = new Set( + guests + .filter((guest) => resourceMatchesSearch(guest, term)) + .map((guest) => getResourceNodeName(guest)) + .filter(Boolean), + ); + return nodes.filter( + (node) => + resourceMatchesSearch(node, term) || + matchingGuestNodeNames.has(getResourceNodeName(node)), + ); +} + export function getResourceVmid(resource: Resource): string { const vmid = resource.proxmox?.vmid; if (typeof vmid === 'number' && Number.isFinite(vmid)) {