mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: use search service (#46714)
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
parent
7589b83c5b
commit
93390b5a1e
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
@ -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,
|
|
||||||
};
|
|
46
public/app/features/search/service/backend.ts
Normal file
46
public/app/features/search/service/backend.ts
Normal 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;
|
||||||
|
}
|
@ -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",
|
|
||||||
]
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
});
|
|
@ -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;
|
|
||||||
}
|
|
2
public/app/features/search/service/index.ts
Normal file
2
public/app/features/search/service/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './types';
|
||||||
|
export { getGrafanaSearcher } from './searcher';
|
231
public/app/features/search/service/minisearcher.ts
Normal file
231
public/app/features/search/service/minisearcher.ts
Normal 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;
|
||||||
|
}
|
56
public/app/features/search/service/searcher.test.ts
Normal file
56
public/app/features/search/service/searcher.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
11
public/app/features/search/service/searcher.ts
Normal file
11
public/app/features/search/service/searcher.ts
Normal 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
Loading…
Reference in New Issue
Block a user