mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add canonical infrastructure search completion
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+6
@@ -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}
|
||||
|
||||
+40
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user