diff --git a/public/app/features/search/service/unified.test.ts b/public/app/features/search/service/unified.test.ts index 4d50e25193b..e62a126f38f 100644 --- a/public/app/features/search/service/unified.test.ts +++ b/public/app/features/search/service/unified.test.ts @@ -1,6 +1,143 @@ -import { toDashboardResults, SearchHit, SearchAPIResponse } from './unified'; +import { BackendSrv } from '@grafana/runtime'; + +import { GrafanaSearcher, SearchQuery } from './types'; +import { toDashboardResults, SearchHit, SearchAPIResponse, UnifiedSearcher } from './unified'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const mockResults: SearchAPIResponse = { + hits: [], + totalHits: 0, +}; + +const mockFolders: SearchAPIResponse = { + hits: [], + totalHits: 0, +}; + +const mockFallbackSearcher = { + search: jest.fn(), +} as unknown as GrafanaSearcher; + +const getResponse = (uri: string) => { + if (uri.endsWith('?type=folders')) { + return Promise.resolve(mockFolders); + } + return Promise.resolve(mockResults); +}; + +const mockSearcher = { + search: (uri: string) => getResponse(uri), +}; + +jest.mock('@grafana/runtime', () => { + const originalRuntime = jest.requireActual('@grafana/runtime'); + return { + ...originalRuntime, + getBackendSrv: () => + ({ + get: (uri: string) => mockSearcher.search(uri), + }) as unknown as BackendSrv, + }; +}); describe('Unified Storage Searcher', () => { + it('should perform search with basic query', async () => { + mockFolders.hits = [ + { + name: 'folder1', + title: 'Folder 1', + resource: 'folders', + } as SearchHit, + ]; + mockResults.hits = [ + { + name: 'dashboard1', + title: 'Dashboard 1', + resource: 'dashboards', + folder: 'folder1', + } as SearchHit, + ]; + + const query: SearchQuery = { + query: 'test', + limit: 50, + }; + + const searcher = new UnifiedSearcher(mockFallbackSearcher); + + const response = await searcher.search(query); + + expect(response.view.length).toBe(1); + expect(response.view.get(0).title).toBe('Dashboard 1'); + + const df = response.view.dataFrame; + const locationInfo = df.meta?.custom?.locationInfo; + expect(locationInfo).toBeDefined(); + expect(locationInfo?.folder1.name).toBe('Folder 1'); + }); + + it('should perform search and sync folders with missing folder', async () => { + const mockFolders = { + hits: [ + { + name: 'folder2', + title: 'Folder 2', + resource: 'folders', + } as SearchHit, + ], + totalHits: 1, + }; + + const mockResults = { + hits: [ + { + name: 'db1', + title: 'DB 1', + resource: 'dashboards', + folder: 'folder1', + } as SearchHit, + { + name: 'db2', + title: 'DB 2', + resource: 'dashboards', + folder: 'folder2', + } as SearchHit, + ], + totalHits: 2, + }; + + jest + .spyOn(mockSearcher, 'search') + .mockResolvedValueOnce(mockFolders) + .mockResolvedValueOnce(mockResults) + .mockResolvedValueOnce(mockFolders); + + const consoleWarn = jest.fn(); + jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn); + + const query: SearchQuery = { + query: 'test', + limit: 50, + }; + + const searcher = new UnifiedSearcher(mockFallbackSearcher); + + const response = await searcher.search(query); + + expect(response.view.length).toBe(1); + expect(response.view.get(0).title).toBe('DB 2'); + + const df = response.view.dataFrame; + const locationInfo = df.meta?.custom?.locationInfo; + expect(locationInfo).toBeDefined(); + expect(locationInfo?.folder2.name).toBe('Folder 2'); + expect(consoleWarn).toHaveBeenCalled(); + expect(mockSearcher.search).toHaveBeenCalledTimes(3); + }); + it('can create dashboard search results and set meta sortBy so column is added for sprinkles sort field', () => { const mockHits: SearchHit[] = [ { diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 700cf566314..9d942f668d3 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -112,7 +112,7 @@ export class UnifiedSearcher implements GrafanaSearcher { async doSearchQuery(query: SearchQuery): Promise { const uri = await this.newRequest(query); - const rsp = await getBackendSrv().get(uri); + const rsp = await this.fetchResponse(uri); const first = toDashboardResults(rsp, query.sort ?? ''); if (first.name === loadingFrameName) { @@ -120,12 +120,6 @@ export class UnifiedSearcher implements GrafanaSearcher { } const meta = first.meta?.custom || ({} as SearchResultMeta); - const locationInfo = await this.locationInfo; - const hasMissing = rsp.hits.some((hit) => !locationInfo[hit.folder]); - if (hasMissing) { - // sync the location info ( folders ) - this.locationInfo = loadLocationInfo(); - } meta.locationInfo = await this.locationInfo; // Set the field name to a better display name @@ -141,14 +135,13 @@ export class UnifiedSearcher implements GrafanaSearcher { let loadMax = 0; let pending: Promise | undefined = undefined; const getNextPage = async () => { - // TODO: implement this correctly while (loadMax > view.dataFrame.length) { const offset = view.dataFrame.length; if (offset >= meta.count) { return; } const nextPageUrl = `${uri}&offset=${offset}`; - const resp = await getBackendSrv().get(nextPageUrl); + const resp = await this.fetchResponse(nextPageUrl); const frame = toDashboardResults(resp, query.sort ?? ''); if (!frame) { console.log('no results', frame); @@ -198,6 +191,40 @@ export class UnifiedSearcher implements GrafanaSearcher { }; } + async fetchResponse(uri: string) { + const rsp = await getBackendSrv().get(uri); + const isFolderCacheStale = await this.isFolderCacheStale(rsp.hits); + if (!isFolderCacheStale) { + return rsp; + } + // sync the location info ( folders ) + this.locationInfo = loadLocationInfo(); + // recheck for missing folders + const hasMissing = await this.isFolderCacheStale(rsp.hits); + if (!hasMissing) { + return rsp; + } + // we still have results here with folders we can't find + // filter the results since we probably don't have access to that folder + const locationInfo = await this.locationInfo; + const hits = rsp.hits.filter((hit) => { + if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) { + return true; + } + console.warn('Dropping search hit with missing folder', hit); + return false; + }); + const totalHits = rsp.totalHits - (rsp.hits.length - hits.length); + return { ...rsp, hits, totalHits }; + } + + async isFolderCacheStale(hits: SearchHit[]): Promise { + const locationInfo = await this.locationInfo; + return hits.some((hit) => { + return hit.folder !== undefined && locationInfo[hit.folder] === undefined; + }); + } + private async newRequest(query: SearchQuery): Promise { query = await replaceCurrentFolderQuery(query);