From 2d62ae5f197d7a5b28fa54b5cdc49aa8f6ab4f0d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 19 May 2026 18:00:43 +0100 Subject: [PATCH] Make platform filters page-specific --- .../subsystems/frontend-primitives.md | 8 +++ .../subsystems/performance-and-scalability.md | 6 +++ .../internal/subsystems/storage-recovery.md | 9 +++- .../src/components/Storage/Storage.tsx | 6 +++ .../Storage/StoragePageControls.tsx | 10 ++-- .../Storage/__tests__/Storage.test.tsx | 32 ++++++++++++ .../components/Workloads/WorkloadsFilter.tsx | 46 ++++++++--------- .../components/Workloads/WorkloadsSurface.tsx | 4 ++ .../__tests__/WorkloadsFilter.test.tsx | 24 +++++++++ .../__tests__/workloadSelectors.test.ts | 49 +++++++++++++++++++ .../Workloads/useWorkloadRouteState.ts | 4 +- .../components/Workloads/useWorkloadsState.ts | 6 +++ .../components/Workloads/workloadSelectors.ts | 38 +++++++++++--- .../Workloads/workloadsFilterModel.ts | 11 +++++ .../src/features/docker/DockerPageSurface.tsx | 11 +++++ .../docker/__tests__/dockerPageModel.test.ts | 3 ++ .../src/features/docker/dockerPageModel.ts | 8 +++ .../kubernetes/KubernetesPageSurface.tsx | 12 +++++ .../KubernetesPageSurface.contract.test.tsx | 10 ++++ .../__tests__/sharedPlatformPage.test.ts | 28 +++++++++++ .../platformPage/sharedPlatformPage.tsx | 32 ++++++++++++ .../features/proxmox/ProxmoxPageSurface.tsx | 14 ++++++ .../features/truenas/TrueNASPageSurface.tsx | 16 ++++++ .../src/features/vmware/VmwarePageSurface.tsx | 16 ++++++ 24 files changed, 364 insertions(+), 39 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b685a24fa..e2e16ddb3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -238,6 +238,14 @@ Feature surfaces under `frontend-modern/src/features/` may own product-specific assessment semantics, but they must keep those semantics in their governed presentation helpers and render them inside the shared neutral Pulse surface language rather than introducing page-local verdict bands or nested cards. + +Shared filter/search primitives may provide the common shell, keyboard behavior, +history, and reset mechanics, but the owning page or table must supply +domain-specific visible copy, scope filters, status labels, and searchable +field coverage. Platform pages must not surface generic "rows/resources" +search affordances when the visible table is actually pods, VMs, datastores, +apps, mail gateways, storage pools, backup jobs, or another product-owned +object model. Patrol's primary assessment strip is descriptive only; it must not render a Patrol-authored recommended next step, suggested prompt chips, or a secondary action band inside the assessment shell. If the same assessment opens diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 83160c168..013d0a408 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -305,6 +305,12 @@ regression protection. Non-Docker Workloads surfaces keep the global `disk` default unless they declare their own scoped contract. 18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` (defaults, `countActiveWorkloadsFilters`, `hasActiveWorkloadsFilters`) rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`. Workloads filter presentation now composes the shared `FilterBar` (`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a per-page `FilterDef[]` catalog rather than the legacy `PageControls` structured control deck. High-frequency Type and Status filters stay in that catalog but render as inline compact segmented controls (`inline: true`), while longer or dynamic scope filters continue through the "+ Filter" menu and chip popovers. View options (grouped/list, charts, columns) sit in the shared `viewOptionsTrailing` slot. + WorkloadsFilter remains the shared interaction shell only; platform-owned + embedded Workloads surfaces must pass page-owned aria labels, search + placeholders, empty-search copy, status labels, and forced view-mode + context into route option derivation so Docker, Kubernetes, TrueNAS, + Proxmox, and VMware pages expose filters for the objects visible on that + page rather than generic mixed-workload labels. 19. Extend threshold-slider value-position math, title/label derivation, and drag scroll-lock runtime through `frontend-modern/src/components/Workloads/thresholdSliderModel.ts` and `frontend-modern/src/components/Workloads/useThresholdSliderState.ts` rather than rebuilding slider-local state and pointer lifecycle inside `frontend-modern/src/components/Workloads/ThresholdSlider.tsx` 20. Extend stacked disk-bar capacity math, segment/tooltip derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedDiskBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedDiskBarState.ts` rather than rebuilding disk-bar-local state, mode branching, and tooltip shaping inside `frontend-modern/src/components/Workloads/StackedDiskBar.tsx`. Compact multi-disk rows default to same-height per-disk lanes so each filesystem has its own visible usage bar without implying the whole host disk state is one max or aggregate percentage; explicit `mode="stacked"` remains the capacity-contribution stack for callers that intentionally need that presentation, while tooltips continue to carry the full per-disk breakdown. 21. Extend stacked memory-bar capacity math, balloon/swap derivation, and resize-observer runtime through `frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts` and `frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts` rather than rebuilding memory-bar-local state, tooltip shaping, and label-fit logic inside `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx` diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index fb36257d6..01054eedc 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -220,8 +220,13 @@ bypass the API fail-closed execution gate. as unlabeled inline primary controls from that catalog in the same compact second-row rail as sort, charts, and reset, while Node, Source, Role, and Group remain menu/chip filters because their option sets are scope- or - data-driven. Subtabs (Pools / Physical Disks) sit above the bar as - navigation, not filters. Platform-page storage and recovery section tabs + data-driven. Embedded platform storage surfaces must provide page-owned + filter aria labels, search placeholders, and empty-search copy for the + visible storage model (for example Proxmox storage, TrueNAS pools and + datasets, or vSphere datastores) while preserving the canonical + `FilterDef[]` route-state contract in `StoragePageControls`. Subtabs + (Pools / Physical Disks) sit above the bar as navigation, not filters. + Platform-page storage and recovery section tabs are still capability-gated navigation: Proxmox Storage, Backups, Ceph, Replication, and adjacent recovery sections may appear only when the platform page model has matching storage, backup, Ceph, diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 4592650cf..adf9b1097 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -26,6 +26,9 @@ type StorageProps = { // page already locks source scope through `forcedSourceFilter`. showFilterToolbar?: boolean; suppressSourceFilter?: boolean; + filterAriaLabel?: string; + filterSearchPlaceholder?: string; + filterSearchEmptyMessage?: string; }; const Storage: Component = (props) => { @@ -186,6 +189,9 @@ const Storage: Component = (props) => { setView={setView} search={search} setSearch={setSearch} + filterAriaLabel={props.filterAriaLabel} + searchPlaceholder={props.filterSearchPlaceholder} + searchEmptyMessage={props.filterSearchEmptyMessage} groupBy={groupBy} setGroupBy={setGroupBy} sortKey={sortKey} diff --git a/frontend-modern/src/components/Storage/StoragePageControls.tsx b/frontend-modern/src/components/Storage/StoragePageControls.tsx index a609db2da..263d07a1c 100644 --- a/frontend-modern/src/components/Storage/StoragePageControls.tsx +++ b/frontend-modern/src/components/Storage/StoragePageControls.tsx @@ -42,6 +42,9 @@ type StoragePageControlsProps = { setView: (value: StorageView) => void; search: () => string; setSearch: (value: string) => void; + filterAriaLabel?: string; + searchPlaceholder?: string; + searchEmptyMessage?: string; searchTrailing?: JSX.Element; groupBy: () => StorageGroupKey; setGroupBy: (value: StorageGroupKey) => void; @@ -266,14 +269,15 @@ export const StoragePageControls: Component = (props) { ); }); + it('passes platform-owned filter copy into embedded storage controls', () => { + mockLocationPath = '/vmware/storage'; + hookResources = [ + buildStorageResource('vsphere-datastore', 'vsan-prod', 'esxi-01', { + platformType: 'vmware-vsphere', + parentName: 'esxi-01', + storage: { + type: 'datastore', + topology: 'datastore', + nodes: ['esxi-01'], + }, + }), + ]; + + render(() => ( + + )); + + expect(screen.getByRole('group', { name: 'vSphere datastore filters' })).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Search vSphere datastores by name, host, or capacity group'), + ).toBeInTheDocument(); + }); + it('supports Proxmox platform table embedding without standalone page chrome', async () => { mockLocationPath = '/proxmox/storage'; hookResources = [ diff --git a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx index 040e79b3c..8c4089993 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx @@ -37,11 +37,10 @@ export const WorkloadsFilter: Component = (props) => { const workloadTypeOptions = (): FilterSelectOption[] => (isProxmoxScope() - ? WORKLOAD_TYPE_OPTIONS.filter((option) => - option.value === 'all' || option.value === 'vm' || option.value === 'container', - ).map((option) => - option.value === 'container' ? { ...option, label: 'LXCs' } : option, - ) + ? WORKLOAD_TYPE_OPTIONS.filter( + (option) => + option.value === 'all' || option.value === 'vm' || option.value === 'container', + ).map((option) => (option.value === 'container' ? { ...option, label: 'LXCs' } : option)) : WORKLOAD_TYPE_OPTIONS ).map((option) => ({ value: option.value, @@ -54,16 +53,11 @@ export const WorkloadsFilter: Component = (props) => { : option.value === 'pod' ? BoxesIcon : undefined, - tone: - option.value === 'vm' - ? 'info' - : option.value === 'container' - ? 'success' - : undefined, + tone: option.value === 'vm' ? 'info' : option.value === 'container' ? 'success' : undefined, })); const workloadStatusOptions = (): FilterSelectOption[] => - WORKLOAD_STATUS_FILTER_OPTIONS.map((option) => ({ + (props.statusOptions ?? WORKLOAD_STATUS_FILTER_OPTIONS).map((option) => ({ value: option.value, label: option.label, leading: @@ -95,6 +89,7 @@ export const WorkloadsFilter: Component = (props) => { hostFilterValue: props.hostFilter?.value, platformFilterValue: props.platformFilter?.value, namespaceFilterValue: props.namespaceFilter?.value, + containerRuntimeFilterValue: props.containerRuntimeFilter?.value, }), ); @@ -128,18 +123,16 @@ export const WorkloadsFilter: Component = (props) => { }); } - filters.push( - { - id: 'workloads-status', - label: 'Status', - group: 'status', - inline: true, - value: props.statusMode, - setValue: (value: string) => props.setStatusMode(value as WorkloadsStatusMode), - defaultValue: DEFAULT_WORKLOADS_STATUS_MODE, - options: workloadStatusOptions, - }, - ); + filters.push({ + id: 'workloads-status', + label: 'Status', + group: 'status', + inline: true, + value: props.statusMode, + setValue: (value: string) => props.setStatusMode(value as WorkloadsStatusMode), + defaultValue: DEFAULT_WORKLOADS_STATUS_MODE, + options: workloadStatusOptions, + }); const hostFilter = props.hostFilter; if (hostFilter) { @@ -199,13 +192,14 @@ export const WorkloadsFilter: Component = (props) => { return ( { expect(screen.getByTestId('search-input')).toBeInTheDocument(); }); + it('accepts page-owned search copy and status labels', () => { + render(() => ( + + )); + + expect(screen.getByPlaceholderText('Search pods by namespace or image')).toBeInTheDocument(); + expect( + within(inlineFilterGroup('Status')).getByRole('button', { name: 'Needs attention' }), + ).toBeInTheDocument(); + expect( + within(inlineFilterGroup('Status')).getByRole('button', { name: 'Not running' }), + ).toBeInTheDocument(); + }); + it('exposes Type and Status as one-click inline controls', () => { render(() => ); expect(inlineFilterGroup('Type')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts index c1fec3fee..ad8b87425 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts @@ -146,6 +146,55 @@ describe('workloadSelectors', () => { expect(combined.map((g) => g.name)).toEqual(['alpha-api']); }); + it('searches workload-native fields shown by platform pages', () => { + const guests = [ + makeGuest(1, { + name: 'api-pod', + type: 'pod', + workloadType: 'pod', + image: 'registry.local/api:v2', + namespace: 'payments', + contextLabel: 'prod-cluster', + node: 'worker-a', + status: 'running', + }), + makeGuest(2, { + name: 'worker-container', + type: 'app-container', + workloadType: 'app-container', + image: 'redis:7', + containerRuntime: 'podman', + contextLabel: 'edge-docker-host', + platformType: 'docker', + status: 'running', + }), + ]; + + expect( + filterWorkloads({ + guests, + viewMode: 'pod', + statusMode: 'all', + searchTerm: 'payments', + selectedNode: null, + selectedHostHint: null, + selectedKubernetesContext: null, + }).map((g) => g.name), + ).toEqual(['api-pod']); + + expect( + filterWorkloads({ + guests, + viewMode: 'app-container', + statusMode: 'all', + searchTerm: 'podman', + selectedNode: null, + selectedHostHint: null, + selectedKubernetesContext: null, + }).map((g) => g.name), + ).toEqual(['worker-container']); + }); + it('filters k8s by selected context key and filters non-k8s by host hint', () => { const guests = [ makeGuest(1, { diff --git a/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts b/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts index ca98a87b4..826a91cb1 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts @@ -13,6 +13,7 @@ import { useWorkloadUrlSync } from './useWorkloadUrlSync'; export interface WorkloadRouteStateOptions { allGuests: Accessor; allowEmbeddedScopeFilters?: boolean; + forcedViewMode?: ViewMode; showFilters: Accessor; setShowFilters: Setter; } @@ -40,6 +41,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) { serialize: (value) => value, }, ); + const filterViewMode = () => options.forcedViewMode ?? viewMode(); const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | 'pmg' | null) => { const selection = resolveWorkloadsWorkloadNodeSelection({ @@ -93,7 +95,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) { allGuests: options.allGuests, isWorkloadsRoute, allowEmbeddedScopeFilters: () => options.allowEmbeddedScopeFilters === true, - viewMode, + viewMode: filterViewMode, containerRuntime, selectedPlatform, selectedNode, diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index 28388c232..320fec59f 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -29,6 +29,7 @@ import { import { type WorkloadsGroupingMode, type WorkloadsMetricDisplayMode, + type WorkloadsStatusOption, type WorkloadsSortKey, } from './workloadsFilterModel'; import { type WorkloadTableMetricHistoryRange } from './workloadMetricHistoryModel'; @@ -65,6 +66,10 @@ export interface WorkloadsSurfaceProps { forcedViewMode?: ViewMode; forcedGroupingMode?: WorkloadsGroupingMode; defaultSortKey?: WorkloadsSortKey; + filterAriaLabel?: string; + filterSearchPlaceholder?: string; + filterSearchEmptyMessage?: string; + filterStatusOptions?: readonly WorkloadsStatusOption[]; // When the surface is mounted inside a platform-first page, the page owns // platform scope through `forcedPlatform`. Setting `showFilterToolbar` // keeps the operator-facing WorkloadsFilter visible alongside the table @@ -189,6 +194,7 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { } = useWorkloadRouteState({ allGuests, allowEmbeddedScopeFilters: props.allowEmbeddedScopeFilters, + forcedViewMode: props.forcedViewMode, showFilters, setShowFilters, }); diff --git a/frontend-modern/src/components/Workloads/workloadSelectors.ts b/frontend-modern/src/components/Workloads/workloadSelectors.ts index fa7518e34..be216d962 100644 --- a/frontend-modern/src/components/Workloads/workloadSelectors.ts +++ b/frontend-modern/src/components/Workloads/workloadSelectors.ts @@ -49,6 +49,36 @@ type SortDirection = 'asc' | 'desc'; type SortValue = string | number | boolean | null | undefined; +const workloadSearchCandidates = (guest: WorkloadGuest): Array => [ + guest.name, + guest.id, + guest.displayId, + guest.vmid, + guest.node, + guest.status, + guest.image, + guest.namespace, + guest.contextLabel, + guest.clusterName, + guest.instance, + guest.platformType, + guest.workloadType, + guest.containerRuntime, + guest.containerId, + guest.dockerHostId, + guest.kubernetesAgentId, +]; + +const matchesWorkloadTextSearch = (guest: WorkloadGuest, term: string): boolean => { + const needle = term.trim().toLowerCase(); + if (!needle) return true; + return workloadSearchCandidates(guest) + .filter( + (value): value is string | number => typeof value === 'string' || typeof value === 'number', + ) + .some((value) => String(value).toLowerCase().includes(needle)); +}; + export const filterWorkloads = ({ guests: allGuests, viewMode, @@ -155,13 +185,7 @@ export const filterWorkloads = ({ if (textSearches.length > 0) { guests = guests.filter((g) => - textSearches.some( - (term) => - g.name.toLowerCase().includes(term) || - g.vmid.toString().includes(term) || - g.node.toLowerCase().includes(term) || - g.status.toLowerCase().includes(term), - ), + textSearches.some((term) => matchesWorkloadTextSearch(g, term)), ); } } diff --git a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts index 0e9d988e9..8e11cbddb 100644 --- a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts +++ b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts @@ -9,6 +9,11 @@ export type WorkloadsGroupingMode = 'grouped' | 'flat'; export type WorkloadsMetricDisplayMode = 'bars' | 'sparklines'; export type WorkloadsSortKey = keyof WorkloadGuest | 'diskIo' | 'netIo'; +export interface WorkloadsStatusOption { + value: WorkloadsStatusMode; + label: string; +} + export interface WorkloadsFilterSelectOption { value: string; label: string; @@ -35,6 +40,10 @@ export interface WorkloadsFilterProps { setSortKey: (value: WorkloadsSortKey) => void; setSortDirection: (value: 'asc' | 'desc') => void; onBeforeAutoFocus?: () => boolean; + ariaLabel?: string; + searchPlaceholder?: string; + searchEmptyMessage?: string; + statusOptions?: readonly WorkloadsStatusOption[]; columnVisibility?: { availableColumns: ColumnDef[]; isColumnHidden: (id: string) => boolean; @@ -67,6 +76,7 @@ export interface CountActiveWorkloadsFiltersOptions { hostFilterValue?: string; platformFilterValue?: string; namespaceFilterValue?: string; + containerRuntimeFilterValue?: string; } export type HasActiveWorkloadsFiltersOptions = CountActiveWorkloadsFiltersOptions; @@ -89,6 +99,7 @@ export const countActiveWorkloadsFilters = ( if ((options.hostFilterValue ?? '') !== '') count++; if ((options.platformFilterValue ?? '') !== '') count++; if ((options.namespaceFilterValue ?? '') !== '') count++; + if ((options.containerRuntimeFilterValue ?? '') !== '') count++; return count; }; diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 3e43a1b91..d80587d4b 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -2,6 +2,7 @@ import ContainerIcon from 'lucide-solid/icons/container'; import { Show, createMemo } from 'solid-js'; import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel'; import { useWorkloadsState } from '@/components/Workloads/useWorkloadsState'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { @@ -27,6 +28,12 @@ const DOCKER_WORKLOAD_COLUMN_LABEL_OVERRIDES = { context: 'Host', disk: 'Writable layer', } as const; +const DOCKER_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [ + { value: 'all', label: 'All' }, + { value: 'running', label: 'Running' }, + { value: 'degraded', label: 'Attention' }, + { value: 'stopped', label: 'Stopped' }, +]; const dockerIcon = () => ; @@ -128,6 +135,10 @@ export function DockerPageSurface() { setSortKey={workloadsState.setSortKey} setSortDirection={workloadsState.setSortDirection} onBeforeAutoFocus={workloadsState.handleBeforeAutoFocus} + ariaLabel="Docker workload filters" + searchPlaceholder="Search containers by name, image, host, or runtime" + searchEmptyMessage="Recent Docker container searches appear here." + statusOptions={DOCKER_WORKLOAD_STATUS_OPTIONS} columnVisibility={workloadsState.workloadsFilterColumnVisibility()} containerRuntimeFilter={workloadsState.containerRuntimeFilterConfig()} hostFilter={workloadsState.hostFilterConfig()} diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index 4e6226661..2f12f388d 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -226,6 +226,9 @@ describe('dockerPageModel', () => { expect(filterDockerHosts(hosts, { searchTerm: 'docker 01' }).map((host) => host.id)).toEqual([ 'agent:docker-01', ]); + expect(filterDockerHosts(hosts, { searchTerm: 'podman' }).map((host) => host.id)).toEqual([ + 'agent:podman-01', + ]); expect( filterDockerHosts(hosts, { selectedHostScope: 'agent:podman-01' }).map((host) => host.id), ).toEqual(['agent:podman-01']); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 640901497..012dda8be 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -159,6 +159,14 @@ const resourceSearchCandidates = (resource: Resource): Array resource.parentName, resource.agent?.hostname, resource.docker?.hostname, + resource.docker?.runtime, + resource.docker?.runtimeVersion, + resource.docker?.dockerVersion, + resource.docker?.os, + resource.docker?.kernelVersion, + resource.docker?.architecture, + resource.docker?.swarm?.clusterName, + resource.docker?.swarm?.nodeRole, resource.identity?.hostname, resource.canonicalIdentity?.displayName, resource.canonicalIdentity?.hostname, diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index b34ed20e3..74993fdd7 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -1,6 +1,7 @@ import ShipWheelIcon from 'lucide-solid/icons/ship-wheel'; import { Show, createMemo } from 'solid-js'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, @@ -20,6 +21,12 @@ const KUBERNETES_RESOURCE_QUERY = 'type=k8s-cluster,k8s-node,pod,k8s-deployment, const KUBERNETES_PLATFORM_FILTER = 'kubernetes'; const KUBERNETES_WORKLOAD_FORCED_VIEW_MODE = 'pod'; const KUBERNETES_WORKLOAD_COLUMN_SCOPE = 'kubernetes-pods'; +const KUBERNETES_POD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [ + { value: 'all', label: 'All' }, + { value: 'running', label: 'Running' }, + { value: 'degraded', label: 'Needs attention' }, + { value: 'stopped', label: 'Not running' }, +]; const k8sIcon = () => ; @@ -94,8 +101,13 @@ export function KubernetesPageSurface() { tableOnly showFilterToolbar suppressPlatformFilter + allowEmbeddedScopeFilters forcedPlatform={KUBERNETES_PLATFORM_FILTER} forcedViewMode={KUBERNETES_WORKLOAD_FORCED_VIEW_MODE} + filterAriaLabel="Kubernetes pod filters" + filterSearchPlaceholder="Search pods by name, namespace, image, cluster, or node" + filterSearchEmptyMessage="Recent Kubernetes pod searches appear here." + filterStatusOptions={KUBERNETES_POD_STATUS_OPTIONS} columnVisibilityStorageScope={KUBERNETES_WORKLOAD_COLUMN_SCOPE} compactGroupHeaders /> diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx index 490d3ec74..16535c87c 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesPageSurface.contract.test.tsx @@ -11,6 +11,8 @@ const mockWorkloadsSurface = vi.fn((props: WorkloadsSurfaceProps) => ( data-forced-platform={props.forcedPlatform} data-forced-view-mode={props.forcedViewMode} data-column-scope={props.columnVisibilityStorageScope} + data-allow-scope-filters={String(props.allowEmbeddedScopeFilters)} + data-filter-placeholder={props.filterSearchPlaceholder} /> )); @@ -76,5 +78,13 @@ describe('KubernetesPageSurface contract', () => { 'data-column-scope', 'kubernetes-pods', ); + expect(screen.getByTestId('kubernetes-workloads-surface')).toHaveAttribute( + 'data-allow-scope-filters', + 'true', + ); + expect(screen.getByTestId('kubernetes-workloads-surface')).toHaveAttribute( + 'data-filter-placeholder', + 'Search pods by name, namespace, image, cluster, or node', + ); }); }); diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts index 57a06827d..98fa27c66 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts @@ -66,6 +66,34 @@ describe('filterPlatformResources', () => { ]); }); + it('searches platform-native metadata used by bespoke tables', () => { + const nativeRows: Resource[] = [ + makeResource({ + id: 'docker-host', + type: 'agent', + status: 'online', + docker: { runtimeVersion: '24.0.7', swarm: { nodeRole: 'manager' } }, + }), + makeResource({ + id: 'k8s-deploy', + type: 'k8s-deployment', + status: 'online', + kubernetes: { + clusterName: 'prod-cluster', + namespace: 'payments', + containerRuntimeVersion: 'containerd://1.7', + }, + }), + ]; + + expect(filterPlatformResources(nativeRows, 'manager', 'all').map((r) => r.id)).toEqual([ + 'docker-host', + ]); + expect(filterPlatformResources(nativeRows, 'payments', 'all').map((r) => r.id)).toEqual([ + 'k8s-deploy', + ]); + }); + it('combines search and status filters', () => { const filtered = filterPlatformResources(resources, 'host', 'degraded'); expect(filtered.map((r) => r.id).sort()).toEqual(['host-charlie', 'host-foxtrot'].sort()); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index f02cef1d1..4659f3580 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -146,6 +146,38 @@ const matchesPlatformSearch = (resource: Resource, search: string): boolean => { resource.displayName, resource.id, resource.parentName, + resource.platformId, + resource.platformType, + resource.agent?.hostname, + resource.identity?.hostname, + resource.canonicalIdentity?.displayName, + resource.canonicalIdentity?.hostname, + resource.canonicalIdentity?.primaryId, + ...(resource.canonicalIdentity?.aliases ?? []), + resource.docker?.hostname, + resource.docker?.runtime, + resource.docker?.runtimeVersion, + resource.docker?.dockerVersion, + resource.docker?.image, + resource.docker?.mode, + resource.docker?.swarm?.clusterName, + resource.docker?.swarm?.nodeRole, + resource.kubernetes?.clusterName, + resource.kubernetes?.clusterId, + resource.kubernetes?.context, + resource.kubernetes?.namespace, + resource.kubernetes?.podName, + resource.kubernetes?.nodeName, + resource.kubernetes?.version, + resource.kubernetes?.kubeletVersion, + resource.kubernetes?.containerRuntimeVersion, + resource.pmg?.hostname, + resource.pmg?.version, + resource.vmware?.connectionName, + resource.vmware?.vcenterHost, + resource.vmware?.runtimeHostName, + resource.vmware?.clusterName, + resource.vmware?.datastoreNames?.join(' '), ...(resource.tags ?? []), ] .filter((value): value is string => typeof value === 'string') diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 28029cd23..6a4f340b0 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -4,6 +4,7 @@ import StorageSurface from '@/components/Storage/Storage'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; import { DEFAULT_WORKLOADS_METRIC_DISPLAY_MODE, + type WorkloadsStatusOption, type WorkloadsMetricDisplayMode, } from '@/components/Workloads/workloadsFilterModel'; import { @@ -45,6 +46,12 @@ const PROXMOX_RESOURCE_QUERY = const PROXMOX_PLATFORM_FILTER = 'proxmox-pve'; const PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE = 'proxmox'; const VALID_TABS = new Set(PROXMOX_TAB_SPECS.map((tab) => tab.id)); +const PROXMOX_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [ + { value: 'all', label: 'All' }, + { value: 'running', label: 'Running' }, + { value: 'degraded', label: 'Attention' }, + { value: 'stopped', label: 'Stopped' }, +]; const proxmoxIcon = () => ; @@ -147,6 +154,10 @@ export function ProxmoxPageSurface() { allowEmbeddedScopeFilters forcedPlatform={PROXMOX_PLATFORM_FILTER} statusModeStorageScope={PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE} + filterAriaLabel="Proxmox workload filters" + filterSearchPlaceholder="Search VMs and LXCs by name, VMID, node, or status" + filterSearchEmptyMessage="Recent Proxmox workload searches appear here." + filterStatusOptions={PROXMOX_WORKLOAD_STATUS_OPTIONS} compactGroupHeaders groupNodeDrawerMode="disabled" metricDisplayMode={metricDisplayMode} @@ -164,6 +175,9 @@ export function ProxmoxPageSurface() { tableOnly showFilterToolbar forcedSourceFilter={PROXMOX_PLATFORM_FILTER} + filterAriaLabel="Proxmox storage filters" + filterSearchPlaceholder="Search Proxmox storage by pool, datastore, node, or device" + filterSearchEmptyMessage="Recent Proxmox storage searches appear here." /> diff --git a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx index f969ea28e..85ad73ef0 100644 --- a/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx +++ b/frontend-modern/src/features/truenas/TrueNASPageSurface.tsx @@ -3,6 +3,7 @@ import DatabaseIcon from 'lucide-solid/icons/database'; import { Show, createMemo } from 'solid-js'; import StorageSurface from '@/components/Storage/Storage'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, @@ -24,6 +25,12 @@ import { const TRUENAS_RESOURCE_QUERY = 'type=agent,app-container,storage,physical_disk'; const TRUENAS_PLATFORM_FILTER = 'truenas'; const VALID_TABS = new Set(TRUENAS_TAB_SPECS.map((tab) => tab.id)); +const TRUENAS_APP_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [ + { value: 'all', label: 'All' }, + { value: 'running', label: 'Running' }, + { value: 'degraded', label: 'Attention' }, + { value: 'stopped', label: 'Stopped' }, +]; const truenasIcon = () => ; @@ -98,7 +105,13 @@ export function TrueNASPageSurface() { tableOnly showFilterToolbar suppressPlatformFilter + allowEmbeddedScopeFilters forcedPlatform={TRUENAS_PLATFORM_FILTER} + forcedViewMode="app-container" + filterAriaLabel="TrueNAS app filters" + filterSearchPlaceholder="Search TrueNAS apps by name, image, namespace, or system" + filterSearchEmptyMessage="Recent TrueNAS app searches appear here." + filterStatusOptions={TRUENAS_APP_STATUS_OPTIONS} compactGroupHeaders /> @@ -110,6 +123,9 @@ export function TrueNASPageSurface() { tableOnly showFilterToolbar forcedSourceFilter={TRUENAS_PLATFORM_FILTER} + filterAriaLabel="TrueNAS storage filters" + filterSearchPlaceholder="Search TrueNAS pools, datasets, disks, or nodes" + filterSearchEmptyMessage="Recent TrueNAS storage searches appear here." /> diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx index 42b80ca83..7afa7c8fa 100644 --- a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -3,6 +3,7 @@ import CpuIcon from 'lucide-solid/icons/cpu'; import { Show, createMemo } from 'solid-js'; import StorageSurface from '@/components/Storage/Storage'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; +import type { WorkloadsStatusOption } from '@/components/Workloads/workloadsFilterModel'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, @@ -18,6 +19,12 @@ import { VMWARE_TAB_SPECS, buildVmwarePageModel, type VmwarePageTabId } from './ const VMWARE_RESOURCE_QUERY = 'type=agent,vm,storage'; const VMWARE_PLATFORM_FILTER = 'vmware-vsphere'; const VALID_TABS = new Set(VMWARE_TAB_SPECS.map((tab) => tab.id)); +const VMWARE_VM_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [ + { value: 'all', label: 'All' }, + { value: 'running', label: 'Powered on' }, + { value: 'degraded', label: 'Attention' }, + { value: 'stopped', label: 'Powered off' }, +]; const vmwareIcon = () => ; @@ -91,7 +98,13 @@ export function VmwarePageSurface() { tableOnly showFilterToolbar suppressPlatformFilter + allowEmbeddedScopeFilters forcedPlatform={VMWARE_PLATFORM_FILTER} + forcedViewMode="vm" + filterAriaLabel="vSphere VM filters" + filterSearchPlaceholder="Search vSphere VMs by name, VM ID, host, or status" + filterSearchEmptyMessage="Recent vSphere VM searches appear here." + filterStatusOptions={VMWARE_VM_STATUS_OPTIONS} compactGroupHeaders /> @@ -103,6 +116,9 @@ export function VmwarePageSurface() { showFilterToolbar forcedSourceFilter={VMWARE_PLATFORM_FILTER} forcedView="pools" + filterAriaLabel="vSphere datastore filters" + filterSearchPlaceholder="Search vSphere datastores by name, host, or capacity group" + filterSearchEmptyMessage="Recent vSphere datastore searches appear here." />