[v10.2.x] DashboardGrid: Add support to filter panels using variable (#77112) (#77995)

Co-authored-by: Jacob Zelek <jzelek@linkedin.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Alexa V
2023-11-10 14:29:53 +01:00
committed by GitHub
co-authored by Jacob Zelek Dominik Prokop
parent 513761e36e
commit 70a62d5fcf
9 changed files with 237 additions and 19 deletions
+1 -1
View File
@@ -3420,7 +3420,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "0"] [0, 0, 0, "Styles should be written using objects.", "0"]
], ],
"public/app/features/dashboard/dashgrid/DashboardGrid.tsx:5381": [ "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": [ "public/app/features/dashboard/dashgrid/DashboardPanel.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@@ -145,4 +145,5 @@ export interface FeatureToggles {
cachingOptimizeSerializationMemoryUsage?: boolean; cachingOptimizeSerializationMemoryUsage?: boolean;
panelTitleSearchInV1?: boolean; panelTitleSearchInV1?: boolean;
pluginsInstrumentationStatusSource?: boolean; pluginsInstrumentationStatusSource?: boolean;
panelFilterVariable?: boolean;
} }
+8
View File
@@ -891,5 +891,13 @@ var (
Stage: FeatureStageExperimental, Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad, Owner: grafanaPluginsPlatformSquad,
}, },
{
Name: "panelFilterVariable",
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
HideFromDocs: true,
},
} }
) )
+1
View File
@@ -126,3 +126,4 @@ awsDatasourcesNewFormStyling,experimental,@grafana/aws-datasources,false,false,f
cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false,false cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false,false
panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false,false panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false,false
pluginsInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,false,false,false,false pluginsInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,false,false,false,false
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,true
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
126 cachingOptimizeSerializationMemoryUsage experimental @grafana/grafana-operator-experience-squad false false false false
127 panelTitleSearchInV1 experimental @grafana/backend-platform true false false false
128 pluginsInstrumentationStatusSource experimental @grafana/plugins-platform-backend false false false false
129 panelFilterVariable experimental @grafana/dashboards-squad false false false true
+4
View File
@@ -514,4 +514,8 @@ const (
// FlagPluginsInstrumentationStatusSource // FlagPluginsInstrumentationStatusSource
// Include a status source label for plugin request metrics and logs // Include a status source label for plugin request metrics and logs
FlagPluginsInstrumentationStatusSource = "pluginsInstrumentationStatusSource" FlagPluginsInstrumentationStatusSource = "pluginsInstrumentationStatusSource"
// 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 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 { 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 { DashboardMeta } from 'app/types';
import { DashboardModel } from '../state'; import { DashboardModel } from '../state';
import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures'; 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'; 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', () => { jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
const LazyLoader = ({ children }: LazyLoaderProps) => { const LazyLoader = ({ children, onLoad }: Pick<LazyLoaderProps, 'children' | 'onLoad'>) => {
return <>{children}</>; useEffectOnce(() => {
onLoad?.();
});
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
}; };
return { LazyLoader }; 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( const data = Object.assign(
{ {
title: 'My dashboard', title: 'My dashboard',
@@ -30,20 +80,20 @@ function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partia
}, },
{ {
id: 2, id: 2,
type: 'graph2', type: 'table',
title: 'My graph2', title: 'My table',
gridPos: { x: 0, y: 10, w: 25, h: 10 }, gridPos: { x: 0, y: 10, w: 25, h: 10 },
}, },
{ {
id: 3, id: 3,
type: 'graph3', type: 'table',
title: 'My graph3', title: 'My table 2',
gridPos: { x: 0, y: 20, w: 25, h: 100 }, gridPos: { x: 0, y: 20, w: 25, h: 100 },
}, },
{ {
id: 4, id: 4,
type: 'graph4', type: 'gauge',
title: 'My graph4', title: 'My gauge',
gridPos: { x: 0, y: 120, w: 25, h: 10 }, gridPos: { x: 0, y: 120, w: 25, h: 10 },
}, },
], ],
@@ -51,17 +101,88 @@ function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partia
overrides overrides
); );
return createDashboardModelFixture(data, metaOverrides); return createDashboardModelFixture(data, metaOverrides, getVariablesFromState);
} }
describe('DashboardGrid', () => { describe('DashboardGrid', () => {
it('should render without error', () => { it('Should render panels', async () => {
const props: Props = { const props: Props = {
editPanel: null, editPanel: null,
viewPanel: null, viewPanel: null,
isEditable: true, isEditable: true,
dashboard: getTestDashboard(), 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 { Subscription } from 'rxjs';
import { config } from '@grafana/runtime'; 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 { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardPanelsChangedEvent } from 'app/types/events'; import { DashboardPanelsChangedEvent } from 'app/types/events';
import { AddLibraryPanelWidget } from '../components/AddLibraryPanelWidget'; import { AddLibraryPanelWidget } from '../components/AddLibraryPanelWidget';
@@ -18,6 +20,8 @@ import { GridPos } from '../state/PanelModel';
import DashboardEmpty from './DashboardEmpty'; import DashboardEmpty from './DashboardEmpty';
import { DashboardPanel } from './DashboardPanel'; import { DashboardPanel } from './DashboardPanel';
export const PANEL_FILTER_VARIABLE = 'systemPanelFilterVar';
export interface Props { export interface Props {
dashboard: DashboardModel; dashboard: DashboardModel;
isEditable: boolean; isEditable: boolean;
@@ -25,7 +29,12 @@ export interface Props {
viewPanel: PanelModel | null; viewPanel: PanelModel | null;
hidePanelMenus?: boolean; 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 panelMap: { [key: string]: PanelModel } = {};
private eventSubs = new Subscription(); private eventSubs = new Subscription();
private windowHeight = 1200; private windowHeight = 1200;
@@ -37,10 +46,43 @@ export class DashboardGrid extends PureComponent<Props> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = {
panelFilter: undefined,
};
} }
componentDidMount() { componentDidMount() {
const { dashboard } = this.props; 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)); this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate));
} }
@@ -48,10 +90,25 @@ export class DashboardGrid extends PureComponent<Props> {
this.eventSubs.unsubscribe(); 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() { buildLayout() {
const layout: ReactGridLayout.Layout[] = []; const layout: ReactGridLayout.Layout[] = [];
this.panelMap = {}; this.panelMap = {};
const { panelFilter } = this.state;
let count = 0;
for (const panel of this.props.dashboard.panels) { for (const panel of this.props.dashboard.panels) {
if (!panel.key) { if (!panel.key) {
panel.key = `panel-${panel.id}-${Date.now()}`; panel.key = `panel-${panel.id}-${Date.now()}`;
@@ -78,13 +135,27 @@ export class DashboardGrid extends PureComponent<Props> {
panelPos.isDraggable = panel.collapsed; 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; return layout;
} }
onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => { onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => {
if (this.state.panelFilter) {
return;
}
for (const newPos of newLayout) { for (const newPos of newLayout) {
this.panelMap[newPos.i!].updateGridPos(newPos, this.isLayoutInitialized); this.panelMap[newPos.i!].updateGridPos(newPos, this.isLayoutInitialized);
} }
@@ -136,6 +207,7 @@ export class DashboardGrid extends PureComponent<Props> {
} }
renderPanels(gridWidth: number, isDashboardDraggable: boolean) { renderPanels(gridWidth: number, isDashboardDraggable: boolean) {
const { panelFilter } = this.state;
const panelElements = []; const panelElements = [];
// Reset last panel bottom // 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 // requires parent create stacking context to prevent overlap with parent elements
const descIndex = this.props.dashboard.panels.length - panelElements.length; const descIndex = this.props.dashboard.panels.length - panelElements.length;
panelElements.push( const p = (
<GrafanaGridItem <GrafanaGridItem
key={panel.key} key={panel.key}
className={panelClasses} className={panelClasses}
@@ -173,6 +245,14 @@ export class DashboardGrid extends PureComponent<Props> {
}} }}
</GrafanaGridItem> </GrafanaGridItem>
); );
if (!panelFilter) {
panelElements.push(p);
} else {
if (panelFilter.test(panel.title)) {
panelElements.push(p);
}
}
} }
return panelElements; return panelElements;
@@ -295,7 +375,7 @@ interface GrafanaGridItemProps extends React.HTMLAttributes<HTMLDivElement> {
isViewing: boolean; isViewing: boolean;
windowHeight: number; windowHeight: number;
windowWidth: number; windowWidth: number;
children: any; children: any; // eslint-disable-line @typescript-eslint/no-explicit-any
} }
/** /**
@@ -623,6 +623,7 @@ export const variableUpdated = (
: { : {
refreshAll: false, refreshAll: false,
panelIds: Array.from(getAllAffectedPanelIdsForVariableChange([variableInState.id], g, panelVars)), panelIds: Array.from(getAllAffectedPanelIdsForVariableChange([variableInState.id], g, panelVars)),
variable: getVariable(identifier, state),
}; };
const node = g.getNode(variableInState.name); const node = g.getNode(variableInState.name);
+2
View File
@@ -8,6 +8,7 @@ import {
QueryEditorProps, QueryEditorProps,
BaseVariableModel, BaseVariableModel,
VariableHide, VariableHide,
TypedVariableModel,
} from '@grafana/data'; } from '@grafana/data';
export { export {
/** @deprecated Import from @grafana/data instead */ /** @deprecated Import from @grafana/data instead */
@@ -95,6 +96,7 @@ export type VariableQueryEditorType<
export interface VariablesChangedEvent { export interface VariablesChangedEvent {
refreshAll: boolean; refreshAll: boolean;
panelIds: number[]; panelIds: number[];
variable?: TypedVariableModel;
} }
export class VariablesChanged extends BusEventWithPayload<VariablesChangedEvent> { export class VariablesChanged extends BusEventWithPayload<VariablesChangedEvent> {