diff --git a/frontend/packages/multicluster-sdk/README.md b/frontend/packages/multicluster-sdk/README.md index 3928ec1ffd8..cb84d7a21ed 100644 --- a/frontend/packages/multicluster-sdk/README.md +++ b/frontend/packages/multicluster-sdk/README.md @@ -57,6 +57,7 @@ Setup depends on your usage scenarios. - [useFleetK8sWatchResource](#gear-usefleetk8swatchresource) - [useFleetK8sWatchResources](#gear-usefleetk8swatchresources) - [useFleetPrometheusPoll](#gear-usefleetprometheuspoll) +- [useFleetSearch](#gear-usefleetsearch) - [useFleetSearchPoll](#gear-usefleetsearchpoll) - [useHubClusterName](#gear-usehubclustername) - [useIsFleetAvailable](#gear-useisfleetavailable) @@ -861,6 +862,88 @@ if (error) { [:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/api/useFleetPrometheusPoll.ts#L86) +### :gear: useFleetSearch + +A React hook that provides fleet-wide search functionality using the ACM search API, +with optional real-time updates via a GraphQL WebSocket subscription. + +When `subscriptionEnabled` is `false` (the default), the hook issues a one-shot +GraphQL query and returns the results. When `subscriptionEnabled` is `true`, the +hook additionally opens a WebSocket subscription and patches the locally-held +results as INSERT, UPDATE, and DELETE events arrive — keeping the data always +up to date without polling. + +Pagination is supported by setting `limit` and `offset` on the `SearchInput` +object. The caller is responsible for constructing those values. + +| Function | Type | +| ---------- | ---------- | +| `useFleetSearch` | `(input: SearchInput or undefined, subscriptionEnabled?: boolean or undefined) => [Fleet[] or undefined, boolean, Error or undefined, () => void]` | + +Parameters: + +* `input`: - The search input object (filters, keywords, limit, offset, etc.). +Pass `undefined` to skip the query entirely. +* `subscriptionEnabled`: - When `true`, a WebSocket subscription is opened +and the local result set is kept current via incremental event patches. +Defaults to `false`. + + +Returns: + +A tuple of: +- `data` — The current search results mapped through +{@link convertSearchItemToResource }, or `undefined` before the first +response arrives. +- `loaded` — `true` once the initial query has completed (regardless of +whether the subscription is active). +- `error` — Any query or subscription error, or `undefined` on success. +- `refetch` — A stable callback that re-executes the base query and resets +the local state to the fresh result. + +Examples: + +```typescript +// Basic query — no real-time updates +const [resources, loaded, error, refetch] = useFleetSearch({ + filters: [ + { property: 'kind', values: ['Pod'] }, + { property: 'namespace', values: ['default'] }, + ], + limit: 100, +}) + +// With real-time subscription — results update automatically +const [resources, loaded, error, refetch] = useFleetSearch( + { + filters: [ + { property: 'kind', values: ['Pod'] }, + { property: 'namespace', values: ['default'] }, + ], + }, + true, +) + +// With subscription enabled and pagination/ordering — page 2 of 20 results sorted by name +const PAGE_SIZE = 20 +const [page, setPage] = useState(1) +const [resources, loaded, error, refetch] = useFleetSearch( + { + filters: [ + { property: 'kind', values: ['Pod'] }, + { property: 'namespace', values: ['default'] }, + ], + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + orderBy: 'name asc', + }, + true, +) +``` + + +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/api/useFleetSearch.ts#L246) + ### :gear: useFleetSearchPoll A React hook that provides fleet-wide search functionality using the ACM search API. @@ -1045,9 +1128,14 @@ if (error) { - [FleetWatchK8sResult](#gear-fleetwatchk8sresult) - [FleetWatchK8sResults](#gear-fleetwatchk8sresults) - [FleetWatchK8sResultsObject](#gear-fleetwatchk8sresultsobject) +- [InputMaybe](#gear-inputmaybe) +- [Maybe](#gear-maybe) - [ResourceRoute](#gear-resourceroute) - [ResourceRouteHandler](#gear-resourceroutehandler) - [ResourceRouteProps](#gear-resourcerouteprops) +- [Scalars](#gear-scalars) +- [SearchFilter](#gear-searchfilter) +- [SearchInput](#gear-searchinput) - [SearchResult](#gear-searchresult) ### :gear: AdvancedSearchFilter @@ -1056,7 +1144,7 @@ if (error) { | ---------- | ---------- | | `AdvancedSearchFilter` | `{ property: string; values: string[] }[]` | -[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L9) +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L87) ### :gear: ClusterSetData @@ -1091,9 +1179,16 @@ The "global" key is a special set that contains all clusters (when includeGlobal Options for advanced cluster name retrieval with cluster set organization. -| Type | Type | -| ---------- | ---------- | -| `FleetClusterNamesOptions` | `{ /** Whether to return all clusters regardless of availability status. Defaults to false. */ returnAllClusters?: boolean /** Specific cluster set names to include. If not specified, includes all cluster sets including "default". Should not include "global" - use includeGlobal instead. */ clusterSets?: string[] /** Whether to include a special "global" set containing all clusters. Defaults to false. */ includeGlobal?: boolean }` | +```typescript +type FleetClusterNamesOptions = { + /** Whether to return all clusters regardless of availability status. Defaults to false. */ + returnAllClusters?: boolean + /** Specific cluster set names to include. If not specified, includes all cluster sets including "default". Should not include "global" - use includeGlobal instead. */ + clusterSets?: string[] + /** Whether to include a special "global" set containing all clusters. Defaults to false. */ + includeGlobal?: boolean +} +``` [:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/fleet.ts#L101) @@ -1209,6 +1304,22 @@ Options for advanced cluster name retrieval with cluster set organization. [:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/fleet.ts#L26) +### :gear: InputMaybe + +| Type | Type | +| ---------- | ---------- | +| `InputMaybe` | `Maybe` | + +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L6) + +### :gear: Maybe + +| Type | Type | +| ---------- | ---------- | +| `Maybe` | `T or null` | + +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L5) + ### :gear: ResourceRoute This extension allows plugins to customize the route used for resources of the given kind. Search results and resource links will direct to the route returned by the implementing function. @@ -1229,11 +1340,65 @@ This extension allows plugins to customize the route used for resources of the g ### :gear: ResourceRouteProps +```typescript +type ResourceRouteProps = { + /** The model for which this resource route should be used. */ + model: ExtensionK8sGroupKindModel + /** The handler function that returns the route path for the resource. */ + handler: CodeRef +} +``` + +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/extensions/resource.ts#L20) + +### :gear: Scalars + +All built-in and custom scalars, mapped to their actual values + | Type | Type | | ---------- | ---------- | -| `ResourceRouteProps` | `{ /** The model for which this resource route should be used. */ model: ExtensionK8sGroupKindModel /** The handler function that returns the route path for the resource. */ handler: CodeRef }` | +| `Scalars` | `{ ID: { input: string; output: string } String: { input: string; output: string } Boolean: { input: boolean; output: boolean } Int: { input: number; output: number } Float: { input: number; output: number } Date: { input: any; output: any } Map: { input: any; output: any } }` | -[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/extensions/resource.ts#L20) +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L8) + +### :gear: SearchFilter + +Defines a key/value to filter results. +When multiple values are provided for a property, it is interpreted as an OR operation. + +```typescript +type SearchFilter = { + /** Name of the property (key). */ + property: string + /** Values for the property. Multiple values per property are interpreted as an OR operation. Optionally one of these operations `=,!,!=,>,>=,<,<=` can be included at the beginning of the value. By default the equality operation is used. The values available for datetime fields (Ex: `created`, `startedAt`) are `hour`, `day`, `week`, `month` and `year`. Property `kind`, if included in the filter, will be matched using a case-insensitive comparison. For example, `kind:Pod` and `kind:pod` will bring up all pods. This is to maintain compatibility with Search V1. Wildcard matching: the `*` character can be used as a wildcard to match any sequence of characters. For example, a filter with property `name` and value `nginx-*` matches any resource whose name starts with `nginx-`. Similarly, property `namespace` with value `prod*` matches any namespace starting with `prod`. Wildcard matches are case-sensitive. */ + values: string[] +} +``` + +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L23) + +### :gear: SearchInput + +Input options to the search query. + +```typescript +type SearchInput = { + /** List of SearchFilter, which is a key(property) and values. When multiple filters are provided, results will match all filters (AND operation). */ + filters?: SearchFilter[] + /** List of strings to match resources. Will match resources containing any of the keywords in any text field. When multiple keywords are provided, it is interpreted as an AND operation. Matches are case insensitive. */ + keywords?: string[] + /** Max number of results returned by the query. **Default is** 10,000 A value of -1 will remove the limit. Use carefully because it may impact the service. */ + limit?: number + /** Number of results to skip before returning results. Used in combination with limit to implement pagination. **Default is** 0 */ + offset?: number + /** Order results by a property and direction. Format: "property_name asc" or "property_name desc" Example: "name desc" or "created asc" */ + orderBy?: string + /** Filter relationships to the specified kinds. If empty, all relationships will be included. This filter is used with the 'related' field on SearchResult. */ + relatedKinds?: string[] +} +``` + +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L44) ### :gear: SearchResult @@ -1241,7 +1406,7 @@ This extension allows plugins to customize the route used for resources of the g | ---------- | ---------- | | `SearchResult` | `R extends (infer T)[] ? Fleet[] : Fleet` | -[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L5) +[:link: Source](https://github.com/stolostron/console/blob/main/frontend/packages/multicluster-sdk/tree/../src/types/search.ts#L83) diff --git a/frontend/packages/multicluster-sdk/generate-doc.mjs b/frontend/packages/multicluster-sdk/generate-doc.mjs index 5847ad0c039..dc6e3ac504c 100755 --- a/frontend/packages/multicluster-sdk/generate-doc.mjs +++ b/frontend/packages/multicluster-sdk/generate-doc.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync, existsSync } from 'fs' +import { existsSync, readFileSync, writeFileSync } from 'fs' import { dirname, extname, resolve } from 'path' import { buildDocumentation, documentationToMarkdown } from 'tsdoc-markdown' import ts from 'typescript' @@ -18,22 +18,21 @@ const result = buildDocumentation({ const sortedResult = result.sort((a, b) => a.name.localeCompare(b.name)) -const markdown = documentationToMarkdown({ entries: sortedResult }); +const markdown = prettifyObjectTypes(documentationToMarkdown({ entries: sortedResult })) -const regex = /()[\s\S]*?()$/gm; -const replace = `\n\n${markdown}\n`; - -const outputFile = './README.md'; -const fileContent = readFileSync(outputFile, 'utf-8'); -writeFileSync(outputFile, fileContent.replace(regex, replace), 'utf-8'); +const regex = /()[\s\S]*?()$/gm +const replace = `\n\n${markdown}\n` +const outputFile = './README.md' +const fileContent = readFileSync(outputFile, 'utf-8') +writeFileSync(outputFile, fileContent.replace(regex, replace), 'utf-8') /** * Get all files referenced by a starting file, recursively (following only export chains) * This function only follows files that are actually exported, not just imported internally. * For example, if index.ts exports { foo } from './module', it will include './module', * but if './module' imports './helper' without exporting it, './helper' won't be included. - * + * * @param {string} startFile - The starting file path * @param {string} baseDir - Base directory for resolving relative paths * @returns {string[]} Array of all referenced file paths @@ -129,7 +128,6 @@ export function getAllReferencedFiles(startFile, baseDir = process.cwd()) { } visit(sourceFile) - } catch (error) { console.error(`Error analyzing ${absolutePath}:`, error.message) } @@ -140,3 +138,115 @@ export function getAllReferencedFiles(startFile, baseDir = process.cwd()) { return allFiles } + +/** + * Simplify internal GraphQL scalar and nullable wrapper types to plain TypeScript equivalents. + * Replacements are ordered most-specific first so nested wrappers collapse correctly. + * + * @param {string} typeStr + * @returns {string} + */ +function simplifyType(typeStr) { + return typeStr + .replace(/Scalars\['String'\]\['(?:input|output)'\]/g, 'string') + .replace(/Scalars\['Int'\]\['(?:input|output)'\]/g, 'number') + .replace(/Scalars\['Float'\]\['(?:input|output)'\]/g, 'number') + .replace(/Scalars\['Boolean'\]\['(?:input|output)'\]/g, 'boolean') + .replace(/Scalars\['ID'\]\['(?:input|output)'\]/g, 'string') + .replace(/Scalars\['Date'\]\['(?:input|output)'\]/g, 'Date') + .replace(/Scalars\['Map'\]\['(?:input|output)'\]/g, 'Record') + .replace(/InputMaybe]+)>>>/g, '$1[]') + .replace(/Array]+)>>/g, '$1[]') + .replace(/InputMaybe]+)>>/g, '$1[]') + .replace(/InputMaybe<([^>]+)>/g, '$1') +} + +/** + * Parse an inline object type string as emitted by tsdoc-markdown — where each property + * is preceded by a collapsed JSDoc block (/** ... *\/) — and format it as a readable + * TypeScript type definition block. + * + * @param {string} typeName + * @param {string} typeStr - raw object type, e.g. "{ /** comment *\/ prop?: Type ... }" + * @returns {string} + */ +function formatObjectType(typeName, typeStr) { + // Strip surrounding braces + const inner = typeStr.slice(1, -1).trim() + + // Split on the start of each JSDoc block so each segment = one property + const segments = inner.split(/(?=\/\*\*)/) + + const lines = [`type ${typeName} = {`] + + for (const segment of segments) { + const trimmed = segment.trim() + if (!trimmed) continue + + let comment = '' + let propDef = trimmed + + const commentMatch = trimmed.match(/^\/\*\*([\s\S]*?)\*\/\s*(.*)$/) + if (commentMatch) { + // Collapse multi-line JSDoc continuation markers into a single-line comment. + // Only strip " * " patterns that act as line-continuation markers (i.e. whitespace + // on both sides), not inline * characters inside backtick spans or words. + comment = commentMatch[1] + .replace(/\s+\*\s+\*\s+/g, ' ') // collapse blank JSDoc paragraph separators " * * " + .replace(/\s+\*\s+/g, ' ') // collapse regular line-continuation " * " markers + .replace(/\s+/g, ' ') + .trim() + propDef = commentMatch[2].trim() + } + + if (!propDef) continue + + const colonIdx = propDef.indexOf(':') + if (colonIdx === -1) continue + + const propName = propDef.slice(0, colonIdx).trim() + const propType = simplifyType(propDef.slice(colonIdx + 1).trim()) + + if (comment) lines.push(` /** ${comment} */`) + lines.push(` ${propName}: ${propType}`) + } + + lines.push('}') + return lines.join('\n') +} + +/** + * Post-process generated markdown to replace unreadable inline object type strings + * (as produced by tsdoc-markdown) with formatted TypeScript code blocks. + * + * tsdoc-markdown emits type aliases as a two-column table: + * + * | Type | Type | + * | --------- | --------- | + * | `TypeName` | `{ /** comment *\/ prop?: Type ... }` | + * + * When the type cell contains embedded JSDoc markers (/** ... *\/), the type is a + * multi-property object that is completely unreadable inline. This function replaces + * those table rows with a formatted TypeScript code block. + * + * The second cell is matched greedily to the LAST backtick on the line, which correctly + * captures type strings that contain backtick characters inside JSDoc comment text. + * + * @param {string} markdown + * @returns {string} + */ +function prettifyObjectTypes(markdown) { + return markdown.replace( + // Match the three-line table tsdoc-markdown emits for type aliases: + // | Type | Type | + // | ------ | ------ | + // | `Name` | `{ ... }` | + // Greedy .+ in the type cell matches to the LAST backtick on the line, correctly + // handling type strings that contain backtick characters inside JSDoc text. + /\| Type +\| Type +\|\n\| [^\n]+ \|\n\| `([^`]+)` \| `(.+)` \|$/gm, + (match, typeName, typeStr) => { + if (!typeStr.startsWith('{') || !typeStr.endsWith('}') || !typeStr.includes('/**')) return match + return '```typescript\n' + formatObjectType(typeName, typeStr) + '\n```' + } + ) +} diff --git a/frontend/packages/multicluster-sdk/src/api/index.ts b/frontend/packages/multicluster-sdk/src/api/index.ts index 88af1388670..6e592931dc2 100644 --- a/frontend/packages/multicluster-sdk/src/api/index.ts +++ b/frontend/packages/multicluster-sdk/src/api/index.ts @@ -6,16 +6,17 @@ export * from './fleetK8sGet' export * from './fleetK8sList' export * from './fleetK8sPatch' export * from './fleetK8sUpdate' +export * from './getFleetK8sAPIPath' export * from './useFleetAccessReview' export * from './useFleetClusterNames' -export * from './useFleetClusterSets' export * from './useFleetClusterSetNames' +export * from './useFleetClusterSets' export * from './useFleetK8sAPIPath' export * from './useFleetK8sWatchResource' export * from './useFleetK8sWatchResources' export * from './useFleetPrometheusPoll' +export * from './useFleetSearch' export * from './useFleetSearchPoll' export * from './useHubClusterName' export * from './useIsFleetAvailable' export * from './useIsFleetObservabilityInstalled' -export * from './getFleetK8sAPIPath' diff --git a/frontend/packages/multicluster-sdk/src/api/useFleetSearch.test.ts b/frontend/packages/multicluster-sdk/src/api/useFleetSearch.test.ts new file mode 100644 index 00000000000..9977612f0f0 --- /dev/null +++ b/frontend/packages/multicluster-sdk/src/api/useFleetSearch.test.ts @@ -0,0 +1,841 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { act, renderHook } from '@testing-library/react-hooks' +import { + SearchResultItemsQuery, + SearchResultItemsQueryVariables, + useSearchResultItemsQuery, +} from '../internal/search/search-sdk' +import { SearchInput } from '../types/search' +import { useFleetSearch } from './useFleetSearch' +import { useFleetSearchSubscription } from './useFleetSearchSubscription' + +// Mock the base query hook +jest.mock('../internal/search/search-sdk', () => ({ + useSearchResultItemsQuery: jest.fn(), +})) + +// Mock the search client +jest.mock('../internal/search/search-client', () => ({ + searchClient: 'mock-search-client', +})) + +// Mock the subscription hook +jest.mock('./useFleetSearchSubscription', () => ({ + useFleetSearchSubscription: jest.fn(), +})) + +const mockUseSearchResultItemsQuery = jest.mocked(useSearchResultItemsQuery) +const mockUseFleetSearchSubscription = jest.mocked(useFleetSearchSubscription) + +const mockInput: SearchInput = { + filters: [{ property: 'kind', values: ['Pod'] }], +} + +const mockSearchItem = { + cluster: 'test-cluster', + apigroup: '', + apiversion: 'v1', + kind: 'Pod', + name: 'test-pod', + namespace: 'default', + created: '2024-01-01T00:00:00Z', + _uid: 'test-cluster/uid-1', +} + +const mockSearchResult = { + searchResult: [{ items: [mockSearchItem] }], +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +// Apollo's `refetch()` resolves with an `ApolloQueryResult`, but our hook only +// ever reads `.data` off of it — so the mock only needs to satisfy that shape, +// rather than fabricating `loading` / `networkStatus` in every test. +type RefetchResult = { data: SearchResultItemsQuery } +type RefetchMock = ReturnType, [Partial?]>> + +// Tests that only assert whether/how many times refetch was called (and don't +// care about post-refetch state) can use a mock that returns a promise which +// never resolves. +function pendingRefetch(): RefetchMock { + return jest + .fn, [Partial?]>() + .mockReturnValue(new Promise(() => {})) +} + +function makeQueryMock(items: object[], refetch: RefetchMock = pendingRefetch()) { + return { + data: { searchResult: [{ items }] }, + loading: false, + error: undefined, + refetch, + } as any +} + +function makeEvent( + operation: 'INSERT' | 'UPDATE' | 'DELETE', + uid: string, + newData: object | null, + oldData: object | null = null +) { + return { uid, operation, newData, oldData, timestamp: new Date() } as any +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('useFleetSearch', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseFleetSearchSubscription.mockReturnValue([undefined, false, undefined]) + }) + + // ── Base query behaviour ──────────────────────────────────────────────────── + + describe('base query', () => { + it('should return [undefined, false, undefined, refetch] while loading', () => { + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: true, + error: undefined, + refetch: jest.fn(), + } as any) + + const { result } = renderHook(() => useFleetSearch(mockInput)) + + const [data, loaded, error, refetch] = result.current + expect(data).toBeUndefined() + expect(loaded).toBe(false) + expect(error).toBeUndefined() + expect(typeof refetch).toBe('function') + }) + + it('should return converted resources when query succeeds', () => { + mockUseSearchResultItemsQuery.mockReturnValue({ + data: mockSearchResult, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + + const { result } = renderHook(() => useFleetSearch(mockInput)) + + const [data, loaded, error] = result.current + expect(loaded).toBe(true) + expect(error).toBeUndefined() + expect(data).toHaveLength(1) + expect(data![0].metadata?.name).toBe('test-pod') + }) + + it('should return undefined data and report error when query fails', () => { + const mockError = new Error('query failed') + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: mockError, + refetch: jest.fn(), + } as any) + + const { result } = renderHook(() => useFleetSearch(mockInput)) + + const [data, loaded, error] = result.current + expect(data).toBeUndefined() + expect(loaded).toBe(true) + expect(error).toBe(mockError) + }) + + it('should skip the query when input is undefined', () => { + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + + renderHook(() => useFleetSearch(undefined)) + + expect(mockUseSearchResultItemsQuery).toHaveBeenCalledWith(expect.objectContaining({ skip: true })) + }) + + it('should pass the correct variables to the query', () => { + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + + renderHook(() => useFleetSearch(mockInput)) + + expect(mockUseSearchResultItemsQuery).toHaveBeenCalledWith( + expect.objectContaining({ + client: 'mock-search-client', + skip: false, + variables: { input: [mockInput] }, + }) + ) + }) + + it('should provide a stable refetch callback', () => { + const mockRefetch = pendingRefetch() + mockUseSearchResultItemsQuery.mockReturnValue({ + data: mockSearchResult, + loading: false, + error: undefined, + refetch: mockRefetch, + } as any) + + const { result } = renderHook(() => useFleetSearch(mockInput)) + + const [, , , refetch] = result.current + refetch() + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + }) + + // ── Subscription enabled / disabled ──────────────────────────────────────── + + describe('subscription disabled (default)', () => { + it('should pass undefined to useFleetSearchSubscription when subscriptionEnabled is false', () => { + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + + renderHook(() => useFleetSearch(mockInput, false)) + + expect(mockUseFleetSearchSubscription).toHaveBeenCalledWith(undefined) + }) + + it('should pass undefined to useFleetSearchSubscription when subscriptionEnabled is omitted', () => { + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + + renderHook(() => useFleetSearch(mockInput)) + + expect(mockUseFleetSearchSubscription).toHaveBeenCalledWith(undefined) + }) + }) + + describe('subscription enabled', () => { + it('should pass input to useFleetSearchSubscription when subscriptionEnabled is true', () => { + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + + renderHook(() => useFleetSearch(mockInput, true)) + + expect(mockUseFleetSearchSubscription).toHaveBeenCalledWith(mockInput) + }) + + it('should surface a subscription error via the error return value', () => { + const subError = new Error('ws error') + mockUseSearchResultItemsQuery.mockReturnValue({ + data: mockSearchResult, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([undefined, false, subError]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + const [, , error] = result.current + expect(error).toBe(subError) + }) + + it('should prefer query error over subscription error', () => { + const queryError = new Error('query error') + const subError = new Error('ws error') + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: queryError, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([undefined, false, subError]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + const [, , error] = result.current + expect(error).toBe(queryError) + }) + }) + + // ── Mode: unbounded (no limit / offset) ──────────────────────────────────── + + describe('unbounded mode — INSERT', () => { + it('should append a new resource on INSERT', () => { + const existingItem = { ...mockSearchItem, name: 'existing-pod', _uid: 'test-cluster/uid-existing' } + const newItem = { ...mockSearchItem, name: 'new-pod', _uid: 'test-cluster/uid-new' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([existingItem])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-new', newItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + expect(result.current[0]).toHaveLength(2) + expect(result.current[0]!.map((r) => r.metadata?.name)).toContain('new-pod') + }) + + it('should not duplicate on INSERT if uid already exists', () => { + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([item])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-1', item), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + expect(result.current[0]).toHaveLength(1) + }) + + it('should insert at the correct sorted position when orderBy is set', () => { + const appleItem = { ...mockSearchItem, name: 'apple', _uid: 'test-cluster/uid-apple' } + const mangoItem = { ...mockSearchItem, name: 'mango', _uid: 'test-cluster/uid-mango' } + const figItem = { ...mockSearchItem, name: 'fig', _uid: 'test-cluster/uid-fig' } + // No limit — unbounded mode even with orderBy + const inputWithOrderBy: SearchInput = { ...mockInput, orderBy: 'name asc' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-fig', figItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(inputWithOrderBy, true)) + + expect(result.current[0]).toHaveLength(3) + expect(result.current[0]!.map((r) => r.metadata?.name)).toEqual(['apple', 'fig', 'mango']) + }) + }) + + describe('unbounded mode — UPDATE', () => { + it('should update fields in place on UPDATE without re-sorting', () => { + // Use `status` as the sort field — it can legitimately change + const pendingItem = { ...mockSearchItem, name: 'pod-a', status: 'Pending', _uid: 'test-cluster/uid-a' } + const runningItem = { ...mockSearchItem, name: 'pod-b', status: 'Running', _uid: 'test-cluster/uid-b' } + // pod-a transitions to Terminated and gains a label; with re-sort it would move to index 1, + // but the spec says UPDATE patches in place without re-sorting. + const updatedItem = { ...pendingItem, status: 'Terminated', label: 'abc=123' } + const inputWithOrderBy: SearchInput = { ...mockInput, orderBy: 'status asc' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([pendingItem, runningItem])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('UPDATE', 'test-cluster/uid-a', updatedItem, pendingItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(inputWithOrderBy, true)) + + expect(result.current[0]).toHaveLength(2) + // pod-a stays at position 0 — no re-sort + expect(result.current[0]![0].metadata?.name).toBe('pod-a') + expect(result.current[0]![1].metadata?.name).toBe('pod-b') + // New label applied + expect(result.current[0]![0].metadata?.labels).toEqual({ abc: '123' }) + }) + + it('should merge updated fields on UPDATE — e.g. adding a label', () => { + const originalItem = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + const updatedItem = { ...mockSearchItem, _uid: 'test-cluster/uid-1', label: 'abc=123' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([originalItem])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('UPDATE', 'test-cluster/uid-1', updatedItem, originalItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + expect(result.current[0]).toHaveLength(1) + expect(result.current[0]![0].metadata?.name).toBe('test-pod') + expect(result.current[0]![0].metadata?.labels).toEqual({ abc: '123' }) + }) + }) + + describe('unbounded mode — DELETE', () => { + it('should remove the matching resource on DELETE', () => { + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([item])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('DELETE', 'test-cluster/uid-1', null, item), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + expect(result.current[0]).toHaveLength(0) + }) + + it('should be a no-op on DELETE when uid does not match any resource', () => { + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([item])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('DELETE', 'test-cluster/uid-nonexistent', null, null), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + expect(result.current[0]).toHaveLength(1) + }) + }) + + // ── Mode: paginated-unordered (limit/offset, no orderBy) ─────────────────── + + describe('paginated-unordered mode', () => { + const paginatedInput: SearchInput = { ...mockInput, limit: 10 } + + it('should trigger a refetch on INSERT', () => { + const mockRefetch = pendingRefetch() + const existingItem = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + const newItem = { ...mockSearchItem, name: 'new-pod', _uid: 'test-cluster/uid-new' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([existingItem], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-new', newItem), + false, + undefined, + ]) + + renderHook(() => useFleetSearch(paginatedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should trigger a refetch on DELETE', () => { + const mockRefetch = pendingRefetch() + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([item], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('DELETE', 'test-cluster/uid-1', null, item), + false, + undefined, + ]) + + renderHook(() => useFleetSearch(paginatedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should patch in place on UPDATE without refetching', () => { + const mockRefetch = pendingRefetch() + const originalItem = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + const updatedItem = { ...mockSearchItem, _uid: 'test-cluster/uid-1', label: 'abc=123' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([originalItem], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('UPDATE', 'test-cluster/uid-1', updatedItem, originalItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(paginatedInput, true)) + + expect(mockRefetch).not.toHaveBeenCalled() + expect(result.current[0]).toHaveLength(1) + expect(result.current[0]![0].metadata?.labels).toEqual({ abc: '123' }) + }) + }) + + // ── Mode: paginated-ordered (limit + orderBy) ─────────────────────────────── + + describe('paginated-ordered mode — INSERT', () => { + const orderedInput: SearchInput = { ...mockInput, limit: 2, orderBy: 'name asc' } + const appleItem = { ...mockSearchItem, name: 'apple', _uid: 'test-cluster/uid-apple' } + const mangoItem = { ...mockSearchItem, name: 'mango', _uid: 'test-cluster/uid-mango' } + + it('should trigger a refetch when new item sorts at or before the first item', () => { + const mockRefetch = pendingRefetch() + // 'aaa' < 'apple' → before first → refetch + const newItem = { ...mockSearchItem, name: 'aaa', _uid: 'test-cluster/uid-aaa' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-aaa', newItem), + false, + undefined, + ]) + + renderHook(() => useFleetSearch(orderedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should insert locally and drop the last item when new item sorts strictly between first and last', () => { + // 'apple' < 'fig' < 'mango' → between → local insert + drop last + const figItem = { ...mockSearchItem, name: 'fig', _uid: 'test-cluster/uid-fig' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-fig', figItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(orderedInput, true)) + + expect(result.current[0]).toHaveLength(2) + expect(result.current[0]!.map((r) => r.metadata?.name)).toEqual(['apple', 'fig']) + }) + + it('should trigger a refetch when new item ties with the last item (server tie-breaking unknown)', () => { + const mockRefetch = pendingRefetch() + // 'mango' === last item 'mango' → tied → refetch + const tiedItem = { ...mockSearchItem, name: 'mango', _uid: 'test-cluster/uid-mango2' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-mango2', tiedItem), + false, + undefined, + ]) + + renderHook(() => useFleetSearch(orderedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should ignore an INSERT that sorts after the last item on the page', () => { + // 'zebra' > 'mango' → after last → not on this page → ignore + const zebraItem = { ...mockSearchItem, name: 'zebra', _uid: 'test-cluster/uid-zebra' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-zebra', zebraItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(orderedInput, true)) + + // Page is unchanged + expect(result.current[0]).toHaveLength(2) + expect(result.current[0]!.map((r) => r.metadata?.name)).toEqual(['apple', 'mango']) + }) + }) + + describe('paginated-ordered mode — DELETE', () => { + const orderedInput: SearchInput = { ...mockInput, limit: 2, orderBy: 'name asc' } + const appleItem = { ...mockSearchItem, name: 'apple', _uid: 'test-cluster/uid-apple' } + const mangoItem = { ...mockSearchItem, name: 'mango', _uid: 'test-cluster/uid-mango' } + + it('should trigger a refetch when the deleted item is on the current page', () => { + const mockRefetch = pendingRefetch() + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('DELETE', 'test-cluster/uid-apple', null, appleItem), + false, + undefined, + ]) + + renderHook(() => useFleetSearch(orderedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should trigger a refetch when deleted item was on an earlier page (sorts ≤ last item)', () => { + const mockRefetch = pendingRefetch() + // 'cherry' is not on this page but sorts before 'mango' — its removal shifts our page + const cherryItem = { ...mockSearchItem, name: 'cherry', _uid: 'test-cluster/uid-cherry' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('DELETE', 'test-cluster/uid-cherry', null, cherryItem), + false, + undefined, + ]) + + renderHook(() => useFleetSearch(orderedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + it('should ignore a DELETE for an item that sorts after the last item on the page', () => { + // 'zebra' > 'mango' → on a later page → deletion has no effect on this page + const zebraItem = { ...mockSearchItem, name: 'zebra', _uid: 'test-cluster/uid-zebra' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([appleItem, mangoItem])) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('DELETE', 'test-cluster/uid-zebra', null, zebraItem), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(orderedInput, true)) + + expect(result.current[0]).toHaveLength(2) + expect(result.current[0]!.map((r) => r.metadata?.name)).toEqual(['apple', 'mango']) + }) + }) + + describe('paginated-ordered mode — UPDATE', () => { + it('should patch in place on UPDATE without refetching', () => { + const mockRefetch = pendingRefetch() + const orderedInput: SearchInput = { ...mockInput, limit: 2, orderBy: 'name asc' } + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + const updatedItem = { ...item, label: 'abc=123' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([item], mockRefetch)) + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('UPDATE', 'test-cluster/uid-1', updatedItem, item), + false, + undefined, + ]) + + const { result } = renderHook(() => useFleetSearch(orderedInput, true)) + + expect(mockRefetch).not.toHaveBeenCalled() + expect(result.current[0]).toHaveLength(1) + expect(result.current[0]![0].metadata?.labels).toEqual({ abc: '123' }) + }) + }) + + // ── Refetch queue ─────────────────────────────────────────────────────────── + + describe('refetch queue', () => { + it('should queue events during a refetch and apply non-refetch events after refetch completes', async () => { + const existingItem = { ...mockSearchItem, name: 'pod-a', _uid: 'test-cluster/uid-a' } + // Paginated-unordered: INSERT triggers refetch + const paginatedInput: SearchInput = { ...mockInput, limit: 10 } + + // Refetch completion is driven by the Promise `refetch()` returns — capture + // the resolver so the test can control exactly when it "completes", after + // the queued UPDATE event has already arrived. + let resolveRefetch: (value: RefetchResult) => void = () => {} + const mockRefetch: RefetchMock = jest.fn( + () => + new Promise((resolve) => { + resolveRefetch = resolve + }) + ) + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([existingItem], mockRefetch)) + // INSERT → triggers refetch + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-b', { ...mockSearchItem, name: 'pod-b', _uid: 'test-cluster/uid-b' }), + false, + undefined, + ]) + + const { result, rerender } = renderHook(() => useFleetSearch(paginatedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + + // UPDATE arrives while refetch is in flight → should be queued + const updateEvent = makeEvent('UPDATE', 'test-cluster/uid-a', { ...existingItem, label: 'abc=123' }, existingItem) + mockUseFleetSearchSubscription.mockReturnValue([updateEvent, false, undefined]) + act(() => { + rerender() + }) + + // Refetch resolves with fresh data (both pods) — note this is delivered + // via the Promise, not by changing the mocked hook's return value, since + // Apollo may keep the same `data` reference when a result is deeply equal + // to what's cached, in which case a `queryData` re-render would never fire. + const freshNewItem = { ...mockSearchItem, name: 'pod-b', _uid: 'test-cluster/uid-b' } + await act(async () => { + resolveRefetch({ data: { searchResult: [{ items: [existingItem, freshNewItem] }] } }) + }) + + // Fresh data (2 items) + queued UPDATE applied → pod-a now has the label + expect(result.current[0]).toHaveLength(2) + const podA = result.current[0]!.find((r) => r.metadata?.name === 'pod-a') + expect(podA?.metadata?.labels).toEqual({ abc: '123' }) + }) + + it('should trigger a second refetch when a queued event requires one', async () => { + const existingItem = { ...mockSearchItem, _uid: 'test-cluster/uid-a' } + const paginatedInput: SearchInput = { ...mockInput, limit: 10 } + + let resolveFirstRefetch: (value: RefetchResult) => void = () => {} + const mockRefetch: RefetchMock = jest + .fn, [Partial?]>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstRefetch = resolve + }) + ) + // The second refetch (triggered by the queued event) is only checked for + // call count, so it can stay pending indefinitely. + .mockReturnValue(new Promise(() => {})) + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([existingItem], mockRefetch)) + // First INSERT → triggers first refetch + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-b', { ...mockSearchItem, _uid: 'test-cluster/uid-b' }), + false, + undefined, + ]) + + const { rerender } = renderHook(() => useFleetSearch(paginatedInput, true)) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + + // Second INSERT arrives while first refetch is in flight → queued + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-c', { ...mockSearchItem, _uid: 'test-cluster/uid-c' }), + false, + undefined, + ]) + act(() => { + rerender() + }) + + // First refetch resolves without the queued item — the queued INSERT + // (paginated-unordered) still requires a refetch, so a second one is chained. + const freshItem = { ...mockSearchItem, name: 'pod-b', _uid: 'test-cluster/uid-b' } + await act(async () => { + resolveFirstRefetch({ data: { searchResult: [{ items: [existingItem, freshItem] }] } }) + }) + + expect(mockRefetch).toHaveBeenCalledTimes(2) + }) + + it('reflects a DELETE after refetch even when the resolved data is identical to what was already cached', async () => { + // Regression test: page starts with 4 items (below limit=5). An item is + // inserted into the last slot purely as a local patch (the base query + // never observes it, since no refetch is needed for an in-page insert). + // Deleting that item then triggers a refetch whose result is byte-for-byte + // identical to what was cached *before* the insert. Because completion is + // driven by the Promise from `refetch()` rather than by `queryData` + // reference identity, the deletion must still be reflected even if Apollo + // would otherwise skip a re-render for "unchanged" data. + const a = { ...mockSearchItem, name: 'a', _uid: 'test-cluster/uid-a' } + const b = { ...mockSearchItem, name: 'b', _uid: 'test-cluster/uid-b' } + const c = { ...mockSearchItem, name: 'c', _uid: 'test-cluster/uid-c' } + const d = { ...mockSearchItem, name: 'd', _uid: 'test-cluster/uid-d' } + const orderedInput: SearchInput = { ...mockInput, limit: 5, orderBy: 'name asc' } + + let resolveRefetch: (value: RefetchResult) => void = () => {} + const mockRefetch: RefetchMock = jest.fn( + () => + new Promise((resolve) => { + resolveRefetch = resolve + }) + ) + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([a, b, c, d], mockRefetch)) + // INSERT 'e' — sorts after 'd' (the last item); page has room so it's + // applied as a local patch without ever refetching the base query. + const eItem = { ...mockSearchItem, name: 'e', _uid: 'test-cluster/uid-e' } + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-e', eItem), + false, + undefined, + ]) + + const { result, rerender } = renderHook(() => useFleetSearch(orderedInput, true)) + + expect(result.current[0]!.map((r) => r.metadata?.name)).toEqual(['a', 'b', 'c', 'd', 'e']) + expect(mockRefetch).not.toHaveBeenCalled() + + // DELETE 'e' — it's on the page, so a refetch is triggered. + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('DELETE', 'test-cluster/uid-e', null, eItem), + false, + undefined, + ]) + act(() => { + rerender() + }) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + + // Refetch resolves with the original 4 items — identical to what the base + // query already had cached before the insert. + await act(async () => { + resolveRefetch({ data: { searchResult: [{ items: [a, b, c, d] }] } }) + }) + + expect(result.current[0]!.map((r) => r.metadata?.name)).toEqual(['a', 'b', 'c', 'd']) + }) + }) + + // ── subscriptionEnabled toggle ────────────────────────────────────────────── + + describe('subscriptionEnabled toggle', () => { + it('should reset to query data when subscriptionEnabled changes from true to false', () => { + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + + mockUseSearchResultItemsQuery.mockReturnValue(makeQueryMock([item])) + // INSERT event makes local state diverge + mockUseFleetSearchSubscription.mockReturnValue([ + makeEvent('INSERT', 'test-cluster/uid-new', { + ...mockSearchItem, + name: 'extra-pod', + _uid: 'test-cluster/uid-new', + }), + false, + undefined, + ]) + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => useFleetSearch(mockInput, enabled), + { initialProps: { enabled: true } } + ) + + expect(result.current[0]).toHaveLength(2) + + mockUseFleetSearchSubscription.mockReturnValue([undefined, false, undefined]) + act(() => { + rerender({ enabled: false }) + }) + + expect(result.current[0]).toHaveLength(1) + }) + }) + + // ── Pagination passthrough ────────────────────────────────────────────────── + + describe('pagination', () => { + it('should pass limit and offset through to the query unchanged', () => { + const paginatedInput: SearchInput = { + filters: [{ property: 'kind', values: ['Pod'] }], + limit: 20, + offset: 40, + } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + + renderHook(() => useFleetSearch(paginatedInput)) + + expect(mockUseSearchResultItemsQuery).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { input: [paginatedInput] }, + }) + ) + }) + }) +}) diff --git a/frontend/packages/multicluster-sdk/src/api/useFleetSearch.ts b/frontend/packages/multicluster-sdk/src/api/useFleetSearch.ts new file mode 100644 index 00000000000..097faea0f78 --- /dev/null +++ b/frontend/packages/multicluster-sdk/src/api/useFleetSearch.ts @@ -0,0 +1,421 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { convertSearchItemToResource } from '../internal/search/convertSearchItemToResource' +import { searchClient } from '../internal/search/search-client' +import { + Event as FleetSearchEvent, + SearchResultItemsQuery, + useSearchResultItemsQuery, +} from '../internal/search/search-sdk' +import { Fleet } from '../types/fleet' +import { SearchInput } from '../types/search' +import { useFleetSearchSubscription } from './useFleetSearchSubscription' + +/** A flat search result item as returned by the search API. */ +type SearchItem = Record + +/** Extract the raw item list from a search query response payload. */ +function extractSearchItems(data: SearchResultItemsQuery | undefined | null): SearchItem[] { + const items = data?.searchResult?.[0]?.items + return items ? (items.filter(Boolean) as SearchItem[]) : [] +} + +/** + * How subscription events should be applied to the local page. + * + * - `unbounded` — no limit/offset; events are patched directly with optional sort. + * - `paginated-unordered` — limit/offset present but no `orderBy`; item positions are + * non-deterministic so ADD and DELETE always trigger a refetch. + * - `paginated-ordered` — limit/offset AND `orderBy` present; positional rules apply. + */ +type SearchMode = 'unbounded' | 'paginated-unordered' | 'paginated-ordered' + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Determine the event-patching mode from the current search input. */ +function getSearchMode(input: SearchInput | undefined): SearchMode { + const hasPagination = + input != null && ((input.limit != null && input.limit > 0) || (input.offset != null && input.offset > 0)) + if (!hasPagination) return 'unbounded' + return input!.orderBy ? 'paginated-ordered' : 'paginated-unordered' +} + +/** + * Signed comparison that accounts for sort direction. + * Negative → `a` comes before `b` in the sorted order, positive → after. + */ +function sortCmp(a: string, b: string, descending: boolean): number { + return descending ? b.localeCompare(a) : a.localeCompare(b) +} + +/** + * Insert `newItem` into `items` at the position dictated by `orderBy`, or + * append it at the end when `orderBy` is absent. + */ +function insertSorted(items: SearchItem[], newItem: SearchItem, orderBy: string | null | undefined): SearchItem[] { + if (!orderBy) return [...items, newItem] + const [field, dir] = orderBy.trim().split(/\s+/) + const descending = dir?.toLowerCase() === 'desc' + const newVal = String(newItem[field] ?? '') + const insertIdx = items.findIndex((item) => sortCmp(newVal, String(item[field] ?? ''), descending) < 0) + if (insertIdx === -1) return [...items, newItem] + return [...items.slice(0, insertIdx), newItem, ...items.slice(insertIdx)] +} + +/** + * Decide whether a subscription event requires a full refetch rather than a + * local patch, given the current page contents and search mode. + * + * Unbounded queries — never refetch; all events patch in place. + * + * Paginated-unordered — ADD and DELETE always refetch because item positions + * are non-deterministic without a sort order. UPDATE patches in place. + * + * Paginated-ordered: + * ADD + * • new item sorts ≤ first item on the page → page has shifted → refetch + * • new item sorts strictly between first and last → local insert + drop last + * • new item sorts equal to last item → server tie-breaking unknown → refetch + * • new item sorts > last item → not on this page → ignore (no refetch) + * DELETE + * • deleted item is on this page → gap opens that needs backfilling → refetch + * • deleted item is not on this page but sorts ≤ last item (earlier page) → shift → refetch + * • deleted item sorts > last item → on a later page → ignore (no refetch) + * • oldData unavailable → position unknown → refetch to be safe + * UPDATE — always patches in place; never triggers a refetch. + */ +function wouldTriggerRefetch( + event: FleetSearchEvent, + current: SearchItem[], + mode: SearchMode, + input: SearchInput | undefined +): boolean { + if (mode === 'unbounded') return false + + if (mode === 'paginated-unordered') { + return event.operation === 'INSERT' || event.operation === 'DELETE' + } + + // ── paginated-ordered ──────────────────────────────────────────────────── + const [field, dir] = (input?.orderBy ?? '').trim().split(/\s+/) + const descending = dir?.toLowerCase() === 'desc' + + if (event.operation === 'INSERT') { + if (!event.newData) return false + if (current.length === 0) return true // empty page — cannot determine position → refetch + const cluster = event.uid.split('/')[0] + const newVal = String(({ ...event.newData, cluster, _uid: event.uid } as SearchItem)[field] ?? '') + const firstVal = String(current[0][field] ?? '') + const lastVal = String(current[current.length - 1][field] ?? '') + const cmpFirst = sortCmp(newVal, firstVal, descending) + const cmpLast = sortCmp(newVal, lastVal, descending) + if (cmpFirst <= 0) return true // ≤ first → page has shifted → refetch + if (cmpLast === 0) return true // tied with last → server tie-breaking unknown → refetch + if (cmpLast > 0) return false // > last → not on this page → ignore + return false // strictly between → local insert + } + + if (event.operation === 'DELETE') { + if (current.length === 0) return false + if (current.some((item) => item._uid === event.uid)) return true // on this page → gap → refetch + // Not on this page. Check position using oldData. + const oldRaw = event.oldData + if (oldRaw) { + const cluster = event.uid.split('/')[0] + const oldVal = String(({ ...oldRaw, cluster, _uid: event.uid } as SearchItem)[field] ?? '') + const lastVal = String(current[current.length - 1][field] ?? '') + // ≤ last → was on an earlier page; its removal shifts our window → refetch + return sortCmp(oldVal, lastVal, descending) <= 0 + } + return true // no oldData — position unknown → refetch to be safe + } + + return false // UPDATE never triggers a refetch +} + +/** + * Apply a subscription event directly to the current page and return the updated array. + * Only called when `wouldTriggerRefetch` returned `false` for this event. + */ +function applyEvent( + current: SearchItem[], + event: FleetSearchEvent, + mode: SearchMode, + input: SearchInput | undefined +): SearchItem[] { + switch (event.operation) { + case 'INSERT': { + if (!event.newData) return current + const cluster = event.uid.split('/')[0] + const patchedItem: SearchItem = { ...event.newData, cluster, _uid: event.uid } + if (current.some((item) => item._uid === patchedItem._uid)) return current // dedup + if (mode === 'unbounded') { + return insertSorted(current, patchedItem, input?.orderBy) + } + // paginated-ordered: item sorts strictly between first and last + const inserted = insertSorted(current, patchedItem, input?.orderBy) + const limit = input?.limit + if (limit != null && limit > 0 && inserted.length > limit) return inserted.slice(0, limit) + return inserted + } + case 'UPDATE': { + if (!event.newData) return current + const cluster = event.uid.split('/')[0] + const patchedItem: SearchItem = { ...event.newData, cluster, _uid: event.uid } + // Update in place. No re-sort: K8s names are immutable, and re-sorting a paginated + // result would cause items to jump across page boundaries unpredictably. + return current.map((item) => (item._uid === patchedItem._uid ? patchedItem : item)) + } + case 'DELETE': { + return current.filter((item) => item._uid !== event.uid) + } + default: + return current + } +} + +/** + * A React hook that provides fleet-wide search functionality using the ACM search API, + * with optional real-time updates via a GraphQL WebSocket subscription. + * + * When `subscriptionEnabled` is `false` (the default), the hook issues a one-shot + * GraphQL query and returns the results. When `subscriptionEnabled` is `true`, the + * hook additionally opens a WebSocket subscription and patches the locally-held + * results as INSERT, UPDATE, and DELETE events arrive — keeping the data always + * up to date without polling. + * + * Pagination is supported by setting `limit` and `offset` on the `SearchInput` + * object. The caller is responsible for constructing those values. + * + * @param input - The search input object (filters, keywords, limit, offset, etc.). + * Pass `undefined` to skip the query entirely. + * @param subscriptionEnabled - When `true`, a WebSocket subscription is opened + * and the local result set is kept current via incremental event patches. + * Defaults to `false`. + * + * @returns A tuple of: + * - `data` — The current search results mapped through + * {@link convertSearchItemToResource}, or `undefined` before the first + * response arrives. + * - `loaded` — `true` once the initial query has completed (regardless of + * whether the subscription is active). + * - `error` — Any query or subscription error, or `undefined` on success. + * - `refetch` — A stable callback that re-executes the base query and resets + * the local state to the fresh result. + * + * @example + * ```typescript + * // Basic query — no real-time updates + * const [resources, loaded, error, refetch] = useFleetSearch({ + * filters: [ + * { property: 'kind', values: ['Pod'] }, + * { property: 'namespace', values: ['default'] }, + * ], + * limit: 100, + * }) + * + * // With real-time subscription — results update automatically + * const [resources, loaded, error, refetch] = useFleetSearch( + * { + * filters: [ + * { property: 'kind', values: ['Pod'] }, + * { property: 'namespace', values: ['default'] }, + * ], + * }, + * true, + * ) + * + * // With subscription enabled and pagination/ordering — page 2 of 20 results sorted by name + * const PAGE_SIZE = 20 + * const [page, setPage] = useState(1) + * const [resources, loaded, error, refetch] = useFleetSearch( + * { + * filters: [ + * { property: 'kind', values: ['Pod'] }, + * { property: 'namespace', values: ['default'] }, + * ], + * limit: PAGE_SIZE, + * offset: (page - 1) * PAGE_SIZE, + * orderBy: 'name asc', + * }, + * true, + * ) + * ``` + */ +export function useFleetSearch( + input: SearchInput | undefined, + subscriptionEnabled?: boolean +): [Fleet[] | undefined, boolean, Error | undefined, () => void] { + // ── Base query ───────────────────────────────────────────────────────────── + + const { + data: queryResult, + loading, + error: queryError, + refetch, + } = useSearchResultItemsQuery({ + client: searchClient, + skip: input === undefined, + variables: { input: [input!] }, + }) + + // Derive the raw item list from the query response — conversion to K8s + // resources happens once at return time via useMemo. + const queryData = useMemo(() => { + const items = queryResult?.searchResult?.[0]?.items + if (!items) return undefined + return items.filter(Boolean) as SearchItem[] + }, [queryResult]) + + // ── Local state (patched by subscription events) ─────────────────────────── + + const [localData, setLocalData] = useState(queryData) + + // Ref that mirrors localData so event-handler effects can read the current + // page contents without listing localData as a dependency (which would cause + // stale-event replay on every data change). + const localDataRef = useRef([]) + + // Tracks whether a refetch is currently in flight. + const isRefetchingRef = useRef(false) + + // Events that arrive while a refetch is in flight are queued here and + // applied (or trigger another refetch) once the fresh data lands. + const eventQueueRef = useRef([]) + + // Keep a ref to the latest input so effects can always read current + // orderBy / limit without triggering re-runs when input changes. + const inputRef = useRef(input) + useEffect(() => { + inputRef.current = input + }) + + // Reconcile events that queued up while a subscription-triggered refetch was + // in flight against the freshly fetched data. If a queued event itself + // demands another refetch, chain into one instead of settling on stale data. + // + // Completion is driven by the Promise returned by `refetch()` rather than by + // observing `queryData` change: Apollo may keep the exact same `data` + // reference (and skip a re-render) when a refetch's result is deeply equal + // to what's already cached — e.g. an item is inserted locally (without + // updating the base query) and then deleted, so the refetch result matches + // the still-cached pre-insert data byte-for-byte. Relying on reference + // identity would leave `isRefetchingRef` stuck `true` forever and local data + // permanently stale. + const settleRefetch = useCallback( + (freshData: SearchItem[]) => { + const queue = eventQueueRef.current + eventQueueRef.current = [] + const currentInput = inputRef.current + const mode = getSearchMode(currentInput) + + // If any queued event would require yet another refetch, chain into it + // and discard the queue — the next fresh result will settle the state. + const refetchEvent = queue.find((e) => wouldTriggerRefetch(e, freshData, mode, currentInput)) + if (refetchEvent) { + localDataRef.current = freshData + setLocalData(freshData) + refetch() + .then((result) => settleRefetch(extractSearchItems(result.data))) + .catch(() => { + isRefetchingRef.current = false + }) + return + } + + // No further refetch needed — walk the queue and apply any events that + // are not already reflected in the fresh data. + const finalData = queue.reduce((acc, e) => applyEvent(acc, e, mode, currentInput), freshData) + localDataRef.current = finalData + isRefetchingRef.current = false + setLocalData(finalData) + }, + [refetch] + ) + + // Kick off a refetch and settle local state once the network response + // resolves, via the returned Promise (see `settleRefetch` for why we can't + // rely on `queryData` reference identity here). + const startRefetch = useCallback(() => { + isRefetchingRef.current = true + eventQueueRef.current = [] + refetch() + .then((result) => settleRefetch(extractSearchItems(result.data))) + .catch(() => { + isRefetchingRef.current = false + }) + }, [refetch, settleRefetch]) + + // When fresh query data arrives from the initial load (or Apollo pushes new + // data outside of our own refetch flow), reset local state. Skipped while a + // subscription-triggered refetch is in flight — that path is settled by + // `settleRefetch` above once the Promise resolves. + useEffect(() => { + if (isRefetchingRef.current) return + localDataRef.current = queryData ?? [] + setLocalData(queryData) + }, [queryData]) + + // When subscriptionEnabled is turned off, reset to the base query result + // and clear any in-flight refetch state. + useEffect(() => { + if (!subscriptionEnabled) { + isRefetchingRef.current = false + eventQueueRef.current = [] + localDataRef.current = queryData ?? [] + setLocalData(queryData) + } + // We intentionally only react to the subscriptionEnabled flag here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscriptionEnabled]) + + // ── Subscription layer ───────────────────────────────────────────────────── + + // Pass undefined as input when subscription is disabled so the inner hook + // skips the WebSocket connection entirely. + const [latestEvent, , subscriptionError] = useFleetSearchSubscription(subscriptionEnabled ? input : undefined) + + // Route each incoming subscription event based on the current search mode. + useEffect(() => { + if (!latestEvent) return + + // While a refetch is in flight, queue events for later processing. + if (isRefetchingRef.current) { + eventQueueRef.current = [...eventQueueRef.current, latestEvent] + return + } + + const currentInput = inputRef.current + const mode = getSearchMode(currentInput) + const current = localDataRef.current + + if (wouldTriggerRefetch(latestEvent, current, mode, currentInput)) { + startRefetch() + return + } + + const next = applyEvent(current, latestEvent, mode, currentInput) + localDataRef.current = next + setLocalData(next) + }, [latestEvent, startRefetch]) + + // ── Stable refetch callback ──────────────────────────────────────────────── + + const triggerRefetch = useCallback(() => { + if (isRefetchingRef.current) return + startRefetch() + }, [startRefetch]) + + // ── Return ───────────────────────────────────────────────────────────────── + + // Convert the raw SearchItems to K8s resources once, only when localData changes. + const data = useMemo[] | undefined>(() => { + if (!localData) return undefined + return localData.map((item) => convertSearchItemToResource(item)) as Fleet[] + }, [localData]) + + const error = queryError ?? subscriptionError + + return [data, !loading, error, triggerRefetch] +} diff --git a/frontend/packages/multicluster-sdk/src/api/useFleetSearchPoll.test.ts b/frontend/packages/multicluster-sdk/src/api/useFleetSearchPoll.test.ts index c14f074ef9e..1bfc35cda7d 100644 --- a/frontend/packages/multicluster-sdk/src/api/useFleetSearchPoll.test.ts +++ b/frontend/packages/multicluster-sdk/src/api/useFleetSearchPoll.test.ts @@ -1,9 +1,9 @@ /* Copyright Contributors to the Open Cluster Management project */ +import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk' import { renderHook } from '@testing-library/react-hooks' -import { useFleetSearchPoll } from './useFleetSearchPoll' import { useSearchResultItemsQuery } from '../internal/search/search-sdk' -import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk' import { FleetWatchK8sResource } from '../types' +import { useFleetSearchPoll } from './useFleetSearchPoll' // Mock the search-sdk hook jest.mock('../internal/search/search-sdk', () => ({ diff --git a/frontend/packages/multicluster-sdk/src/api/useFleetSearchSubscription.test.ts b/frontend/packages/multicluster-sdk/src/api/useFleetSearchSubscription.test.ts new file mode 100644 index 00000000000..d2c98c5b7da --- /dev/null +++ b/frontend/packages/multicluster-sdk/src/api/useFleetSearchSubscription.test.ts @@ -0,0 +1,241 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { renderHook } from '@testing-library/react-hooks' +import { useFleetSearchSubscription } from './useFleetSearchSubscription' +import { useSearchSubscription } from '../internal/search/search-sdk' +import { SearchInput } from '../types/search' + +// Mock the generated Apollo subscription hook +jest.mock('../internal/search/search-sdk', () => ({ + useSearchSubscription: jest.fn(), +})) + +// Mock the search client +jest.mock('../internal/search/search-client', () => ({ + searchClient: 'mock-search-client', +})) + +const mockUseSearchSubscription = useSearchSubscription as jest.MockedFunction + +describe('useFleetSearchSubscription', () => { + const mockInput: SearchInput = { + filters: [ + { property: 'kind', values: ['Pod'] }, + { property: 'namespace', values: ['default'] }, + ], + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('basic functionality', () => { + it('should return [undefined, true, undefined] while loading', () => { + mockUseSearchSubscription.mockReturnValue({ + data: undefined, + loading: true, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + + const [latestEvent, loading, error] = result.current + expect(latestEvent).toBeUndefined() + expect(loading).toBe(true) + expect(error).toBeUndefined() + }) + + it('should return the latest event when data arrives', () => { + const mockEvent = { + uid: 'abc-123', + operation: 'INSERT', + newData: { kind: 'Pod', name: 'test-pod', namespace: 'default' }, + oldData: null, + timestamp: new Date('2024-01-01T00:00:00Z'), + } + + mockUseSearchSubscription.mockReturnValue({ + data: { searchSubscription: mockEvent }, + loading: false, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + + const [latestEvent, loading, error] = result.current + expect(latestEvent).toEqual(mockEvent) + expect(loading).toBe(false) + expect(error).toBeUndefined() + }) + + it('should return undefined latestEvent when data is undefined', () => { + mockUseSearchSubscription.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + + const [latestEvent, loading, error] = result.current + expect(latestEvent).toBeUndefined() + expect(loading).toBe(false) + expect(error).toBeUndefined() + }) + + it('should return undefined latestEvent when searchSubscription is null', () => { + mockUseSearchSubscription.mockReturnValue({ + data: { searchSubscription: null }, + loading: false, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + + const [latestEvent] = result.current + expect(latestEvent).toBeUndefined() + }) + + it('should return error when subscription errors', () => { + const mockError = new Error('WebSocket connection failed') + + mockUseSearchSubscription.mockReturnValue({ + data: undefined, + loading: false, + error: mockError, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + + const [latestEvent, loading, error] = result.current + expect(latestEvent).toBeUndefined() + expect(loading).toBe(false) + expect(error).toBe(mockError) + }) + }) + + describe('skip behavior', () => { + it('should pass skip: true to useSearchSubscription when input is undefined', () => { + mockUseSearchSubscription.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + } as any) + + renderHook(() => useFleetSearchSubscription(undefined)) + + expect(mockUseSearchSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + skip: true, + client: 'mock-search-client', + }) + ) + }) + + it('should pass skip: false to useSearchSubscription when input is defined', () => { + mockUseSearchSubscription.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + } as any) + + renderHook(() => useFleetSearchSubscription(mockInput)) + + expect(mockUseSearchSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + skip: false, + client: 'mock-search-client', + variables: { input: mockInput }, + }) + ) + }) + + it('should return [undefined, false, undefined] when input is undefined', () => { + mockUseSearchSubscription.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(undefined)) + + const [latestEvent, loading, error] = result.current + expect(latestEvent).toBeUndefined() + expect(loading).toBe(false) + expect(error).toBeUndefined() + }) + }) + + describe('event types', () => { + it('should return an INSERT event', () => { + const insertEvent = { + uid: 'uid-insert', + operation: 'INSERT', + newData: { kind: 'Pod', name: 'new-pod' }, + oldData: null, + timestamp: new Date(), + } + + mockUseSearchSubscription.mockReturnValue({ + data: { searchSubscription: insertEvent }, + loading: false, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + expect(result.current[0]?.operation).toBe('INSERT') + expect(result.current[0]?.uid).toBe('uid-insert') + }) + + it('should return an UPDATE event', () => { + const updateEvent = { + uid: 'uid-update', + operation: 'UPDATE', + newData: { kind: 'Pod', name: 'updated-pod' }, + oldData: { kind: 'Pod', name: 'old-pod' }, + timestamp: new Date(), + } + + mockUseSearchSubscription.mockReturnValue({ + data: { searchSubscription: updateEvent }, + loading: false, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + expect(result.current[0]?.operation).toBe('UPDATE') + }) + + it('should return a DELETE event', () => { + const deleteEvent = { + uid: 'uid-delete', + operation: 'DELETE', + newData: null, + oldData: { kind: 'Pod', name: 'deleted-pod' }, + timestamp: new Date(), + } + + mockUseSearchSubscription.mockReturnValue({ + data: { searchSubscription: deleteEvent }, + loading: false, + error: undefined, + } as any) + + const { result } = renderHook(() => useFleetSearchSubscription(mockInput)) + expect(result.current[0]?.operation).toBe('DELETE') + }) + }) + + describe('client configuration', () => { + it('should always pass the searchClient to the underlying hook', () => { + mockUseSearchSubscription.mockReturnValue({ + data: undefined, + loading: false, + error: undefined, + } as any) + + renderHook(() => useFleetSearchSubscription(mockInput)) + + expect(mockUseSearchSubscription).toHaveBeenCalledWith(expect.objectContaining({ client: 'mock-search-client' })) + }) + }) +}) diff --git a/frontend/packages/multicluster-sdk/src/api/useFleetSearchSubscription.ts b/frontend/packages/multicluster-sdk/src/api/useFleetSearchSubscription.ts new file mode 100644 index 00000000000..3bc0abf04c8 --- /dev/null +++ b/frontend/packages/multicluster-sdk/src/api/useFleetSearchSubscription.ts @@ -0,0 +1,56 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import { searchClient } from '../internal/search/search-client' +import { Event as FleetSearchEvent, useSearchSubscription } from '../internal/search/search-sdk' +import { SearchInput } from '../types/search' + +/** + * A React hook that opens a GraphQL WebSocket subscription to the ACM search API + * and streams real-time change events for resources matching the given input. + * + * Events are pushed by the server over a WebSocket connection managed by the + * Apollo client's `graphql-ws` split link. Apollo re-renders the hook with the + * latest event on each push. Only the **most recent** event is returned — callers + * that need to react to each event should use a `useEffect` watching `latestEvent`. + * + * @param input - The search input filters that define which resources to watch. + * Pass `undefined` to skip opening the WebSocket connection entirely. + * + * @returns A tuple of: + * - `latestEvent` — the most recent {@link FleetSearchEvent} received from the + * WebSocket stream, or `undefined` before the first event arrives or when + * `input` is `undefined`. + * - `loading` — `true` until the WebSocket connection is established and the + * subscription is active. + * - `error` — any Apollo subscription error, or `undefined` on success. + * + * @example + * ```typescript + * // Watch for changes to all Pods in the default namespace + * const [latestEvent, loading, error] = useFleetSearchSubscription({ + * filters: [ + * { property: 'kind', values: ['Pod'] }, + * { property: 'namespace', values: ['default'] }, + * ], + * }) + * + * useEffect(() => { + * if (!latestEvent) return + * console.log(`${latestEvent.operation} on uid ${latestEvent.uid}`) + * }, [latestEvent]) + * + * // Skip the subscription entirely + * const [latestEvent, loading, error] = useFleetSearchSubscription(undefined) + * ``` + */ +export function useFleetSearchSubscription( + input: SearchInput | undefined +): [FleetSearchEvent | undefined, boolean, Error | undefined] { + const { data, loading, error } = useSearchSubscription({ + client: searchClient, + variables: { input }, + skip: input === undefined, + }) + + return [data?.searchSubscription ?? undefined, loading, error] +} diff --git a/frontend/packages/multicluster-sdk/src/index.test.ts b/frontend/packages/multicluster-sdk/src/index.test.ts index dbb82efd613..9d79e3ffb9d 100644 --- a/frontend/packages/multicluster-sdk/src/index.test.ts +++ b/frontend/packages/multicluster-sdk/src/index.test.ts @@ -25,6 +25,7 @@ describe('package index', () => { 'useFleetK8sWatchResource', 'useFleetK8sWatchResources', 'useFleetPrometheusPoll', + 'useFleetSearch', 'useFleetSearchPoll', 'useHubClusterName', 'useIsFleetAvailable', diff --git a/frontend/packages/multicluster-sdk/src/internal/search/convertSearchItemToResource.ts b/frontend/packages/multicluster-sdk/src/internal/search/convertSearchItemToResource.ts index 3c66b9671a2..e5d870c01ba 100644 --- a/frontend/packages/multicluster-sdk/src/internal/search/convertSearchItemToResource.ts +++ b/frontend/packages/multicluster-sdk/src/internal/search/convertSearchItemToResource.ts @@ -89,10 +89,10 @@ const parseConditionString = (conditionString: string): Array<{ type: string; st * @param mapString - A semicolon-separated string of key-value pairs in "key=value" format * @returns An object with the key-value pairs, or undefined if the input falsy or not a string */ -const parseMapString = (mapString: string): Record | undefined => { - if (!mapString || typeof mapString !== 'string') { - return undefined - } +const parseMapString = (mapString: string | Record): Record | undefined => { + if (!mapString) return undefined + if (typeof mapString === 'object') return mapString as Record + if (typeof mapString !== 'string') return undefined return Object.fromEntries(mapString.split(';').map((pair) => pair.trimStart().split('='))) } diff --git a/frontend/packages/multicluster-sdk/src/internal/search/search-client.ts b/frontend/packages/multicluster-sdk/src/internal/search/search-client.ts index 5236cae4639..9754ae4bff7 100644 --- a/frontend/packages/multicluster-sdk/src/internal/search/search-client.ts +++ b/frontend/packages/multicluster-sdk/src/internal/search/search-client.ts @@ -1,8 +1,42 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { ApolloClient, ApolloLink, from, HttpLink, InMemoryCache } from '@apollo/client' -import { getCookie } from './searchUtils' +import { ApolloClient, ApolloLink, from, HttpLink, InMemoryCache, split } from '@apollo/client' +import { GraphQLWsLink } from '@apollo/client/link/subscriptions' +import { getMainDefinition } from '@apollo/client/utilities' +import { createClient } from 'graphql-ws' import { BACKEND_URL } from '../constants' +import { getCookie } from './searchUtils' + +const httpLink = new HttpLink({ + uri: () => `${BACKEND_URL}/proxy/search`, +}) + +/** WebSocket URL for GraphQL subscriptions (graphql-ws), aligned with {@link httpLink}. */ +function getSearchWebSocketUrl(): string { + const httpEndpoint = `${BACKEND_URL}/proxy/search` + + if (httpEndpoint.startsWith('http://')) { + return httpEndpoint.replace(/^http/, 'ws') + } + if (httpEndpoint.startsWith('https://')) { + return httpEndpoint.replace(/^https/, 'wss') + } + + if (typeof window === 'undefined') { + return 'ws://localhost/proxy/search' + } + + const url = new URL(httpEndpoint, window.location.origin) + url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return url.href +} + +const wsLink = new GraphQLWsLink( + createClient({ + url: () => getSearchWebSocketUrl(), + lazy: true, + }) +) const csrfHeaderLink = new ApolloLink((operation, forward) => { const csrfToken = getCookie('csrf-token') @@ -18,13 +52,20 @@ const csrfHeaderLink = new ApolloLink((operation, forward) => { return forward(operation) }) -const httpLink = new HttpLink({ - uri: () => `${BACKEND_URL}/proxy/search`, -}) +const httpChain = from([csrfHeaderLink, httpLink]) + +const link = split( + ({ query }) => { + const definition = getMainDefinition(query) + return definition.kind === 'OperationDefinition' && definition.operation === 'subscription' + }, + wsLink, + httpChain +) export const searchClient = new ApolloClient({ connectToDevTools: process.env.NODE_ENV === 'development', - link: from([csrfHeaderLink, httpLink]), + link, cache: new InMemoryCache(), credentials: 'same-origin', diff --git a/frontend/packages/multicluster-sdk/src/internal/search/search-sdk.ts b/frontend/packages/multicluster-sdk/src/internal/search/search-sdk.ts index 98dc2890476..ed1291d5e98 100644 --- a/frontend/packages/multicluster-sdk/src/internal/search/search-sdk.ts +++ b/frontend/packages/multicluster-sdk/src/internal/search/search-sdk.ts @@ -16,9 +16,27 @@ export type Scalars = { Boolean: { input: boolean; output: boolean } Int: { input: number; output: number } Float: { input: number; output: number } + Date: { input: any; output: any } Map: { input: any; output: any } } +/** Event represents a changed resource in the search index. */ +export type Event = { + /** New data recorded on the search index. */ + newData?: Maybe + /** Previous resource data from the search index. */ + oldData?: Maybe + /** Values: INSERT, UPDATE, or DELETE */ + operation: Scalars['String']['output'] + /** + * Time the change event is registered in the search index. + * Note there's a delay from the time the resource changed in kubernetes. + */ + timestamp: Scalars['Date']['output'] + /** Kubernetes resource UID. */ + uid: Scalars['ID']['output'] +} + /** A message is used to communicate conditions detected while executing a query on the server. */ export type Message = { /** Message text. */ @@ -58,7 +76,7 @@ export type Query = { /** * Returns all fields from resources currently in the index. * Optionally, a query can be included to filter the results. - * For example, if we want to only get fields for Pod resources, we can pass a query with the filter `{property: kind, values:['Pod']}` + * For example, if we want to only get fields for Pod resources, we can pass in a query with the filter `{property: kind, values:['Pod']}` */ searchSchema?: Maybe } @@ -94,6 +112,11 @@ export type SearchFilter = { * The values available for datetime fields (Ex: `created`, `startedAt`) are `hour`, `day`, `week`, `month` and `year`. * Property `kind`, if included in the filter, will be matched using a case-insensitive comparison. * For example, `kind:Pod` and `kind:pod` will bring up all pods. This is to maintain compatibility with Search V1. + * + * Wildcard matching: the `*` character can be used as a wildcard to match any sequence of characters. + * For example, a filter with property `name` and value `nginx-*` matches any resource whose name starts with `nginx-`. + * Similarly, property `namespace` with value `prod*` matches any namespace starting with `prod`. + * Wildcard matches are case-sensitive. */ values: Array> } @@ -118,6 +141,18 @@ export type SearchInput = { * A value of -1 will remove the limit. Use carefully because it may impact the service. */ limit?: InputMaybe + /** + * Number of results to skip before returning results. + * Used in combination with limit to implement pagination. + * **Default is** 0 + */ + offset?: InputMaybe + /** + * Order results by a property and direction. + * Format: "property_name asc" or "property_name desc" + * Example: "name desc" or "created asc" + */ + orderBy?: InputMaybe /** * Filter relationships to the specified kinds. * If empty, all relationships will be included. @@ -154,6 +189,21 @@ export type SearchResult = { related?: Maybe>> } +/** Subscriptions implemented by the Search Query API. */ +export type Subscription = { + /** + * Watch changes to the data in the search index. An event is generated for each change + * matching the input filters. User's permissions (RBAC) are applied to each event resource. + * Events are generated from the search index and don't match the changes on Kubernetes. + */ + watch?: Maybe +} + +/** Subscriptions implemented by the Search Query API. */ +export type SubscriptionWatchArgs = { + input?: InputMaybe +} + export type SearchSchemaQueryVariables = Exact<{ query?: InputMaybe }> @@ -215,6 +265,20 @@ export type GetMessagesQuery = { messages?: Array<{ id: string; kind?: string | null; description?: string | null } | null> | null } +export type SearchSubscriptionSubscriptionVariables = Exact<{ + input?: InputMaybe +}> + +export type SearchSubscriptionSubscription = { + searchSubscription?: { + uid: string + operation: string + newData?: any | null + oldData?: any | null + timestamp: any + } | null +} + export const SearchSchemaDocument = gql` query searchSchema($query: SearchInput) { searchSchema(query: $query) @@ -668,3 +732,42 @@ export type GetMessagesQueryHookResult = ReturnType export type GetMessagesLazyQueryHookResult = ReturnType export type GetMessagesSuspenseQueryHookResult = ReturnType export type GetMessagesQueryResult = Apollo.QueryResult +export const SearchSubscriptionDocument = gql` + subscription searchSubscription($input: SearchInput) { + searchSubscription: watch(input: $input) { + uid + operation + newData + oldData + timestamp + } + } +` + +/** + * __useSearchSubscription__ + * + * To run a query within a React component, call `useSearchSubscription` and pass it any options that fit your needs. + * When your component renders, `useSearchSubscription` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useSearchSubscription({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useSearchSubscription( + baseOptions?: Apollo.SubscriptionHookOptions +) { + const options = { ...defaultOptions, ...baseOptions } + return Apollo.useSubscription( + SearchSubscriptionDocument, + options + ) +} +export type SearchSubscriptionSubscriptionHookResult = ReturnType +export type SearchSubscriptionSubscriptionResult = Apollo.SubscriptionResult diff --git a/frontend/packages/multicluster-sdk/src/internal/search/subscription.graphql b/frontend/packages/multicluster-sdk/src/internal/search/subscription.graphql new file mode 100644 index 00000000000..d485f8f75d1 --- /dev/null +++ b/frontend/packages/multicluster-sdk/src/internal/search/subscription.graphql @@ -0,0 +1,9 @@ +subscription searchSubscription($input: SearchInput) { + searchSubscription: watch(input: $input) { + uid + operation + newData + oldData + timestamp + } +} diff --git a/frontend/packages/multicluster-sdk/src/types/search.ts b/frontend/packages/multicluster-sdk/src/types/search.ts index 12fe655e3ef..88ae6c611f5 100644 --- a/frontend/packages/multicluster-sdk/src/types/search.ts +++ b/frontend/packages/multicluster-sdk/src/types/search.ts @@ -2,6 +2,84 @@ import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk' import { Fleet } from './fleet' +export type Maybe = T | null +export type InputMaybe = Maybe +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string } + String: { input: string; output: string } + Boolean: { input: boolean; output: boolean } + Int: { input: number; output: number } + Float: { input: number; output: number } + Date: { input: any; output: any } + Map: { input: any; output: any } +} + +/** + * Defines a key/value to filter results. + * When multiple values are provided for a property, it is interpreted as an OR operation. + */ +// Copied from internal/search/search-sdk +export type SearchFilter = { + /** Name of the property (key). */ + property: Scalars['String']['input'] + /** + * Values for the property. Multiple values per property are interpreted as an OR operation. + * Optionally one of these operations `=,!,!=,>,>=,<,<=` can be included at the beginning of the value. + * By default the equality operation is used. + * The values available for datetime fields (Ex: `created`, `startedAt`) are `hour`, `day`, `week`, `month` and `year`. + * Property `kind`, if included in the filter, will be matched using a case-insensitive comparison. + * For example, `kind:Pod` and `kind:pod` will bring up all pods. This is to maintain compatibility with Search V1. + * + * Wildcard matching: the `*` character can be used as a wildcard to match any sequence of characters. + * For example, a filter with property `name` and value `nginx-*` matches any resource whose name starts with `nginx-`. + * Similarly, property `namespace` with value `prod*` matches any namespace starting with `prod`. + * Wildcard matches are case-sensitive. + */ + values: Array> +} + +/** Input options to the search query. */ +// Copied from internal/search/search-sdk +export type SearchInput = { + /** + * List of SearchFilter, which is a key(property) and values. + * When multiple filters are provided, results will match all filters (AND operation). + */ + filters?: InputMaybe>> + /** + * List of strings to match resources. + * Will match resources containing any of the keywords in any text field. + * When multiple keywords are provided, it is interpreted as an AND operation. + * Matches are case insensitive. + */ + keywords?: InputMaybe>> + /** + * Max number of results returned by the query. + * **Default is** 10,000 + * A value of -1 will remove the limit. Use carefully because it may impact the service. + */ + limit?: InputMaybe + /** + * Number of results to skip before returning results. + * Used in combination with limit to implement pagination. + * **Default is** 0 + */ + offset?: InputMaybe + /** + * Order results by a property and direction. + * Format: "property_name asc" or "property_name desc" + * Example: "name desc" or "created asc" + */ + orderBy?: InputMaybe + /** + * Filter relationships to the specified kinds. + * If empty, all relationships will be included. + * This filter is used with the 'related' field on SearchResult. + */ + relatedKinds?: InputMaybe>> +} + export type SearchResult = R extends (infer T)[] ? Fleet[] : Fleet