Keep a guest's host node visible when searching the Proxmox overview

The Proxmox overview's shared search box is a VM/LXC filter, but filteredNodes
applied the same term to the nodes table independently. Searching a guest name
(e.g. 'debian-go') matched no node name, so the nodes table collapsed to the
'No Proxmox VE nodes' empty state — which reads as 'you have no Proxmox
infrastructure' even though the matching guest was listed right below it on its
host node.

Extract filterProxmoxNodesForSearch: keep a node when it matches the term
directly OR when it hosts a guest that matches the term. A guest search now
keeps that guest's host node visible for context; a node-name search still
narrows to the matching node; an empty term still shows every node. Regression
tests cover all three cases; verified live (searching 'debian-go' now shows the
minipc host node instead of the empty state).
This commit is contained in:
rcourtman
2026-06-09 16:19:54 +01:00
parent a4602fe731
commit 7234fbe71f
3 changed files with 74 additions and 6 deletions
@@ -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 (
<div class="space-y-4">
@@ -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({
@@ -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)) {