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>
pull/77622/head
Jacob Zelek 2 years ago committed by GitHub
parent 577b3f2fb2
commit 6bf4d0cbc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .betterer.results
  2. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  3. 8
      pkg/services/featuremgmt/registry.go
  4. 1
      pkg/services/featuremgmt/toggles_gen.csv
  5. 4
      pkg/services/featuremgmt/toggles_gen.go
  6. 149
      public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx
  7. 88
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  8. 1
      public/app/features/variables/state/actions.ts
  9. 2
      public/app/features/variables/types.ts

@ -3292,7 +3292,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"] [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"]
], ],
"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"]

@ -157,4 +157,5 @@ export interface FeatureToggles {
annotationPermissionUpdate?: boolean; annotationPermissionUpdate?: boolean;
extractFieldsNameDeduplication?: boolean; extractFieldsNameDeduplication?: boolean;
dashboardSceneForViewers?: boolean; dashboardSceneForViewers?: boolean;
panelFilterVariable?: boolean;
} }

@ -974,5 +974,13 @@ var (
FrontendOnly: true, FrontendOnly: true,
Owner: grafanaDashboardsSquad, 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 annotationPermissionUpdate,experimental,@grafana/grafana-authnz-team,false,false,false,false
extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,false,false,true
dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
138 annotationPermissionUpdate experimental @grafana/grafana-authnz-team false false false false
139 extractFieldsNameDeduplication experimental @grafana/grafana-bi-squad false false false true
140 dashboardSceneForViewers experimental @grafana/dashboards-squad false false false true
141 panelFilterVariable experimental @grafana/dashboards-squad false false false true

@ -562,4 +562,8 @@ const (
// FlagDashboardSceneForViewers // FlagDashboardSceneForViewers
// Enables dashboard rendering using Scenes for viewer roles // Enables dashboard rendering using Scenes for viewer roles
FlagDashboardSceneForViewers = "dashboardSceneForViewers" 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 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);

@ -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> {

Loading…
Cancel
Save