Search: fuzzy match dashboard names in frontend from full list (#55721)

This commit is contained in:
Leon Sorokin
2022-10-05 18:32:35 -04:00
committed by GitHub
parent 69c49f6ba2
commit 8300702524
6 changed files with 171 additions and 8 deletions
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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<QueryResponse> {
return Promise.resolve(this.expectedSearchResponse!);
}
async starred(query: SearchQuery): Promise<QueryResponse> {
return Promise.resolve(this.expectedStarsResponse!);
}
async getSortOptions(): Promise<SelectableValue[]> {
return Promise.resolve(this.expectedSortResponse);
}
async tags(query: SearchQuery): Promise<TermCount[]> {
return Promise.resolve(this.expectedTagsResponse);
}
}
@@ -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');
});
});
+42 -8
View File
@@ -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<SelectableValue[]> {
return this.parent.getSortOptions();
@@ -68,11 +78,19 @@ export class FrontendSearcher implements GrafanaSearcher {
}
class FullResultCache {
readonly lower: string[];
readonly names: string[];
empty: DataFrameView<DashboardQueryResult>;
ufuzzy = new uFuzzy({
intraMode: 1,
intraIns: 1,
intraSub: 1,
intraTrn: 1,
intraDel: 1,
});
constructor(private full: DataFrameView<DashboardQueryResult>) {
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<DashboardQueryResult>({
@@ -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<number>();
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) => {
+9
View File
@@ -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