Search: add a simple search page (behind feature flag) (#45487)

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Ryan McKinley 2022-03-03 14:56:14 -08:00 committed by GitHub
parent 085a8fde67
commit 0aad61d0ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 401 additions and 0 deletions

View File

@ -19,6 +19,7 @@ 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,
@ -39,6 +40,7 @@ const rootReducers = {
...panelEditorReducers,
...panelsReducers,
...templatingReducers,
...searchPageReducers,
plugins: pluginsReducer,
};

View File

@ -0,0 +1,89 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Input, useStyles2 } 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 { SearchPageDashboardList } from './SearchPageDashboardList';
import { loadResults } from './state/actions';
import { StoreState } from 'app/types';
import { SearchPageStats } from './SearchPageStats';
import { buildStatsTable } from './data';
const node: NavModelItem = {
id: 'search',
text: 'Search',
icon: 'dashboard',
url: 'search',
};
export default function SearchPage() {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const dashboards = useSelector((state: StoreState) => state.searchPage.data.dashboards);
const panels = useSelector((state: StoreState) => state.searchPage.data.panels);
const [query, setQuery] = useState('');
const loadDashboardResults = useCallback(async () => {
await dispatch(loadResults(query));
}, [query, dispatch]);
useEffect(() => {
loadDashboardResults();
}, [query, loadDashboardResults]);
if (!config.featureToggles.panelTitleSearch) {
return <div className={styles.unsupported}>Unsupported</div>;
}
return (
<Page navModel={{ node: node, main: node }}>
<Page.Contents>
<Input value={query} onChange={(e) => setQuery(e.currentTarget.value)} autoFocus spellCheck={false} />
<br /> <br />
{!dashboards && <div>Loading....</div>}
{dashboards && (
<div>
<AutoSizer style={{ width: '100%', height: '1000px' }}>
{({ width }) => {
return (
<div>
{dashboards && <SearchPageDashboardList dashboards={dashboards} />}
<br />
{dashboards.dataFrame && dashboards.dataFrame.length > 0 && (
<SearchPageDashboards dashboards={dashboards.dataFrame} width={width} />
)}
{panels && (
<SearchPageStats
panelTypes={buildStatsTable(panels.fields.find((f) => f.name === 'Type'))}
width={width}
/>
)}
</div>
);
}}
</AutoSizer>
</div>
)}
</Page.Contents>
</Page>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
unsupported: css`
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 18px;
`,
});

View File

@ -0,0 +1,29 @@
import React from 'react';
import { DataFrameView, GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
type Props = {
dashboards: DataFrameView;
};
export const SearchPageDashboardList = ({ dashboards }: Props) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.listContainer}>
{dashboards.map((dash) => (
<div key={dash.UID}>
<a href={dash.URL}>{dash.Name}</a>
</div>
))}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
listContainer: css`
max-height: 300px;
overflow: scroll;
`,
});

View File

@ -0,0 +1,27 @@
import React from 'react';
import { DataFrame, LoadingState } from '@grafana/data';
import { PanelRenderer } from '@grafana/runtime';
type Props = {
dashboards: DataFrame;
width: number;
};
export const SearchPageDashboards = ({ dashboards, width }: Props) => {
return (
<>
<h1>Dashboards ({dashboards.length})</h1>
<PanelRenderer
pluginId="table"
title="Dashboards"
data={{ series: [dashboards], state: LoadingState.Done } as any}
options={{}}
width={width}
height={300}
fieldConfig={{ defaults: {}, overrides: [] }}
timeZone="browser"
/>
<br />
</>
);
};

View File

@ -0,0 +1,42 @@
import React from 'react';
import { DataFrame, GrafanaTheme2, LoadingState } from '@grafana/data';
import { PanelRenderer } from '@grafana/runtime';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
type Props = {
panelTypes: DataFrame;
width: number;
};
export const SearchPageStats = ({ panelTypes, width }: Props) => {
const styles = useStyles2(getStyles);
return (
<>
<h1>Stats</h1>
<table className={styles.table}>
<tr>
<td>
<PanelRenderer
pluginId="table"
title="Panels"
data={{ series: [panelTypes], state: LoadingState.Done } as any}
options={{}}
width={width / 2}
height={200}
fieldConfig={{ defaults: {}, overrides: [] }}
timeZone="browser"
/>
</td>
</tr>
</table>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
table: css`
width: 100%;
`,
});

View File

