Filter upstream tables on platform overviews

The workload-filter search at the top of each platform overview page now
also narrows the Hosts / Nodes / Clusters tables above the workloads
table, mirroring how Docker filters its hosts table from the same input.
Typing "pve1" on the Proxmox overview now reduces the Nodes table to
matching rows; typing a cluster or namespace fragment on Kubernetes
narrows clusters/nodes/deployments. Status / view / runtime filters
remain scoped to the workloads table (they don't apply to nodes).

Adds a shared `resourceMatchesSearch` helper in src/utils that walks the
common Resource identity fields (name, displayName, hostnames, cluster
and namespace names, tags, canonical aliases).
This commit is contained in:
rcourtman
2026-05-19 22:18:29 +01:00
parent cd85d581a3
commit 8ae422c243
5 changed files with 77 additions and 6 deletions
@@ -5,6 +5,7 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import { resourceMatchesSearch } from '@/utils/resourceSearchMatch';
import {
PlatformErrorState,
PlatformSectionTabs,
@@ -116,6 +117,21 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
workloadsState.surfaceInitialDataReceived() &&
workloadsState.allGuests().length > 0,
);
const searchTerm = createMemo(() => workloadsState.search().trim());
const filteredClusters = createMemo(() => {
if (!searchTerm()) return props.model().clusters;
return props.model().clusters.filter((cluster) => resourceMatchesSearch(cluster, searchTerm()));
});
const filteredNodes = createMemo(() => {
if (!searchTerm()) return props.model().nodes;
return props.model().nodes.filter((node) => resourceMatchesSearch(node, searchTerm()));
});
const filteredDeployments = createMemo(() => {
if (!searchTerm()) return props.model().deployments;
return props.model().deployments.filter((deployment) =>
resourceMatchesSearch(deployment, searchTerm()),
);
});
return (
<div class="space-y-4">
@@ -159,7 +175,7 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
</div>
</Show>
<KubernetesClustersTable
clusters={props.model().clusters}
clusters={filteredClusters()}
scope={props.model().resources}
emptyIcon={k8sIcon()}
emptyTitle="No clusters reported"
@@ -167,7 +183,7 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
showToolbar={false}
/>
<KubernetesNodesTable
resources={props.model().nodes}
resources={filteredNodes()}
emptyIcon={k8sIcon()}
emptyTitle="No nodes reported"
emptyDescription="Kubernetes nodes appear here as soon as the agent enumerates them."
@@ -187,7 +203,7 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
/>
<Show when={props.model().deployments.length > 0}>
<KubernetesDeploymentsTable
resources={props.model().deployments}
resources={filteredDeployments()}
emptyIcon={k8sIcon()}
emptyTitle="No deployments reported"
emptyDescription="Deployments appear here once the cluster reports them."
@@ -17,6 +17,7 @@ import {
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { resourceMatchesSearch } from '@/utils/resourceSearchMatch';
import {
PlatformErrorState,
PlatformSectionTabs,
@@ -220,6 +221,11 @@ 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));
});
return (
<div class="space-y-4">
@@ -262,7 +268,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
</div>
</Show>
<ProxmoxNodesTable
nodes={props.model().pveNodes}
nodes={filteredNodes()}
guests={props.model().guests}
metricDisplayMode={props.metricDisplayMode}
metricHistoryRange={props.metricHistoryRange}
@@ -7,6 +7,7 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import { resourceMatchesSearch } from '@/utils/resourceSearchMatch';
import {
PlatformErrorState,
PlatformSectionTabs,
@@ -135,6 +136,11 @@ function TrueNASOverview(props: TrueNASOverviewProps) {
workloadsState.surfaceInitialDataReceived() &&
workloadsState.allGuests().length > 0,
);
const filteredSystems = createMemo(() => {
const term = workloadsState.search().trim();
if (!term) return props.model().systems;
return props.model().systems.filter((system) => resourceMatchesSearch(system, term));
});
return (
<div class="space-y-4">
@@ -178,7 +184,7 @@ function TrueNASOverview(props: TrueNASOverviewProps) {
</div>
</Show>
<TrueNASSystemsTable
systems={props.model().systems}
systems={filteredSystems()}
scope={props.model().resources}
emptyIcon={truenasIcon()}
emptyTitle="No TrueNAS systems"
@@ -7,6 +7,7 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState';
import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import { resourceMatchesSearch } from '@/utils/resourceSearchMatch';
import {
PlatformErrorState,
PlatformSectionTabs,
@@ -133,6 +134,11 @@ function VmwareOverview(props: VmwareOverviewProps) {
workloadsState.surfaceInitialDataReceived() &&
workloadsState.allGuests().length > 0,
);
const filteredHosts = createMemo(() => {
const term = workloadsState.search().trim();
if (!term) return props.model().hosts;
return props.model().hosts.filter((host) => resourceMatchesSearch(host, term));
});
return (
<div class="space-y-4">
@@ -176,7 +182,7 @@ function VmwareOverview(props: VmwareOverviewProps) {
</div>
</Show>
<VsphereHostsTable
hosts={props.model().hosts}
hosts={filteredHosts()}
scope={props.model().resources}
emptyIcon={vmwareIcon()}
emptyTitle="No vSphere hosts"
@@ -0,0 +1,37 @@
import type { Resource } from '@/types/resource';
const collectSearchCandidates = (resource: Resource): string[] => {
const raw: Array<string | undefined> = [
resource.id,
resource.name,
resource.displayName,
resource.parentName,
resource.agent?.hostname,
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.primaryId,
resource.docker?.hostname,
resource.kubernetes?.clusterName,
resource.kubernetes?.nodeName,
resource.kubernetes?.namespace,
resource.vmware?.runtimeHostName,
resource.vmware?.clusterName,
resource.vmware?.datacenterName,
resource.proxmox?.node,
resource.proxmox?.nodeName,
resource.proxmox?.clusterName,
...(resource.canonicalIdentity?.aliases ?? []),
...(resource.tags ?? []),
];
return raw
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0);
};
export function resourceMatchesSearch(resource: Resource, term: string | null | undefined): boolean {
const needle = (term ?? '').trim().toLowerCase();
if (!needle) return true;
return collectSearchCandidates(resource).some((value) => value.includes(needle));
}