From 83007025246028775a054e9fef8163b3ca237011 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Wed, 5 Oct 2022 17:32:35 -0500 Subject: [PATCH] Search: fuzzy match dashboard names in frontend from full list (#55721) --- package.json | 1 + packages/grafana-ui/package.json | 1 + public/app/features/search/service/dummy.ts | 37 +++++++++ .../features/search/service/frontend.test.ts | 81 +++++++++++++++++++ .../app/features/search/service/frontend.ts | 50 ++++++++++-- yarn.lock | 9 +++ 6 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 public/app/features/search/service/dummy.ts create mode 100644 public/app/features/search/service/frontend.test.ts diff --git a/package.json b/package.json index 58909552b6b..c2087d20f08 100644 --- a/package.json +++ b/package.json @@ -264,6 +264,7 @@ "@grafana/ui": "workspace:*", "@jaegertracing/jaeger-ui-components": "workspace:*", "@kusto/monaco-kusto": "5.2.0", + "@leeoniya/ufuzzy": "0.7.0", "@lezer/common": "1.0.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "1.2.3", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 3b80c51d1d4..b7277aa52e8 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -50,6 +50,7 @@ "@grafana/data": "9.3.0-pre", "@grafana/e2e-selectors": "9.3.0-pre", "@grafana/schema": "9.3.0-pre", + "@leeoniya/ufuzzy": "0.7.0", "@monaco-editor/react": "4.4.5", "@popperjs/core": "2.11.5", "@react-aria/button": "3.6.1", diff --git a/public/app/features/search/service/dummy.ts b/public/app/features/search/service/dummy.ts new file mode 100644 index 00000000000..24428971d12 --- /dev/null +++ b/public/app/features/search/service/dummy.ts @@ -0,0 +1,37 @@ +import { SelectableValue, DataFrame, DataFrameView } from '@grafana/data'; +import { TermCount } from 'app/core/components/TagFilter/TagFilter'; + +import { GrafanaSearcher, QueryResponse, SearchQuery } from '.'; + +// This is a dummy search useful for tests +export class DummySearcher implements GrafanaSearcher { + expectedSearchResponse: QueryResponse | undefined; + expectedStarsResponse: QueryResponse | undefined; + expectedSortResponse: SelectableValue[] = []; + expectedTagsResponse: TermCount[] = []; + + setExpectedSearchResult(result: DataFrame) { + this.expectedSearchResponse = { + view: new DataFrameView(result), + isItemLoaded: () => true, + loadMoreItems: () => Promise.resolve(), + totalRows: result.length, + }; + } + + async search(query: SearchQuery): Promise { + return Promise.resolve(this.expectedSearchResponse!); + } + + async starred(query: SearchQuery): Promise { + return Promise.resolve(this.expectedStarsResponse!); + } + + async getSortOptions(): Promise { + return Promise.resolve(this.expectedSortResponse); + } + + async tags(query: SearchQuery): Promise { + return Promise.resolve(this.expectedTagsResponse); + } +} diff --git a/public/app/features/search/service/frontend.test.ts b/public/app/features/search/service/frontend.test.ts new file mode 100644 index 00000000000..11beefac3e9 --- /dev/null +++ b/public/app/features/search/service/frontend.test.ts @@ -0,0 +1,81 @@ +import { toDataFrame, FieldType } from '@grafana/data'; + +import { DummySearcher } from './dummy'; +import { FrontendSearcher } from './frontend'; + +describe('FrontendSearcher', () => { + const upstream = new DummySearcher(); + upstream.setExpectedSearchResult( + toDataFrame({ + meta: { + custom: { + something: 8, + }, + }, + fields: [{ name: 'name', type: FieldType.string, values: ['foo cat', 'bar dog', 'cow baz'] }], + }) + ); + + it('should call search api with correct query for general folder', async () => { + const frontendSearcher = new FrontendSearcher(upstream); + const query = { + query: '*', + kind: ['dashboard'], + location: 'General', + }; + const results = await frontendSearcher.search(query); + + expect(results.view.fields.name.values.toArray()).toMatchInlineSnapshot(` + Array [ + "foo cat", + "bar dog", + "cow baz", + ] + `); + }); + + it('should return correct results for single prefix', async () => { + const frontendSearcher = new FrontendSearcher(upstream); + const query = { + query: 'ba', + kind: ['dashboard'], + location: 'General', + }; + const results = await frontendSearcher.search(query); + + expect(results.view.fields.name.values.toArray()).toMatchInlineSnapshot(` + Array [ + "bar dog", + "cow baz", + ] + `); + }); + + it('should return correct results out-of-order prefixes', async () => { + const frontendSearcher = new FrontendSearcher(upstream); + const query = { + query: 'do ba', + kind: ['dashboard'], + location: 'General', + }; + const results = await frontendSearcher.search(query); + + expect(results.view.fields.name.values.toArray()).toMatchInlineSnapshot(` + Array [ + "bar dog", + ] + `); + }); + + it('should barf when attempting a custom sort strategy', async () => { + const frontendSearcher = new FrontendSearcher(upstream); + const query = { + query: 'ba', + kind: ['dashboard'], + location: 'General', + sort: 'name_sort', + }; + + await expect(frontendSearcher.search(query)).rejects.toThrow('custom sorting is not supported yet'); + }); +}); diff --git a/public/app/features/search/service/frontend.ts b/public/app/features/search/service/frontend.ts index 26aa066d6e1..2b44fefe2cf 100644 --- a/public/app/features/search/service/frontend.ts +++ b/public/app/features/search/service/frontend.ts @@ -1,3 +1,5 @@ +import uFuzzy from '@leeoniya/ufuzzy'; + import { DataFrameView, SelectableValue, ArrayVector } from '@grafana/data'; import { TermCount } from 'app/core/components/TagFilter/TagFilter'; @@ -12,6 +14,12 @@ export class FrontendSearcher implements GrafanaSearcher { if (query.facet?.length) { throw new Error('facets not supported!'); } + + // we don't yet support anything except default (relevance) + if (query.sort != null) { + throw new Error('custom sorting is not supported yet'); + } + // Don't bother... not needed for this exercise if (query.tags?.length || query.ds_uid?.length) { return this.parent.search(query); @@ -57,6 +65,8 @@ export class FrontendSearcher implements GrafanaSearcher { return this.parent.starred(query); } + sortPlaceholder = 'Default (Relevance)'; + // returns the appropriate sorting options async getSortOptions(): Promise { return this.parent.getSortOptions(); @@ -68,11 +78,19 @@ export class FrontendSearcher implements GrafanaSearcher { } class FullResultCache { - readonly lower: string[]; + readonly names: string[]; empty: DataFrameView; + ufuzzy = new uFuzzy({ + intraMode: 1, + intraIns: 1, + intraSub: 1, + intraTrn: 1, + intraDel: 1, + }); + constructor(private full: DataFrameView) { - this.lower = this.full.fields.name.values.toArray().map((v) => (v ? v.toLowerCase() : '')); + this.names = this.full.fields.name.values.toArray(); // Copy with empty values this.empty = new DataFrameView({ @@ -87,19 +105,35 @@ class FullResultCache { if (!query?.length || query === '*') { return this.full; } - const match = query.toLowerCase(); + const allFields = this.full.dataFrame.fields; + const haystack = this.names; // eslint-disable-next-line const values = allFields.map((v) => [] as any[]); // empty value for each field - for (let i = 0; i < this.lower.length; i++) { - if (this.lower[i].indexOf(match) >= 0) { - for (let c = 0; c < allFields.length; c++) { - values[c].push(allFields[c].values.get(i)); + // out-of-order terms + const oooIdxs = new Set(); + const queryTerms = this.ufuzzy.split(query); + const oooNeedles = uFuzzy.permute(queryTerms).map((terms) => terms.join(' ')); + + oooNeedles.forEach((needle) => { + let idxs = this.ufuzzy.filter(haystack, needle); + let info = this.ufuzzy.info(idxs, haystack, needle); + let order = this.ufuzzy.sort(info, haystack, needle); + + for (let i = 0; i < order.length; i++) { + let haystackIdx = info.idx[order[i]]; + + if (!oooIdxs.has(haystackIdx)) { + oooIdxs.add(haystackIdx); + + for (let c = 0; c < allFields.length; c++) { + values[c].push(allFields[c].values.get(haystackIdx)); + } } } - } + }); // mutates the search object this.empty.dataFrame.fields.forEach((f, idx) => { diff --git a/yarn.lock b/yarn.lock index c171b359390..14b9c5cb777 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5602,6 +5602,7 @@ __metadata: "@grafana/e2e-selectors": 9.3.0-pre "@grafana/schema": 9.3.0-pre "@grafana/tsconfig": ^1.2.0-rc1 + "@leeoniya/ufuzzy": 0.7.0 "@mdx-js/react": 1.6.22 "@monaco-editor/react": 4.4.5 "@popperjs/core": 2.11.5 @@ -6608,6 +6609,13 @@ __metadata: languageName: node linkType: hard +"@leeoniya/ufuzzy@npm:0.7.0": + version: 0.7.0 + resolution: "@leeoniya/ufuzzy@npm:0.7.0" + checksum: c2dd65b1f2ded54cd28a4294aa6083a30c633b55bff643cf1f6835b8c5022e0c6e8172958db3ae6a5871ed816c5bacec04184d1a6707638ed4d0a63506627557 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.3 resolution: "@leichtgewicht/ip-codec@npm:2.0.3" @@ -23159,6 +23167,7 @@ __metadata: "@grafana/ui": "workspace:*" "@jaegertracing/jaeger-ui-components": "workspace:*" "@kusto/monaco-kusto": 5.2.0 + "@leeoniya/ufuzzy": 0.7.0 "@lezer/common": 1.0.0 "@lezer/highlight": ^1.0.0 "@lezer/lr": 1.2.3