Search: use search service (#46714)

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Ryan McKinley 2022-03-17 17:36:32 -07:00 committed by GitHub
parent 7589b83c5b
commit 93390b5a1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 356 additions and 7743 deletions

View File

@ -19,7 +19,6 @@ import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/s
import panelsReducers from 'app/features/panel/state/reducers'; import panelsReducers from 'app/features/panel/state/reducers';
import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers'; import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers';
import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; import templatingReducers from 'app/features/variables/state/keyedVariablesReducer';
import searchPageReducers from 'app/features/search/page/state/reducers';
const rootReducers = { const rootReducers = {
...sharedReducers, ...sharedReducers,
@ -40,7 +39,6 @@ const rootReducers = {
...panelEditorReducers, ...panelEditorReducers,
...panelsReducers, ...panelsReducers,
...templatingReducers, ...templatingReducers,
...searchPageReducers,
plugins: pluginsReducer, plugins: pluginsReducer,
}; };

View File

@ -1,15 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Input, useStyles2 } from '@grafana/ui'; import { Input, useStyles2, Spinner } from '@grafana/ui';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import Page from 'app/core/components/Page/Page'; import Page from 'app/core/components/Page/Page';
import { SearchPageDashboards } from './SearchPageDashboards'; import { SearchPageDashboards } from './SearchPageDashboards';
import { loadResults } from './state/actions'; import { useAsync } from 'react-use';
import { StoreState } from 'app/types'; import { getGrafanaSearcher } from '../service';
const node: NavModelItem = { const node: NavModelItem = {
id: 'search', id: 'search',
@ -19,20 +18,12 @@ const node: NavModelItem = {
}; };
export default function SearchPage() { export default function SearchPage() {
const dispatch = useDispatch();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const results = useSelector((state: StoreState) => state.searchPage.data.results);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const loadDashboardResults = useCallback(async () => { const results = useAsync(() => {
await dispatch(loadResults(query)); return getGrafanaSearcher().search(query);
}, [query, dispatch]); }, [query]);
useEffect(() => {
loadDashboardResults();
}, [query, loadDashboardResults]);
if (!config.featureToggles.panelTitleSearch) { if (!config.featureToggles.panelTitleSearch) {
return <div className={styles.unsupported}>Unsupported</div>; return <div className={styles.unsupported}>Unsupported</div>;
@ -43,19 +34,14 @@ export default function SearchPage() {
<Page.Contents> <Page.Contents>
<Input value={query} onChange={(e) => setQuery(e.currentTarget.value)} autoFocus spellCheck={false} /> <Input value={query} onChange={(e) => setQuery(e.currentTarget.value)} autoFocus spellCheck={false} />
<br /> <br /> <br /> <br />
{!results && query && <div>Loading....</div>} {results.loading && <Spinner />}
<div> {results.value?.body && (
FIELDS: {results?.body.fields.length}
<br />
RESULT: {results?.body.length}
</div>
{results?.body && (
<div> <div>
<AutoSizer style={{ width: '100%', height: '1000px' }}> <AutoSizer style={{ width: '100%', height: '1000px' }}>
{({ width }) => { {({ width }) => {
return ( return (
<div> <div>
<SearchPageDashboards dashboards={results?.body!} width={width} /> <SearchPageDashboards dashboards={results.value!.body} width={width} />
</div> </div>
); );
}} }}

View File

@ -1,29 +0,0 @@
import { reduceField } from '@grafana/data';
import { ThunkResult } from 'app/types';
import { getRawIndexData, getFrontendGrafanaSearcher } from '../../service/frontend';
import { fetchResults } from './reducers';
export const loadResults = (query: string): ThunkResult<void> => {
return async (dispatch) => {
const data = await getRawIndexData();
if (!data.dashboard) {
return;
}
const searcher = getFrontendGrafanaSearcher(data);
const results = await searcher.search(query);
// HACK avoid redux error!
results.body.fields.forEach((f) => {
reduceField({ field: f, reducers: ['min', 'max'] });
});
return dispatch(
fetchResults({
data: {
results,
},
})
);
};
};

View File

@ -1,33 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { QueryResponse } from '../../service/types';
export interface SearchPageState {
data: {
results?: QueryResponse;
};
}
export const initialState: SearchPageState = {
data: {
results: undefined,
},
};
export const searchPageSlice = createSlice({
name: 'searchPage',
initialState: initialState,
reducers: {
fetchResults: (state, action: PayloadAction<SearchPageState>): SearchPageState => {
return { ...action.payload };
},
},
});
export const { fetchResults } = searchPageSlice.actions;
export const searchPageReducer = searchPageSlice.reducer;
export default {
searchPage: searchPageReducer,
};

View File

@ -0,0 +1,46 @@
import { DataFrame, getDisplayProcessor } from '@grafana/data';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource';
import { lastValueFrom } from 'rxjs';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
// The raw restuls from query server
export interface RawIndexData {
folder?: DataFrame;
dashboard?: DataFrame;
panel?: DataFrame;
}
export type rawIndexSupplier = () => Promise<RawIndexData>;
export async function getRawIndexData(): Promise<RawIndexData> {
const ds = (await getDataSourceSrv().get('-- Grafana --')) as GrafanaDatasource;
const rsp = await lastValueFrom(
ds.query({
targets: [
{ refId: 'A', queryType: GrafanaQueryType.Search }, // gets all data
],
} as any)
);
const data: RawIndexData = {};
for (const f of rsp.data) {
const frame = f as DataFrame;
for (const field of frame.fields) {
field.display = getDisplayProcessor({ field, theme: config.theme2 });
}
switch (frame.name) {
case 'dashboards':
data.dashboard = frame;
break;
case 'panels':
data.panel = frame;
break;
case 'folders':
data.folder = frame;
break;
}
}
return data;
}

View File

@ -1,72 +0,0 @@
import { dataFrameFromJSON, DataFrameJSON, toDataFrame } from '@grafana/data';
import { dump } from './testData';
import { getFrontendGrafanaSearcher, RawIndexData } from './frontend';
describe('simple search', () => {
it('should support frontend search', async () => {
const raw: RawIndexData = {
dashboard: toDataFrame([
{ Name: 'A name (dash)', Description: 'A descr (dash)' },
{ Name: 'B name (dash)', Description: 'B descr (dash)' },
]),
panel: toDataFrame([
{ Name: 'A name (panels)', Description: 'A descr (panels)' },
{ Name: 'B name (panels)', Description: 'B descr (panels)' },
]),
};
const searcher = getFrontendGrafanaSearcher(raw);
let results = await searcher.search('name');
expect(results.body.fields[1].values.toArray()).toMatchInlineSnapshot(`
Array [
"A name (dash)",
"B name (dash)",
"A name (panels)",
"B name (panels)",
]
`);
results = await searcher.search('B');
expect(results.body.fields[1].values.toArray()).toMatchInlineSnapshot(`
Array [
"B name (dash)",
"B name (panels)",
]
`);
});
it('should work with query results', async () => {
const data: RawIndexData = {};
for (const frame of dump.results.A.frames) {
switch (frame.schema.name) {
case 'folder':
case 'folders':
data.folder = dataFrameFromJSON(frame as DataFrameJSON);
break;
case 'dashboards':
case 'dashboard':
data.dashboard = dataFrameFromJSON(frame as DataFrameJSON);
break;
case 'panels':
case 'panel':
data.panel = dataFrameFromJSON(frame as DataFrameJSON);
break;
}
}
const searcher = getFrontendGrafanaSearcher(data);
const results = await searcher.search('automation');
expect(results.body.fields[1].values.toArray()).toMatchInlineSnapshot(`
Array [
"Home automation",
"Panel name with automation",
"Tides",
"Gaps & null between every point for series B",
]
`);
});
});

View File

@ -1,249 +0,0 @@
import MiniSearch, { SearchResult } from 'minisearch';
import { lastValueFrom } from 'rxjs';
import { ArrayVector, DataFrame, FieldType, Vector } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { GrafanaDatasource } from 'app/plugins/datasource/grafana/datasource';
import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { GrafanaSearcher, QueryFilters, QueryResponse } from './types';
export interface RawIndexData {
dashboard?: DataFrame;
panel?: DataFrame;
folder?: DataFrame;
}
export type SearchResultKind = keyof RawIndexData;
interface InputDoc {
kind: SearchResultKind;
index: number;
// Fields
id?: Vector<number>;
url?: Vector<string>;
uid?: Vector<string>;
name?: Vector<string>;
description?: Vector<string>;
dashboardID?: Vector<number>;
type?: Vector<string>;
tags?: Vector<string>; // JSON strings?
}
interface CompositeKey {
kind: SearchResultKind;
index: number;
}
type Lookup = Map<SearchResultKind, InputDoc>;
const generateResults = (rawResults: SearchResult[], lookup: Lookup): DataFrame => {
// frame fields
const url: string[] = [];
const kind: string[] = [];
const type: string[] = [];
const name: string[] = [];
const info: any[] = [];
const score: number[] = [];
for (const res of rawResults) {
const key = res.id as CompositeKey;
const index = key.index;
const input = lookup.get(key.kind);
if (!input) {
continue;
}
url.push(input.url?.get(index) ?? '?');
kind.push(key.kind);
name.push(input.name?.get(index) ?? '?');
type.push(input.type?.get(index) as any);
info.push(res.match); // ???
score.push(res.score);
}
return {
fields: [
{ name: 'Kind', config: {}, type: FieldType.string, values: new ArrayVector(kind) },
{ name: 'Name', config: {}, type: FieldType.string, values: new ArrayVector(name) },
{
name: 'URL',
config: {
links: [
{
title: 'view',
url: '?',
onClick: (evt) => {
const { field, rowIndex } = evt.origin;
if (field && rowIndex != null) {
const url = field.values.get(rowIndex) as string;
window.location.href = url; // HACK!
}
},
},
],
},
type: FieldType.string,
values: new ArrayVector(url),
},
{ name: 'type', config: {}, type: FieldType.other, values: new ArrayVector(type) },
{ name: 'info', config: {}, type: FieldType.other, values: new ArrayVector(info) },
{ name: 'score', config: {}, type: FieldType.number, values: new ArrayVector(score) },
],
length: url.length,
};
};
export function getFrontendGrafanaSearcher(data: RawIndexData): GrafanaSearcher {
const searcher = new MiniSearch<InputDoc>({
idField: '__id',
fields: ['name', 'description', 'tags'], // fields to index for full-text search
searchOptions: {
boost: {
name: 3,
description: 1,
},
// boost dashboard matches first
boostDocument: (documentId: any, term: string) => {
const kind = documentId.kind;
if (kind === 'dashboard') {
return 1.4;
}
if (kind === 'folder') {
return 1.2;
}
return 1;
},
prefix: true, // (term) => term.length > 3,
fuzzy: (term) => (term.length > 4 ? 0.2 : false),
},
extractField: (doc, name) => {
// return a composite key for the id
if (name === '__id') {
return {
kind: doc.kind,
index: doc.index,
};
}
const values = (doc as any)[name] as Vector;
if (!values) {
return undefined;
}
return values.get(doc.index);
},
});
const lookup: Lookup = new Map<SearchResultKind, InputDoc>();
for (const [key, frame] of Object.entries(data)) {
const kind = key as SearchResultKind;
const input = getInputDoc(kind, frame);
lookup.set(kind, input);
for (let i = 0; i < frame.length; i++) {
input.index = i;
searcher.add(input);
}
}
// Construct the URL field for each panel
if (true) {
const dashboard = lookup.get('dashboard');
const panel = lookup.get('panel');
if (dashboard?.id && panel?.dashboardID && dashboard.url) {
const dashIDToIndex = new Map<number, number>();
for (let i = 0; i < dashboard.id?.length; i++) {
dashIDToIndex.set(dashboard.id.get(i), i);
}
const urls: string[] = new Array(panel.dashboardID.length);
for (let i = 0; i < panel.dashboardID.length; i++) {
const dashboardID = panel.dashboardID.get(i);
const index = dashIDToIndex.get(dashboardID);
if (index != null) {
urls[i] = dashboard.url.get(index) + '?viewPanel=' + panel.id?.get(i);
}
}
panel.url = new ArrayVector(urls);
}
}
return {
search: async (query: string, filter?: QueryFilters) => {
const found = searcher.search(query);
const results = generateResults(found, lookup);
const searchResult: QueryResponse = {
body: results,
};
return searchResult;
},
};
}
export function getInputDoc(kind: SearchResultKind, frame: DataFrame): InputDoc {
const input: InputDoc = {
kind,
index: 0,
};
for (const field of frame.fields) {
switch (field.name) {
case 'name':
case 'Name':
input.name = field.values;
break;
case 'Description':
case 'Description':
input.description = field.values;
break;
case 'url':
case 'URL':
input.url = field.values;
break;
case 'uid':
case 'UID':
input.uid = field.values;
break;
case 'id':
case 'ID':
input.id = field.values;
break;
case 'DashboardID':
case 'dashboardID':
input.dashboardID = field.values;
break;
case 'Type':
case 'type':
input.type = field.values;
break;
}
}
return input;
}
export async function getRawIndexData(): Promise<RawIndexData> {
const ds = (await getDataSourceSrv().get('-- Grafana --')) as GrafanaDatasource;
const rsp = await lastValueFrom(
ds.query({
targets: [
{ refId: 'A', queryType: GrafanaQueryType.Search }, // gets all data
],
} as any)
);
const data: RawIndexData = {};
for (const f of rsp.data) {
switch (f.name) {
case 'dashboards':
data.dashboard = f;
break;
case 'panels':
data.panel = f;
break;
case 'folders':
data.folder = f;
break;
}
}
return data;
}

View File

@ -0,0 +1,2 @@
export * from './types';
export { getGrafanaSearcher } from './searcher';

View File

@ -0,0 +1,231 @@
import MiniSearch from 'minisearch';
import { ArrayVector, DataFrame, Field, FieldType, getDisplayProcessor, Vector } from '@grafana/data';
import { config } from '@grafana/runtime';
import { GrafanaSearcher, QueryFilters, QueryResponse } from './types';
import { getRawIndexData, RawIndexData, rawIndexSupplier } from './backend';
export type SearchResultKind = keyof RawIndexData;
interface InputDoc {
kind: SearchResultKind;
index: number;
// Fields
id?: Vector<number>;
url?: Vector<string>;
uid?: Vector<string>;
name?: Vector<string>;
description?: Vector<string>;
dashboardID?: Vector<number>;
type?: Vector<string>;
tags?: Vector<string>; // JSON strings?
}
interface CompositeKey {
kind: SearchResultKind;
index: number;
}
// This implements search in the frontend using the minisearch library
export class MiniSearcher implements GrafanaSearcher {
lookup = new Map<SearchResultKind, InputDoc>();
data: RawIndexData = {};
index?: MiniSearch<InputDoc>;
constructor(private supplier: rawIndexSupplier = getRawIndexData) {
// waits for first request to load data
}
private async initIndex() {
const data = await this.supplier();
const searcher = new MiniSearch<InputDoc>({
idField: '__id',
fields: ['name', 'description', 'tags'], // fields to index for full-text search
searchOptions: {
boost: {
name: 3,
description: 1,
},
// boost dashboard matches first
boostDocument: (documentId: any, term: string) => {
const kind = documentId.kind;
if (kind === 'dashboard') {
return 1.4;
}
if (kind === 'folder') {
return 1.2;
}
return 1;
},
prefix: true,
fuzzy: (term) => (term.length > 4 ? 0.2 : false),
},
extractField: (doc, name) => {
// return a composite key for the id
if (name === '__id') {
return {
kind: doc.kind,
index: doc.index,
};
}
const values = (doc as any)[name] as Vector;
if (!values) {
return undefined;
}
return values.get(doc.index);
},
});
const lookup = new Map<SearchResultKind, InputDoc>();
for (const [key, frame] of Object.entries(data)) {
const kind = key as SearchResultKind;
const input = getInputDoc(kind, frame);
lookup.set(kind, input);
for (let i = 0; i < frame.length; i++) {
input.index = i;
searcher.add(input);
}
}
// Construct the URL field for each panel
const dashboard = lookup.get('dashboard');
const panel = lookup.get('panel');
if (dashboard?.id && panel?.dashboardID && dashboard.url) {
const dashIDToIndex = new Map<number, number>();
for (let i = 0; i < dashboard.id?.length; i++) {
dashIDToIndex.set(dashboard.id.get(i), i);
}
const urls: string[] = new Array(panel.dashboardID.length);
for (let i = 0; i < panel.dashboardID.length; i++) {
const dashboardID = panel.dashboardID.get(i);
const index = dashIDToIndex.get(dashboardID);
if (index != null) {
urls[i] = dashboard.url.get(index) + '?viewPanel=' + panel.id?.get(i);
}
}
panel.url = new ArrayVector(urls);
}
this.index = searcher;
this.data = data;
this.lookup = lookup;
}
async search(query: string, filter?: QueryFilters): Promise<QueryResponse> {
if (!this.index) {
await this.initIndex();
}
// empty query can return everything
if (!query && this.data.dashboard) {
return {
body: this.data.dashboard,
};
}
const found = this.index!.search(query);
// frame fields
const url: string[] = [];
const kind: string[] = [];
const type: string[] = [];
const name: string[] = [];
const info: any[] = [];
const score: number[] = [];
for (const res of found) {
const key = res.id as CompositeKey;
const index = key.index;
const input = this.lookup.get(key.kind);
if (!input) {
continue;
}
url.push(input.url?.get(index) ?? '?');
kind.push(key.kind);
name.push(input.name?.get(index) ?? '?');
type.push(input.type?.get(index) as any);
info.push(res.match); // ???
score.push(res.score);
}
const fields: Field[] = [
{ name: 'Kind', config: {}, type: FieldType.string, values: new ArrayVector(kind) },
{ name: 'Name', config: {}, type: FieldType.string, values: new ArrayVector(name) },
{
name: 'URL',
config: {
links: [
{
title: 'view',
url: '?',
onClick: (evt) => {
const { field, rowIndex } = evt.origin;
if (field && rowIndex != null) {
const url = field.values.get(rowIndex) as string;
window.location.href = url; // HACK!
}
},
},
],
},
type: FieldType.string,
values: new ArrayVector(url),
},
{ name: 'type', config: {}, type: FieldType.other, values: new ArrayVector(type) },
{ name: 'info', config: {}, type: FieldType.other, values: new ArrayVector(info) },
{ name: 'score', config: {}, type: FieldType.number, values: new ArrayVector(score) },
];
for (const field of fields) {
field.display = getDisplayProcessor({ field, theme: config.theme2 });
}
return {
body: {
fields,
length: kind.length,
},
};
}
}
function getInputDoc(kind: SearchResultKind, frame: DataFrame): InputDoc {
const input: InputDoc = {
kind,
index: 0,
};
for (const field of frame.fields) {
switch (field.name) {
case 'name':
case 'Name':
input.name = field.values;
break;
case 'Description':
case 'Description':
input.description = field.values;
break;
case 'url':
case 'URL':
input.url = field.values;
break;
case 'uid':
case 'UID':
input.uid = field.values;
break;
case 'id':
case 'ID':
input.id = field.values;
break;
case 'DashboardID':
case 'dashboardID':
input.dashboardID = field.values;
break;
case 'Type':
case 'type':
input.type = field.values;
break;
}
}
return input;
}

View File

@ -0,0 +1,56 @@
import { toDataFrame } from '@grafana/data';
import { rawIndexSupplier } from './backend';
import { MiniSearcher } from './minisearcher';
jest.mock('@grafana/data', () => ({
...jest.requireActual('@grafana/data'),
getDisplayProcessor: jest
.fn()
.mockName('mockedGetDisplayProcesser')
.mockImplementation(() => ({})),
}));
describe('simple search', () => {
it('should support frontend search', async () => {
const supplier: rawIndexSupplier = () =>
Promise.resolve({
dashboard: toDataFrame([
{ Name: 'A name (dash)', Description: 'A descr (dash)' },
{ Name: 'B name (dash)', Description: 'B descr (dash)' },
]),
panel: toDataFrame([
{ Name: 'A name (panels)', Description: 'A descr (panels)' },
{ Name: 'B name (panels)', Description: 'B descr (panels)' },
]),
});
const searcher = new MiniSearcher(supplier);
let results = await searcher.search('name');
expect(results.body.fields[1].values.toArray()).toMatchInlineSnapshot(`
Array [
"A name (dash)",
"B name (dash)",
"A name (panels)",
"B name (panels)",
]
`);
results = await searcher.search('B');
expect(results.body.fields[1].values.toArray()).toMatchInlineSnapshot(`
Array [
"B name (dash)",
"B name (panels)",
]
`);
// All fields must have display set
for (const field of results.body.fields) {
expect(field.display).toBeDefined();
}
// Empty search has defined values
results = await searcher.search('');
expect(results.body.fields.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,11 @@
import { MiniSearcher } from './minisearcher';
import { GrafanaSearcher } from './types';
let searcher: GrafanaSearcher | undefined = undefined;
export function getGrafanaSearcher(): GrafanaSearcher {
if (!searcher) {
searcher = new MiniSearcher();
}
return searcher!;
}

File diff suppressed because it is too large Load Diff