mirror of
https://github.com/grafana/grafana.git
synced 2024-11-26 02:40:26 -06:00
DashboardGrid: Add support to filter panels using variable (#77112)
* DashboardGrid panel filter * Missed segment and changes per PR discussion * Hide feature flag from docs --------- Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
parent
577b3f2fb2
commit
6bf4d0cbc6
@ -3292,7 +3292,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"]
|
||||
],
|
||||
"public/app/features/dashboard/dashgrid/DashboardGrid.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard/dashgrid/DashboardPanel.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
|
@ -157,4 +157,5 @@ export interface FeatureToggles {
|
||||
annotationPermissionUpdate?: boolean;
|
||||
extractFieldsNameDeduplication?: boolean;
|
||||
dashboardSceneForViewers?: boolean;
|
||||
panelFilterVariable?: boolean;
|
||||
}
|
||||
|
@ -974,5 +974,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelFilterVariable",
|
||||
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -138,3 +138,4 @@ alertmanagerRemoteOnly,experimental,@grafana/alerting-squad,false,false,false,fa
|
||||
annotationPermissionUpdate,experimental,@grafana/grafana-authnz-team,false,false,false,false
|
||||
extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true
|
||||
dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true
|
||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,true
|
||||
|
|
@ -562,4 +562,8 @@ const (
|
||||
// FlagDashboardSceneForViewers
|
||||
// Enables dashboard rendering using Scenes for viewer roles
|
||||
FlagDashboardSceneForViewers = "dashboardSceneForViewers"
|
||||
|
||||
// FlagPanelFilterVariable
|
||||
// Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
FlagPanelFilterVariable = "panelFilterVariable"
|
||||
)
|
||||
|
@ -1,23 +1,73 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { TextBoxVariableModel } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Dashboard } from '@grafana/schema';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
import { GetVariables } from 'app/features/variables/state/selectors';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { DashboardMeta } from 'app/types';
|
||||
|
||||
import { DashboardModel } from '../state';
|
||||
import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures';
|
||||
|
||||
import { DashboardGrid, Props } from './DashboardGrid';
|
||||
import { DashboardGrid, PANEL_FILTER_VARIABLE, Props } from './DashboardGrid';
|
||||
import { Props as LazyLoaderProps } from './LazyLoader';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
config: {
|
||||
...jest.requireActual('@grafana/runtime').config,
|
||||
featureToggles: {
|
||||
panelFilterVariable: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
|
||||
const LazyLoader = ({ children }: LazyLoaderProps) => {
|
||||
return <>{children}</>;
|
||||
const LazyLoader = ({ children, onLoad }: Pick<LazyLoaderProps, 'children' | 'onLoad'>) => {
|
||||
useEffectOnce(() => {
|
||||
onLoad?.();
|
||||
});
|
||||
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
|
||||
};
|
||||
return { LazyLoader };
|
||||
});
|
||||
|
||||
function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partial<DashboardMeta>): DashboardModel {
|
||||
jest.mock('react-virtualized-auto-sizer', () => {
|
||||
// The size of the children need to be small enough to be outside the view.
|
||||
// So it does not trigger the query to be run by the PanelQueryRunner.
|
||||
return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 });
|
||||
});
|
||||
|
||||
function setup(props: Props) {
|
||||
const context = getGrafanaContextMock();
|
||||
const store = configureStore({});
|
||||
|
||||
return render(
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<Provider store={store}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<DashboardGrid {...props} />
|
||||
</Router>
|
||||
</Provider>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function getTestDashboard(
|
||||
overrides?: Partial<Dashboard>,
|
||||
metaOverrides?: Partial<DashboardMeta>,
|
||||
getVariablesFromState?: GetVariables
|
||||
): DashboardModel {
|
||||
const data = Object.assign(
|
||||
{
|
||||
title: 'My dashboard',
|
||||
@ -30,20 +80,20 @@ function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partia
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'graph2',
|
||||
title: 'My graph2',
|
||||
type: 'table',
|
||||
title: 'My table',
|
||||
gridPos: { x: 0, y: 10, w: 25, h: 10 },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'graph3',
|
||||
title: 'My graph3',
|
||||
type: 'table',
|
||||
title: 'My table 2',
|
||||
gridPos: { x: 0, y: 20, w: 25, h: 100 },
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'graph4',
|
||||
title: 'My graph4',
|
||||
type: 'gauge',
|
||||
title: 'My gauge',
|
||||
gridPos: { x: 0, y: 120, w: 25, h: 10 },
|
||||
},
|
||||
],
|
||||
@ -51,17 +101,88 @@ function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partia
|
||||
overrides
|
||||
);
|
||||
|
||||
return createDashboardModelFixture(data, metaOverrides);
|
||||
return createDashboardModelFixture(data, metaOverrides, getVariablesFromState);
|
||||
}
|
||||
|
||||
describe('DashboardGrid', () => {
|
||||
it('should render without error', () => {
|
||||
it('Should render panels', async () => {
|
||||
const props: Props = {
|
||||
editPanel: null,
|
||||
viewPanel: null,
|
||||
isEditable: true,
|
||||
dashboard: getTestDashboard(),
|
||||
};
|
||||
expect(() => render(<DashboardGrid {...props} />)).not.toThrow();
|
||||
|
||||
act(() => {
|
||||
setup(props);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('My graph')).toBeInTheDocument();
|
||||
expect(await screen.findByText('My table')).toBeInTheDocument();
|
||||
expect(await screen.findByText('My table 2')).toBeInTheDocument();
|
||||
expect(await screen.findByText('My gauge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should allow filtering panels', async () => {
|
||||
const props: Props = {
|
||||
editPanel: null,
|
||||
viewPanel: null,
|
||||
isEditable: true,
|
||||
dashboard: getTestDashboard(),
|
||||
};
|
||||
act(() => {
|
||||
setup(props);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
appEvents.publish(
|
||||
new VariablesChanged({
|
||||
panelIds: [],
|
||||
refreshAll: false,
|
||||
variable: {
|
||||
type: 'textbox',
|
||||
id: PANEL_FILTER_VARIABLE,
|
||||
current: {
|
||||
value: 'My graph',
|
||||
},
|
||||
} as TextBoxVariableModel,
|
||||
})
|
||||
);
|
||||
});
|
||||
const table = screen.queryByText('My table');
|
||||
const table2 = screen.queryByText('My table 2');
|
||||
const gauge = screen.queryByText('My gauge');
|
||||
|
||||
expect(await screen.findByText('My graph')).toBeInTheDocument();
|
||||
expect(table).toBeNull();
|
||||
expect(table2).toBeNull();
|
||||
expect(gauge).toBeNull();
|
||||
});
|
||||
|
||||
it('Should rendered filtered panels on init when filter variable is present', async () => {
|
||||
const props: Props = {
|
||||
editPanel: null,
|
||||
viewPanel: null,
|
||||
isEditable: true,
|
||||
dashboard: getTestDashboard(undefined, undefined, () => [
|
||||
{
|
||||
id: PANEL_FILTER_VARIABLE,
|
||||
type: 'textbox',
|
||||
query: 'My tab',
|
||||
} as TextBoxVariableModel,
|
||||
]),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
setup(props);
|
||||
});
|
||||
|
||||
const graph = screen.queryByText('My graph');
|
||||
const gauge = screen.queryByText('My gauge');
|
||||
|
||||
expect(await screen.findByText('My table')).toBeInTheDocument();
|
||||
expect(await screen.findByText('My table 2')).toBeInTheDocument();
|
||||
expect(graph).toBeNull();
|
||||
expect(gauge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
@ -5,8 +5,10 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { VariablesChanged } from 'app/features/variables/types';
|
||||
import { DashboardPanelsChangedEvent } from 'app/types/events';
|
||||
|
||||
import { AddLibraryPanelWidget } from '../components/AddLibraryPanelWidget';
|
||||
@ -18,6 +20,8 @@ import { GridPos } from '../state/PanelModel';
|
||||
import DashboardEmpty from './DashboardEmpty';
|
||||
import { DashboardPanel } from './DashboardPanel';
|
||||
|
||||
export const PANEL_FILTER_VARIABLE = 'systemPanelFilterVar';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardModel;
|
||||
isEditable: boolean;
|
||||
@ -25,7 +29,12 @@ export interface Props {
|
||||
viewPanel: PanelModel | null;
|
||||
hidePanelMenus?: boolean;
|
||||
}
|
||||
export class DashboardGrid extends PureComponent<Props> {
|
||||
|
||||
interface State {
|
||||
panelFilter?: RegExp;
|
||||
}
|
||||
|
||||
export class DashboardGrid extends PureComponent<Props, State> {
|
||||
private panelMap: { [key: string]: PanelModel } = {};
|
||||
private eventSubs = new Subscription();
|
||||
private windowHeight = 1200;
|
||||
@ -37,10 +46,43 @@ export class DashboardGrid extends PureComponent<Props> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
panelFilter: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { dashboard } = this.props;
|
||||
|
||||
if (config.featureToggles.panelFilterVariable) {
|
||||
// If panel filter variable is set on load then
|
||||
// update state to filter panels
|
||||
for (const variable of dashboard.getVariables()) {
|
||||
if (variable.id === PANEL_FILTER_VARIABLE) {
|
||||
if ('query' in variable) {
|
||||
this.setPanelFilter(variable.query);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.eventSubs.add(
|
||||
appEvents.subscribe(VariablesChanged, (e) => {
|
||||
if (e.payload.variable?.id === PANEL_FILTER_VARIABLE) {
|
||||
if ('current' in e.payload.variable) {
|
||||
let variable = e.payload.variable.current;
|
||||
if ('value' in variable) {
|
||||
let value = variable.value;
|
||||
if (typeof value === 'string') {
|
||||
this.setPanelFilter(value as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate));
|
||||
}
|
||||
|
||||
@ -48,10 +90,25 @@ export class DashboardGrid extends PureComponent<Props> {
|
||||
this.eventSubs.unsubscribe();
|
||||
}
|
||||
|
||||
setPanelFilter(regex: string) {
|
||||
// Only set the panels filter if the systemPanelFilterVar variable
|
||||
// is a non-empty string
|
||||
let panelFilter = undefined;
|
||||
if (regex.length > 0) {
|
||||
panelFilter = new RegExp(regex, 'i');
|
||||
}
|
||||
|
||||
this.setState({
|
||||
panelFilter: panelFilter,
|
||||
});
|
||||
}
|
||||
|
||||
buildLayout() {
|
||||
const layout: ReactGridLayout.Layout[] = [];
|
||||
this.panelMap = {};
|
||||
const { panelFilter } = this.state;
|
||||
|
||||
let count = 0;
|
||||
for (const panel of this.props.dashboard.panels) {
|
||||
if (!panel.key) {
|
||||
panel.key = `panel-${panel.id}-${Date.now()}`;
|
||||
@ -78,13 +135,27 @@ export class DashboardGrid extends PureComponent<Props> {
|
||||
panelPos.isDraggable = panel.collapsed;
|
||||
}
|
||||
|
||||
layout.push(panelPos);
|
||||
if (!panelFilter) {
|
||||
layout.push(panelPos);
|
||||
} else {
|
||||
if (panelFilter.test(panel.title)) {
|
||||
panelPos.isResizable = false;
|
||||
panelPos.isDraggable = false;
|
||||
panelPos.x = (count % 2) * GRID_COLUMN_COUNT;
|
||||
panelPos.y = Math.floor(count / 2);
|
||||
layout.push(panelPos);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => {
|
||||
if (this.state.panelFilter) {
|
||||
return;
|
||||
}
|
||||
for (const newPos of newLayout) {
|
||||
this.panelMap[newPos.i!].updateGridPos(newPos, this.isLayoutInitialized);
|
||||
}
|
||||
@ -136,6 +207,7 @@ export class DashboardGrid extends PureComponent<Props> {
|
||||
}
|
||||
|
||||
renderPanels(gridWidth: number, isDashboardDraggable: boolean) {
|
||||
const { panelFilter } = this.state;
|
||||
const panelElements = [];
|
||||
|
||||
// Reset last panel bottom
|
||||
@ -156,7 +228,7 @@ export class DashboardGrid extends PureComponent<Props> {
|
||||
// requires parent create stacking context to prevent overlap with parent elements
|
||||
const descIndex = this.props.dashboard.panels.length - panelElements.length;
|
||||
|
||||
panelElements.push(
|
||||
const p = (
|
||||
<GrafanaGridItem
|
||||
key={panel.key}
|
||||
className={panelClasses}
|
||||
@ -173,6 +245,14 @@ export class DashboardGrid extends PureComponent<Props> {
|
||||
}}
|
||||
</GrafanaGridItem>
|
||||
);
|
||||
|
||||
if (!panelFilter) {
|
||||
panelElements.push(p);
|
||||
} else {
|
||||
if (panelFilter.test(panel.title)) {
|
||||
panelElements.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return panelElements;
|
||||
@ -295,7 +375,7 @@ interface GrafanaGridItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
isViewing: boolean;
|
||||
windowHeight: number;
|
||||
windowWidth: number;
|
||||
children: any;
|
||||
children: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -623,6 +623,7 @@ export const variableUpdated = (
|
||||
: {
|
||||
refreshAll: false,
|
||||
panelIds: Array.from(getAllAffectedPanelIdsForVariableChange([variableInState.id], g, panelVars)),
|
||||
variable: getVariable(identifier, state),
|
||||
};
|
||||
|
||||
const node = g.getNode(variableInState.name);
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
QueryEditorProps,
|
||||
BaseVariableModel,
|
||||
VariableHide,
|
||||
TypedVariableModel,
|
||||
} from '@grafana/data';
|
||||
export {
|
||||
/** @deprecated Import from @grafana/data instead */
|
||||
@ -95,6 +96,7 @@ export type VariableQueryEditorType<
|
||||
export interface VariablesChangedEvent {
|
||||
refreshAll: boolean;
|
||||
panelIds: number[];
|
||||
variable?: TypedVariableModel;
|
||||
}
|
||||
|
||||
export class VariablesChanged extends BusEventWithPayload<VariablesChangedEvent> {
|
||||
|
Loading…
Reference in New Issue
Block a user