Add canonical infrastructure search completion

This commit is contained in:
rcourtman
2026-08-20 17:22:37 +01:00
parent 23304bc017
commit fd35c54773
81 changed files with 1453 additions and 198 deletions
@@ -212,10 +212,11 @@ puts the same machine on two surfaces that do not share an identity.
129. `frontend-modern/scripts/shared-template-audit.mjs`
130. `frontend-modern/scripts/shared-template-registry.json`
131. `frontend-modern/src/features/platformPage/sharedPlatformPage.tsx`
131a. `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
131b. `frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx`
131c. `frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx`
131d. `frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts`
131a. `frontend-modern/src/features/platformPage/platformSearchSuggestions.ts`
131b. `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
131c. `frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx`
131d. `frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx`
131e. `frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts`
132. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
133. `frontend-modern/src/utils/platformSupportManifest.ts`
134. `frontend-modern/src/utils/sourcePlatformOptions.ts`
@@ -4185,6 +4186,15 @@ and `frontend-modern/src/components/shared/searchInputModel.ts` owns the shared
search-input contract plus shortcut-hint and trailing-control policy. Future
search-input work should extend those owners instead of pushing type-to-search
or enhancement wiring back into the shared shell.
Infrastructure-aware completion follows that owner split. Product surfaces
provide safe canonical identity projections through `SearchInput` suggestions;
`useSearchInputEnhancements.ts` ranks those identities and exposes only the
single dimmed inline suffix after the current query. It must not introduce a
second results dropdown. When several identities match, the suffix stops at
their unambiguous common prefix instead of selecting the first object. Tab or
Right Arrow accepts that inline text. Enter commits either an exact identity or
a shorter query that still resolves to known suggestions; unmatched prose
remains ordinary free-text search.
The shared page-controls bar now follows that same owner split.
`frontend-modern/src/components/shared/PageControls.tsx` stays the render shell
for canonical page-level control composition, while
@@ -4214,6 +4224,16 @@ inherit the same ownership through `PlatformTableToolbar`. Recovery is event-fir
and does not use equal workspace subtabs for protected rollups versus event
history; Storage subtabs (Pools / Physical Disks) sit above the bar as
navigation, not filters.
`FilterBar` owns committed infrastructure search terms as removable pills,
separate from its consumer-owned structured `FilterDef` chips. An exact
infrastructure completion or a recognized abbreviated query clears the draft
field, adds one search-term pill, and serializes committed terms as
comma-separated inclusive alternatives so operators can select several
objects without turning arbitrary phrases into chips. Removing a search-term
pill must immediately update the consumer search state. Platform consumers
project only canonical object identity, type, scope, status, and safe aliases through
`frontend-modern/src/features/platformPage/platformSearchSuggestions.ts`;
arbitrary resource metadata and secrets are not autocomplete candidates.
`PlatformTableToolbar` must always expose contextual Clear filters for a
non-empty search, including simple table-local search/status state that does
not need a feature-owned reset. Route-owned or multi-facet platform surfaces
@@ -84,6 +84,7 @@ start a goroutine, timer, or notification lifecycle per target.
50. `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`
51. `frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts`
52. `frontend-modern/src/components/Workloads/workloadSelectors.ts`
52a. `frontend-modern/src/components/Workloads/workloadSearchSuggestions.ts`
53. `frontend-modern/src/components/Workloads/workloadTopology.ts`
54. `frontend-modern/src/components/Workloads/workloadSelectionModel.ts`
55. `frontend-modern/src/components/Workloads/workloadRouteModel.ts`
@@ -719,6 +720,19 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
reads the unfiltered guest set so narrowing the table by search or status
never removes the column.
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. The Add filter control inherits FilterBar's compact accessible-only label by default instead of paying for a page-local labelled-field shell. Durable presentation controls pass only their panel content through `FilterBar.viewOptions`; the shared FilterBar owns the single View trigger and popover. Contextual actions use `leadingControls`, while frequently changed analytical orientation such as the active trend range uses `trailingControls`.
Workload autocomplete must project from the same unfiltered guest inventory
and the same search-candidate helper used by the workload predicate. The
projection includes workload identity plus deduplicated node, Docker host,
Kubernetes context, cluster, and namespace scopes so Proxmox, vSphere,
Docker, and Kubernetes embedded workload surfaces all inherit the same
inline completion and multi-term pill behavior. Committed comma-separated
workload terms, including recognized abbreviated queries, retain the
existing inclusive OR semantics; arbitrary unmatched draft text remains the
existing workload free-text query.
On the Proxmox overview, those workload terms filter only the workload
table. The node summary retains the real already-loaded node inventory so a
workload query can never produce or appear to produce the estate-empty "No
Proxmox VE nodes" state.
Desktop filter/action wrap alignment must remain a pure shared flex-layout
contract: the parent distributes the two rails while they fit and a wrapped
action rail starts at the leading edge. It must not add resize observers,
@@ -4731,6 +4731,7 @@
"frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx",
"frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"frontend-modern/src/features/platformPage/platformSearchSuggestions.ts",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/hooks/createNonSuspendingQuery.ts",
"frontend-modern/src/i18n/index.ts",
@@ -5245,6 +5246,7 @@
"frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx",
"frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"frontend-modern/src/features/platformPage/platformSearchSuggestions.ts",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx"
],
"allow_same_subsystem_tests": false,
@@ -5253,7 +5255,8 @@
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx",
"frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts",
"frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts"
"frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts",
"frontend-modern/src/features/platformPage/platformSearchSuggestions.test.ts"
]
},
{
@@ -6382,6 +6385,7 @@
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts",
"frontend-modern/src/components/Workloads/workloadRouteStateModel.ts",
"frontend-modern/src/components/Workloads/workloadSearchSuggestions.ts",
"frontend-modern/src/components/Workloads/workloadSelectionModel.ts",
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
@@ -6545,6 +6549,7 @@
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
"frontend-modern/src/components/Workloads/workloadRouteModel.ts",
"frontend-modern/src/components/Workloads/workloadRouteStateModel.ts",
"frontend-modern/src/components/Workloads/workloadSearchSuggestions.ts",
"frontend-modern/src/components/Workloads/workloadSelectionModel.ts",
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
@@ -6586,6 +6591,7 @@
"frontend-modern/src/components/Workloads/__tests__/workloadMetricHistoryModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadSearchSuggestions.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadSelectionModel.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts",
"frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx",
@@ -413,8 +413,11 @@ the shared bounded table-preview disclosure so workload controls remain in the
initial reading flow, but it must retain canonical node ordering and reveal the
same already-loaded collection rather than changing backup or recovery scope.
The workload filter belongs immediately before the workload table it controls,
after the node preview; it must not appear to filter the node table. Those
counts and layout controls must not imply
after the node preview; it must not filter the node table. The nodes table must
always receive the real already-loaded PVE node collection, so committed or
draft workload terms can never manufacture the estate-empty `No Proxmox VE
nodes` state. That empty state is reserved for genuinely absent node inventory.
Those counts and layout controls must not imply
protection, verification, or restore readiness beyond the evidence held by the
workflow-owned Storage, Backups, Ceph, and Mail surfaces.
Proxmox backup inventory loading and load-failure chrome is likewise a
@@ -382,6 +382,16 @@ touch- and keyboard-operable inline detail disclosure.
Future platform tables must keep that split: row data and platform semantics
stay in the unified-resource consumer, and the repeated table shell stays in the
shared frontend primitive.
Platform search completion follows the same ownership split. Unified-resource
consumers must project canonical resource identity, display name, type, scope,
status, and safe aliases through
`frontend-modern/src/features/platformPage/platformSearchSuggestions.ts` and
pass that projection to the shared platform toolbar. They must not expose raw
`platformData`, secret payloads, or arbitrary metadata as completion
candidates. The frontend primitive owns ranking, common-prefix completion,
recognized abbreviated terms, removable pills, and inclusive multi-term query
serialization; each platform consumer remains responsible for applying that
query to the same canonical resource collection its table renders.
Large platform inventories may bound their initial row projection through the
shared platform-table preview primitive so the next canonical surface remains
in the initial reading flow. Proxmox node consumers retain the page model's
+157 -40
View File
@@ -1,38 +1,148 @@
{
"version": 1,
"base_sha": "a64dd2f0052aa498e7e771466fc61a3055c77721",
"verified_at": "2026-08-20T13:26:51Z",
"base_sha": "23304bc0173919390e7953f9de671306064af78e",
"verified_at": "2026-08-20T16:13:18Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"frontend-modern/src/components/Settings/GuestDockerDiscoverySettingsCard.tsx",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx",
"frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx",
"frontend-modern/src/components/Settings/useSystemSettingsState.ts",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/types/config.ts",
"frontend-modern/src/utils/systemSettingsPresentation.ts"
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
"frontend-modern/src/components/Workloads/workloadSearchSuggestions.ts",
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx",
"frontend-modern/src/components/shared/FilterBar/filterCatalog.ts",
"frontend-modern/src/components/shared/SearchField.tsx",
"frontend-modern/src/components/shared/SearchInput.tsx",
"frontend-modern/src/components/shared/searchFieldModel.ts",
"frontend-modern/src/components/shared/searchInputEnhancementsModel.ts",
"frontend-modern/src/components/shared/searchInputModel.ts",
"frontend-modern/src/components/shared/useSearchFieldState.ts",
"frontend-modern/src/components/shared/useSearchInputEnhancements.ts",
"frontend-modern/src/components/shared/useSearchInputState.ts",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx",
"frontend-modern/src/features/docker/DockerPageSurface.tsx",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx",
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx",
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx",
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx",
"frontend-modern/src/features/platformPage/platformSearchSuggestions.ts",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx",
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx",
"frontend-modern/src/utils/searchQuery.ts"
],
"content_sha256": {
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "28dcc409ee274cb87100bad34c5923141f2991428bff5530cd3f40d005cb016b",
"frontend-modern/src/components/Settings/GuestDockerDiscoverySettingsCard.tsx": "12dcae6e8e515e3d0e60a5f93e3eb43e324643c9e8c7e36733b0fdcd0c5710dc",
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "dde3334841438625575add15d5fa8becf46af2ad4db91d9aaff2a6e944024d1f",
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "155682d67bb2a9e482160d98d21b69bfb6600adbab1a5a8bffb1186a0c343467",
"frontend-modern/src/components/Settings/useSettingsSystemPanels.tsx": "aede4d05bd687dd28606b0c621d2c2a13ab9ac0e2782c688b3c016ee766ea320",
"frontend-modern/src/components/Settings/useSystemSettingsState.ts": "216e99c171fed89fb8dd1b2df87fe54b3e6f28aaf9fdd2335d22ec49e87aa906",
"frontend-modern/src/i18n/messages.de.ts": "9313d26271469cdd8f53d89ddecfd7223b08766b47333a7be79d080504a0c424",
"frontend-modern/src/i18n/messages.es.ts": "3306c37ad2155c46e4f453ea80d6633131760733dd345484f5d3df238da72aa5",
"frontend-modern/src/i18n/messages.ts": "5001d13ef5fce44bdadf54343ae260e44e4e07cf1d02b8bb2e2a0da38dfe96b3",
"frontend-modern/src/types/config.ts": "275188a0f2c35fe521a3ae1bc67ca222966bc7460f1bcfd396c7fb236b90c81c",
"frontend-modern/src/utils/systemSettingsPresentation.ts": "e24b3b467cacd963723bd83c00c55b511a41928d66930b7c662226cd2939e436"
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "b94ca6ca1c6f713cba7b9e7363d3e4b637c46bf26de6176e6497557f7e3e6075",
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "32d512c214f965ebf5b910b87f2e34658c3d27533c9ed544259f731735ab0420",
"frontend-modern/src/components/Workloads/workloadSearchSuggestions.ts": "68cb9c975d9698262fd7259514f738747b2e83f3274dd37df402c96a53f48642",
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "ca011fd00dccffb6cc5abdbd16bbeeab6c3880f4793273445a869c3639ca64d5",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "35c02e57b1f64c1c2b2e553242db00fa579e780a0a0407c1d38b13217f7e641c",
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "ff8b016b41e7d044bb0d587d1fb7a83bdcd0ffd48cd865ba2fe8f68650a6b901",
"frontend-modern/src/components/shared/FilterBar/filterCatalog.ts": "ee1d4e7bc8a92d9314041a403385c548dfc3651c970d2be99c05bfe2fe8b92ad",
"frontend-modern/src/components/shared/SearchField.tsx": "7729c081135ada657cf3321c780c30ab1bac6ce485a74f061fa4f47f366eac85",
"frontend-modern/src/components/shared/SearchInput.tsx": "815e420fba5d57a9b384a4f24e0da8717b42717a572871033c02eb7896826f41",
"frontend-modern/src/components/shared/searchFieldModel.ts": "9fc0197e2fa357eb419935326f8f279b11ce07dc9af3d264ffdf6a5063116cc6",
"frontend-modern/src/components/shared/searchInputEnhancementsModel.ts": "0da3597962d0267e7ca8f388029796390f61b831352148dd3ccbc8107a70531d",
"frontend-modern/src/components/shared/searchInputModel.ts": "dd01c4f223003c9e339bd10266fdd6ad3af5d6df61d491b987a6a004b7878115",
"frontend-modern/src/components/shared/useSearchFieldState.ts": "1dd0190af3c6387044315414182e5133feeb709a289f65fa4e074691a655c8ff",
"frontend-modern/src/components/shared/useSearchInputEnhancements.ts": "c4229e312439676cde3eaac1db305191f1cfa0dc0822ca35792222777ece2817",
"frontend-modern/src/components/shared/useSearchInputState.ts": "ad2221df597f07d418d082164d11a3654261cede7c388d2a8bd7e0304075c4f6",
"frontend-modern/src/features/docker/DockerAlertsTable.tsx": "5fde1ec1acac4011a5bfa4a5467558104b536b752bc49dfc24c691a531cc5c59",
"frontend-modern/src/features/docker/DockerConfigsTable.tsx": "05b1c2ad08ee3ae7b18881a932923fe7a66c299020af1d4b323fe733d2240d2e",
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "51593751c5626b671af125b4a9c0e108f66e99bcae3c2eac6d329d592c839b50",
"frontend-modern/src/features/docker/DockerHostsTable.tsx": "ff52844b66e99012e53d83308bf6fd02cf15ee1550cd136a20c2abbd262d72dd",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "cd091441e2217fd3fa6646bb7e7509dcb708214096c9ab32fb119c09c7a1636c",
"frontend-modern/src/features/docker/DockerNetworksTable.tsx": "1a9ababc04f5d1fdc3a58ec7744894bba284345a7c6ab61da3b4d7689dbdcbbc",
"frontend-modern/src/features/docker/DockerPageSurface.tsx": "01a95750034ef0b6c42deda8de7921a68b54bafcd3988099c5976b4e66ce78fd",
"frontend-modern/src/features/docker/DockerSecretsTable.tsx": "3ef4d3899e6ab7d4e02411aba2100839f6bacd90fb43efdd9084799db0002409",
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "6d727875002d36e9747a43c835c13a89319e385239f686274e58595ab399bc62",
"frontend-modern/src/features/docker/DockerStorageUsageTable.tsx": "9e74a2b5709cc3a20af2242e24c3c74c68ceb341abf38d7104be04e4c5e734b7",
"frontend-modern/src/features/docker/DockerSwarmNodesTable.tsx": "597b3a676b235d1d5354db64bbe1697ea5ac686c72a2ec7d6529e2e5dc053f0c",
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "91ace55b7b2b0dabb83fecac2d65b9423242a9d2d9dec7c0121462a890a986b5",
"frontend-modern/src/features/docker/DockerVolumesTable.tsx": "1edfa81bbe2d183bc2c0e8088fa484a648c2299b390fba99767a8d469a9ea30a",
"frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "d6d6f90dcd4a8e2750249bcc58d4d8b20a0ec90479e4a015a7e2e57e0e118f9b",
"frontend-modern/src/features/kubernetes/KubernetesAutoscalingTable.tsx": "d96477efd6b3d42ccc18b675ca40c73de32261b0c1a08ac21475fb4b9425e810",
"frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx": "bbfc48536109d2758cb6ddd774c34a4c97182f1b1ec78963db192ce53cd23e46",
"frontend-modern/src/features/kubernetes/KubernetesConfigTable.tsx": "302c2d1d472dbd5525c900fec3915b73e8dc03a1172a5bdacad23d4d169b0b6d",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "a582c21961c42a3b06ce783e62beac08acb6057a7abdc45d1a7a8de1e9da9813",
"frontend-modern/src/features/kubernetes/KubernetesDeploymentsTable.tsx": "25d03d803ccd3b22a183992c17dbcb48899e474f8d7416fb51f40d462f149491",
"frontend-modern/src/features/kubernetes/KubernetesEventsTable.tsx": "26ea9234a477e1e496482c25b6d71c78cf88d4f542267300b1940ddfb0919578",
"frontend-modern/src/features/kubernetes/KubernetesNetworkingTable.tsx": "525288625d6c0ca2a17cf19b1768fa96d9dc7488c9c3bfc879216d3e7005477d",
"frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx": "1bcd857e3d7f178f3385f19ac6b10fb0f99c8a016c20b855c13beee4bbb9ce56",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "9c9866f7fab9276e671cc9b5eaec6b559ba51fa8a76194558f9c6f1ca6779ac3",
"frontend-modern/src/features/kubernetes/KubernetesPodsTable.tsx": "bd7e43a79321c78b792eac4577d6460f306fdfef883073840c4f18bcec99d880",
"frontend-modern/src/features/kubernetes/KubernetesPolicyTable.tsx": "8cd19ccb1cbc0aa26c001fc4337be137e6b82dfe28a49e12b8832cf4f6fded3c",
"frontend-modern/src/features/kubernetes/KubernetesServicesTable.tsx": "055a440d609bf469c7b5d45bf12c419f133393eb79598902c4059f86bd9979ee",
"frontend-modern/src/features/kubernetes/KubernetesStorageTable.tsx": "1b3de34f73440adcdcfa4fb78fd6b7bb6987b0409d96ddbe18ae23c12d863a15",
"frontend-modern/src/features/platformPage/platformSearchSuggestions.ts": "f9b29fa75e0bf001b43c3da6163209cdb61f1a6603c68b1a3d67dd5c747d2ea3",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "9ee68caab9eaf7204b4317ecaba505450e237b90c71d2f77c9c181164277c046",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "ef78efa893e62a7c9470bea2fae96f41d8204d1514105d5c60abaa45b37625cb",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "9843ecae3662aac07c2e2e2004cec333b66c2b4eb53f251b8c04bd9e712d405a",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "0d549da64da0d7887a6b118df9b863110fbd078a36960c16868f427c127bc51a",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "1e0706c4c672ac1e8353fa0b8d052a6a8ccd1285d7b7583ffd6472ff99a8d344",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "be7f59a030f38a935c79a7f1039776bc4d37023fd0ee800ed0a50f161a7b7cde",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "fa095fc470308986d56171874d027b9fbdffec9cdf373197b714c510f7b13da5",
"frontend-modern/src/features/standalone/AvailabilityChecksTable.tsx": "d3bde05cd00db0fa38f14198804ddbdf811d0dbc3d3e8d5dc080381f9b0f0838",
"frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "b92b8b1ab41b714c17dacf2f3349f20dc6bac374f72b9d1c342a5896ddf03c40",
"frontend-modern/src/features/truenas/TrueNASAppsTable.tsx": "d9f93ad4786c87b9a6fead883c44a256f268982db1459b65f00ee6473cf131ab",
"frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx": "c8d98f26b2fdd057e5c99248d72b077effb2f0e9ec623bf337cd7f12a370297f",
"frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx": "b1a43478f05d35ee4cf34c36d0107b6b3c9de7e4f04ef840c436558bd680ba85",
"frontend-modern/src/features/truenas/TrueNASServicesTable.tsx": "cef42e9d146bd3bb3fe7fbcaf4f59db6ec7ff6da01832bd6a677c080610e27d4",
"frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "fcd0b97fda7083b2d09276781b246a226fe3290875916923fa000503003408b9",
"frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx": "af5d52d7468768d81a3f85cf19d4fe5d5b537ab1e0fa5703d6a2543c7cfd4669",
"frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx": "88f0bc8d7e7c3c47932282e68fbf477b12fd060ebff6b5a8ed7d461b5928c1bb",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "5b82730dd147401f9d44fef0125210e863a2f895ac238d1d45d94dc38fae722a",
"frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "570c73ff43fa1478ddabc3708c38379ce526eb0476c4025fd92d152d626c6094",
"frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "888d0dd01d5b10dcea6a061949b33e80e29e89a5554e2fdda4fc14925b3b0584",
"frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx": "d1dc4a1ab8888cf96d7d02d48c478c63c948fe4d618305191859821df333b092",
"frontend-modern/src/features/vmware/VsphereHostsTable.tsx": "01d51d3a0b24f42fd1439f9531a932f7c58e2e9603e38260197ac64931ec2f4e",
"frontend-modern/src/features/vmware/VsphereNetworksTable.tsx": "0cdf8b6965d42d13064773d9ffcf71edae78403ffe9e91e2418a5e390ee45339",
"frontend-modern/src/utils/searchQuery.ts": "37a6ec34da3f0ca2812b7c1787dcb4dcc5ebba6216300e79cddaa88d285956f0"
},
"routes": [
"/settings/system-general",
"/settings/infrastructure?add=docker",
"/settings/infrastructure?add=pve"
"/proxmox/overview",
"/docker/overview",
"/kubernetes/overview"
],
"viewports": [
{
@@ -40,23 +150,30 @@
"height": 720
},
{
"width": 375,
"height": 812
"width": 390,
"height": 844
}
],
"states": [
"General panel: Docker in Proxmox LXCs card rendered below the Docker/Podman updates card with toggle ON reflecting the persisted server value",
"General panel: toggle OFF state after disabling (aria-pressed=false), backend value false, monitor checker and collector torn down per server log",
"General panel: toggle restored ON, backend value true persisted to system.json and surviving a server restart",
"Infrastructure installer (add=docker): Docker-inside-Proxmox-LXCs callout points at the Settings toggle first with the env variable as the locking override",
"PVE node setup guide (Host Telemetry Agent): opt-in callout names the toggle, the Settings path, and both environment variables",
"Mobile viewport: card renders with no horizontal overflow, env hint wraps with break-all"
"Proxmox overview with no search: three real nodes and the complete workload inventory",
"Proxmox overview with wireguard-edge-01 and reporting-api-01 committed: two removable pills and two matching workloads while all three real nodes remained visible",
"Docker overview with vaultwarden committed: one removable pill and the matching container",
"Kubernetes overview with chec committed: abbreviated pill retained every matching checkout object",
"Kubernetes overview with checkout- committed: both checkout API and checkout web pods remained visible",
"Kubernetes overview with checkout-api-6c746d5bcf-c7z2p committed: only the exact pod remained",
"Kubernetes overview with unmatched phrase: free text remained in the field and no pill was created",
"Narrow Proxmox and Kubernetes layouts at 390x844: search remained usable, filter count stayed visible, tables stayed contained, and the independent node inventory or exact matching pod remained visible"
],
"interactions": [
"Clicked the Discover Docker in LXC guests toggle off: settings update POST returned 200, GET showed false, server logged \"Docker detection disabled\"/\"Docker inventory collection disabled\" and \"opt-in changed via settings\" enabled=false",
"Clicked the toggle back on: GET showed true, server logged \"Docker inventory collection enabled\" and the collector reconfigure line",
"Restarted the backend with the opt-in persisted true: GET after boot returned true (persistence across restart)",
"Selected the Host Telemetry Agent source in the PVE setup guide to render the Docker-in-LXC callout",
"Scrolled the General panel to the new card at desktop and mobile widths"
"Typed wire on Proxmox and verified one dim inline guard-edge-01 suffix with no suggestion listbox",
"Pressed Tab then Enter to commit wireguard-edge-01 as a removable search pill",
"Typed report, pressed Right Arrow then Enter, and committed reporting-api-01 as a second inclusive pill",
"Verified the Proxmox workload terms filtered only the workload table: West Production A, B, and C all remained in the node summary and No Proxmox VE nodes never rendered",
"Typed vault on Docker, pressed Tab then Enter, and verified the canonical completion and pill behavior outside Proxmox",
"Typed chec on Kubernetes and verified the ambiguous suffix stopped at the shared checkout- prefix; pressed Enter directly to commit chec while all matching checkout objects remained",
"Typed chec again, pressed Right Arrow then Enter, and verified checkout- committed without arbitrarily selecting one object",
"Typed checkout-api-6c746d5bcf-c, pressed Right Arrow then Enter, and verified the unique checkout-api-6c746d5bcf-c7z2p pod committed and filtered exactly",
"Typed unmatched phrase and pressed Enter; verified it remained ordinary free text and did not become a pill",
"Expanded the compact mobile Filters control and verified committed pills and Clear filters remained operable"
]
}
@@ -38,6 +38,7 @@ import type {
WorkloadsMemoryDisplayBasis,
WorkloadsStatusMode,
} from './workloadsFilterModel';
import { buildWorkloadSearchSuggestions } from './workloadSearchSuggestions';
import {
DEFAULT_WORKLOADS_SORT_DIRECTION,
DEFAULT_WORKLOADS_SORT_KEY,
@@ -288,6 +289,10 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
return filters;
};
const searchSuggestions = createMemo(() =>
buildWorkloadSearchSuggestions(props.searchSuggestionWorkloads?.() ?? []),
);
return (
<FilterBar
role="group"
@@ -299,6 +304,7 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
placeholder: props.searchPlaceholder ?? 'Search workloads by name, ID, node, or image',
historyKey: STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY,
emptyMessage: props.searchEmptyMessage ?? 'Recent workload searches appear here.',
suggestions: searchSuggestions,
onBeforeAutoFocus: props.onBeforeAutoFocus,
}}
searchTrailing={props.searchTrailing}
@@ -134,6 +134,7 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
ariaLabel={props.filterAriaLabel}
searchPlaceholder={props.filterSearchPlaceholder}
searchEmptyMessage={props.filterSearchEmptyMessage}
searchSuggestionWorkloads={state.allGuests}
statusOptions={props.filterStatusOptions}
columnVisibility={state.workloadsFilterColumnVisibility()}
containerRuntimeFilter={state.containerRuntimeFilterConfig()}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import type { WorkloadGuest } from '@/types/workloads';
import { buildWorkloadSearchSuggestions } from '../workloadSearchSuggestions';
describe('buildWorkloadSearchSuggestions', () => {
it('projects workload names, ids, hosts, types, and status for every platform workload', () => {
const workload = {
id: 'docker:host-a:container:abc123',
name: 'grafana-agent-01',
displayId: 'abc123',
type: 'container',
status: 'running',
dockerHostName: 'host-a',
technology: 'docker',
} as unknown as WorkloadGuest;
const suggestions = buildWorkloadSearchSuggestions([workload]);
const suggestion = suggestions.find((item) => item.id.startsWith('workload:'))!;
expect(suggestion.label).toBe('grafana-agent-01');
expect(suggestion.description).toContain('host-a');
expect(suggestion.description).toContain('Running');
expect(suggestion.keywords).toEqual(
expect.arrayContaining(['grafana-agent-01', 'abc123', 'host-a']),
);
expect(suggestions).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'workload-scope:host:host-a', value: 'host-a' }),
]),
);
});
});
@@ -0,0 +1,94 @@
import type { SearchInputSuggestion } from '@/components/shared/SearchInput';
import type { WorkloadGuest } from '@/types/workloads';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation';
import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads';
import { getWorkloadSearchCandidates } from './workloadSelectors';
const clean = (value: unknown): string =>
typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
const workloadDisplayId = (guest: WorkloadGuest): string =>
clean(guest.displayId) ||
(Number.isFinite(guest.vmid) && guest.vmid > 0 ? String(guest.vmid) : '');
const workloadLabel = (guest: WorkloadGuest): string =>
clean(guest.name) || workloadDisplayId(guest) || clean(guest.id) || 'Unnamed workload';
const workloadDescription = (guest: WorkloadGuest): string => {
const type = getWorkloadTypePresentation(resolveWorkloadType(guest)).label;
const displayId = workloadDisplayId(guest);
const identity = displayId ? `${type} ${displayId}` : type;
const node = clean(guest.node) || clean(guest.dockerHostName) || clean(guest.contextLabel);
const status = titleCaseDelimitedLabel(clean(guest.status));
return [identity, node, status].filter(Boolean).join(' · ');
};
export const buildWorkloadSearchSuggestions = (
workloads: readonly WorkloadGuest[],
): SearchInputSuggestion[] => {
const nameCounts = new Map<string, number>();
for (const workload of workloads) {
const name = workloadLabel(workload).toLowerCase();
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
}
const seen = new Set<string>();
const suggestions: SearchInputSuggestion[] = [];
const scopeFields: Array<{
key: 'node' | 'host' | 'context' | 'cluster' | 'namespace';
label: string;
value: (workload: WorkloadGuest) => string;
}> = [
{ key: 'node', label: 'Node', value: (workload) => clean(workload.node) },
{ key: 'host', label: 'Host', value: (workload) => clean(workload.dockerHostName) },
{ key: 'context', label: 'Context', value: (workload) => clean(workload.contextLabel) },
{ key: 'cluster', label: 'Cluster', value: (workload) => clean(workload.clusterName) },
{ key: 'namespace', label: 'Namespace', value: (workload) => clean(workload.namespace) },
];
for (const field of scopeFields) {
const values = new Set(workloads.map(field.value).filter(Boolean));
for (const value of values) {
suggestions.push({
id: `workload-scope:${field.key}:${value}`,
label: value,
value,
description: field.label,
group: 'Infrastructure',
keywords: [field.label, value],
});
}
}
for (const workload of workloads) {
const canonicalId = getCanonicalWorkloadId(workload) || workload.id;
if (!canonicalId || seen.has(canonicalId)) continue;
seen.add(canonicalId);
const label = workloadLabel(workload);
const duplicateName = (nameCounts.get(label.toLowerCase()) ?? 0) > 1;
suggestions.push({
id: `workload:${canonicalId}`,
label,
value: duplicateName ? canonicalId : label,
description: workloadDescription(workload),
group: 'Infrastructure',
keywords: getWorkloadSearchCandidates(workload).map(clean).filter(Boolean),
completions: [
workload.name,
workload.displayId,
workload.vmid,
workload.id,
workload.node,
workload.dockerHostName,
workload.contextLabel,
workload.clusterName,
]
.map(clean)
.filter(Boolean),
});
}
return suggestions;
};
@@ -86,7 +86,9 @@ type SortDirection = 'asc' | 'desc';
type SortValue = string | number | boolean | null | undefined;
const workloadSearchCandidates = (guest: WorkloadGuest): Array<string | number | undefined> => [
export const getWorkloadSearchCandidates = (
guest: WorkloadGuest,
): Array<string | number | undefined> => [
guest.name,
guest.id,
guest.displayId,
@@ -104,13 +106,14 @@ const workloadSearchCandidates = (guest: WorkloadGuest): Array<string | number |
guest.containerRuntime,
guest.containerId,
guest.dockerHostId,
guest.dockerHostName,
guest.kubernetesAgentId,
];
const matchesWorkloadTextSearch = (guest: WorkloadGuest, term: string): boolean => {
const needle = term.trim().toLowerCase();
if (!needle) return true;
return workloadSearchCandidates(guest)
return getWorkloadSearchCandidates(guest)
.filter(
(value): value is string | number => typeof value === 'string' || typeof value === 'number',
)
@@ -55,6 +55,7 @@ export interface WorkloadsFilterProps {
ariaLabel?: string;
searchPlaceholder?: string;
searchEmptyMessage?: string;
searchSuggestionWorkloads?: () => readonly WorkloadGuest[];
statusOptions?: readonly WorkloadsStatusOption[];
inventoryStats?: () => WorkloadsInventoryStats;
inventoryCountsVisible?: () => boolean;
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-lib
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FilterBar } from './FilterBar';
import type { FilterDef } from './filterCatalog';
import { createSignal } from 'solid-js';
const search = {
value: () => '',
@@ -302,4 +303,133 @@ describe('FilterBar', () => {
expect(label).toHaveTextContent('Filter');
expect(select.parentElement).toHaveClass('p-0.5');
});
it('completes a matching filter inline before pinning it as a chip on Enter', () => {
const Harness = () => {
const [query, setQuery] = createSignal('');
const [node, setNode] = createSignal('');
const nodeFilter: FilterDef = {
id: 'node',
label: 'Node',
group: 'scope',
value: node,
setValue: setNode,
defaultValue: '',
options: () => [
{ value: '', label: 'All nodes' },
{ value: 'pve1', label: 'pve1' },
],
};
return (
<FilterBar
search={{ value: query, setValue: setQuery, placeholder: 'Search infrastructure' }}
filters={[nodeFilter]}
isMobile={() => false}
/>
);
};
const { container } = render(() => <Harness />);
const input = screen.getByPlaceholderText('Search infrastructure');
input.focus();
fireEvent.input(input, { target: { value: 'pv' } });
expect(container.querySelector('[data-search-completion-suffix]')).toHaveTextContent('e1');
fireEvent.keyDown(input, { key: 'Tab' });
expect(input).toHaveValue('pve1');
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByRole('button', { name: 'Remove Node filter' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Node: pve1' })).toBeInTheDocument();
expect(input).toHaveValue('');
});
it('commits multiple infrastructure completions as removable inclusive search pills', () => {
const Harness = () => {
const [query, setQuery] = createSignal('');
return (
<>
<FilterBar
search={{
value: query,
setValue: setQuery,
placeholder: 'Search objects',
suggestions: () => [
{ id: 'node:pve1', label: 'pve1', value: 'pve1' },
{ id: 'host:docker-a', label: 'docker-a', value: 'docker-a' },
],
}}
filters={[]}
isMobile={() => false}
/>
<output data-testid="effective-search">{query()}</output>
</>
);
};
render(() => <Harness />);
const input = screen.getByPlaceholderText('Search objects');
input.focus();
fireEvent.input(input, { target: { value: 'pv' } });
fireEvent.keyDown(input, { key: 'Tab' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByRole('button', { name: 'Remove search term pve1' })).toBeInTheDocument();
fireEvent.input(input, { target: { value: 'doc' } });
fireEvent.keyDown(input, { key: 'Tab' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByRole('button', { name: 'Remove search term docker-a' })).toBeInTheDocument();
expect(screen.getByTestId('effective-search')).toHaveTextContent('pve1, docker-a');
fireEvent.click(screen.getByRole('button', { name: 'Remove search term pve1' }));
expect(
screen.queryByRole('button', { name: 'Remove search term pve1' }),
).not.toBeInTheDocument();
expect(screen.getByTestId('effective-search')).toHaveTextContent('docker-a');
});
it('commits a recognized abbreviated query without choosing one ambiguous object', () => {
const Harness = () => {
const [query, setQuery] = createSignal('');
return (
<>
<FilterBar
search={{
value: query,
setValue: setQuery,
placeholder: 'Search Kubernetes objects',
suggestions: () => [
{ id: 'deployment:checkout-api', label: 'checkout-api' },
{ id: 'deployment:checkout-web', label: 'checkout-web' },
{ id: 'pod:checkout-api-a', label: 'checkout-api-6c746d5bcf-c7z2p' },
],
}}
filters={[]}
isMobile={() => false}
/>
<output data-testid="effective-search">{query()}</output>
</>
);
};
const { container } = render(() => <Harness />);
const input = screen.getByPlaceholderText('Search Kubernetes objects');
input.focus();
fireEvent.input(input, { target: { value: 'chec' } });
expect(container.querySelector('[data-search-completion-suffix]')).toHaveTextContent('kout-');
fireEvent.keyDown(input, { key: 'Enter' });
expect(input).toHaveValue('');
expect(screen.getByRole('button', { name: 'Remove search term chec' })).toBeInTheDocument();
expect(screen.getByTestId('effective-search')).toHaveTextContent('chec');
fireEvent.input(input, { target: { value: 'unmatched prose' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(input).toHaveValue('unmatched prose');
expect(
screen.queryByRole('button', { name: 'Remove search term unmatched prose' }),
).not.toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { Component, For, JSX, Show, createMemo, createSignal } from 'solid-js';
import { Component, For, JSX, Show, createEffect, createMemo, createSignal } from 'solid-js';
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
import { Card } from '@/components/shared/Card';
import { FilterButtonGroup } from '@/components/shared/FilterButtonGroup';
@@ -8,11 +8,16 @@ import { AddFilterMenu } from './AddFilterMenu';
import { FilterChip } from './FilterChip';
import { ViewOptionsMenu } from './ViewOptionsMenu';
import {
buildFilterSearchSuggestions,
clearFilter,
hasAddableFilterOptions,
getSearchSuggestionValues,
isFilterSet,
serializeFilterSearch,
toFilterSearchTerm,
type FilterBarProps,
type FilterDef,
type FilterSearchTerm,
} from './filterCatalog';
const FilterBarClearAllButton: Component<{ onClick: () => void }> = (props) => (
@@ -54,8 +59,28 @@ const FilterBarRailDivider: Component = () => (
<div aria-hidden="true" class="hidden h-6 w-px bg-border-subtle sm:block" />
);
const FilterSearchTermChip: Component<{
term: FilterSearchTerm;
onRemove: () => void;
}> = (props) => (
<div class="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 text-xs dark:border-blue-900 dark:bg-blue-950/40">
<span class="py-0.5 pl-2 pr-1 font-medium text-base-content">{props.term.label}</span>
<button
type="button"
onClick={props.onRemove}
aria-label={`Remove search term ${props.term.label}`}
class="rounded-r-full py-0.5 pl-1 pr-1.5 text-muted hover:bg-blue-100 hover:text-base-content dark:hover:bg-blue-900/50"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
export const FilterBar: Component<FilterBarProps> = (props) => {
const [mobileExpanded, setMobileExpanded] = createSignal(false);
const [searchDraft, setSearchDraft] = createSignal('');
const [searchTerms, setSearchTerms] = createSignal<FilterSearchTerm[]>([]);
let lastAppliedSearch: string | undefined;
const inlineFilters = createMemo<FilterDef[]>(() =>
props.filters.filter((filter) => filter.inline),
@@ -64,11 +89,81 @@ export const FilterBar: Component<FilterBarProps> = (props) => {
props.filters.filter((filter) => !filter.inline),
);
const activeMenuFilters = createMemo<FilterDef[]>(() => menuFilters().filter(isFilterSet));
const activeCount = createMemo(() => props.filters.filter(isFilterSet).length);
const activeCount = createMemo(
() =>
props.filters.filter(isFilterSet).length +
searchTerms().length +
(searchDraft().trim() ? 1 : 0),
);
const hasMenuFilters = createMemo(() => menuFilters().length > 0);
const hasAddableMenuFilters = createMemo(() => hasAddableFilterOptions(menuFilters()));
const applySearch = (terms: readonly FilterSearchTerm[], draft: string) => {
const serialized = serializeFilterSearch(terms, draft);
lastAppliedSearch = serialized;
props.search.setValue(serialized);
};
const addSearchTerm = (term: FilterSearchTerm) => {
const next = searchTerms().some(
(current) => current.value.toLowerCase() === term.value.toLowerCase(),
)
? searchTerms()
: [...searchTerms(), term];
setSearchTerms(next);
setSearchDraft('');
applySearch(next, '');
};
const commitRecognizedSearchQuery = (value: string) =>
addSearchTerm({
id: `search-query:${value.toLowerCase()}`,
label: value,
value,
});
const removeSearchTerm = (id: string) => {
const next = searchTerms().filter((term) => term.id !== id);
setSearchTerms(next);
applySearch(next, searchDraft());
};
const infrastructureSuggestions = createMemo(() =>
(props.search.suggestions?.() ?? []).map((suggestion) => ({
...suggestion,
onSelect: () => addSearchTerm(toFilterSearchTerm(suggestion)),
})),
);
const searchSuggestions = createMemo(() => [
...infrastructureSuggestions(),
...buildFilterSearchSuggestions(props.filters),
]);
createEffect(() => {
const externalSearch = props.search.value();
const suggestions = props.search.suggestions?.() ?? [];
if (externalSearch === lastAppliedSearch) return;
const parts = externalSearch
.split(',')
.map((part) => part.trim())
.filter(Boolean);
const matched = parts.map((part) =>
suggestions.find((suggestion) =>
getSearchSuggestionValues(suggestion).some(
(value) => value.toLowerCase() === part.toLowerCase(),
),
),
);
if (parts.length > 0 && matched.every(Boolean)) {
setSearchTerms(matched.map((suggestion) => toFilterSearchTerm(suggestion!)));
setSearchDraft('');
} else {
setSearchTerms([]);
setSearchDraft(externalSearch);
}
if (externalSearch || suggestions.length > 0) lastAppliedSearch = externalSearch;
});
const clearAll = () => {
setSearchTerms([]);
setSearchDraft('');
lastAppliedSearch = '';
if (props.onClearAll) {
props.onClearAll();
return;
@@ -90,7 +185,8 @@ export const FilterBar: Component<FilterBarProps> = (props) => {
hasAuxiliaryControls() ||
hasClearableState());
const showInlineRow = () => props.isMobile() && mobileExpanded() && inlineFilters().length > 0;
const showDesktopChipRow = () => !props.isMobile() && activeMenuFilters().length > 0;
const showDesktopChipRow = () =>
!props.isMobile() && (activeMenuFilters().length > 0 || searchTerms().length > 0);
const showMobileBody = () => props.isMobile() && mobileExpanded();
const showMobileActionRow = () => showMobileBody() && hasAuxiliaryControls();
const showAddFilterInMobileActionRow = () =>
@@ -103,6 +199,7 @@ export const FilterBar: Component<FilterBarProps> = (props) => {
showDesktopChipRow() ||
(showMobileBody() &&
(activeMenuFilters().length > 0 ||
searchTerms().length > 0 ||
(hasAddableMenuFilters() && !showAddFilterInMobileActionRow())));
const showClearAllInMobileActionRow = () =>
showMobileActionRow() && hasClearableState() && !showChipRow();
@@ -124,8 +221,11 @@ export const FilterBar: Component<FilterBarProps> = (props) => {
<div class="flex w-full items-center gap-2">
<div class="min-w-0 flex-1">
<SearchInput
value={props.search.value}
onChange={props.search.setValue}
value={searchDraft}
onChange={(value) => {
setSearchDraft(value);
applySearch(searchTerms(), value);
}}
placeholder={props.search.placeholder}
class="w-full"
typeToSearch
@@ -133,6 +233,10 @@ export const FilterBar: Component<FilterBarProps> = (props) => {
onBeforeAutoFocus={props.search.onBeforeAutoFocus}
history={searchHistory()}
tips={props.search.tips}
suggestions={{
items: searchSuggestions,
onCommitQuery: commitRecognizedSearchQuery,
}}
/>
</div>
{props.searchTrailing}
@@ -199,6 +303,11 @@ export const FilterBar: Component<FilterBarProps> = (props) => {
<Show when={showChipRow()}>
<div class="flex flex-wrap items-center gap-2">
<For each={searchTerms()}>
{(term) => (
<FilterSearchTermChip term={term} onRemove={() => removeSearchTerm(term.id)} />
)}
</For>
<For each={activeMenuFilters()}>{(filter) => <FilterChip filter={filter} />}</For>
<Show when={showMobileBody()}>
<Show when={hasAddableMenuFilters()}>
@@ -1,6 +1,7 @@
import type { Accessor, JSX } from 'solid-js';
import type { FilterButtonGroupOptionTone } from '@/components/shared/filterButtonGroupModel';
import type { SearchTipsConfig } from '@/components/shared/useSearchInputEnhancements';
import type { SearchInputSuggestion } from '@/components/shared/searchInputModel';
export interface FilterSelectOption {
value: string;
@@ -40,6 +41,7 @@ export interface FilterBarSearch {
historyKey?: string;
emptyMessage?: string;
tips?: SearchTipsConfig;
suggestions?: Accessor<readonly SearchInputSuggestion[]>;
clearOnEscape?: boolean;
onBeforeAutoFocus?: () => boolean;
}
@@ -63,6 +65,29 @@ export interface FilterBarProps {
showAddFilterLabel?: boolean;
}
export interface FilterSearchTerm {
id: string;
label: string;
value: string;
}
export const getSearchSuggestionValues = (suggestion: SearchInputSuggestion): string[] =>
[
suggestion.value,
suggestion.completion,
suggestion.label,
...(suggestion.completions ?? []),
].filter((value): value is string => Boolean(value?.trim()));
export const toFilterSearchTerm = (suggestion: SearchInputSuggestion): FilterSearchTerm => ({
id: suggestion.id,
label: suggestion.label,
value: (suggestion.value ?? suggestion.completion ?? suggestion.label).trim(),
});
export const serializeFilterSearch = (terms: readonly FilterSearchTerm[], draft: string): string =>
[...terms.map((term) => term.value), draft.trim()].filter(Boolean).join(', ');
export const isFilterSet = (filter: FilterDef): boolean => filter.value() !== filter.defaultValue;
export const hasSelectableFilterOptions = (filter: FilterDef): boolean =>
@@ -82,3 +107,21 @@ export const formatFilterChipValue = (filter: FilterDef): string => {
const match = options.find((option) => option.value === value);
return match?.label ?? value;
};
export const buildFilterSearchSuggestions = (
filters: readonly FilterDef[],
): SearchInputSuggestion[] =>
filters.flatMap((filter) =>
filter
.options()
.filter((option) => option.value !== filter.defaultValue && option.value !== filter.value())
.map((option) => ({
id: `filter:${filter.id}:${option.value}`,
label: `${filter.label}: ${option.label}`,
completion: option.label,
description: `Apply ${filter.label.toLowerCase()} filter`,
group: 'Filters',
keywords: [filter.label, option.label, option.value],
onSelect: () => filter.setValue(option.value),
})),
);
@@ -17,13 +17,27 @@ export const SearchField: Component<SearchFieldProps> = (props) => {
disabled={props.disabled}
onInput={(e) => props.onChange(e.currentTarget.value)}
onKeyDown={search.handleKeyDown}
onFocus={search.handleFocus}
onBlur={search.handleBlur}
aria-label={props.title ?? props.placeholder ?? 'Search'}
role={props.role}
aria-autocomplete={props.ariaAutocomplete}
class={`min-h-11 w-full pl-8 sm:min-h-10 sm:pl-9 ${search.inputPaddingRight()} py-1.5 sm:py-2 text-sm border border-border rounded-md
bg-surface text-base-content placeholder-muted
focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all disabled:opacity-60 disabled:cursor-not-allowed ${props.inputClass ?? ''}`}
title={props.title}
/>
<Show when={props.completionSuffix}>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 flex min-h-11 items-center overflow-hidden whitespace-pre pl-8 pr-8 text-sm sm:min-h-10 sm:pl-9"
>
<span class="text-transparent">{props.value}</span>
<span class="text-muted opacity-60" data-search-completion-suffix>
{props.completionSuffix}
</span>
</div>
</Show>
<svg
class="absolute left-2.5 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted"
fill="none"
@@ -7,7 +7,11 @@ import {
import { type SearchInputProps } from './searchInputModel';
import { useSearchInputState } from './useSearchInputState';
export type { SearchInputProps } from './searchInputModel';
export type {
SearchInputProps,
SearchInputSuggestion,
SearchInputSuggestionsConfig,
} from './searchInputModel';
export const SearchInput: Component<SearchInputProps> = (props) => {
const search = useSearchInputState(props);
@@ -16,7 +20,7 @@ export const SearchInput: Component<SearchInputProps> = (props) => {
<div class={`relative w-full ${props.class ?? ''}`}>
<SearchField
value={props.value()}
onChange={props.onChange}
onChange={search.enhancements.onValueChange}
placeholder={props.placeholder}
title={props.title}
inputRef={search.setInputRef}
@@ -28,7 +32,11 @@ export const SearchInput: Component<SearchInputProps> = (props) => {
trailingControlCount={search.trailingControlCount()}
onClearMouseDown={search.enhancements.onClearMouseDown}
onKeyDown={search.enhancements.onFieldKeyDown}
onFocus={search.enhancements.onFieldFocus}
onBlur={search.enhancements.onFieldBlur}
completionSuffix={search.enhancements.completionSuffix()}
role={search.enhancements.hasSuggestions() ? 'combobox' : undefined}
ariaAutocomplete={search.enhancements.hasSuggestions() ? 'inline' : undefined}
trailingControls={
<SearchInputTrailingControls state={search.enhancements} tips={props.tips} />
}
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import { SearchInput } from '@/components/shared/SearchInput';
import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
@@ -253,4 +253,73 @@ describe('SearchInput', () => {
expect(document.activeElement).toBe(second);
});
});
it('offers one inline completion and applies an exact structured match on Enter', async () => {
const applyNode = vi.fn();
const InlineCompletionHarness = () => {
const [value, setValue] = createSignal('');
return (
<SearchInput
value={value}
onChange={setValue}
placeholder="Infrastructure search"
suggestions={{
items: () => [
{
id: 'filter:node:pve1',
label: 'Node: pve1',
completion: 'pve1',
keywords: ['node', 'pve1'],
onSelect: applyNode,
},
],
}}
/>
);
};
const { container } = render(() => <InlineCompletionHarness />);
const input = screen.getByPlaceholderText('Infrastructure search');
input.focus();
fireEvent.input(input, { target: { value: 'pv' } });
expect(container.querySelector('[data-search-completion-suffix]')).toHaveTextContent('e1');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Tab' });
expect(input).toHaveValue('pve1');
expect(applyNode).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(applyNode).toHaveBeenCalledTimes(1);
expect(input).toHaveValue('');
});
it('accepts inline completion with Right Arrow but preserves unmatched free text', () => {
const InlineCompletionHarness = () => {
const [value, setValue] = createSignal('');
return (
<SearchInput
value={value}
onChange={setValue}
placeholder="Mixed search"
suggestions={{
items: () => [{ id: 'resource:alpha', label: 'alpha-host', value: 'alpha-host' }],
}}
/>
);
};
render(() => <InlineCompletionHarness />);
const input = screen.getByPlaceholderText('Mixed search') as HTMLInputElement;
input.focus();
fireEvent.input(input, { target: { value: 'alp' } });
input.setSelectionRange(3, 3);
fireEvent.keyDown(input, { key: 'ArrowRight' });
expect(input).toHaveValue('alpha-host');
fireEvent.input(input, { target: { value: 'arbitrary phrase' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(input).toHaveValue('arbitrary phrase');
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
rankSearchInputSuggestions,
resolveSearchInputInlineCompletion,
scoreSearchInputSuggestion,
} from '@/components/shared/searchInputModel';
describe('searchInputModel', () => {
it('matches the inline completion text as well as visible labels and infrastructure keywords', () => {
const suggestion = {
id: 'filter:node:pve2',
label: 'Node: West Production B',
completion: 'pve2',
keywords: ['production-west'],
};
expect(scoreSearchInputSuggestion(suggestion, 'pve')).toBe(1);
expect(scoreSearchInputSuggestion(suggestion, 'West')).toBe(2);
expect(scoreSearchInputSuggestion(suggestion, 'production-west')).toBe(0);
});
it('keeps the source group order while ranking stronger matches within each group', () => {
const ranked = rankSearchInputSuggestions(
[
{ id: 'filter', label: 'Node: pve2', completion: 'pve2', group: 'Filters' },
{ id: 'weak-filter', label: 'Cluster: production-pve', group: 'Filters' },
{ id: 'resource', label: 'pve2', group: 'Infrastructure' },
],
'pve',
);
expect(ranked.map((suggestion) => suggestion.id)).toEqual([
'filter',
'weak-filter',
'resource',
]);
});
it('completes only the unambiguous common prefix when several objects match', () => {
const suggestions = [
{ id: 'deployment:checkout-api', label: 'checkout-api' },
{ id: 'deployment:checkout-web', label: 'checkout-web' },
{ id: 'pod:checkout-api-a', label: 'checkout-api-6c746d5bcf-c7z2p' },
];
expect(resolveSearchInputInlineCompletion(suggestions, 'chec')?.text).toBe('checkout-');
expect(resolveSearchInputInlineCompletion(suggestions, 'checkout-api-6c746d5bcf-c')?.text).toBe(
'checkout-api-6c746d5bcf-c7z2p',
);
});
});
@@ -25,6 +25,7 @@ export interface SearchFieldProps {
inputClass?: string;
disabled?: boolean;
onKeyDown?: (event: SearchFieldKeyboardEvent) => void;
onFocus?: (event: SearchFieldFocusEvent) => void;
onBlur?: (event: SearchFieldFocusEvent) => void;
showClearButton?: boolean;
clearOnFocusedEscape?: boolean;
@@ -33,6 +34,9 @@ export interface SearchFieldProps {
trailingControlCount?: number;
trailingControls?: JSX.Element;
onClearMouseDown?: (event: SearchFieldMouseEvent) => void;
completionSuffix?: string;
role?: JSX.InputHTMLAttributes<HTMLInputElement>['role'];
ariaAutocomplete?: JSX.AriaAttributes['aria-autocomplete'];
}
export const shouldShowSearchFieldShortcutHint = (value: string, shortcutHint?: string) =>
@@ -6,7 +6,6 @@ export const SEARCH_HISTORY_ROW_CLASS =
export const SEARCH_HISTORY_ENTRY_BUTTON_CLASS =
'flex-1 truncate pr-2 text-left text-sm text-base-content transition-colors hover:text-blue-600 focus:outline-none dark:hover:text-blue-300';
export const SEARCH_HISTORY_CLEAR_LABEL = 'Clear history';
export function getSearchHistoryToggleButtonClass(isOpen: boolean): string {
return `flex h-11 w-11 items-center justify-center rounded-md transition-colors sm:h-7 sm:w-7 ${
isOpen
@@ -1,3 +1,4 @@
import type { Accessor } from 'solid-js';
import type {
SearchHistoryConfig,
SearchTipsConfig,
@@ -8,6 +9,28 @@ export type SearchInputKeyboardEvent = KeyboardEvent & {
target: Element;
};
export interface SearchInputSuggestion {
id: string;
label: string;
/** Preferred text completed inline after the current query. Defaults to the label. */
completion?: string;
/** Alternate canonical identities that can be completed inline. */
completions?: readonly string[];
value?: string;
description?: string;
group?: string;
keywords?: readonly string[];
onSelect?: () => void;
}
export interface SearchInputSuggestionsConfig {
items: Accessor<readonly SearchInputSuggestion[]>;
minQueryLength?: number;
maxResults?: number;
/** Commit a non-exact query when it still resolves to known suggestions. */
onCommitQuery?: (query: string, matches: readonly SearchInputSuggestion[]) => void;
}
export interface SearchInputProps {
value: () => string;
onChange: (value: string) => void;
@@ -15,6 +38,7 @@ export interface SearchInputProps {
title?: string;
history?: SearchHistoryConfig;
tips?: SearchTipsConfig;
suggestions?: SearchInputSuggestionsConfig;
inputRef?: (el: HTMLInputElement) => void;
class?: string;
inputClass?: string;
@@ -33,3 +57,131 @@ export const getSearchInputShortcutHint = (isSimple: boolean, shortcutHint?: str
isSimple ? shortcutHint : undefined;
export const shouldSearchInputShowTrailingControls = (isSimple: boolean) => !isSimple;
const normalizedSearchSuggestionFields = (suggestion: SearchInputSuggestion): string[] =>
[
suggestion.label,
suggestion.completion,
...(suggestion.completions ?? []),
suggestion.value,
...(suggestion.keywords ?? []),
]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim().toLowerCase());
export const getSearchInputSuggestionCompletions = (suggestion: SearchInputSuggestion): string[] =>
[suggestion.completion, suggestion.label, suggestion.value, ...(suggestion.completions ?? [])]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim());
export interface SearchInputInlineCompletion {
suggestion: SearchInputSuggestion;
text: string;
}
export const resolveSearchInputInlineCompletion = (
suggestions: readonly SearchInputSuggestion[],
query: string,
): SearchInputInlineCompletion | undefined => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery || query !== query.trim()) return undefined;
const candidates = suggestions.flatMap((suggestion) =>
getSearchInputSuggestionCompletions(suggestion)
.filter((completion) => completion.toLowerCase().startsWith(normalizedQuery))
.map((completion) => ({ suggestion, text: completion })),
);
if (candidates.length === 0) return undefined;
const uniqueCandidates = Array.from(
new Map(candidates.map((candidate) => [candidate.text.toLowerCase(), candidate])).values(),
);
let commonLength = uniqueCandidates[0].text.length;
for (let index = 1; index < uniqueCandidates.length; index += 1) {
const left = uniqueCandidates[0].text.toLowerCase();
const right = uniqueCandidates[index].text.toLowerCase();
commonLength = Math.min(commonLength, right.length);
let position = normalizedQuery.length;
while (position < commonLength && left[position] === right[position]) position += 1;
commonLength = position;
}
if (commonLength <= query.length) return undefined;
return {
suggestion: uniqueCandidates[0].suggestion,
text: uniqueCandidates[0].text.slice(0, commonLength),
};
};
const isSubsequence = (query: string, candidate: string): boolean => {
let queryIndex = 0;
for (const character of candidate) {
if (character === query[queryIndex]) queryIndex += 1;
if (queryIndex === query.length) return true;
}
return false;
};
export const scoreSearchInputSuggestion = (
suggestion: SearchInputSuggestion,
query: string,
): number | null => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return null;
const fields = normalizedSearchSuggestionFields(suggestion);
if (fields.some((field) => field === normalizedQuery)) return 0;
if (fields.some((field) => field.startsWith(normalizedQuery))) return 1;
if (
fields.some((field) =>
field.split(/[^a-z0-9]+/).some((token) => token.startsWith(normalizedQuery)),
)
) {
return 2;
}
if (fields.some((field) => field.includes(normalizedQuery))) return 3;
if (
normalizedQuery.length >= 3 &&
fields.some((field) => isSubsequence(normalizedQuery, field))
) {
return 4;
}
return null;
};
export const rankSearchInputSuggestions = (
suggestions: readonly SearchInputSuggestion[],
query: string,
maxResults = 10,
): SearchInputSuggestion[] => {
const groupOrder = new Map<string, number>();
for (const suggestion of suggestions) {
const group = suggestion.group ?? '';
if (!groupOrder.has(group)) groupOrder.set(group, groupOrder.size);
}
return suggestions
.map((suggestion, sourceIndex) => ({
suggestion,
sourceIndex,
score: scoreSearchInputSuggestion(suggestion, query),
}))
.filter(
(
candidate,
): candidate is {
suggestion: SearchInputSuggestion;
sourceIndex: number;
score: number;
} => candidate.score !== null,
)
.sort((left, right) => {
const leftGroup = groupOrder.get(left.suggestion.group ?? '') ?? 0;
const rightGroup = groupOrder.get(right.suggestion.group ?? '') ?? 0;
return (
leftGroup - rightGroup || left.score - right.score || left.sourceIndex - right.sourceIndex
);
})
.slice(0, Math.max(1, maxResults))
.map((candidate) => candidate.suggestion);
};
@@ -15,6 +15,7 @@ type SearchFieldStateOptions = Pick<
| 'inputRef'
| 'onBlur'
| 'onChange'
| 'onFocus'
| 'onKeyDown'
| 'showClearButton'
| 'shortcutHint'
@@ -64,13 +65,19 @@ export function useSearchFieldState(options: SearchFieldStateOptions) {
};
const handleKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
options.onKeyDown?.(normalizeEventTarget(event) as SearchFieldKeyboardEvent);
if (event.defaultPrevented) return;
if (event.key === 'Escape' && (options.clearOnFocusedEscape ?? true)) {
if (options.value) {
options.onChange('');
}
inputEl?.blur();
}
options.onKeyDown?.(normalizeEventTarget(event) as SearchFieldKeyboardEvent);
};
const handleFocus = (event: FocusEvent & { currentTarget: HTMLInputElement }) => {
options.onFocus?.(normalizeEventTarget(event) as SearchFieldFocusEvent);
};
const handleBlur = (event: FocusEvent & { currentTarget: HTMLInputElement }) => {
@@ -79,6 +86,7 @@ export function useSearchFieldState(options: SearchFieldStateOptions) {
return {
handleBlur,
handleFocus,
handleKeyDown,
inputPaddingRight,
setInputRef,
@@ -1,6 +1,13 @@
import { createSignal, onMount, createEffect, onCleanup } from 'solid-js';
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import type { Accessor } from 'solid-js';
import { createSearchHistoryManager } from '@/utils/searchHistory';
import {
getSearchInputSuggestionCompletions,
rankSearchInputSuggestions,
resolveSearchInputInlineCompletion,
type SearchInputSuggestion,
type SearchInputSuggestionsConfig,
} from '@/components/shared/searchInputModel';
export interface SearchTip {
code: string;
@@ -23,6 +30,7 @@ export interface SearchTipsConfig {
interface SearchInputEnhancementsOptions {
history?: SearchHistoryConfig;
tips?: SearchTipsConfig;
suggestions?: SearchInputSuggestionsConfig;
onFieldKeyDown?: (event: SearchInputKeyboardEvent) => void;
}
@@ -44,9 +52,11 @@ type SearchInputMouseEvent = MouseEvent & {
export interface SearchInputEnhancementsState {
hasHistory: Accessor<boolean>;
hasTips: Accessor<boolean>;
hasSuggestions: Accessor<boolean>;
isSimple: Accessor<boolean>;
searchHistory: Accessor<string[]>;
isHistoryOpen: Accessor<boolean>;
completionSuffix: Accessor<string>;
emptyHistoryMessage: Accessor<string>;
tipsPopoverId: Accessor<string>;
onClearMouseDown?: (event: SearchInputMouseEvent) => void;
@@ -57,7 +67,9 @@ export interface SearchInputEnhancementsState {
clearHistory: () => void;
deleteHistoryEntry: (term: string) => void;
selectHistoryEntry: (term: string) => void;
onValueChange: (value: string) => void;
onFieldKeyDown: (event: SearchInputKeyboardEvent) => void;
onFieldFocus: (event: SearchInputFocusEvent) => void;
onFieldBlur: (event: SearchInputFocusEvent) => void;
}
@@ -70,6 +82,7 @@ export const useSearchInputEnhancements = (
): SearchInputEnhancementsState => {
const hasHistory = () => !!options.history;
const hasTips = () => !!options.tips;
const hasSuggestions = () => !!options.suggestions;
const isSimple = () => !hasHistory() && !hasTips();
const tipsPopoverId = () => options.tips?.popoverId ?? '';
const emptyHistoryMessage = () =>
@@ -81,15 +94,54 @@ export const useSearchInputEnhancements = (
const [searchHistory, setSearchHistory] = createSignal<string[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = createSignal(false);
const [isFieldFocused, setIsFieldFocused] = createSignal(false);
const [showInlineCompletion, setShowInlineCompletion] = createSignal(true);
const [acceptedSuggestionId, setAcceptedSuggestionId] = createSignal<string>();
const rankedSuggestions = createMemo<SearchInputSuggestion[]>(() => {
const config = options.suggestions;
const query = options.value().trim();
if (!config || query.length < (config.minQueryLength ?? 1)) return [];
const items = config.items();
return rankSearchInputSuggestions(items, query, config.maxResults ?? items.length);
});
const inlineCompletion = createMemo(() =>
resolveSearchInputInlineCompletion(rankedSuggestions(), options.value()),
);
const exactSuggestion = (): SearchInputSuggestion | undefined => {
const normalizedQuery = options.value().trim().toLowerCase();
if (!normalizedQuery) return undefined;
const acceptedId = acceptedSuggestionId();
return (
rankedSuggestions().find(
(suggestion) =>
suggestion.id === acceptedId &&
getSearchInputSuggestionCompletions(suggestion).some(
(completion) => completion.toLowerCase() === normalizedQuery,
),
) ??
rankedSuggestions().find((suggestion) =>
getSearchInputSuggestionCompletions(suggestion).some(
(completion) => completion.toLowerCase() === normalizedQuery,
),
)
);
};
const completionSuffix = () => {
if (!isFieldFocused() || !showInlineCompletion()) return '';
const completion = inlineCompletion();
return completion ? completion.text.slice(options.value().length) : '';
};
let historyMenuRef: HTMLDivElement | undefined;
let historyToggleRef: HTMLButtonElement | undefined;
let suppressBlurCommit = false;
onMount(() => {
if (historyManager) {
setSearchHistory(historyManager.read());
}
if (historyManager) setSearchHistory(historyManager.read());
});
const commitSearchToHistory = (term: string) => {
@@ -124,29 +176,48 @@ export const useSearchInputEnhancements = (
const target = event.target as Node;
const clickedMenu = historyMenuRef?.contains(target) ?? false;
const clickedToggle = historyToggleRef?.contains(target) ?? false;
if (!clickedMenu && !clickedToggle) {
closeHistory();
}
if (!clickedMenu && !clickedToggle) closeHistory();
};
createEffect(() => {
if (isHistoryOpen()) {
document.addEventListener('mousedown', handleDocumentClick);
} else {
document.removeEventListener('mousedown', handleDocumentClick);
}
if (isHistoryOpen()) document.addEventListener('mousedown', handleDocumentClick);
else document.removeEventListener('mousedown', handleDocumentClick);
});
onCleanup(() => {
document.removeEventListener('mousedown', handleDocumentClick);
});
onCleanup(() => document.removeEventListener('mousedown', handleDocumentClick));
const acceptInlineCompletion = () => {
const completion = inlineCompletion();
if (!completion) return;
setAcceptedSuggestionId(completion.suggestion.id);
options.onChange(completion.text);
setShowInlineCompletion(false);
};
const applyExactSuggestion = (suggestion: SearchInputSuggestion, input: HTMLInputElement) => {
closeHistory();
if (suggestion.onSelect) {
suggestion.onSelect();
options.onChange('');
setAcceptedSuggestionId(undefined);
options.focusInput();
return;
}
const value = suggestion.value ?? options.value().trim();
options.onChange(value);
commitSearchToHistory(value);
input.blur();
};
return {
hasHistory,
hasTips,
hasSuggestions,
isSimple,
searchHistory,
isHistoryOpen,
completionSuffix,
emptyHistoryMessage,
tipsPopoverId,
onClearMouseDown: hasHistory() ? markSuppressCommit : undefined,
@@ -157,13 +228,7 @@ export const useSearchInputEnhancements = (
historyToggleRef = el;
},
toggleHistory: () => {
setIsHistoryOpen((prev) => {
const next = !prev;
if (!next) {
queueMicrotask(() => historyToggleRef?.blur());
}
return next;
});
setIsHistoryOpen((previous) => !previous);
},
closeHistory,
clearHistory,
@@ -172,24 +237,68 @@ export const useSearchInputEnhancements = (
options.onChange(term);
commitSearchToHistory(term);
setIsHistoryOpen(false);
setAcceptedSuggestionId(undefined);
options.focusInput();
},
onFieldKeyDown: (e) => {
if (hasHistory()) {
if (e.key === 'Enter') {
commitSearchToHistory(e.currentTarget.value);
closeHistory();
} else if (e.key === 'ArrowDown' && searchHistory().length > 0) {
e.preventDefault();
setIsHistoryOpen(true);
}
}
options.onFieldKeyDown?.(e);
onValueChange: (value) => {
options.onChange(value);
setAcceptedSuggestionId(undefined);
setShowInlineCompletion(true);
setIsHistoryOpen(false);
},
onFieldBlur: (e) => {
if (!hasHistory()) return;
onFieldKeyDown: (event) => {
const cursorAtEnd =
event.currentTarget.selectionStart === options.value().length &&
event.currentTarget.selectionEnd === options.value().length;
if (
completionSuffix() &&
(event.key === 'Tab' || (event.key === 'ArrowRight' && cursorAtEnd))
) {
event.preventDefault();
acceptInlineCompletion();
return;
}
if (event.key === 'Enter') {
const exact = exactSuggestion();
if (exact) {
event.preventDefault();
applyExactSuggestion(exact, event.currentTarget);
return;
}
const query = options.value().trim();
const matches = rankedSuggestions();
if (query && matches.length > 0 && options.suggestions?.onCommitQuery) {
event.preventDefault();
closeHistory();
options.suggestions.onCommitQuery(query, matches);
options.onChange('');
setAcceptedSuggestionId(undefined);
options.focusInput();
return;
}
commitSearchToHistory(event.currentTarget.value);
closeHistory();
event.currentTarget.blur();
} else if (hasHistory() && event.key === 'ArrowDown' && searchHistory().length > 0) {
event.preventDefault();
setIsHistoryOpen(true);
} else if (event.key === 'ArrowLeft' || event.key === 'Home') {
setShowInlineCompletion(false);
}
options.onFieldKeyDown?.(event);
},
onFieldFocus: (event) => {
setIsFieldFocused(true);
setShowInlineCompletion(
event.currentTarget.selectionStart === event.currentTarget.value.length,
);
},
onFieldBlur: (event) => {
setIsFieldFocused(false);
if (suppressBlurCommit) return;
const next = e.relatedTarget as HTMLElement | null;
const next = event.relatedTarget as HTMLElement | null;
const interactingWithHistory = next
? historyMenuRef?.contains(next) || historyToggleRef?.contains(next)
: false;
@@ -197,7 +306,7 @@ export const useSearchInputEnhancements = (
? next?.getAttribute('aria-controls') === tipsPopoverId()
: false;
if (!interactingWithHistory && !interactingWithTips) {
commitSearchToHistory(e.currentTarget.value);
commitSearchToHistory(event.currentTarget.value);
}
},
};
@@ -20,6 +20,7 @@ type SearchInputStateOptions = Pick<
| 'onChange'
| 'onKeyDown'
| 'shortcutHint'
| 'suggestions'
| 'tips'
| 'typeToSearch'
| 'value'
@@ -51,6 +52,7 @@ export function useSearchInputState(options: SearchInputStateOptions): {
const enhancements = useSearchInputEnhancements({
history: options.history,
suggestions: options.suggestions,
tips: options.tips,
value: options.value,
onChange: options.onChange,
@@ -135,6 +135,7 @@ export const DockerAlertsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Docker alerts"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={DOCKER_INCIDENT_STATUS_OPTIONS}
@@ -89,6 +89,7 @@ export const DockerConfigsTable: Component<DockerNativeTableProps> = (props) =>
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Swarm configs"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -726,6 +726,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search containers"
searchSuggestions={tableState.searchSuggestions}
searchTips={DOCKER_CONTAINER_SEARCH_TIPS}
status={tableState.status()}
onStatusChange={tableState.setStatus}
@@ -210,6 +210,7 @@ export const DockerHostsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Docker hosts"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -110,6 +110,7 @@ export const DockerImagesTable: Component<
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search image, host, or update state"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -298,6 +298,7 @@ const AttachmentDetail: Component<{ rows: readonly DockerNetworkAttachmentRow[]
search={tableState.search}
onSearchChange={setSearch}
searchPlaceholder="Search attached containers"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={setStatus}
statusOptions={ATTACHMENT_STATUS_FILTER_OPTIONS}
@@ -441,6 +442,7 @@ export const DockerNetworksTable: Component<DockerNetworksTableProps> = (props)
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search networks"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -53,6 +53,7 @@ import { DOCKER_QUERY_PARAMS, buildStandalonePath } from '@/routing/resourceLink
import { asTrimmedString } from '@/utils/stringUtils';
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
import type { Resource } from '@/types/resource';
import { buildPlatformResourceSearchSuggestions } from '@/features/platformPage/platformSearchSuggestions';
const DOCKER_RESOURCE_QUERY =
'type=agent,docker-host,app-container,docker-service,docker-image,docker-volume,docker-network,docker-task,docker-swarm-node,docker-secret,docker-config';
@@ -205,6 +206,9 @@ function DockerStorage(props: { model: DockerPageModel }) {
const [search, setSearch] = createSignal('');
const [status, setStatus] = createSignal<DockerResourceStatusFilter>('all');
const storageHosts = createMemo(() => props.model.hosts.filter(hasDockerEngineStorageUsage));
const searchSuggestions = createMemo(() =>
buildPlatformResourceSearchSuggestions([...storageHosts(), ...props.model.volumes]),
);
const totalRows = createMemo(() => storageHosts().length + props.model.volumes.length);
const visibleRows = createMemo(
() =>
@@ -233,6 +237,7 @@ function DockerStorage(props: { model: DockerPageModel }) {
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search storage usage and volumes"
searchSuggestions={searchSuggestions}
status={status()}
onStatusChange={setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -91,6 +91,7 @@ export const DockerSecretsTable: Component<DockerNativeTableProps> = (props) =>
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Swarm secrets"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -170,6 +170,7 @@ export const DockerServicesTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Swarm services"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -91,6 +91,7 @@ export const DockerStorageUsageTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search storage usage"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -122,6 +122,7 @@ export const DockerSwarmNodesTable: Component<DockerNativeTableProps> = (props)
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Swarm nodes"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -105,6 +105,7 @@ export const DockerTasksTable: Component<DockerNativeTableProps> = (props) => {
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Swarm tasks"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -108,6 +108,7 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search volumes"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -139,6 +139,7 @@ export const KubernetesAlertsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Kubernetes alerts"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={KUBERNETES_INCIDENT_STATUS_OPTIONS}
@@ -138,6 +138,7 @@ export const KubernetesAutoscalingTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search autoscalers"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -151,6 +151,7 @@ export const KubernetesClustersTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search clusters"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -230,6 +230,7 @@ export const KubernetesConfigTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search config inventory"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -249,6 +249,7 @@ export const KubernetesControllersTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search controllers"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -138,6 +138,7 @@ export const KubernetesDeploymentsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search deployments"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -82,6 +82,7 @@ export const KubernetesEventsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search events"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -174,6 +174,7 @@ export const KubernetesNetworkingTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search networking inventory"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -165,6 +165,7 @@ export const KubernetesNodesTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search nodes"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -21,6 +21,7 @@ import {
PlatformTableToolbar,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import { buildPlatformResourceSearchSuggestions } from '@/features/platformPage/platformSearchSuggestions';
import { KubernetesAlertsTable } from './KubernetesAlertsTable';
import { KubernetesAutoscalingTable } from './KubernetesAutoscalingTable';
import { KubernetesClustersTable } from './KubernetesClustersTable';
@@ -366,6 +367,9 @@ function KubernetesWorkloads(props: {
props.controllers,
props.model.autoscaling,
]);
const searchSuggestions = createMemo(() =>
buildPlatformResourceSearchSuggestions(sections().flat()),
);
const totalRows = createMemo(() => sections().reduce((sum, rows) => sum + rows.length, 0));
// Cluster and namespace scope apply across every workload section at once
@@ -402,6 +406,7 @@ function KubernetesWorkloads(props: {
search={toolbar.search}
onSearchChange={toolbar.setSearch}
searchPlaceholder="Search workload inventory"
searchSuggestions={searchSuggestions}
status={toolbar.status()}
onStatusChange={toolbar.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -487,6 +492,9 @@ function KubernetesServices(props: { model: KubernetesPageModel }) {
props.model.services,
props.model.serviceNetworking,
]);
const searchSuggestions = createMemo(() =>
buildPlatformResourceSearchSuggestions(sections().flat()),
);
const totalRows = createMemo(() => sections().reduce((sum, rows) => sum + rows.length, 0));
const scope = createKubernetesInventoryScope(sections);
const scopedServices = () => scope.scopedSections()[0];
@@ -517,6 +525,7 @@ function KubernetesServices(props: { model: KubernetesPageModel }) {
search={toolbar.search}
onSearchChange={toolbar.setSearch}
searchPlaceholder="Search services and networking"
searchSuggestions={searchSuggestions}
status={toolbar.status()}
onStatusChange={toolbar.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -560,6 +569,9 @@ function KubernetesConfiguration(props: { model: KubernetesPageModel }) {
);
const toolbar = createKubernetesSharedToolbar();
const sections = createMemo<Resource[][]>(() => [props.model.config, props.model.policy]);
const searchSuggestions = createMemo(() =>
buildPlatformResourceSearchSuggestions(sections().flat()),
);
const totalRows = createMemo(() => sections().reduce((sum, rows) => sum + rows.length, 0));
const scope = createKubernetesInventoryScope(sections);
const scopedConfig = () => scope.scopedSections()[0];
@@ -590,6 +602,7 @@ function KubernetesConfiguration(props: { model: KubernetesPageModel }) {
search={toolbar.search}
onSearchChange={toolbar.setSearch}
searchPlaceholder="Search configuration and policy"
searchSuggestions={searchSuggestions}
status={toolbar.status()}
onStatusChange={toolbar.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -181,6 +181,7 @@ export const KubernetesPodsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search pods"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -212,6 +212,7 @@ export const KubernetesPolicyTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search policy resources"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -132,6 +132,7 @@ export const KubernetesServicesTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search services"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -244,6 +244,7 @@ export const KubernetesStorageTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search storage inventory"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -44,6 +44,7 @@ import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?ra
import proxmoxPageSurfaceSource from '@/features/proxmox/ProxmoxPageSurface.tsx?raw';
import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplicationTable.tsx?raw';
import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.tsx?raw';
import platformSearchSuggestionsSource from '@/features/platformPage/platformSearchSuggestions.ts?raw';
import truenasAlertsTableSource from '@/features/truenas/TrueNASAlertsTable.tsx?raw';
import truenasAppsTableSource from '@/features/truenas/TrueNASAppsTable.tsx?raw';
import truenasNetworkSharesTableSource from '@/features/truenas/TrueNASNetworkSharesTable.tsx?raw';
@@ -218,6 +219,7 @@ describe('platform overview layout guardrails', () => {
for (const source of platformToolbarTableSources) {
expect(source).toContain('PlatformTableToolbar');
expect(source).toContain('createPlatformTableFilterState');
expect(source).toContain('searchSuggestions=');
expect(source).toContain('PLATFORM_HEALTH_FILTER_OPTIONS');
expect(source).not.toContain('ViewOptionsMenu');
expect(source).not.toContain("from '@/components/shared/SearchInput'");
@@ -228,6 +230,10 @@ describe('platform overview layout guardrails', () => {
// out of the net, since that is shared-FilterBar plumbing, not a rogue box.
expect(source).not.toContain('const [search, ');
}
expect(sharedPlatformPageSource).toContain('searchSuggestions={tableState.searchSuggestions}');
expect(platformSearchSuggestionsSource).toContain('buildPlatformResourceSearchSuggestions');
expect(platformSearchSuggestionsSource).not.toContain('platformData');
});
it('keeps Proxmox detail tables on the shared platform table primitives', () => {
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { buildPlatformResourceSearchSuggestions } from './platformSearchSuggestions';
describe('buildPlatformResourceSearchSuggestions', () => {
it('projects canonical resource identity without exposing arbitrary metadata', () => {
const resource = {
id: 'k8s:cluster-a:pod:api-7f9',
type: 'pod',
name: 'api-7f9',
displayName: 'api',
status: 'running',
parentName: 'cluster-a',
tags: ['production'],
canonicalIdentity: {
displayName: 'api-primary',
aliases: ['api-service'],
primaryId: 'pod/api-7f9',
},
metadata: { secretValue: 'must-not-leak' },
} as unknown as Resource;
const [suggestion] = buildPlatformResourceSearchSuggestions([resource]);
expect(suggestion.label).toBe('api-primary');
expect(suggestion.value).toBe('api-primary');
expect(suggestion.description).toContain('cluster-a');
expect(suggestion.keywords).toEqual(
expect.arrayContaining(['k8s:cluster-a:pod:api-7f9', 'api-service', 'production']),
);
expect(suggestion.keywords).not.toContain('must-not-leak');
});
it('uses canonical ids as exact search values when display names collide', () => {
const resources = ['cluster-a', 'cluster-b'].map(
(cluster) =>
({
id: `${cluster}:pod:api`,
type: 'pod',
name: 'api',
displayName: 'api',
status: 'running',
parentName: cluster,
}) as Resource,
);
expect(buildPlatformResourceSearchSuggestions(resources).map((item) => item.value)).toEqual([
'cluster-a:pod:api',
'cluster-b:pod:api',
]);
});
});
@@ -0,0 +1,119 @@
import type { SearchInputSuggestion } from '@/components/shared/SearchInput';
import type { Resource } from '@/types/resource';
import { getCanonicalStatusLabel } from '@/utils/status';
import { getResourceTypeLabel } from '@/utils/resourceTypePresentation';
const clean = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
export interface PlatformSearchSuggestionCandidate {
id: string;
label: string;
value?: string;
description?: string;
keywords?: readonly string[];
completions?: readonly string[];
}
export const buildPlatformSearchSuggestions = (
candidates: readonly PlatformSearchSuggestionCandidate[],
idNamespace = 'infrastructure',
): SearchInputSuggestion[] => {
const labelCounts = new Map<string, number>();
for (const candidate of candidates) {
const label = clean(candidate.label);
if (!label) continue;
const key = label.toLowerCase();
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
}
const seen = new Set<string>();
const suggestions: SearchInputSuggestion[] = [];
for (const candidate of candidates) {
const id = clean(candidate.id);
const label = clean(candidate.label);
if (!id || !label || seen.has(id)) continue;
seen.add(id);
const duplicateLabel = (labelCounts.get(label.toLowerCase()) ?? 0) > 1;
suggestions.push({
id: `${idNamespace}:${id}`,
label,
value: clean(candidate.value) || (duplicateLabel ? id : label),
description: clean(candidate.description) || undefined,
group: 'Infrastructure',
keywords: [id, ...(candidate.keywords ?? [])].map(clean).filter(Boolean),
completions: candidate.completions?.map(clean).filter(Boolean),
});
}
return suggestions.sort((left, right) => left.label.localeCompare(right.label));
};
const resourceDisplayName = (resource: Resource): string =>
clean(resource.canonicalIdentity?.displayName) ||
clean(resource.displayName) ||
clean(resource.name) ||
resource.id;
const resourceSearchKeywords = (resource: Resource): string[] =>
[
resource.id,
resource.name,
resource.displayName,
resource.type,
resource.technology,
resource.status,
resource.parentName,
resource.platformId,
resource.platformType,
resource.clusterId,
resource.agent?.hostname,
resource.identity?.hostname,
resource.identity?.clusterName,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.platformId,
resource.canonicalIdentity?.primaryId,
...(resource.canonicalIdentity?.aliases ?? []),
...(resource.tags ?? []),
].filter((value): value is string => Boolean(clean(value)));
const resourceSuggestionDescription = (resource: Resource): string => {
const type = getResourceTypeLabel(resource.type) ?? 'Resource';
const parent = clean(resource.parentName) || clean(resource.identity?.clusterName);
const status = getCanonicalStatusLabel(resource.status);
return [type, parent, status].filter(Boolean).join(' · ');
};
export const isPlatformSearchSuggestionResource = (value: unknown): value is Resource => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<Resource>;
return (
typeof candidate.id === 'string' &&
typeof candidate.type === 'string' &&
typeof candidate.name === 'string'
);
};
export const buildPlatformResourceSearchSuggestions = (
resources: readonly Resource[],
): SearchInputSuggestion[] =>
buildPlatformSearchSuggestions(
resources.map((resource) => ({
id: resource.id,
label: resourceDisplayName(resource),
description: resourceSuggestionDescription(resource),
keywords: resourceSearchKeywords(resource),
completions: [
resource.name,
resource.displayName,
resource.id,
resource.agent?.hostname,
resource.identity?.hostname,
resource.canonicalIdentity?.displayName,
resource.canonicalIdentity?.hostname,
resource.canonicalIdentity?.primaryId,
...(resource.canonicalIdentity?.aliases ?? []),
].filter((value): value is string => Boolean(clean(value))),
})),
'resource',
);
@@ -22,7 +22,7 @@ import { type FilterOption as PlatformTableFilterOption } from '@/components/sha
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
import { FilterSegmentedControl } from '@/components/shared/FilterToolbar';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
import { type SearchInputProps } from '@/components/shared/SearchInput';
import { type SearchInputProps, type SearchInputSuggestion } from '@/components/shared/SearchInput';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
@@ -40,6 +40,10 @@ import {
import { asTrimmedString } from '@/utils/stringUtils';
import { formatVmwareClusterServices } from '@/utils/vmwareDisplay';
import { getPlatformColumnAlign, type PlatformTableColumnKind } from './columnAlignment';
import {
buildPlatformResourceSearchSuggestions,
isPlatformSearchSuggestionResource,
} from './platformSearchSuggestions';
import {
PLATFORM_ESTATE_COUNTS_STORAGE_KEY,
deserializePlatformEstateCountsVisibility,
@@ -1153,6 +1157,13 @@ export function createPlatformTableFilterState<Row, Status extends string | numb
const hasActiveFilters = createMemo(
() => search().trim().length > 0 || status() !== props.initialStatus,
);
const searchSuggestions = createMemo(() => {
const resources: Resource[] = [];
for (const row of props.resources()) {
if (isPlatformSearchSuggestionResource(row)) resources.push(row);
}
return buildPlatformResourceSearchSuggestions(resources);
});
const resetFilters = () => {
setSearch('');
setStatus(props.initialStatus);
@@ -1167,6 +1178,7 @@ export function createPlatformTableFilterState<Row, Status extends string | numb
visible,
total,
hasActiveFilters,
searchSuggestions,
resetFilters,
};
}
@@ -1228,6 +1240,7 @@ export function PlatformTableToolbar<T extends string | number>(props: {
searchPlaceholder: string;
searchHistory?: SearchInputProps['history'];
searchTips?: SearchInputProps['tips'];
searchSuggestions?: () => readonly SearchInputSuggestion[];
status: T;
onStatusChange: (value: T) => void;
statusOptions: PlatformTableFilterOption<T>[];
@@ -1305,6 +1318,7 @@ export function PlatformTableToolbar<T extends string | number>(props: {
historyKey: props.searchHistory?.storageKey,
emptyMessage: props.searchHistory?.emptyMessage,
tips: props.searchTips,
suggestions: props.searchSuggestions,
}}
filters={allFilters}
showAddFilterLabel={false}
@@ -1374,6 +1388,7 @@ export const PlatformResourceTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder={props.searchPlaceholder ?? 'Search rows'}
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_STATUS_FILTER_OPTIONS}
@@ -29,6 +29,8 @@ import { PlatformResourceDetailToggleButton } from '@/features/platformPage/Plat
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import type { Resource, ResourceCephServiceMeta } from '@/types/resource';
import { ProxmoxCephClusterDrawer } from './ProxmoxCephClusterDrawer';
import { buildPlatformResourceSearchSuggestions } from '@/features/platformPage/platformSearchSuggestions';
import { matchesSearchTermSplit, splitSearchExclusions } from '@/utils/searchQuery';
// Ceph clusters are first-class Resources (type='ceph') with structured
// metadata: pools, monitors, managers, OSDs, placement groups, health.
@@ -238,11 +240,11 @@ export const ProxmoxCephTable: Component<{
};
const filtered = createMemo(() => {
const term = search().trim().toLowerCase();
const split = splitSearchExclusions(search());
const want = status();
return props.resources.filter((cluster) => {
if (want !== 'all' && classify(cluster) !== want) return false;
if (!term) return true;
if (!split.needle && split.excludes.length === 0) return true;
const haystack = [
cluster.name,
cluster.displayName,
@@ -254,12 +256,15 @@ export const ProxmoxCephTable: Component<{
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(term);
return matchesSearchTermSplit(haystack, split);
});
});
const total = createMemo(() => props.resources.length);
const visible = createMemo(() => filtered().length);
const searchSuggestions = createMemo(() =>
buildPlatformResourceSearchSuggestions(props.resources),
);
const observedWidth = useObservedElementWidth();
const layout = createMemo(() =>
getPlatformTableContainerLayout(observedWidth.width() ?? 1920, [520, 720, 960, 1200]),
@@ -300,6 +305,7 @@ export const ProxmoxCephTable: Component<{
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search clusters, pools, FSID"
searchSuggestions={searchSuggestions}
status={status()}
onStatusChange={setStatus}
statusOptions={STATUS_FILTER_OPTIONS}
@@ -121,6 +121,7 @@ export const ProxmoxMailGatewayTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search Mail Gateways"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -52,7 +52,6 @@ import {
PROXMOX_TAB_SPECS,
buildProxmoxPageModel,
buildVisibleProxmoxTabSpecs,
filterProxmoxNodesForSearch,
type ProxmoxPageModel,
type ProxmoxPageTabId,
} from './proxmoxPageModel';
@@ -318,18 +317,11 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
workloadsState.allGuests().length > 0,
);
const estateTopology = createMemo(() => buildProxmoxEstateTopology(currentModel().resources));
const filteredNodes = createMemo(() =>
filterProxmoxNodesForSearch(
currentModel().pveNodes,
currentModel().guests,
workloadsState.search(),
),
);
return (
<div ref={overviewWidth.setElement} class="pulse-wide-data-surface space-y-4">
<ProxmoxNodesTable
nodes={filteredNodes()}
nodes={currentModel().pveNodes}
guests={currentModel().guests}
metricDisplayMode={props.metricDisplayMode}
metricHistoryRange={props.metricHistoryRange}
@@ -357,6 +349,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
ariaLabel="Proxmox workload filters"
searchPlaceholder="Search VMs and LXCs by name, VMID, node, or status"
searchEmptyMessage="Recent Proxmox workload searches appear here."
searchSuggestionWorkloads={workloadsState.allGuests}
statusOptions={PROXMOX_WORKLOAD_STATUS_OPTIONS}
inventoryStats={workloadsState.inventoryStats}
inventoryCountsVisible={props.inventoryCountsVisible}
@@ -24,6 +24,8 @@ import {
} from '@/features/platformPage/sharedPlatformPage';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import type { ReplicationJob, ReplicationJobsResponse } from '@/types/api';
import { buildPlatformSearchSuggestions } from '@/features/platformPage/platformSearchSuggestions';
import { matchesSearchTermSplit, splitSearchExclusions } from '@/utils/searchQuery';
// Replication is a Proxmox-specific concept (zfs send/receive scheduled
// between PVE nodes), so this table is bespoke rather than a filtered
@@ -274,11 +276,11 @@ export const ProxmoxReplicationTable: Component<{
const [expandedJobKey, setExpandedJobKey] = createSignal<string | null>(null);
const filtered = createMemo(() => {
const term = search().trim().toLowerCase();
const split = splitSearchExclusions(search());
const want = status();
return (props.jobs ?? []).filter((job) => {
if (want !== 'all' && classifyJob(job) !== want) return false;
if (!term) return true;
if (!split.needle && split.excludes.length === 0) return true;
const haystack = [
job.jobId,
job.guest,
@@ -292,12 +294,34 @@ export const ProxmoxReplicationTable: Component<{
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(term);
return matchesSearchTermSplit(haystack, split);
});
});
const total = createMemo(() => (props.jobs ?? []).length);
const visible = createMemo(() => filtered().length);
const searchSuggestions = createMemo(() =>
buildPlatformSearchSuggestions(
(props.jobs ?? []).map((job, index) => ({
id: job.jobId || job.id || `job-${index}`,
label: job.guestName?.trim() || job.guest?.trim() || job.jobId || job.id,
description: [
job.jobId,
job.sourceNode && job.targetNode ? `${job.sourceNode}${job.targetNode}` : '',
]
.filter(Boolean)
.join(' · '),
keywords: [
job.guest ?? '',
job.guestId?.toString() ?? '',
job.sourceNode ?? '',
job.targetNode ?? '',
job.instance ?? '',
],
})),
'replication-job',
),
);
const observedWidth = useObservedElementWidth();
const layout = createMemo(() =>
getPlatformTableContainerLayout(observedWidth.width() ?? 1920, [520, 720, 960, 1200]),
@@ -358,6 +382,7 @@ export const ProxmoxReplicationTable: Component<{
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search jobs, guests, nodes"
searchSuggestions={searchSuggestions}
status={status()}
onStatusChange={setStatus}
statusOptions={STATUS_FILTER_OPTIONS}
@@ -12,6 +12,7 @@ const mockVersionInfo = vi.hoisted(() => vi.fn());
const mockStorageProps = vi.hoisted(() => vi.fn());
const mockTotalStats = vi.hoisted(() => vi.fn());
const mockNodesTableProps = vi.hoisted(() => vi.fn());
const mockWorkloadSearch = vi.hoisted(() => vi.fn(() => ''));
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
({
@@ -87,7 +88,7 @@ vi.mock('@/components/Workloads/useWorkloadsState', () => ({
surfaceInitialDataReceived: () => false,
allGuests: () => [],
totalStats: mockTotalStats,
search: () => '',
search: mockWorkloadSearch,
setSearch: vi.fn(),
}),
}));
@@ -153,6 +154,7 @@ describe('ProxmoxPageSurface contract', () => {
appContainers: 0,
pods: 0,
});
mockWorkloadSearch.mockReturnValue('');
});
afterEach(() => {
@@ -210,6 +212,43 @@ describe('ProxmoxPageSurface contract', () => {
);
});
it('keeps the node inventory independent from committed workload search terms', () => {
mockWorkloadSearch.mockReturnValue('reporting-api-01, wireguard-edge-01');
setResources([
makeResource({
id: 'agent:pve1',
type: 'agent',
proxmox: { nodeName: 'pve1', clusterName: 'production' },
}),
makeResource({
id: 'agent:pve2',
type: 'agent',
proxmox: { nodeName: 'pve2', clusterName: 'production' },
}),
makeResource({
id: 'lxc:108',
type: 'system-container',
name: 'reporting-api-01',
proxmox: { nodeName: 'pve1', vmid: 108 },
}),
makeResource({
id: 'lxc:111',
type: 'system-container',
name: 'wireguard-edge-01',
proxmox: { nodeName: 'pve2', vmid: 111 },
}),
]);
renderSurface();
expect(screen.getByTestId('nodes-table')).toHaveAttribute('data-rows', '2');
expect(mockNodesTableProps).toHaveBeenCalledWith(
expect.objectContaining({ nodes: expect.arrayContaining([expect.any(Object)]) }),
);
expect(proxmoxPageSurfaceSource).toContain('nodes={currentModel().pveNodes}');
expect(proxmoxPageSurfaceSource).not.toContain('filterProxmoxNodesForSearch');
});
it('does not call a retained version from a stopped Proxmox agent currently running', () => {
mockVersionInfo.mockReturnValue({
version: 'v6.0.0-rc.6',
@@ -4,7 +4,6 @@ import {
PROXMOX_TAB_SPECS,
buildProxmoxPageModel,
buildVisibleProxmoxTabSpecs,
filterProxmoxNodesForSearch,
getResourceVersion,
resolveProxmoxPlatformScope,
} from '../proxmoxPageModel';
@@ -32,67 +31,6 @@ 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('does not attach a matching guest to a same-named node in another provider', () => {
const siteANode = makeResource({
id: 'site-a-node',
type: 'agent',
platformId: 'site-a',
proxmox: { instance: 'site-a', nodeName: 'pve-1', clusterName: 'production' },
});
const siteBNode = makeResource({
id: 'site-b-node',
type: 'agent',
platformId: 'site-b',
proxmox: { instance: 'site-b', nodeName: 'pve-1', clusterName: 'production' },
});
const siteBGuest = makeResource({
id: 'site-b-vm',
type: 'vm',
name: 'database-b',
platformId: 'site-b',
proxmox: { instance: 'site-b', nodeName: 'pve-1', vmid: 101 },
});
expect(
filterProxmoxNodesForSearch([siteANode, siteBNode], [siteBGuest], 'database-b'),
).toEqual([siteBNode]);
});
});
it('builds a Proxmox-first estate model from canonical v6 resources', () => {
const model = buildProxmoxPageModel([
makeResource({
@@ -1,6 +1,5 @@
import type { Resource, ResourceMetric, ResourceType } from '@/types/resource';
import { formatProxmoxVersion } from '@/utils/proxmoxVersion';
import { resourceMatchesSearch } from '@/utils/resourceSearchMatch';
export type ProxmoxPageTabId = 'overview' | 'storage' | 'replication' | 'backups' | 'ceph' | 'mail';
@@ -198,27 +197,6 @@ export function isProxmoxChildOfNode(child: Resource, node: Resource): boolean {
);
}
// 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 matchingGuests = guests.filter((guest) => resourceMatchesSearch(guest, term));
return nodes.filter(
(node) =>
resourceMatchesSearch(node, term) ||
matchingGuests.some((guest) => isProxmoxChildOfNode(guest, node)),
);
}
export function getResourceVmid(resource: Resource): string {
const vmid = resource.proxmox?.vmid;
if (typeof vmid === 'number' && Number.isFinite(vmid)) {
@@ -1403,6 +1403,7 @@ export const AgentsMachinesTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search machines"
searchSuggestions={tableState.searchSuggestions}
searchHistory={{
storageKey: STORAGE_KEYS.MACHINES_SEARCH_HISTORY,
emptyMessage: 'Machine searches you run will appear here.',
@@ -109,6 +109,7 @@ export const AvailabilityChecksTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search availability checks"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -139,6 +139,7 @@ export const TrueNASAlertsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS alerts"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_INCIDENT_STATUS_OPTIONS}
@@ -217,6 +217,7 @@ export const TrueNASAppsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS apps"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_APP_STATUS_OPTIONS}
@@ -159,6 +159,7 @@ export const TrueNASNetworkSharesTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS shares"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_SHARE_STATUS_OPTIONS}
@@ -416,6 +416,7 @@ export const TrueNASProtectionTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search snapshots or replication"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={statusOptions()}
@@ -223,6 +223,7 @@ export const TrueNASServicesTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS services"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_SERVICE_STATUS_OPTIONS}
@@ -314,6 +314,7 @@ export const TrueNASStorageTopologyTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS pools, datasets, or disks"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_STORAGE_STATUS_OPTIONS}
@@ -186,6 +186,7 @@ export const TrueNASSystemsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS systems"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -181,6 +181,7 @@ export const TrueNASVirtualMachinesTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search TrueNAS VMs"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={TRUENAS_VM_STATUS_OPTIONS}
@@ -404,6 +404,7 @@ function VmwareOverview(props: VmwareOverviewProps) {
ariaLabel="vSphere workload filters"
searchPlaceholder="Search vSphere VMs by name, host, cluster, or status"
searchEmptyMessage="Recent vSphere workload searches appear here."
searchSuggestionWorkloads={workloadsState.allGuests}
statusOptions={VMWARE_WORKLOAD_STATUS_OPTIONS}
inventoryStats={workloadsState.inventoryStats}
suppressTypeFilter
@@ -278,6 +278,7 @@ export const VsphereActivityTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search vSphere activity"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={VSPHERE_ACTIVITY_STATUS_OPTIONS}
@@ -150,6 +150,7 @@ export const VsphereAlertsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search vSphere health"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={statusOptions()}
@@ -170,6 +170,7 @@ export const VsphereDatastoresTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search vSphere datastores"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={VSPHERE_DATASTORE_STATUS_OPTIONS}
@@ -175,6 +175,7 @@ export const VsphereHostsTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search ESXi hosts"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -146,6 +146,7 @@ export const VsphereNetworksTable: Component<{
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search vSphere networks"
searchSuggestions={tableState.searchSuggestions}
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={VSPHERE_NETWORK_STATUS_OPTIONS}
@@ -418,5 +418,14 @@ describe('evaluateFilterStack', () => {
const split = splitSearchExclusions('');
expect(matchesSearchTermSplit('anything', split)).toBe(true);
});
it('treats comma-separated committed object terms as inclusive alternatives', () => {
const split = splitSearchExclusions('pve1, docker-a');
expect(split.alternatives).toEqual(['pve1', 'docker-a']);
expect(matchesSearchTermSplit('node pve1 running', split)).toBe(true);
expect(matchesSearchTermSplit('host docker-a online', split)).toBe(true);
expect(matchesSearchTermSplit('host truenas-a online', split)).toBe(false);
});
});
});
+10 -1
View File
@@ -11,6 +11,7 @@ import { getWorkloadCPUPercent } from '@/utils/workloads';
export interface SearchTermSplit {
needle: string;
excludes: string[];
alternatives?: string[];
}
export function splitSearchExclusions(search: string): SearchTermSplit {
@@ -24,7 +25,12 @@ export function splitSearchExclusions(search: string): SearchTermSplit {
kept.push(token);
}
}
return { needle: kept.join(' ').toLowerCase(), excludes };
const needle = kept.join(' ').toLowerCase();
const alternatives = needle
.split(',')
.map((term) => term.trim())
.filter(Boolean);
return alternatives.length > 1 ? { needle, excludes, alternatives } : { needle, excludes };
}
export function matchesSearchTermSplit(haystack: string, split: SearchTermSplit): boolean {
@@ -32,6 +38,9 @@ export function matchesSearchTermSplit(haystack: string, split: SearchTermSplit)
if (haystack.includes(exclude)) return false;
}
if (!split.needle) return true;
if (split.alternatives) {
return split.alternatives.some((alternative) => haystack.includes(alternative));
}
return haystack.includes(split.needle);
}