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