@ -0,0 +1,114 @@
import { ArrayVector, DataFrame, Field, FieldType } 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 { lastValueFrom } from 'rxjs';
export interface DashboardData {
dashboards: DataFrame;
panels: DataFrame;
panelTypes: DataFrame;
schemaVersions: DataFrame;
}
export async function getDashboardData(): Promise<DashboardData> {
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: DashboardData = {} as any;
for (const f of rsp.data) {
switch (f.name) {
case 'dashboards':
data.dashboards = f;
break;
case 'panels':
data.panels = f;
break;
}
}
data.panelTypes = buildStatsTable(data.panels.fields.find((f) => f.name === 'Type'));
data.schemaVersions = buildStatsTable(data.dashboards.fields.find((f) => f.name === 'SchemaVersion'));
return data;
}
export function filterDataFrame(query: string, frame: DataFrame, ...fields: string[]): DataFrame {
if (!frame || !query?.length) {
return frame;
}
query = query.toLowerCase();
const checkIndex: number[] = [];
const buffer: any[][] = [];
const copy = frame.fields.map((f, idx) => {
if (f.type === FieldType.string && fields.includes(f.name)) {
checkIndex.push(idx);
}
const v: any[] = [];
buffer.push(v);
return { ...f, values: new ArrayVector(v) };
});
for (let i = 0; i < frame.length; i++) {
let match = false;
for (const idx of checkIndex) {
const v = frame.fields[idx].values.get(i) as string;
if (v && v.toLowerCase().indexOf(query) >= 0) {
match = true;
break;
}
}
if (match) {
for (let idx = 0; idx < buffer.length; idx++) {
buffer[idx].push(frame.fields[idx].values.get(i));
}
}
}
return {
fields: copy,
length: buffer[0].length,
};
}
export function buildStatsTable(field?: Field): DataFrame {
if (!field) {
return { length: 0, fields: [] };
}
const counts = new Map<any, number>();
for (let i = 0; i < field.values.length; i++) {
const k = field.values.get(i);
const v = counts.get(k) ?? 0;
counts.set(k, v + 1);
}
// Sort largest first
counts[Symbol.iterator] = function* () {
yield* [...this.entries()].sort((a, b) => b[1] - a[1]);
};
const keys: any[] = [];
const vals: number[] = [];
for (let [k, v] of counts) {
keys.push(k);
vals.push(v);
}
return {
fields: [
{ ...field, values: new ArrayVector(keys) },
{ name: 'Count', type: FieldType.number, values: new ArrayVector(vals), config: {} },
],
length: keys.length,
};
}

View File

@ -0,0 +1,39 @@
import { DataFrameView } from '@grafana/data';
import { ThunkResult } from 'app/types';
import { getDashboardData, filterDataFrame } from '../data';
import { DashboardResult } from '../types';
import { fetchResults } from './reducers';
export const loadResults = (query: string): ThunkResult<void> => {
return async (dispatch) => {
const data = await getDashboardData();
if (!data.dashboards || !data.panels) {
return;
}
if (!data.dashboards.length || !query.length) {
return dispatch(
fetchResults({
data: {
dashboards: new DataFrameView<DashboardResult>(data.dashboards),
panels: data.panels,
},
})
);
}
const dashboards = filterDataFrame(query, data.dashboards, 'Name', 'Description', 'Tags');
const panels = filterDataFrame(query, data.panels, 'Name', 'Description', 'Type');
return dispatch(
fetchResults({
data: {
dashboards: new DataFrameView<DashboardResult>(dashboards),
panels: panels,
},
})
);
};
};

View File

@ -0,0 +1,36 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { DataFrame, DataFrameView } from '@grafana/data';
import { DashboardResult } from '../types';
export interface SearchPageState {
data: {
dashboards: DataFrameView<DashboardResult> | null;
panels: DataFrame | null;
};
}
export const initialState: SearchPageState = {
data: {
dashboards: null,
panels: null,
},
};
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,17 @@
import { Dispatch } from 'react';
import { Action } from 'redux';
export interface DashboardResult {
UID: string;
URL: string;
Name: string;
Description: string;
Created: number;
Updated: number;
}
export interface SearchPageAction extends Action {
payload?: any;
}
export type SearchPageReducer<S> = [S, Dispatch<SearchPageAction>];

View File

@ -397,6 +397,12 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "PlaylistEditPage"*/ 'app/features/playlist/PlaylistEditPage')
),
},
{
path: '/search',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "SearchPage"*/ 'app/features/search/page/SearchPage')
),
},
{
path: '/sandbox/benchmarks',
component: SafeDynamicImport(