mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: add a simple search page (behind feature flag) (#45487)
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
parent
085a8fde67
commit
0aad61d0ac
@ -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,
|
||||
};
|
||||
|
||||
|
89
public/app/features/search/page/SearchPage.tsx
Normal file
89
public/app/features/search/page/SearchPage.tsx
Normal 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;
|
||||
`,
|
||||
});
|
29
public/app/features/search/page/SearchPageDashboardList.tsx
Normal file
29
public/app/features/search/page/SearchPageDashboardList.tsx
Normal 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;
|
||||
`,
|
||||
});
|
27
public/app/features/search/page/SearchPageDashboards.tsx
Normal file
27
public/app/features/search/page/SearchPageDashboards.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
};
|
42
public/app/features/search/page/SearchPageStats.tsx
Normal file
42
public/app/features/search/page/SearchPageStats.tsx
Normal 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%;
|
||||
`,
|
||||
});
|
114
public/app/features/search/page/data.ts
Normal file
114
public/app/features/search/page/data.ts
Normal 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,
|
||||
};
|
||||
}
|
39
public/app/features/search/page/state/actions.ts
Normal file
39
public/app/features/search/page/state/actions.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
};
|
36
public/app/features/search/page/state/reducers.ts
Normal file
36
public/app/features/search/page/state/reducers.ts
Normal 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,
|
||||
};
|
17
public/app/features/search/page/types.ts
Normal file
17
public/app/features/search/page/types.ts
Normal 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>];
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user