diff --git a/frontend/packages/multicluster-sdk/README.md b/frontend/packages/multicluster-sdk/README.md index 3928ec1ffd8..57317119627 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#L119) + ### :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..7d0a2d8cc0a --- /dev/null +++ b/frontend/packages/multicluster-sdk/src/api/useFleetSearch.test.ts @@ -0,0 +1,534 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { act, renderHook } from '@testing-library/react-hooks' +import { 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 = useSearchResultItemsQuery as jest.MockedFunction +const mockUseFleetSearchSubscription = useFleetSearchSubscription as jest.MockedFunction< + typeof 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] }], +} + +describe('useFleetSearch', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Default: subscription disabled (no event, not loading, no error) + mockUseFleetSearchSubscription.mockReturnValue([undefined, false, undefined]) + }) + + // ── Basic 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 = jest.fn() + 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 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) + }) + }) + + // ── Subscription enabled ─────────────────────────────────────────────────── + + 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) + }) + }) + + // ── Subscription event patching ──────────────────────────────────────────── + + describe('INSERT event', () => { + 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' } + const insertEvent = { + uid: 'test-cluster/uid-new', + operation: 'INSERT', + newData: newItem, + oldData: null, + timestamp: new Date(), + } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [existingItem] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([insertEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + const [data] = result.current + expect(data).toHaveLength(2) + expect(data!.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' } + const insertEvent = { + uid: 'test-cluster/uid-1', + operation: 'INSERT', + newData: item, + oldData: null, + timestamp: new Date(), + } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [item] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([insertEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + const [data] = result.current + expect(data).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' } + const insertEvent = { + uid: 'test-cluster/uid-fig', + operation: 'INSERT', + newData: figItem, + oldData: null, + timestamp: new Date(), + } + const inputWithOrderBy: SearchInput = { ...mockInput, orderBy: 'name asc' } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [appleItem, mangoItem] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([insertEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(inputWithOrderBy, true)) + + const [data] = result.current + expect(data).toHaveLength(3) + expect(data!.map((r) => r.metadata?.name)).toEqual(['apple', 'fig', 'mango']) + }) + + it('should drop the last item when an INSERT causes the page to exceed its limit', () => { + 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' } + const insertEvent = { + uid: 'test-cluster/uid-fig', + operation: 'INSERT', + newData: figItem, + oldData: null, + timestamp: new Date(), + } + // limit: 2 means at most 2 items should be kept on the page + const inputWithLimit: SearchInput = { ...mockInput, limit: 2, orderBy: 'name asc' } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [appleItem, mangoItem] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([insertEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(inputWithLimit, true)) + + const [data] = result.current + // 'fig' sorts between 'apple' and 'mango'; 'mango' is bumped off the page + expect(data).toHaveLength(2) + expect(data!.map((r) => r.metadata?.name)).toEqual(['apple', 'fig']) + }) + }) + + describe('UPDATE event', () => { + it('should merge updated fields on UPDATE — e.g. adding a label', () => { + const originalItem = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + // Simulate a label being added: label field uses "key=value" format + const updatedItem = { ...mockSearchItem, _uid: 'test-cluster/uid-1', label: 'abc=123' } + const updateEvent = { + uid: 'test-cluster/uid-1', + operation: 'UPDATE', + newData: updatedItem, + oldData: originalItem, + timestamp: new Date(), + } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [originalItem] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([updateEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + const [data] = result.current + expect(data).toHaveLength(1) + // Name is unchanged (K8s names are immutable) + expect(data![0].metadata?.name).toBe('test-pod') + // New label should be present after conversion + expect(data![0].metadata?.labels).toEqual({ abc: '123' }) + }) + + it('should re-sort the page after an UPDATE when orderBy is set', () => { + // Use `status` as the sort field — it can legitimately change (e.g. Pending → Running) + 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 from Pending to Terminated, which sorts after Running + const updatedItem = { ...pendingItem, status: 'Terminated', label: 'abc=123' } + const updateEvent = { + uid: 'test-cluster/uid-a', + operation: 'UPDATE', + newData: updatedItem, + oldData: pendingItem, + timestamp: new Date(), + } + const inputWithOrderBy: SearchInput = { ...mockInput, orderBy: 'status asc' } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [pendingItem, runningItem] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([updateEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(inputWithOrderBy, true)) + + const [data] = result.current + expect(data).toHaveLength(2) + // 'Running' < 'Terminated' alphabetically → pod-b sorts first + expect(data![0].metadata?.name).toBe('pod-b') + expect(data![1].metadata?.name).toBe('pod-a') + // Confirm the label was also applied to pod-a + expect(data![1].metadata?.labels).toEqual({ abc: '123' }) + }) + }) + + describe('DELETE event', () => { + it('should remove the matching resource on DELETE', () => { + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + const deleteEvent = { + uid: 'test-cluster/uid-1', + operation: 'DELETE', + newData: null, + oldData: item, + timestamp: new Date(), + } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [item] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([deleteEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + const [data] = result.current + expect(data).toHaveLength(0) + }) + + it('should be a no-op DELETE when uid does not match any resource', () => { + const item = { ...mockSearchItem, _uid: 'test-cluster/uid-1' } + const deleteEvent = { + uid: 'uid-nonexistent', + operation: 'DELETE', + newData: null, + oldData: null, + timestamp: new Date(), + } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [item] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([deleteEvent as any, false, undefined]) + + const { result } = renderHook(() => useFleetSearch(mockInput, true)) + + const [data] = result.current + expect(data).toHaveLength(1) + }) + }) + + // ── 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' } + // Provide an INSERT event so local state diverges from query data + const insertEvent = { + uid: 'test-cluster/uid-new', + operation: 'INSERT', + newData: { ...mockSearchItem, name: 'extra-pod', _uid: 'test-cluster/uid-new' }, + oldData: null, + timestamp: new Date(), + } + + mockUseSearchResultItemsQuery.mockReturnValue({ + data: { searchResult: [{ items: [item] }] }, + loading: false, + error: undefined, + refetch: jest.fn(), + } as any) + mockUseFleetSearchSubscription.mockReturnValue([insertEvent as any, false, undefined]) + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => useFleetSearch(mockInput, enabled), + { initialProps: { enabled: true } } + ) + + // With subscription on, we should have 2 items (original + inserted) + expect(result.current[0]).toHaveLength(2) + + // Disable subscription — subscription hook now returns no event + mockUseFleetSearchSubscription.mockReturnValue([undefined, false, undefined]) + + act(() => { + rerender({ enabled: false }) + }) + + // Should reset to the base query data (1 item) + 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..81ac4ecdc84 --- /dev/null +++ b/frontend/packages/multicluster-sdk/src/api/useFleetSearch.ts @@ -0,0 +1,237 @@ +/* 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 { 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 + +// ── Pagination helpers ───────────────────────────────────────────────────── + +/** + * Return a new array sorted according to a search-API `orderBy` string + * ("fieldName asc" or "fieldName desc"). + * Fields are read directly from the flat SearchItem, so the sort is always + * reliable regardless of resource kind. + */ +function sortByOrderBy(items: SearchItem[], orderBy: string | null | undefined): SearchItem[] { + if (!orderBy) return items + const [field, dir] = orderBy.trim().split(/\s+/) + const descending = dir?.toLowerCase() === 'desc' + return [...items].sort((a, b) => { + const cmp = String(a[field] ?? '').localeCompare(String(b[field] ?? '')) + return descending ? -cmp : cmp + }) +} + +/** + * 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] ?? '') + // Find the first existing item that newItem should sort before. + const insertIdx = items.findIndex((item) => { + const cmp = newVal.localeCompare(String(item[field] ?? '')) + return descending ? cmp > 0 : cmp < 0 + }) + if (insertIdx === -1) return [...items, newItem] + return [...items.slice(0, insertIdx), newItem, ...items.slice(insertIdx)] +} + +/** + * 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) + + // When the base query returns fresh data (initial load or after refetch), + // reset local state to match. + useEffect(() => { + setLocalData(queryData) + }, [queryData]) + + // When subscriptionEnabled is turned off, reset to the base query result. + useEffect(() => { + if (!subscriptionEnabled) { + setLocalData(queryData) + } + // We intentionally only react to the subscriptionEnabled flag here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscriptionEnabled]) + + // ── Subscription layer ───────────────────────────────────────────────────── + + // Keep a ref to the latest input so the event-patch effect can always read + // the current orderBy / limit without being listed as a dependency. Adding + // `input` to the effect's dep array would cause stale events to be replayed + // whenever the query input changes (e.g. page/sort update), because the + // queryData reset effect and this effect would both fire in the same cycle. + const inputRef = useRef(input) + useEffect(() => { + inputRef.current = input + }) + + // Pass undefined as input when subscription is disabled so the inner hook + // skips the WebSocket connection entirely. + const [latestEvent, , subscriptionError] = useFleetSearchSubscription(subscriptionEnabled ? input : undefined) + + // Patch local state whenever a new event arrives. + useEffect(() => { + if (!latestEvent) return + + setLocalData((prev) => { + const current: SearchItem[] = Array.isArray(prev) ? prev : [] + + switch (latestEvent.operation) { + case 'INSERT': { + if (!latestEvent.newData) return prev + const cluster = latestEvent.uid.split('/')[0] + const patchedItem: SearchItem = { ...latestEvent.newData, cluster, _uid: latestEvent.uid } + // Avoid duplicate insertions. + if (current.some((item) => item._uid === patchedItem._uid)) return prev + // Insert at the position dictated by orderBy. + const inserted = insertSorted(current, patchedItem, inputRef.current?.orderBy) + // Honour the page limit: if the page is now over capacity, drop the last item. + const limit = inputRef.current?.limit + if (limit != null && limit > 0 && inserted.length > limit) { + return inserted.slice(0, limit) + } + return inserted + } + case 'UPDATE': { + if (!latestEvent.newData) return prev + const cluster = latestEvent.uid.split('/')[0] + const patchedItem: SearchItem = { ...latestEvent.newData, cluster, _uid: latestEvent.uid } + const updated = current.map((item) => (item._uid === patchedItem._uid ? patchedItem : item)) + // Re-sort after the update in case the sort-key field changed. + return inputRef.current?.orderBy ? sortByOrderBy(updated, inputRef.current.orderBy) : updated + } + case 'DELETE': { + // Match directly on the search _uid — no K8s resource conversion needed. + return current.filter((item) => item._uid !== latestEvent.uid) + } + default: + return prev + } + }) + }, [latestEvent]) + + // ── Stable refetch callback ──────────────────────────────────────────────── + + const triggerRefetch = useCallback(() => { + refetch() + }, [refetch]) + + // ── 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