Search: fuzzy match dashboard names in frontend from full list (#55721)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user