mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Variables: Only update panels that are impacted by variable change (#39420)
* Refactor: adds affectedPanelIds and fixes some bugs * Refactor: Fixes all dependencies and affected panel ids * Refactor: glue it together with events * Chore: remove debug code * Chore: remove unused events * Chore: removes unused function * Chore: reverts processRepeats * Chore: update to use redux state * Refactor: adds feature toggle in variables settings * Refactor: adds appEvents to jest-setup * Tests: adds tests for strict panel refresh logic * Refactor: small refactor * Refactor: moved to more events * Tests: fixes test * Refactor: makes sure we store strictPanelRefreshMode in dashboard model * Refactor: reporting and adds tests * Tests: fix broken tests * Tests: fix broken initDashboard test * Tests: fix broken Wrapper test * Refactor: adds solution for $__all_variables * Chore: updates to radio button * Refactor: removes toggle and calculates threshold instead * Chore: fix up tests * Refactor: moving functions around * Tests: fixes broken test * Update public/app/features/dashboard/services/TimeSrv.ts Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com> * Chore: fix after PR comments * Chore: fix import and add comment * Chore: update after PR comments Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
This commit is contained in:
parent
077b1e847a
commit
b4f00d6312
@ -1,9 +1,13 @@
|
||||
import { includes, isDate } from 'lodash';
|
||||
import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper';
|
||||
import { DateTime, dateTime, dateTimeForTimeZone, DurationUnit, isDateTime, ISO_8601 } from './moment_wrapper';
|
||||
import { TimeZone } from '../types/index';
|
||||
|
||||
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's', 'Q'];
|
||||
|
||||
/**
|
||||
* Determine if a string contains a relative date time.
|
||||
* @param text
|
||||
*/
|
||||
export function isMathString(text: string | DateTime | Date): boolean {
|
||||
if (!text) {
|
||||
return false;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { TimeSrv } from './TimeSrv';
|
||||
import { ContextSrvStub } from 'test/specs/helpers';
|
||||
import { isDateTime, dateTime } from '@grafana/data';
|
||||
import { dateTime, isDateTime } from '@grafana/data';
|
||||
import { HistoryWrapper, locationService, setLocationService } from '@grafana/runtime';
|
||||
import { beforeEach } from '../../../../test/lib/common';
|
||||
|
||||
jest.mock('app/core/core', () => ({
|
||||
appEvents: {
|
||||
@ -261,4 +262,42 @@ describe('timeSrv', () => {
|
||||
expect(_dashboard.refresh).toBe('10s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRefreshOutsideThreshold', () => {
|
||||
const originalNow = Date.now;
|
||||
|
||||
beforeEach(() => {
|
||||
Date.now = jest.fn(() => 60000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Date.now = originalNow;
|
||||
});
|
||||
|
||||
describe('when called and current time range is absolute', () => {
|
||||
it('then it should return false', () => {
|
||||
timeSrv.setTime({ from: dateTime(), to: dateTime() });
|
||||
|
||||
expect(timeSrv.isRefreshOutsideThreshold(0, 0.05)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called and current time range is relative', () => {
|
||||
describe('and last refresh is within threshold', () => {
|
||||
it('then it should return false', () => {
|
||||
timeSrv.setTime({ from: 'now-1m', to: 'now' });
|
||||
|
||||
expect(timeSrv.isRefreshOutsideThreshold(57001, 0.05)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and last refresh is outside the threshold', () => {
|
||||
it('then it should return true', () => {
|
||||
timeSrv.setTime({ from: 'now-1m', to: 'now' });
|
||||
|
||||
expect(timeSrv.isRefreshOutsideThreshold(57000, 0.05)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -353,6 +353,23 @@ export class TimeSrv {
|
||||
to: toUtc(to),
|
||||
});
|
||||
}
|
||||
|
||||
// isRefreshOutsideThreshold function calculates the difference between last refresh and now
|
||||
// if the difference is outside 5% of the current set time range then the function will return true
|
||||
// if the difference is within 5% of the current set time range then the function will return false
|
||||
// if the current time range is absolute (i.e. not using relative strings like now-5m) then the function will return false
|
||||
isRefreshOutsideThreshold(lastRefresh: number, threshold = 0.05) {
|
||||
const timeRange = this.timeRange();
|
||||
|
||||
if (dateMath.isMathString(timeRange.raw.from)) {
|
||||
const totalRange = timeRange.to.diff(timeRange.from);
|
||||
const msSinceLastRefresh = Date.now() - lastRefresh;
|
||||
const msThreshold = totalRange * threshold;
|
||||
return msSinceLastRefresh >= msThreshold;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: TimeSrv | undefined;
|
||||
|
@ -0,0 +1,121 @@
|
||||
import { DashboardModel } from './DashboardModel';
|
||||
import { appEvents } from '../../../core/core';
|
||||
import { VariablesChanged } from '../../variables/types';
|
||||
import { PanelModel } from './PanelModel';
|
||||
import { getTimeSrv, setTimeSrv } from '../services/TimeSrv';
|
||||
import { afterEach, beforeEach } from '../../../../test/lib/common';
|
||||
|
||||
function getTestContext({
|
||||
usePanelInEdit,
|
||||
usePanelInView,
|
||||
}: { usePanelInEdit?: boolean; usePanelInView?: boolean } = {}) {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const dashboard = new DashboardModel({});
|
||||
const startRefreshMock = jest.fn();
|
||||
dashboard.startRefresh = startRefreshMock;
|
||||
const panelInView = new PanelModel({ id: 99 });
|
||||
const panelInEdit = new PanelModel({ id: 100 });
|
||||
const panelIds = [1, 2, 3];
|
||||
if (usePanelInEdit) {
|
||||
dashboard.panelInEdit = panelInEdit;
|
||||
panelIds.push(panelInEdit.id);
|
||||
}
|
||||
if (usePanelInView) {
|
||||
dashboard.panelInView = panelInView;
|
||||
panelIds.push(panelInView.id);
|
||||
}
|
||||
|
||||
appEvents.publish(new VariablesChanged({ panelIds }));
|
||||
|
||||
return { dashboard, startRefreshMock, panelInEdit, panelInView };
|
||||
}
|
||||
|
||||
describe('Strict panel refresh', () => {
|
||||
describe('when there is no panel in full view or panel in panel edit during variable change', () => {
|
||||
it('then all affected panels should be refreshed', () => {
|
||||
const { startRefreshMock } = getTestContext();
|
||||
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testing refresh threshold', () => {
|
||||
const originalTimeSrv = getTimeSrv();
|
||||
let isRefreshOutsideThreshold = false;
|
||||
|
||||
beforeEach(() => {
|
||||
setTimeSrv({
|
||||
isRefreshOutsideThreshold: () => isRefreshOutsideThreshold,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setTimeSrv(originalTimeSrv);
|
||||
});
|
||||
|
||||
describe('when the dashboard has not been refreshed within the threshold', () => {
|
||||
it(' then all panels should be refreshed', () => {
|
||||
isRefreshOutsideThreshold = true;
|
||||
const { startRefreshMock } = getTestContext();
|
||||
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the dashboard has been refreshed within the threshold', () => {
|
||||
it('then all affected panels should be refreshed', () => {
|
||||
isRefreshOutsideThreshold = false;
|
||||
const { startRefreshMock } = getTestContext();
|
||||
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is a panel in full view during variable change', () => {
|
||||
it('then all affected panels should be refreshed', () => {
|
||||
const { panelInView, startRefreshMock } = getTestContext({ usePanelInView: true });
|
||||
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3, panelInView.id]);
|
||||
});
|
||||
|
||||
describe('and when exitViewPanel is called', () => {
|
||||
it('then all affected panels except the panel in full view should be refreshed', () => {
|
||||
const { dashboard, panelInView, startRefreshMock } = getTestContext({ usePanelInView: true });
|
||||
startRefreshMock.mockClear();
|
||||
|
||||
dashboard.exitViewPanel(panelInView);
|
||||
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
|
||||
expect(dashboard['panelsAffectedByVariableChange']).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is a panel in panel edit during variable change', () => {
|
||||
it('then all affected panels should be refreshed', () => {
|
||||
const { panelInEdit, startRefreshMock } = getTestContext({ usePanelInEdit: true });
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3, panelInEdit.id]);
|
||||
});
|
||||
|
||||
describe('and when exitViewPanel is called', () => {
|
||||
it('then all affected panels except the panel in panel edit should be refreshed', () => {
|
||||
const { dashboard, startRefreshMock } = getTestContext({ usePanelInEdit: true });
|
||||
startRefreshMock.mockClear();
|
||||
|
||||
dashboard.exitPanelEditor();
|
||||
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenLastCalledWith([1, 2, 3]);
|
||||
expect(dashboard['panelsAffectedByVariableChange']).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -915,17 +915,6 @@ describe('exitViewPanel', () => {
|
||||
|
||||
expect(dashboard.startRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('and there is a change that affects all panels', () => {
|
||||
it('then startRefresh is not called', () => {
|
||||
const { dashboard, panel } = getTestContext();
|
||||
dashboard.setChangeAffectsAllPanels();
|
||||
|
||||
dashboard.exitViewPanel(panel);
|
||||
|
||||
expect(dashboard.startRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -977,44 +966,9 @@ describe('exitPanelEditor', () => {
|
||||
dashboard.exitPanelEditor();
|
||||
expect(timeSrvMock.resumeAutoRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('and there is a change that affects all panels', () => {
|
||||
it('then startRefresh is called', () => {
|
||||
const { dashboard } = getTestContext();
|
||||
dashboard.setChangeAffectsAllPanels();
|
||||
|
||||
dashboard.exitPanelEditor();
|
||||
|
||||
expect(dashboard.startRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setChangeAffectsAllPanels', () => {
|
||||
it.each`
|
||||
panelInEdit | panelInView | expected
|
||||
${null} | ${null} | ${false}
|
||||
${undefined} | ${undefined} | ${false}
|
||||
${null} | ${{}} | ${true}
|
||||
${undefined} | ${{}} | ${true}
|
||||
${{}} | ${null} | ${true}
|
||||
${{}} | ${undefined} | ${true}
|
||||
${{}} | ${{}} | ${true}
|
||||
`(
|
||||
'when called and panelInEdit:{$panelInEdit} and panelInView:{$panelInView}',
|
||||
({ panelInEdit, panelInView, expected }) => {
|
||||
const dashboard = new DashboardModel({});
|
||||
dashboard.panelInEdit = panelInEdit;
|
||||
dashboard.panelInView = panelInView;
|
||||
|
||||
dashboard.setChangeAffectsAllPanels();
|
||||
|
||||
expect(dashboard['hasChangesThatAffectsAllPanels']).toEqual(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('initEditPanel', () => {
|
||||
function getTestContext() {
|
||||
const dashboard = new DashboardModel({});
|
||||
|
@ -47,6 +47,13 @@ import { getTimeSrv } from '../services/TimeSrv';
|
||||
import { mergePanels, PanelMergeInfo } from '../utils/panelMerge';
|
||||
import { isOnTheSameGridRow } from './utils';
|
||||
import { RefreshEvent, TimeRangeUpdatedEvent } from '@grafana/runtime';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { appEvents } from '../../../core/core';
|
||||
import {
|
||||
VariablesChanged,
|
||||
VariablesChangedInUrl,
|
||||
VariablesFinishedProcessingTimeRangeChange,
|
||||
} from '../../variables/types';
|
||||
|
||||
export interface CloneOptions {
|
||||
saveVariables?: boolean;
|
||||
@ -100,7 +107,9 @@ export class DashboardModel {
|
||||
panelInEdit?: PanelModel;
|
||||
panelInView?: PanelModel;
|
||||
fiscalYearStartMonth?: number;
|
||||
private hasChangesThatAffectsAllPanels: boolean;
|
||||
private panelsAffectedByVariableChange: number[] | null;
|
||||
private appEventsSubscription: Subscription;
|
||||
private lastRefresh: number;
|
||||
|
||||
// ------------------
|
||||
// not persisted
|
||||
@ -123,7 +132,9 @@ export class DashboardModel {
|
||||
panelInView: true,
|
||||
getVariablesFromState: true,
|
||||
formatDate: true,
|
||||
hasChangesThatAffectsAllPanels: true,
|
||||
appEventsSubscription: true,
|
||||
panelsAffectedByVariableChange: true,
|
||||
lastRefresh: true,
|
||||
};
|
||||
|
||||
constructor(data: any, meta?: DashboardMeta, private getVariablesFromState: GetVariables = getVariables) {
|
||||
@ -167,7 +178,19 @@ export class DashboardModel {
|
||||
|
||||
this.addBuiltInAnnotationQuery();
|
||||
this.sortPanelsByGridPos();
|
||||
this.hasChangesThatAffectsAllPanels = false;
|
||||
this.panelsAffectedByVariableChange = null;
|
||||
this.appEventsSubscription = new Subscription();
|
||||
this.lastRefresh = Date.now();
|
||||
this.appEventsSubscription.add(appEvents.subscribe(VariablesChanged, this.variablesChangedHandler.bind(this)));
|
||||
this.appEventsSubscription.add(
|
||||
appEvents.subscribe(
|
||||
VariablesFinishedProcessingTimeRangeChange,
|
||||
this.variablesFinishedProcessingTimeRangeChangeHandler.bind(this)
|
||||
)
|
||||
);
|
||||
this.appEventsSubscription.add(
|
||||
appEvents.subscribe(VariablesChangedInUrl, this.variablesChangedInUrlHandler.bind(this))
|
||||
);
|
||||
}
|
||||
|
||||
addBuiltInAnnotationQuery() {
|
||||
@ -355,17 +378,22 @@ export class DashboardModel {
|
||||
dispatch(onTimeRangeUpdated(timeRange));
|
||||
}
|
||||
|
||||
startRefresh() {
|
||||
startRefresh(affectedPanelIds?: number[]) {
|
||||
this.events.publish(new RefreshEvent());
|
||||
this.lastRefresh = Date.now();
|
||||
|
||||
if (this.panelInEdit) {
|
||||
this.panelInEdit.refresh();
|
||||
return;
|
||||
if (!affectedPanelIds || affectedPanelIds.includes(this.panelInEdit.id)) {
|
||||
this.panelInEdit.refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const panel of this.panels) {
|
||||
if (!this.otherPanelInFullscreen(panel)) {
|
||||
panel.refresh();
|
||||
if (!affectedPanelIds || affectedPanelIds.includes(panel.id)) {
|
||||
panel.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -403,29 +431,23 @@ export class DashboardModel {
|
||||
exitViewPanel(panel: PanelModel) {
|
||||
this.panelInView = undefined;
|
||||
panel.setIsViewing(false);
|
||||
this.refreshIfChangeAffectsAllPanels();
|
||||
this.refreshIfPanelsAffectedByVariableChange();
|
||||
}
|
||||
|
||||
exitPanelEditor() {
|
||||
this.panelInEdit!.destroy();
|
||||
this.panelInEdit = undefined;
|
||||
this.refreshIfChangeAffectsAllPanels();
|
||||
getTimeSrv().resumeAutoRefresh();
|
||||
this.refreshIfPanelsAffectedByVariableChange();
|
||||
}
|
||||
|
||||
setChangeAffectsAllPanels() {
|
||||
if (this.panelInEdit || this.panelInView) {
|
||||
this.hasChangesThatAffectsAllPanels = true;
|
||||
}
|
||||
}
|
||||
|
||||
private refreshIfChangeAffectsAllPanels() {
|
||||
if (!this.hasChangesThatAffectsAllPanels) {
|
||||
private refreshIfPanelsAffectedByVariableChange() {
|
||||
if (!this.panelsAffectedByVariableChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasChangesThatAffectsAllPanels = false;
|
||||
this.startRefresh();
|
||||
this.startRefresh(this.panelsAffectedByVariableChange);
|
||||
this.panelsAffectedByVariableChange = null;
|
||||
}
|
||||
|
||||
private ensureListExist(data: any) {
|
||||
@ -916,6 +938,7 @@ export class DashboardModel {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.appEventsSubscription.unsubscribe();
|
||||
this.events.removeAllListeners();
|
||||
for (const panel of this.panels) {
|
||||
panel.destroy();
|
||||
@ -1195,4 +1218,40 @@ export class DashboardModel {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private variablesChangedHandler(event: VariablesChanged) {
|
||||
this.variablesChangedBaseHandler(event, true);
|
||||
}
|
||||
|
||||
private variablesFinishedProcessingTimeRangeChangeHandler(event: VariablesFinishedProcessingTimeRangeChange) {
|
||||
this.variablesChangedBaseHandler(event);
|
||||
}
|
||||
|
||||
private variablesChangedBaseHandler(
|
||||
event: VariablesChanged | VariablesFinishedProcessingTimeRangeChange,
|
||||
processRepeats = false
|
||||
) {
|
||||
if (processRepeats) {
|
||||
this.processRepeats();
|
||||
}
|
||||
|
||||
if (!event.payload.panelIds || getTimeSrv().isRefreshOutsideThreshold(this.lastRefresh)) {
|
||||
// passing undefined in panelIds means we want to update all panels
|
||||
this.startRefresh(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.panelInEdit || this.panelInView) {
|
||||
this.panelsAffectedByVariableChange = event.payload.panelIds.filter(
|
||||
(id) => id !== (this.panelInEdit?.id ?? this.panelInView?.id)
|
||||
);
|
||||
}
|
||||
|
||||
this.startRefresh(event.payload.panelIds);
|
||||
}
|
||||
|
||||
private variablesChangedInUrlHandler(event: VariablesChangedInUrl) {
|
||||
this.templateVariableValueUpdated();
|
||||
this.startRefresh(event.payload.panelIds);
|
||||
}
|
||||
}
|
||||
|
@ -34,6 +34,10 @@ jest.mock('app/core/core', () => {
|
||||
contextSrv: {
|
||||
hasPermission: () => true,
|
||||
},
|
||||
appEvents: {
|
||||
subscribe: () => {},
|
||||
publish: () => {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -18,11 +18,11 @@ import {
|
||||
decorateWithLogsResult,
|
||||
decorateWithTableResult,
|
||||
} from './decorators';
|
||||
import { describe } from '../../../../test/lib/common';
|
||||
import { ExplorePanelData } from 'app/types';
|
||||
import TableModel from 'app/core/table_model';
|
||||
|
||||
jest.mock('@grafana/data/src/datetime/formatter', () => ({
|
||||
jest.mock('@grafana/data', () => ({
|
||||
...(jest.requireActual('@grafana/data') as any),
|
||||
dateTimeFormat: () => 'format() jest mocked',
|
||||
dateTimeFormatTimeAgo: (ts: any) => 'fromNow() jest mocked',
|
||||
}));
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,10 @@
|
||||
import { variableAdapters } from '../adapters';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
import { DashboardModel, PanelModel } from '../../dashboard/state';
|
||||
import { isAdHoc } from '../guard';
|
||||
import { safeStringifyValue } from '../../../core/utils/explore';
|
||||
import { VariableModel } from '../types';
|
||||
import { containsVariable, variableRegex, variableRegexExec } from '../utils';
|
||||
import { DataLinkBuiltInVars } from '@grafana/data';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
@ -108,7 +109,7 @@ export const getUnknownVariableStrings = (variables: VariableModel[], model: any
|
||||
};
|
||||
|
||||
const validVariableNames: Record<string, RegExp[]> = {
|
||||
alias: [/^m$/, /^measurement$/, /^col$/, /^tag_\w+|\d+$/],
|
||||
alias: [/^m$/, /^measurement$/, /^col$/, /^tag_(\w+|\d+)$/],
|
||||
query: [/^timeFilter$/],
|
||||
};
|
||||
|
||||
@ -122,7 +123,12 @@ export const getPropsWithVariable = (variableId: string, parent: { key: string;
|
||||
const isValidName = validVariableNames[key]
|
||||
? validVariableNames[key].find((regex: RegExp) => regex.test(variableId))
|
||||
: undefined;
|
||||
const hasVariable = containsVariable(value, variableId);
|
||||
|
||||
let hasVariable = containsVariable(value, variableId);
|
||||
if (key === 'repeat' && value === variableId) {
|
||||
// repeat stores value without variable format
|
||||
hasVariable = true;
|
||||
}
|
||||
|
||||
if (!isValidName && hasVariable) {
|
||||
all = {
|
||||
@ -132,13 +138,18 @@ export const getPropsWithVariable = (variableId: string, parent: { key: string;
|
||||
}
|
||||
|
||||
return all;
|
||||
}, {});
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
const objectValues = Object.keys(parent.value).reduce((all, key) => {
|
||||
const value = parent.value[key];
|
||||
if (value && typeof value === 'object' && Object.keys(value).length) {
|
||||
const id = value.title || value.name || value.id || key;
|
||||
let id = value.title || value.name || value.id || key;
|
||||
if (Array.isArray(parent.value) && parent.key === 'panels') {
|
||||
id = `${id}[${value.id}]`;
|
||||
}
|
||||
|
||||
const newResult = getPropsWithVariable(variableId, { key, value }, {});
|
||||
|
||||
if (Object.keys(newResult).length) {
|
||||
all = {
|
||||
...all,
|
||||
@ -148,7 +159,7 @@ export const getPropsWithVariable = (variableId: string, parent: { key: string;
|
||||
}
|
||||
|
||||
return all;
|
||||
}, {});
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
if (Object.keys(stringValues).length || Object.keys(objectValues).length) {
|
||||
result = {
|
||||
@ -206,6 +217,93 @@ export const createUsagesNetwork = (variables: VariableModel[], dashboard: Dashb
|
||||
return { unUsed, unknown, usages };
|
||||
};
|
||||
|
||||
/*
|
||||
getAllAffectedPanelIdsForVariableChange is a function that extracts all the panel ids that are affected by a single variable
|
||||
change. It will traverse all chained variables to identify all cascading changes too.
|
||||
|
||||
This is done entirely by parsing the current dashboard json and doesn't take under consideration a user cancelling
|
||||
a variable query or any faulty variable queries.
|
||||
|
||||
This doesn't take circular dependencies in consideration.
|
||||
*/
|
||||
export function getAllAffectedPanelIdsForVariableChange(
|
||||
variableId: string,
|
||||
variables: VariableModel[],
|
||||
panels: PanelModel[]
|
||||
): number[] {
|
||||
let affectedPanelIds: number[] = getAffectedPanelIdsForVariable(variableId, panels);
|
||||
const affectedPanelIdsForAllVariables = getAffectedPanelIdsForVariable(DataLinkBuiltInVars.includeVars, panels);
|
||||
affectedPanelIds = [...new Set([...affectedPanelIdsForAllVariables, ...affectedPanelIds])];
|
||||
|
||||
const dependencies = getDependenciesForVariable(variableId, variables, new Set());
|
||||
for (const dependency of dependencies) {
|
||||
const affectedPanelIdsForDependency = getAffectedPanelIdsForVariable(dependency, panels);
|
||||
affectedPanelIds = [...new Set([...affectedPanelIdsForDependency, ...affectedPanelIds])];
|
||||
}
|
||||
|
||||
return affectedPanelIds;
|
||||
}
|
||||
|
||||
export function getDependenciesForVariable(
|
||||
variableId: string,
|
||||
variables: VariableModel[],
|
||||
deps: Set<string>
|
||||
): Set<string> {
|
||||
if (!variables.length) {
|
||||
return deps;
|
||||
}
|
||||
|
||||
for (const variable of variables) {
|
||||
if (variable.name === variableId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const depends = variableAdapters.get(variable.type).dependsOn(variable, { name: variableId });
|
||||
if (!depends) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deps.add(variable.name);
|
||||
deps = getDependenciesForVariable(variable.name, variables, deps);
|
||||
}
|
||||
|
||||
return deps;
|
||||
}
|
||||
|
||||
export function getAffectedPanelIdsForVariable(variableId: string, panels: PanelModel[]): number[] {
|
||||
if (!panels.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const affectedPanelIds: number[] = [];
|
||||
const repeatRegex = new RegExp(`"repeat":"${variableId}"`);
|
||||
for (const panel of panels) {
|
||||
const panelAsJson = safeStringifyValue(panel.getSaveModel());
|
||||
|
||||
// check for repeats that don't use variableRegex
|
||||
const repeatMatches = panelAsJson.match(repeatRegex);
|
||||
if (repeatMatches?.length) {
|
||||
affectedPanelIds.push(panel.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = panelAsJson.match(variableRegex);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const variableName = getVariableName(match);
|
||||
if (variableName === variableId) {
|
||||
affectedPanelIds.push(panel.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return affectedPanelIds;
|
||||
}
|
||||
|
||||
export interface UsagesToNetwork {
|
||||
variable: VariableModel;
|
||||
nodes: GraphNode[];
|
||||
|
@ -62,7 +62,7 @@ import { expect } from '../../../../test/lib/common';
|
||||
import { ConstantVariableModel, VariableRefresh } from '../types';
|
||||
import { updateVariableOptions } from '../query/reducer';
|
||||
import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner';
|
||||
import { setDataSourceSrv, setLocationService } from '@grafana/runtime';
|
||||
import * as runtime from '@grafana/runtime';
|
||||
import { LoadingState } from '@grafana/data';
|
||||
import { toAsyncOfResult } from '../../query/state/DashboardQueryRunner/testHelpers';
|
||||
|
||||
@ -86,7 +86,7 @@ jest.mock('app/features/dashboard/services/TimeSrv', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
setDataSourceSrv({
|
||||
runtime.setDataSourceSrv({
|
||||
get: getDatasource,
|
||||
getList: getMetricSources,
|
||||
} as any);
|
||||
@ -151,7 +151,7 @@ describe('shared actions', () => {
|
||||
templating: ({} as unknown) as TemplatingState,
|
||||
};
|
||||
const locationService: any = { getSearchObject: () => ({}) };
|
||||
setLocationService(locationService);
|
||||
runtime.setLocationService(locationService);
|
||||
const variableQueryRunner: any = {
|
||||
cancelRequest: jest.fn(),
|
||||
queueRequest: jest.fn(),
|
||||
@ -219,7 +219,7 @@ describe('shared actions', () => {
|
||||
const list = [stats, substats];
|
||||
const query = { orgId: '1', 'var-stats': 'response', 'var-substats': ALL_VARIABLE_TEXT };
|
||||
const locationService: any = { getSearchObject: () => query };
|
||||
setLocationService(locationService);
|
||||
runtime.setLocationService(locationService);
|
||||
const preloadedState = {
|
||||
templating: ({} as unknown) as TemplatingState,
|
||||
};
|
||||
@ -578,13 +578,19 @@ describe('shared actions', () => {
|
||||
});
|
||||
|
||||
describe('initVariablesTransaction', () => {
|
||||
const constant = constantBuilder().withId('constant').withName('constant').build();
|
||||
const templating: any = { list: [constant] };
|
||||
const uid = 'uid';
|
||||
const dashboard: any = { title: 'Some dash', uid, templating };
|
||||
function getTestContext() {
|
||||
const reportSpy = jest.spyOn(runtime, 'reportInteraction').mockReturnValue(undefined);
|
||||
const constant = constantBuilder().withId('constant').withName('constant').build();
|
||||
const templating: any = { list: [constant] };
|
||||
const uid = 'uid';
|
||||
const dashboard: any = { title: 'Some dash', uid, templating };
|
||||
|
||||
return { reportSpy, constant, templating, uid, dashboard };
|
||||
}
|
||||
|
||||
describe('when called and the previous dashboard has completed', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { constant, uid, dashboard } = getTestContext();
|
||||
const tester = await reduxTester<RootReducerType>()
|
||||
.givenRootReducer(getRootReducer())
|
||||
.whenAsyncActionIsDispatched(initVariablesTransaction(uid, dashboard));
|
||||
@ -611,6 +617,7 @@ describe('shared actions', () => {
|
||||
|
||||
describe('when called and the previous dashboard is still processing variables', () => {
|
||||
it('then correct actions are dispatched', async () => {
|
||||
const { constant, uid, dashboard } = getTestContext();
|
||||
const transactionState = { uid: 'previous-uid', status: TransactionStatus.Fetching };
|
||||
|
||||
const tester = await reduxTester<RootReducerType>({
|
||||
|
@ -12,6 +12,9 @@ import {
|
||||
VariableModel,
|
||||
VariableOption,
|
||||
VariableRefresh,
|
||||
VariablesChanged,
|
||||
VariablesChangedInUrl,
|
||||
VariablesFinishedProcessingTimeRangeChange,
|
||||
VariableWithMultiSupport,
|
||||
VariableWithOptions,
|
||||
} from '../types';
|
||||
@ -64,6 +67,8 @@ import { getDatasourceSrv } from '../../plugins/datasource_srv';
|
||||
import { cleanEditorState } from '../editor/reducer';
|
||||
import { cleanPickerState } from '../pickers/OptionsPicker/reducer';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { appEvents } from '../../../core/core';
|
||||
import { getAllAffectedPanelIdsForVariableChange } from '../inspect/utils';
|
||||
|
||||
// process flow queryVariable
|
||||
// thunk => processVariables
|
||||
@ -492,13 +497,15 @@ const createGraph = (variables: VariableModel[]) => {
|
||||
|
||||
export const variableUpdated = (
|
||||
identifier: VariableIdentifier,
|
||||
emitChangeEvents: boolean
|
||||
emitChangeEvents: boolean,
|
||||
events: typeof appEvents = appEvents
|
||||
): ThunkResult<Promise<void>> => {
|
||||
return async (dispatch, getState) => {
|
||||
const variableInState = getVariable(identifier.id, getState());
|
||||
const state = getState();
|
||||
const variableInState = getVariable(identifier.id, state);
|
||||
|
||||
// if we're initializing variables ignore cascading update because we are in a boot up scenario
|
||||
if (getState().templating.transaction.status === TransactionStatus.Fetching) {
|
||||
if (state.templating.transaction.status === TransactionStatus.Fetching) {
|
||||
if (getVariableRefresh(variableInState) === VariableRefresh.never) {
|
||||
// for variable types with updates that go the setValueFromUrl path in the update let's make sure their state is set to Done.
|
||||
await dispatch(upgradeLegacyQueries(toVariableIdentifier(variableInState)));
|
||||
@ -507,8 +514,10 @@ export const variableUpdated = (
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const variables = getVariables(getState());
|
||||
const variables = getVariables(state);
|
||||
const g = createGraph(variables);
|
||||
const panels = state.dashboard?.getModel()?.panels ?? [];
|
||||
const affectedPanelIds = getAllAffectedPanelIdsForVariableChange(variableInState.id, variables, panels);
|
||||
|
||||
const node = g.getNode(variableInState.name);
|
||||
let promises: Array<Promise<any>> = [];
|
||||
@ -525,11 +534,8 @@ export const variableUpdated = (
|
||||
|
||||
return Promise.all(promises).then(() => {
|
||||
if (emitChangeEvents) {
|
||||
const dashboard = getState().dashboard.getModel();
|
||||
dashboard?.setChangeAffectsAllPanels();
|
||||
dashboard?.processRepeats();
|
||||
events.publish(new VariablesChanged({ panelIds: affectedPanelIds }));
|
||||
locationService.partial(getQueryWithVariables(getState));
|
||||
dashboard?.startRefresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -537,11 +543,12 @@ export const variableUpdated = (
|
||||
|
||||
export interface OnTimeRangeUpdatedDependencies {
|
||||
templateSrv: TemplateSrv;
|
||||
events: typeof appEvents;
|
||||
}
|
||||
|
||||
export const onTimeRangeUpdated = (
|
||||
timeRange: TimeRange,
|
||||
dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv() }
|
||||
dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: getTemplateSrv(), events: appEvents }
|
||||
): ThunkResult<Promise<void>> => async (dispatch, getState) => {
|
||||
dependencies.templateSrv.updateTimeRange(timeRange);
|
||||
const variablesThatNeedRefresh = getVariables(getState()).filter((variable) => {
|
||||
@ -559,9 +566,7 @@ export const onTimeRangeUpdated = (
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
const dashboard = getState().dashboard.getModel();
|
||||
dashboard?.setChangeAffectsAllPanels();
|
||||
dashboard?.startRefresh();
|
||||
dependencies.events.publish(new VariablesFinishedProcessingTimeRangeChange({ panelIds: undefined }));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
dispatch(notifyApp(createVariableErrorNotification('Template variable service failed', error)));
|
||||
@ -583,10 +588,10 @@ const timeRangeUpdated = (identifier: VariableIdentifier): ThunkResult<Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
export const templateVarsChangedInUrl = (vars: ExtendedUrlQueryMap): ThunkResult<void> => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
export const templateVarsChangedInUrl = (
|
||||
vars: ExtendedUrlQueryMap,
|
||||
events: typeof appEvents = appEvents
|
||||
): ThunkResult<void> => async (dispatch, getState) => {
|
||||
const update: Array<Promise<any>> = [];
|
||||
const dashboard = getState().dashboard.getModel();
|
||||
for (const variable of getVariables(getState())) {
|
||||
@ -617,8 +622,7 @@ export const templateVarsChangedInUrl = (vars: ExtendedUrlQueryMap): ThunkResult
|
||||
|
||||
if (update.length) {
|
||||
await Promise.all(update);
|
||||
dashboard?.templateVariableValueUpdated();
|
||||
dashboard?.startRefresh();
|
||||
events.publish(new VariablesChangedInUrl({ panelIds: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -23,10 +23,15 @@ import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsole
|
||||
import { notifyApp } from '../../../core/reducers/appNotification';
|
||||
import { expect } from '../../../../test/lib/common';
|
||||
import { TemplatingState } from './reducers';
|
||||
import { appEvents } from '../../../core/core';
|
||||
|
||||
variableAdapters.setInit(() => [createIntervalVariableAdapter(), createConstantVariableAdapter()]);
|
||||
|
||||
const dashboard = new DashboardModel({});
|
||||
|
||||
const getTestContext = () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const interval = intervalBuilder()
|
||||
.withId('interval-0')
|
||||
.withName('interval-0')
|
||||
@ -52,21 +57,19 @@ const getTestContext = () => {
|
||||
};
|
||||
const updateTimeRangeMock = jest.fn();
|
||||
const templateSrvMock = ({ updateTimeRange: updateTimeRangeMock } as unknown) as TemplateSrv;
|
||||
const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock };
|
||||
const dependencies: OnTimeRangeUpdatedDependencies = { templateSrv: templateSrvMock, events: appEvents };
|
||||
const templateVariableValueUpdatedMock = jest.fn();
|
||||
const setChangeAffectsAllPanelsMock = jest.fn();
|
||||
const dashboard = ({
|
||||
getModel: () =>
|
||||
(({
|
||||
templateVariableValueUpdated: templateVariableValueUpdatedMock,
|
||||
startRefresh: startRefreshMock,
|
||||
setChangeAffectsAllPanels: setChangeAffectsAllPanelsMock,
|
||||
} as unknown) as DashboardModel),
|
||||
} as unknown) as DashboardState;
|
||||
const startRefreshMock = jest.fn();
|
||||
const dashboardState = ({
|
||||
getModel: () => {
|
||||
dashboard.templateVariableValueUpdated = templateVariableValueUpdatedMock;
|
||||
dashboard.startRefresh = startRefreshMock;
|
||||
return dashboard;
|
||||
},
|
||||
} as unknown) as DashboardState;
|
||||
const adapter = variableAdapters.get('interval');
|
||||
const preloadedState = ({
|
||||
dashboard,
|
||||
dashboard: dashboardState,
|
||||
templating: ({
|
||||
variables: {
|
||||
'interval-0': { ...interval },
|
||||
@ -84,7 +87,6 @@ const getTestContext = () => {
|
||||
updateTimeRangeMock,
|
||||
templateVariableValueUpdatedMock,
|
||||
startRefreshMock,
|
||||
setChangeAffectsAllPanelsMock,
|
||||
};
|
||||
};
|
||||
|
||||
@ -98,7 +100,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
|
||||
updateTimeRangeMock,
|
||||
templateVariableValueUpdatedMock,
|
||||
startRefreshMock,
|
||||
setChangeAffectsAllPanelsMock,
|
||||
} = getTestContext();
|
||||
|
||||
const tester = await reduxTester<RootReducerType>({ preloadedState })
|
||||
@ -121,7 +122,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
|
||||
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
|
||||
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(setChangeAffectsAllPanelsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -135,7 +135,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
|
||||
updateTimeRangeMock,
|
||||
templateVariableValueUpdatedMock,
|
||||
startRefreshMock,
|
||||
setChangeAffectsAllPanelsMock,
|
||||
} = getTestContext();
|
||||
|
||||
const base = await reduxTester<RootReducerType>({ preloadedState })
|
||||
@ -160,7 +159,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
|
||||
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
|
||||
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(setChangeAffectsAllPanelsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -175,7 +173,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
|
||||
updateTimeRangeMock,
|
||||
templateVariableValueUpdatedMock,
|
||||
startRefreshMock,
|
||||
setChangeAffectsAllPanelsMock,
|
||||
} = getTestContext();
|
||||
|
||||
adapter.updateOptions = jest.fn().mockRejectedValue(new Error('Something broke'));
|
||||
@ -204,7 +201,6 @@ describe('when onTimeRangeUpdated is dispatched', () => {
|
||||
expect(updateTimeRangeMock).toHaveBeenCalledWith(range);
|
||||
expect(templateVariableValueUpdatedMock).toHaveBeenCalledTimes(0);
|
||||
expect(startRefreshMock).toHaveBeenCalledTimes(0);
|
||||
expect(setChangeAffectsAllPanelsMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -9,6 +9,8 @@ import { createCustomVariableAdapter } from '../custom/adapter';
|
||||
import { VariablesState } from './types';
|
||||
import { DashboardModel } from '../../dashboard/state';
|
||||
|
||||
const dashboardModel = new DashboardModel({});
|
||||
|
||||
variableAdapters.setInit(() => [createCustomVariableAdapter()]);
|
||||
|
||||
async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}) {
|
||||
@ -20,14 +22,14 @@ async function getTestContext(urlQueryMap: ExtendedUrlQueryMap = {}) {
|
||||
|
||||
const templateVariableValueUpdatedMock = jest.fn();
|
||||
const startRefreshMock = jest.fn();
|
||||
const dashboardModel: Partial<DashboardModel> = {
|
||||
templateVariableValueUpdated: templateVariableValueUpdatedMock,
|
||||
startRefresh: startRefreshMock,
|
||||
templating: { list: [custom] },
|
||||
};
|
||||
const dashboard: DashboardState = {
|
||||
...initialState,
|
||||
getModel: () => (dashboardModel as unknown) as DashboardModel,
|
||||
getModel: () => {
|
||||
dashboardModel.templateVariableValueUpdated = templateVariableValueUpdatedMock;
|
||||
dashboardModel.startRefresh = startRefreshMock;
|
||||
dashboardModel.templating = { list: [custom] };
|
||||
return dashboardModel;
|
||||
},
|
||||
};
|
||||
|
||||
const variables: VariablesState = { custom };
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ComponentType } from 'react';
|
||||
import {
|
||||
BusEventWithPayload,
|
||||
DataQuery,
|
||||
DataSourceJsonData,
|
||||
DataSourceRef,
|
||||
@ -151,3 +152,19 @@ export type VariableQueryEditorType<
|
||||
TQuery extends DataQuery = DataQuery,
|
||||
TOptions extends DataSourceJsonData = DataSourceJsonData
|
||||
> = ComponentType<VariableQueryProps> | ComponentType<QueryEditorProps<any, TQuery, TOptions, any>> | null;
|
||||
|
||||
export interface VariablesChangedEvent {
|
||||
panelIds?: number[];
|
||||
}
|
||||
|
||||
export class VariablesChanged extends BusEventWithPayload<VariablesChangedEvent> {
|
||||
static type = 'variables-changed';
|
||||
}
|
||||
|
||||
export class VariablesFinishedProcessingTimeRangeChange extends BusEventWithPayload<VariablesChangedEvent> {
|
||||
static type = 'variables-finished-processing-time-range-change';
|
||||
}
|
||||
|
||||
export class VariablesChangedInUrl extends BusEventWithPayload<VariablesChangedEvent> {
|
||||
static type = 'variables-changed-in-url';
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ jest.mock('app/core/core', () => ({
|
||||
directive: () => {},
|
||||
},
|
||||
appEvents: {
|
||||
subscribe: () => {},
|
||||
on: () => {},
|
||||
},
|
||||
}));
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { configure } from 'enzyme';
|
||||
import { EventBusSrv } from '@grafana/data';
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import $ from 'jquery';
|
||||
import 'mutationobserver-shim';
|
||||
|
||||
const testAppEvents = new EventBusSrv();
|
||||
const global = window as any;
|
||||
global.$ = global.jQuery = $;
|
||||
|
||||
@ -33,7 +35,7 @@ angular.module('grafana.directives', []);
|
||||
angular.module('grafana.filters', []);
|
||||
angular.module('grafana.routes', ['ngRoute']);
|
||||
|
||||
jest.mock('../app/core/core', () => ({}));
|
||||
jest.mock('../app/core/core', () => ({ appEvents: testAppEvents }));
|
||||
jest.mock('../app/angular/partials', () => ({}));
|
||||
jest.mock('../app/features/plugins/plugin_loader', () => ({}));
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user