From 0aad61d0ac9ab63688104319e40f8d84e4136df8 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 3 Mar 2022 14:56:14 -0800 Subject: [PATCH] Search: add a simple search page (behind feature flag) (#45487) Co-authored-by: nmarrs --- public/app/core/reducers/root.ts | 2 + .../app/features/search/page/SearchPage.tsx | 89 ++++++++++++++ .../search/page/SearchPageDashboardList.tsx | 29 +++++ .../search/page/SearchPageDashboards.tsx | 27 +++++ .../features/search/page/SearchPageStats.tsx | 42 +++++++ public/app/features/search/page/data.ts | 114 ++++++++++++++++++ .../app/features/search/page/state/actions.ts | 39 ++++++ .../features/search/page/state/reducers.ts | 36 ++++++ public/app/features/search/page/types.ts | 17 +++ public/app/routes/routes.tsx | 6 + 10 files changed, 401 insertions(+) create mode 100644 public/app/features/search/page/SearchPage.tsx create mode 100644 public/app/features/search/page/SearchPageDashboardList.tsx create mode 100644 public/app/features/search/page/SearchPageDashboards.tsx create mode 100644 public/app/features/search/page/SearchPageStats.tsx create mode 100644 public/app/features/search/page/data.ts create mode 100644 public/app/features/search/page/state/actions.ts create mode 100644 public/app/features/search/page/state/reducers.ts create mode 100644 public/app/features/search/page/types.ts diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index fe0d22f773c..b5836a4cb4b 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -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, }; diff --git a/public/app/features/search/page/SearchPage.tsx b/public/app/features/search/page/SearchPage.tsx new file mode 100644 index 00000000000..596338e5778 --- /dev/null +++ b/public/app/features/search/page/SearchPage.tsx @@ -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
Unsupported
; + } + + return ( + + + setQuery(e.currentTarget.value)} autoFocus spellCheck={false} /> +

+ {!dashboards &&
Loading....
} + {dashboards && ( +
+ + {({ width }) => { + return ( +
+ {dashboards && } +
+ {dashboards.dataFrame && dashboards.dataFrame.length > 0 && ( + + )} + + {panels && ( + f.name === 'Type'))} + width={width} + /> + )} +
+ ); + }} +
+
+ )} +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + unsupported: css` + padding: 10px; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 18px; + `, +}); diff --git a/public/app/features/search/page/SearchPageDashboardList.tsx b/public/app/features/search/page/SearchPageDashboardList.tsx new file mode 100644 index 00000000000..12b073951bd --- /dev/null +++ b/public/app/features/search/page/SearchPageDashboardList.tsx @@ -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 ( +
+ {dashboards.map((dash) => ( +
+ {dash.Name} +
+ ))} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + listContainer: css` + max-height: 300px; + overflow: scroll; + `, +}); diff --git a/public/app/features/search/page/SearchPageDashboards.tsx b/public/app/features/search/page/SearchPageDashboards.tsx new file mode 100644 index 00000000000..ed4b1cf7927 --- /dev/null +++ b/public/app/features/search/page/SearchPageDashboards.tsx @@ -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 ( + <> +

Dashboards ({dashboards.length})

+ +
+ + ); +}; diff --git a/public/app/features/search/page/SearchPageStats.tsx b/public/app/features/search/page/SearchPageStats.tsx new file mode 100644 index 00000000000..68f64e15307 --- /dev/null +++ b/public/app/features/search/page/SearchPageStats.tsx @@ -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 ( + <> +

Stats

+ + + + +
+ +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css` + width: 100%; + `, +}); diff --git a/public/app/features/search/page/data.ts b/public/app/features/search/page/data.ts new file mode 100644 index 00000000000..1d4c1db2833 --- /dev/null +++ b/public/app/features/search/page/data.ts @@ -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 { + 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(); + 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, + }; +} diff --git a/public/app/features/search/page/state/actions.ts b/public/app/features/search/page/state/actions.ts new file mode 100644 index 00000000000..61da1d97c51 --- /dev/null +++ b/public/app/features/search/page/state/actions.ts @@ -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 => { + 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(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(dashboards), + panels: panels, + }, + }) + ); + }; +}; diff --git a/public/app/features/search/page/state/reducers.ts b/public/app/features/search/page/state/reducers.ts new file mode 100644 index 00000000000..965d94eae1f --- /dev/null +++ b/public/app/features/search/page/state/reducers.ts @@ -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 | 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 => { + return { ...action.payload }; + }, + }, +}); + +export const { fetchResults } = searchPageSlice.actions; + +export const searchPageReducer = searchPageSlice.reducer; + +export default { + searchPage: searchPageReducer, +}; diff --git a/public/app/features/search/page/types.ts b/public/app/features/search/page/types.ts new file mode 100644 index 00000000000..44a67e21857 --- /dev/null +++ b/public/app/features/search/page/types.ts @@ -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, Dispatch]; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 28fb00dbef7..938cafd5f8e 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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(