From b4f00d63124192fd633ad3f843b731681905ed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 9 Nov 2021 12:30:04 +0100 Subject: [PATCH] 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> --- .../grafana-data/src/datetime/datemath.ts | 6 +- .../dashboard/services/TimeSrv.test.ts | 41 +- .../features/dashboard/services/TimeSrv.ts | 17 + .../state/DashboardModel.refresh.test.ts | 121 ++ .../dashboard/state/DashboardModel.test.ts | 46 - .../dashboard/state/DashboardModel.ts | 97 +- public/app/features/explore/Wrapper.test.tsx | 4 + .../features/explore/utils/decorators.test.ts | 4 +- .../features/variables/inspect/utils.test.ts | 1828 ++++++++++++++++- .../app/features/variables/inspect/utils.ts | 110 +- .../features/variables/state/actions.test.ts | 23 +- .../app/features/variables/state/actions.ts | 40 +- .../state/onTimeRangeUpdated.test.ts | 32 +- .../state/templateVarsChangedInUrl.test.ts | 14 +- public/app/features/variables/types.ts | 17 + .../plugins/panel/graph/specs/graph.test.ts | 1 + public/test/jest-setup.ts | 4 +- 17 files changed, 2278 insertions(+), 127 deletions(-) create mode 100644 public/app/features/dashboard/state/DashboardModel.refresh.test.ts diff --git a/packages/grafana-data/src/datetime/datemath.ts b/packages/grafana-data/src/datetime/datemath.ts index 99dceeb4044..a14aceeb7e9 100644 --- a/packages/grafana-data/src/datetime/datemath.ts +++ b/packages/grafana-data/src/datetime/datemath.ts @@ -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; diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts index 4838a8a2436..e00e63cc059 100644 --- a/public/app/features/dashboard/services/TimeSrv.test.ts +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -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); + }); + }); + }); + }); }); diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index ceed1f16986..68576ec0a45 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -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; diff --git a/public/app/features/dashboard/state/DashboardModel.refresh.test.ts b/public/app/features/dashboard/state/DashboardModel.refresh.test.ts new file mode 100644 index 00000000000..a5a76eab2c3 --- /dev/null +++ b/public/app/features/dashboard/state/DashboardModel.refresh.test.ts @@ -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(); + }); + }); + }); +}); diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts index d8448135e2e..7460e7ff29d 100644 --- a/public/app/features/dashboard/state/DashboardModel.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -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({}); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 0a179f2d5a4..164e17b6010 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -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); + } } diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 171ee041ea0..d2ec3d3b584 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -34,6 +34,10 @@ jest.mock('app/core/core', () => { contextSrv: { hasPermission: () => true, }, + appEvents: { + subscribe: () => {}, + publish: () => {}, + }, }; }); diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index ffc6abcc775..02d4d3e7dfb 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -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', })); diff --git a/public/app/features/variables/inspect/utils.test.ts b/public/app/features/variables/inspect/utils.test.ts index 77bf105604d..209c5d25241 100644 --- a/public/app/features/variables/inspect/utils.test.ts +++ b/public/app/features/variables/inspect/utils.test.ts @@ -1,4 +1,14 @@ -import { getPropsWithVariable } from './utils'; +import { + getAffectedPanelIdsForVariable, + getAllAffectedPanelIdsForVariableChange, + getDependenciesForVariable, + getPropsWithVariable, +} from './utils'; +import { PanelModel } from '@grafana/data'; +import { variableAdapters } from '../adapters'; +import { createDataSourceVariableAdapter } from '../datasource/adapter'; +import { createCustomVariableAdapter } from '../custom/adapter'; +import { createQueryVariableAdapter } from '../query/adapter'; describe('getPropsWithVariable', () => { it('when called it should return the correct graph', () => { @@ -144,4 +154,1820 @@ describe('getPropsWithVariable', () => { }, }); }); + + it('when using a real world example with rows and repeats', () => { + const result = getPropsWithVariable( + 'query0', + { + key: 'model', + value: dashWithRepeatsAndRows, + }, + {} + ); + + expect(result).toEqual({ + panels: { + 'Panel with var in title $query0[15]': { + title: 'Panel with var in title $query0', + }, + 'Panel with var in target[16]': { + targets: { + '0': { + alias: '$query0', + }, + }, + }, + 'Panel with var repeat[17]': { + repeat: 'query0', + }, + 'Panel with var in title $query0[11]': { + title: 'Panel with var in title $query0', + }, + 'Panel with var in target[12]': { + targets: { + '0': { + alias: '$query0', + }, + }, + }, + 'Panel with var repeat[13]': { + repeat: 'query0', + }, + 'Row with var[2]': { + repeat: 'query0', + }, + 'Panel with var in title $query0[5]': { + title: 'Panel with var in title $query0', + }, + 'Panel with var in target[7]': { + targets: { + '0': { + alias: '$query0', + }, + }, + }, + 'Panel with var repeat[6]': { + repeat: 'query0', + }, + }, + }); + }); }); + +describe('getAffectedPanelIdsForVariable', () => { + describe('when called with a real world example with rows and repeats', () => { + it('then it should return correct panel ids', () => { + const panels = dashWithRepeatsAndRows.panels.map( + (panel: PanelModel) => + (({ + id: panel.id, + getSaveModel: () => panel, + } as unknown) as PanelModel) + ); + const result = getAffectedPanelIdsForVariable('query0', panels); + expect(result).toEqual([15, 16, 17, 11, 12, 13, 2, 5, 7, 6]); + }); + }); +}); + +variableAdapters.setInit(() => [ + createDataSourceVariableAdapter(), + createCustomVariableAdapter(), + createQueryVariableAdapter(), +]); + +describe('getDependenciesForVariable', () => { + describe('when called with a real world example with dependencies', () => { + it('then it should return correct dependencies', () => { + const { + templating: { list: variables }, + } = dashWithTemplateDependenciesAndPanels; + const result = getDependenciesForVariable('ds_instance', variables, new Set()); + expect([...result]).toEqual([ + 'ds', + 'query_with_ds', + 'depends_on_query_with_ds', + 'depends_on_query_with_ds_regex', + 'depends_on_all', + ]); + }); + }); +}); + +describe('getAllAffectedPanelIdsForVariableChange ', () => { + describe('when called with a real world example with dependencies and panels', () => { + it('then it should return correct panelIds', () => { + const { + panels: panelsAsJson, + templating: { list: variables }, + } = dashWithTemplateDependenciesAndPanels; + const panels = panelsAsJson.map( + (panel: PanelModel) => + (({ + id: panel.id, + getSaveModel: () => panel, + } as unknown) as PanelModel) + ); + const result = getAllAffectedPanelIdsForVariableChange('ds_instance', variables, panels); + expect(result).toEqual([2, 3, 4, 5]); + }); + }); + + describe('when called with a real world example with dependencies and panels on a leaf variable', () => { + it('then it should return correct panelIds', () => { + const { + panels: panelsAsJson, + templating: { list: variables }, + } = dashWithTemplateDependenciesAndPanels; + const panels = panelsAsJson.map( + (panel: PanelModel) => + (({ + id: panel.id, + getSaveModel: () => panel, + } as unknown) as PanelModel) + ); + const result = getAllAffectedPanelIdsForVariableChange('depends_on_all', variables, panels); + expect(result).toEqual([2]); + }); + }); + + describe('when called with a real world example with $__all_variables in links', () => { + it('then it should return correct panelIds', () => { + const { + panels: panelsAsJson, + templating: { list: variables }, + } = dashWithAllVariables; + const panels = panelsAsJson.map( + (panel: PanelModel) => + (({ + id: panel.id, + getSaveModel: () => panel, + } as unknown) as PanelModel) + ); + const result = getAllAffectedPanelIdsForVariableChange('unknown', variables, panels); + expect(result).toEqual([2, 3]); + }); + }); +}); + +const dashWithRepeatsAndRows: any = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + target: { + limit: 100, + matchAny: false, + tags: [], + type: 'dashboard', + }, + type: 'dashboard', + }, + ], + }, + editable: true, + gnetId: null, + graphTooltip: 0, + id: 518, + iteration: 1631794309996, + links: [], + liveNow: false, + panels: [ + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 0, + y: 0, + }, + id: 14, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel without vars', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 5, + y: 0, + }, + id: 15, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var in title $query0', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 10, + y: 0, + }, + id: 16, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + alias: '$query0', + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var in target', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 24, + x: 0, + y: 3, + }, + id: 17, + maxPerRow: 2, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + repeat: 'query0', + repeatDirection: 'v', + targets: [ + { + alias: '', + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var repeat', + type: 'stat', + }, + { + collapsed: false, + datasource: null, + gridPos: { + h: 1, + w: 24, + x: 0, + y: 6, + }, + id: 9, + panels: [], + title: 'Row without var', + type: 'row', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 0, + y: 7, + }, + id: 4, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel without vars', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 5, + y: 7, + }, + id: 11, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var in title $query0', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 10, + y: 7, + }, + id: 12, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + alias: '$query0', + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var in target', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 24, + x: 0, + y: 10, + }, + id: 13, + maxPerRow: 2, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + repeat: 'query0', + repeatDirection: 'v', + targets: [ + { + alias: '', + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var repeat', + type: 'stat', + }, + { + collapsed: false, + datasource: null, + gridPos: { + h: 1, + w: 24, + x: 0, + y: 13, + }, + id: 2, + panels: [], + repeat: 'query0', + title: 'Row with var', + type: 'row', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 0, + y: 14, + }, + id: 10, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel without vars', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 5, + y: 14, + }, + id: 5, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var in title $query0', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 5, + x: 10, + y: 14, + }, + id: 7, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + targets: [ + { + alias: '$query0', + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var in target', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 3, + w: 24, + x: 0, + y: 17, + }, + id: 6, + maxPerRow: 2, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + text: {}, + textMode: 'auto', + tooltip: { + mode: 'single', + }, + }, + pluginVersion: '8.2.0-pre', + repeat: 'query0', + repeatDirection: 'v', + targets: [ + { + alias: '', + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Panel with var repeat', + type: 'stat', + }, + ], + schemaVersion: 31, + style: 'dark', + tags: [], + templating: { + list: [ + { + allValue: null, + current: { + selected: true, + text: ['A'], + value: ['A'], + }, + datasource: 'gdev-testdata', + definition: '*', + description: null, + error: null, + hide: 0, + includeAll: true, + label: null, + multi: true, + name: 'query0', + options: [], + query: { + query: '*', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + ], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: '', + title: 'Variables update POC', + uid: 'tISItwInz', + version: 2, +}; + +const dashWithTemplateDependenciesAndPanels: any = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + target: { + limit: 100, + matchAny: false, + tags: [], + type: 'dashboard', + }, + type: 'dashboard', + }, + ], + }, + editable: true, + gnetId: null, + graphTooltip: 0, + id: 522, + iteration: 1632133230646, + links: [], + liveNow: false, + panels: [ + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 6, + w: 4, + x: 0, + y: 0, + }, + id: 2, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + tooltip: { + mode: 'single', + }, + }, + title: 'Depends on all $depends_on_all [2]', + type: 'timeseries', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 6, + w: 4, + x: 4, + y: 0, + }, + id: 3, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + tooltip: { + mode: 'single', + }, + }, + title: 'Depends on regex $depends_on_query_with_ds_regex [3]', + type: 'timeseries', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 6, + w: 4, + x: 8, + y: 0, + }, + id: 4, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + tooltip: { + mode: 'single', + }, + }, + title: 'Depends on query $depends_on_query_with_ds [4]', + type: 'timeseries', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: false, + }, + lineInterpolation: 'linear', + lineWidth: 1, + pointSize: 5, + scaleDistribution: { + type: 'linear', + }, + showPoints: 'auto', + spanNulls: false, + stacking: { + group: 'A', + mode: 'none', + }, + thresholdsStyle: { + mode: 'off', + }, + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 6, + w: 4, + x: 12, + y: 0, + }, + id: 5, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + tooltip: { + mode: 'single', + }, + }, + title: 'Depends on ds $query_with_ds [5]', + type: 'timeseries', + }, + ], + schemaVersion: 31, + style: 'dark', + tags: [], + templating: { + list: [ + { + current: { + selected: false, + text: 'TestData DB', + value: 'TestData DB', + }, + description: null, + error: null, + hide: 0, + includeAll: false, + label: null, + multi: false, + name: 'ds', + options: [], + query: 'testdata', + queryValue: '', + refresh: 1, + regex: '/$ds_instance/', + skipUrlSync: false, + type: 'datasource', + }, + { + allValue: null, + current: { + selected: true, + text: ['A'], + value: ['A'], + }, + datasource: '${ds}', + definition: '*', + description: null, + error: null, + hide: 0, + includeAll: true, + label: null, + multi: true, + name: 'query_with_ds', + options: [], + query: { + query: '*', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + { + allValue: null, + current: { + selected: true, + text: ['AA'], + value: ['AA'], + }, + datasource: null, + definition: '$query_with_ds.*', + description: null, + error: null, + hide: 0, + includeAll: true, + label: null, + multi: true, + name: 'depends_on_query_with_ds', + options: [], + query: { + query: '$query_with_ds.*', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + { + allValue: null, + current: { + selected: false, + text: 'A', + value: 'A', + }, + datasource: null, + definition: '*', + description: null, + error: null, + hide: 0, + includeAll: false, + label: null, + multi: false, + name: 'depends_on_query_with_ds_regex', + options: [], + query: { + query: '*', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '/.*$query_with_ds.*/', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + { + allValue: null, + current: { + selected: false, + text: 'AB', + value: 'AB', + }, + datasource: '${ds}', + definition: '$query_with_ds.*', + description: null, + error: null, + hide: 0, + includeAll: false, + label: null, + multi: false, + name: 'depends_on_all', + options: [], + query: { + query: '$query_with_ds.*', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '/.*$depends_on_query_with_ds_regex.*/', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + { + allValue: null, + current: { + selected: true, + text: 'TestData DB', + value: 'TestData DB', + }, + description: null, + error: null, + hide: 0, + includeAll: false, + label: null, + multi: false, + name: 'ds_instance', + options: [ + { + selected: true, + text: 'TestData DB', + value: 'TestData DB', + }, + { + selected: false, + text: 'gdev-testdata', + value: 'gdev-testdata', + }, + ], + query: 'TestData DB, gdev-testdata', + queryValue: '', + skipUrlSync: false, + type: 'custom', + }, + ], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: '', + title: 'Variables dependencies update POC', + uid: 'n60iRMNnk', + version: 6, +}; + +const dashWithAllVariables: any = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + target: { + limit: 100, + matchAny: false, + tags: [], + type: 'dashboard', + }, + type: 'dashboard', + }, + ], + }, + editable: true, + fiscalYearStartMonth: 0, + gnetId: null, + graphTooltip: 0, + id: 603, + iteration: 1635254953926, + links: [], + liveNow: false, + panels: [ + { + datasource: null, + description: '', + fieldConfig: { + defaults: { + color: { + mode: 'thresholds', + }, + links: [ + { + targetBlank: true, + title: 'Depends on Data Link', + url: 'http://www.grafana.com?${__all_variables}', + }, + ], + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 9, + w: 12, + x: 0, + y: 0, + }, + id: 2, + links: [], + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + textMode: 'auto', + }, + pluginVersion: '8.3.0-pre', + title: 'Depends on Data Link', + type: 'stat', + }, + { + datasource: null, + description: '', + fieldConfig: { + defaults: { + color: { + mode: 'thresholds', + }, + links: [], + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 9, + w: 12, + x: 12, + y: 0, + }, + id: 3, + links: [ + { + targetBlank: true, + title: 'Panel Link', + url: 'http://www.grafana.com?${__all_variables}', + }, + ], + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + textMode: 'auto', + }, + pluginVersion: '8.3.0-pre', + title: 'Depends on Panel Link', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'thresholds', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 0, + y: 9, + }, + id: 5, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + textMode: 'auto', + }, + pluginVersion: '8.3.0-pre', + title: 'Depends on none', + type: 'stat', + }, + { + datasource: null, + fieldConfig: { + defaults: { + color: { + mode: 'thresholds', + }, + mappings: [], + thresholds: { + mode: 'absolute', + steps: [ + { + color: 'green', + value: null, + }, + { + color: 'red', + value: 80, + }, + ], + }, + }, + overrides: [], + }, + gridPos: { + h: 8, + w: 12, + x: 12, + y: 9, + }, + id: 6, + options: { + colorMode: 'value', + graphMode: 'area', + justifyMode: 'auto', + orientation: 'auto', + reduceOptions: { + calcs: ['lastNotNull'], + fields: '', + values: false, + }, + textMode: 'auto', + }, + pluginVersion: '8.3.0-pre', + targets: [ + { + alias: '', + datasource: 'gdev-testdata', + refId: 'A', + scenarioId: 'random_walk', + }, + ], + title: 'Depends on var $custom', + type: 'stat', + }, + ], + revision: null, + schemaVersion: 31, + style: 'dark', + tags: [], + templating: { + list: [ + { + allValue: null, + current: { + selected: true, + text: ['1'], + value: ['1'], + }, + description: null, + error: null, + hide: 0, + includeAll: true, + label: null, + multi: true, + name: 'custom', + options: [ + { + selected: false, + text: 'All', + value: '$__all', + }, + { + selected: true, + text: '1', + value: '1', + }, + { + selected: false, + text: '2', + value: '2', + }, + { + selected: false, + text: '3', + value: '3', + }, + ], + query: '1,2,3', + queryValue: '', + skipUrlSync: false, + type: 'custom', + }, + ], + }, + time: { + from: 'now-6h', + to: 'now', + }, + timepicker: {}, + timezone: '', + title: 'Depends on Links', + uid: 'XkBHMzF7z', + version: 6, + weekStart: '', +}; diff --git a/public/app/features/variables/inspect/utils.ts b/public/app/features/variables/inspect/utils.ts index c25a3f547cd..79f27b10d3f 100644 --- a/public/app/features/variables/inspect/utils.ts +++ b/public/app/features/variables/inspect/utils.ts @@ -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 = { - 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); 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); 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 +): Set { + 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[]; diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index 18806f37884..4b1713238a1 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -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() .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({ diff --git a/public/app/features/variables/state/actions.ts b/public/app/features/variables/state/actions.ts index 5d4a2bae90c..45c88555a2a 100644 --- a/public/app/features/variables/state/actions.ts +++ b/public/app/features/variables/state/actions.ts @@ -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> => { 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> = []; @@ -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> => 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 => async ( - dispatch, - getState -) => { +export const templateVarsChangedInUrl = ( + vars: ExtendedUrlQueryMap, + events: typeof appEvents = appEvents +): ThunkResult => async (dispatch, getState) => { const update: Array> = []; 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 })); } }; diff --git a/public/app/features/variables/state/onTimeRangeUpdated.test.ts b/public/app/features/variables/state/onTimeRangeUpdated.test.ts index eda8183399a..6ca50e46b99 100644 --- a/public/app/features/variables/state/onTimeRangeUpdated.test.ts +++ b/public/app/features/variables/state/onTimeRangeUpdated.test.ts @@ -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({ 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({ 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); }); }); }); diff --git a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts index 7a68a5b2b4b..248c21123d2 100644 --- a/public/app/features/variables/state/templateVarsChangedInUrl.test.ts +++ b/public/app/features/variables/state/templateVarsChangedInUrl.test.ts @@ -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 = { - 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 }; diff --git a/public/app/features/variables/types.ts b/public/app/features/variables/types.ts index 34a0e16c0f8..eed6e0efd5a 100644 --- a/public/app/features/variables/types.ts +++ b/public/app/features/variables/types.ts @@ -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 | ComponentType> | null; + +export interface VariablesChangedEvent { + panelIds?: number[]; +} + +export class VariablesChanged extends BusEventWithPayload { + static type = 'variables-changed'; +} + +export class VariablesFinishedProcessingTimeRangeChange extends BusEventWithPayload { + static type = 'variables-finished-processing-time-range-change'; +} + +export class VariablesChangedInUrl extends BusEventWithPayload { + static type = 'variables-changed-in-url'; +} diff --git a/public/app/plugins/panel/graph/specs/graph.test.ts b/public/app/plugins/panel/graph/specs/graph.test.ts index 8b0c4815c1e..8334afff4f0 100644 --- a/public/app/plugins/panel/graph/specs/graph.test.ts +++ b/public/app/plugins/panel/graph/specs/graph.test.ts @@ -23,6 +23,7 @@ jest.mock('app/core/core', () => ({ directive: () => {}, }, appEvents: { + subscribe: () => {}, on: () => {}, }, })); diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts index 373fdd72088..8b505d5ff00 100644 --- a/public/test/jest-setup.ts +++ b/public/test/jest-setup.ts @@ -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', () => ({}));