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) => (
+
+ ))}
+
+ );
+};
+
+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(