From cd4b7ef9dbf4551966546c47d11173b54a94a3ae Mon Sep 17 00:00:00 2001 From: Alexa V <239999+axelavargas@users.noreply.github.com> Date: Tue, 20 Aug 2024 17:05:12 +0200 Subject: [PATCH] Dashboard Scene: Fix snapshots not displaying variables values (#88967) * Use new snapshot variables from scenes * Add snapshotVariable implementation * Refactor: Extract variables logic from transforSaveModelToScene file --------- Co-authored-by: Dominik Prokop --- kinds/dashboard/dashboard_kind.cue | 5 +- packages/grafana-data/src/index.ts | 1 + .../grafana-data/src/types/templateVars.ts | 8 +- .../raw/dashboard/x/dashboard_types.gen.ts | 2 +- pkg/kinds/dashboard/dashboard_spec_gen.go | 1 + .../SnapshotVariable.test.tsx | 33 + .../custom-variables/SnapshotVariable.tsx | 81 ++ .../transformSaveModelToScene.test.ts | 766 +++-------------- .../transformSaveModelToScene.ts | 162 +--- .../settings/variables/utils.ts | 3 +- .../dashboard-scene/utils/variables.test.ts | 775 ++++++++++++++++++ .../dashboard-scene/utils/variables.ts | 238 ++++++ public/app/features/variables/guard.test.ts | 26 +- .../variables/state/__tests__/fixtures.ts | 11 + public/app/plugins/panel/logs/panelcfg.cue | 4 +- 15 files changed, 1286 insertions(+), 830 deletions(-) create mode 100644 public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.test.tsx create mode 100644 public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.tsx create mode 100644 public/app/features/dashboard-scene/utils/variables.test.ts create mode 100644 public/app/features/dashboard-scene/utils/variables.ts diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 58ae9457718..6a24fc92266 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -294,7 +294,8 @@ lineage: schemas: [{ // `textbox`: Display a free text input field with an optional default value. // `custom`: Define the variable options manually using a comma-separated list. // `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables - #VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | "system" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) + #VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" | + "system" | "snapshot" @cuetsy(kind="type") @grafanamaturity(NeedsExpertReview) // Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. // Continuous color interpolates a color using the percentage of a value relative to min and max. @@ -594,7 +595,7 @@ lineage: schemas: [{ // Dynamically load the panel libraryPanel?: #LibraryPanelRef - // Sets panel queries cache timeout. + // Sets panel queries cache timeout. cacheTimeout?: string // Overrides the data source configured time-to-live for a query cache item in milliseconds diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 005e89554da..e2cd45f4fb2 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -515,6 +515,7 @@ export { type UserVariableModel, type SystemVariable, type BaseVariableModel, + type SnapshotVariableModel, } from './types/templateVars'; export { type Threshold, ThresholdsMode, type ThresholdsConfig } from './types/thresholds'; export { diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 1b01e5b9e53..14a1cd38fc8 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -22,7 +22,8 @@ export type TypedVariableModel = | CustomVariableModel | UserVariableModel | OrgVariableModel - | DashboardVariableModel; + | DashboardVariableModel + | SnapshotVariableModel; export enum VariableRefresh { never, // removed from the UI @@ -178,3 +179,8 @@ export interface BaseVariableModel { description: string | null; usedInRepeat?: boolean; } + +export interface SnapshotVariableModel extends VariableWithOptions { + type: 'snapshot'; + query: string; +} diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index 784b96494f4..a67ab34ae38 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -349,7 +349,7 @@ export type DashboardLinkType = ('link' | 'dashboards'); * `custom`: Define the variable options manually using a comma-separated list. * `system`: Variables defined by Grafana. See: https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables */ -export type VariableType = ('query' | 'adhoc' | 'groupby' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom' | 'system'); +export type VariableType = ('query' | 'adhoc' | 'groupby' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom' | 'system' | 'snapshot'); /** * Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value. diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 60ac16f3ea0..de16129aa20 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -168,6 +168,7 @@ const ( VariableTypeGroupby VariableType = "groupby" VariableTypeInterval VariableType = "interval" VariableTypeQuery VariableType = "query" + VariableTypeSnapshot VariableType = "snapshot" VariableTypeSystem VariableType = "system" VariableTypeTextbox VariableType = "textbox" ) diff --git a/public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.test.tsx b/public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.test.tsx new file mode 100644 index 00000000000..39beea7a92a --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.test.tsx @@ -0,0 +1,33 @@ +import { SnapshotVariable } from './SnapshotVariable'; + +describe('SnapshotVariable', () => { + describe('SnapshotVariable state', () => { + it('should create a new snapshotVariable when custom variable is passed', () => { + const { multiVariable } = setupScene(); + const snapshot = new SnapshotVariable(multiVariable); + //expect snapshot to be defined + expect(snapshot).toBeDefined(); + expect(snapshot.state).toBeDefined(); + expect(snapshot.state.type).toBe('snapshot'); + expect(snapshot.state.isReadOnly).toBe(true); + expect(snapshot.state.value).toBe(multiVariable.value); + expect(snapshot.state.text).toBe(multiVariable.text); + expect(snapshot.state.hide).toBe(multiVariable.hide); + }); + }); +}); + +function setupScene() { + // create custom variable type custom + + const multiVariable = { + name: 'Multi', + description: 'Define variable values manually', + text: 'myMultiText', + value: 'myMultiValue', + multi: true, + hide: 0, + }; + + return { multiVariable }; +} diff --git a/public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.tsx b/public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.tsx new file mode 100644 index 00000000000..3a84ade2759 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/custom-variables/SnapshotVariable.tsx @@ -0,0 +1,81 @@ +import { Observable, map, of } from 'rxjs'; + +import { + MultiValueVariable, + MultiValueVariableState, + SceneComponentProps, + ValidateAndUpdateResult, + VariableDependencyConfig, + VariableValueOption, + renderSelectForVariable, + sceneGraph, + VariableGetOptionsArgs, +} from '@grafana/scenes'; + +export interface SnapshotVariableState extends MultiValueVariableState { + query?: string; +} + +export class SnapshotVariable extends MultiValueVariable { + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: [], + }); + + public constructor(initialState: Partial) { + super({ + name: '', + type: 'snapshot', + isReadOnly: true, + query: '', + value: '', + text: '', + options: [], + ...initialState, + }); + } + + public getValueOptions(args: VariableGetOptionsArgs): Observable { + const interpolated = sceneGraph.interpolate(this, this.state.query); + const match = interpolated.match(/(?:\\,|[^,])+/g) ?? []; + + const options = match.map((text) => { + text = text.replace(/\\,/g, ','); + const textMatch = /^(.+)\s:\s(.+)$/g.exec(text) ?? []; + if (textMatch.length === 3) { + const [, key, value] = textMatch; + return { label: key.trim(), value: value.trim() }; + } else { + return { label: text.trim(), value: text.trim() }; + } + }); + + return of(options); + } + + public validateAndUpdate(): Observable { + return this.getValueOptions({}).pipe( + map((options) => { + if (this.state.options !== options) { + this._updateValueGivenNewOptions(options); + } + return {}; + }) + ); + } + + public static Component = ({ model }: SceneComponentProps>) => { + return renderSelectForVariable(model); + }; + // we will always preserve the current value and text for snapshots + private _updateValueGivenNewOptions(options: VariableValueOption[]) { + const { value: currentValue, text: currentText } = this.state; + const stateUpdate: Partial = { + options, + loading: false, + value: currentValue ?? [], + text: currentText ?? [], + }; + + this.setState(stateUpdate); + } +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index ea3e0c936f5..c779254f1d3 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -1,24 +1,10 @@ -import { - LoadingState, - ConstantVariableModel, - CustomVariableModel, - DataSourceVariableModel, - QueryVariableModel, - IntervalVariableModel, - TypedVariableModel, - TextBoxVariableModel, - GroupByVariableModel, -} from '@grafana/data'; +import { LoadingState } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { config } from '@grafana/runtime'; import { AdHocFiltersVariable, behaviors, ConstantVariable, - CustomVariable, - DataSourceVariable, - GroupByVariable, - QueryVariable, SceneDataLayerControls, SceneDataTransformer, SceneGridLayout, @@ -50,12 +36,12 @@ import { getQueryRunnerFor } from '../utils/utils'; import { buildNewDashboardSaveModel } from './buildNewDashboardSaveModel'; import { GRAFANA_DATASOURCE_REF } from './const'; +import { SnapshotVariable } from './custom-variables/SnapshotVariable'; import dashboard_to_load1 from './testfiles/dashboard_to_load1.json'; import repeatingRowsAndPanelsDashboardJson from './testfiles/repeating_rows_and_panels.json'; import { createDashboardSceneFromDashboardModel, buildGridItemForPanel, - createSceneVariableFromVariableModel, transformSaveModelToScene, convertOldSnapshotToScenesSnapshot, buildGridItemForLibPanel, @@ -193,6 +179,113 @@ describe('transformSaveModelToScene', () => { }); }); + describe('When creating a snapshot dashboard scene', () => { + it('should initialize a dashboard scene with SnapshotVariables', () => { + const customVariable = { + current: { + selected: false, + text: 'a', + value: 'a', + }, + hide: 0, + includeAll: false, + multi: false, + name: 'custom0', + options: [], + query: 'a,b,c,d', + skipUrlSync: false, + type: 'custom' as VariableType, + rootStateKey: 'N4XLmH5Vz', + }; + + const intervalVariable = { + current: { + selected: false, + text: '10s', + value: '10s', + }, + hide: 0, + includeAll: false, + multi: false, + name: 'interval0', + options: [], + query: '10s,20s,30s', + skipUrlSync: false, + type: 'interval' as VariableType, + rootStateKey: 'N4XLmH5Vz', + }; + + const adHocVariable = { + global: false, + name: 'CoolFilters', + label: 'CoolFilters Label', + type: 'adhoc' as VariableType, + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + hide: 0, + index: 0, + }; + + const snapshot = { + ...defaultDashboard, + title: 'snapshot dash', + uid: 'test-uid', + time: { from: 'now-10h', to: 'now' }, + weekStart: 'saturday', + fiscalYearStartMonth: 2, + timezone: 'America/New_York', + timepicker: { + ...defaultTimePickerConfig, + hidden: true, + }, + links: [{ ...NEW_LINK, title: 'Link 1' }], + templating: { + list: [customVariable, adHocVariable, intervalVariable], + }, + }; + + const oldModel = new DashboardModel(snapshot, { isSnapshot: true }); + const scene = createDashboardSceneFromDashboardModel(oldModel, snapshot); + + // check variables were converted to snapshot variables + expect(scene.state.$variables?.state.variables).toHaveLength(3); + expect(scene.state.$variables?.getByName('custom0')).toBeInstanceOf(SnapshotVariable); + expect(scene.state.$variables?.getByName('CoolFilters')).toBeInstanceOf(AdHocFiltersVariable); + expect(scene.state.$variables?.getByName('interval0')).toBeInstanceOf(SnapshotVariable); + // custom snapshot + const customSnapshot = scene.state.$variables?.getByName('custom0') as SnapshotVariable; + expect(customSnapshot.state.value).toBe('a'); + expect(customSnapshot.state.text).toBe('a'); + expect(customSnapshot.state.isReadOnly).toBe(true); + // adhoc snapshot + const adhocSnapshot = scene.state.$variables?.getByName('CoolFilters') as AdHocFiltersVariable; + expect(adhocSnapshot.state.filters).toEqual(adHocVariable.filters); + expect(adhocSnapshot.state.readOnly).toBe(true); + + // interval snapshot + const intervalSnapshot = scene.state.$variables?.getByName('interval0') as SnapshotVariable; + expect(intervalSnapshot.state.value).toBe('10s'); + expect(intervalSnapshot.state.text).toBe('10s'); + expect(intervalSnapshot.state.isReadOnly).toBe(true); + }); + }); + describe('when organizing panels as scene children', () => { it('should create panels within collapsed rows', () => { const panel = createPanelSaveModel({ @@ -593,647 +686,6 @@ describe('transformSaveModelToScene', () => { }); }); - describe('when creating variables objects', () => { - it('should migrate custom variable', () => { - const variable: CustomVariableModel = { - current: { - selected: false, - text: 'a', - value: 'a', - }, - hide: 0, - includeAll: false, - multi: false, - name: 'query0', - options: [ - { - selected: true, - text: 'a', - value: 'a', - }, - { - selected: false, - text: 'b', - value: 'b', - }, - { - selected: false, - text: 'c', - value: 'c', - }, - { - selected: false, - text: 'd', - value: 'd', - }, - ], - query: 'a,b,c,d', - skipUrlSync: false, - type: 'custom', - rootStateKey: 'N4XLmH5Vz', - id: 'query0', - global: false, - index: 0, - state: LoadingState.Done, - error: null, - description: null, - allValue: null, - }; - - const migrated = createSceneVariableFromVariableModel(variable); - const { key, ...rest } = migrated.state; - - expect(migrated).toBeInstanceOf(CustomVariable); - expect(rest).toEqual({ - allValue: undefined, - defaultToAll: false, - description: null, - includeAll: false, - isMulti: false, - label: undefined, - name: 'query0', - options: [], - query: 'a,b,c,d', - skipUrlSync: false, - text: 'a', - type: 'custom', - value: 'a', - hide: 0, - }); - }); - - it('should migrate query variable with definition', () => { - const variable: QueryVariableModel = { - allValue: null, - current: { - text: 'America', - value: 'America', - selected: false, - }, - datasource: { - uid: 'P15396BDD62B2BE29', - type: 'influxdb', - }, - definition: 'SHOW TAG VALUES WITH KEY = "datacenter"', - hide: 0, - includeAll: false, - label: 'Datacenter', - multi: false, - name: 'datacenter', - options: [ - { - text: 'America', - value: 'America', - selected: true, - }, - { - text: 'Africa', - value: 'Africa', - selected: false, - }, - { - text: 'Asia', - value: 'Asia', - selected: false, - }, - { - text: 'Europe', - value: 'Europe', - selected: false, - }, - ], - query: 'SHOW TAG VALUES WITH KEY = "datacenter" ', - refresh: 1, - regex: '', - skipUrlSync: false, - sort: 0, - type: 'query', - rootStateKey: '000000002', - id: 'datacenter', - global: false, - index: 0, - state: LoadingState.Done, - error: null, - description: null, - }; - - const migrated = createSceneVariableFromVariableModel(variable); - const { key, ...rest } = migrated.state; - - expect(migrated).toBeInstanceOf(QueryVariable); - expect(rest).toEqual({ - allValue: undefined, - datasource: { - type: 'influxdb', - uid: 'P15396BDD62B2BE29', - }, - defaultToAll: false, - description: null, - includeAll: false, - isMulti: false, - label: 'Datacenter', - name: 'datacenter', - options: [], - query: 'SHOW TAG VALUES WITH KEY = "datacenter" ', - refresh: 1, - regex: '', - skipUrlSync: false, - sort: 0, - text: 'America', - type: 'query', - value: 'America', - hide: 0, - definition: 'SHOW TAG VALUES WITH KEY = "datacenter"', - }); - }); - - it('should migrate datasource variable', () => { - const variable: DataSourceVariableModel = { - id: 'query1', - rootStateKey: 'N4XLmH5Vz', - name: 'query1', - type: 'datasource', - global: false, - index: 1, - hide: 0, - skipUrlSync: false, - state: LoadingState.Done, - error: null, - description: null, - current: { - value: ['gdev-prometheus', 'gdev-slow-prometheus'], - text: ['gdev-prometheus', 'gdev-slow-prometheus'], - selected: true, - }, - regex: '/^gdev/', - options: [ - { - text: 'All', - value: '$__all', - selected: false, - }, - { - text: 'gdev-prometheus', - value: 'gdev-prometheus', - selected: true, - }, - { - text: 'gdev-slow-prometheus', - value: 'gdev-slow-prometheus', - selected: false, - }, - ], - query: 'prometheus', - multi: true, - includeAll: true, - refresh: 1, - allValue: 'Custom all', - }; - - const migrated = createSceneVariableFromVariableModel(variable); - const { key, ...rest } = migrated.state; - - expect(migrated).toBeInstanceOf(DataSourceVariable); - expect(rest).toEqual({ - allValue: 'Custom all', - defaultToAll: true, - includeAll: true, - label: undefined, - name: 'query1', - options: [], - pluginId: 'prometheus', - regex: '/^gdev/', - skipUrlSync: false, - text: ['gdev-prometheus', 'gdev-slow-prometheus'], - type: 'datasource', - value: ['gdev-prometheus', 'gdev-slow-prometheus'], - isMulti: true, - description: null, - hide: 0, - }); - }); - - it('should migrate constant variable', () => { - const variable: ConstantVariableModel = { - hide: 2, - label: 'constant', - name: 'constant', - skipUrlSync: false, - type: 'constant', - rootStateKey: 'N4XLmH5Vz', - current: { - selected: true, - text: 'test', - value: 'test', - }, - options: [ - { - selected: true, - text: 'test', - value: 'test', - }, - ], - query: 'test', - id: 'constant', - global: false, - index: 3, - state: LoadingState.Done, - error: null, - description: null, - }; - - const migrated = createSceneVariableFromVariableModel(variable); - const { key, ...rest } = migrated.state; - - expect(rest).toEqual({ - description: null, - hide: 2, - label: 'constant', - name: 'constant', - skipUrlSync: true, - type: 'constant', - value: 'test', - }); - }); - - it('should migrate interval variable', () => { - const variable: IntervalVariableModel = { - name: 'intervalVar', - label: 'Interval Label', - type: 'interval', - rootStateKey: 'N4XLmH5Vz', - auto: false, - refresh: 2, - auto_count: 30, - auto_min: '10s', - current: { - selected: true, - text: '1m', - value: '1m', - }, - options: [ - { - selected: true, - text: '1m', - value: '1m', - }, - ], - query: '1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d, 7d, 14d, 30d', - id: 'intervalVar', - global: false, - index: 4, - hide: 0, - skipUrlSync: false, - state: LoadingState.Done, - error: null, - description: null, - }; - - const migrated = createSceneVariableFromVariableModel(variable); - const { key, ...rest } = migrated.state; - expect(rest).toEqual({ - label: 'Interval Label', - autoEnabled: false, - autoMinInterval: '10s', - autoStepCount: 30, - description: null, - refresh: 2, - intervals: ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d'], - hide: 0, - name: 'intervalVar', - skipUrlSync: false, - type: 'interval', - value: '1m', - }); - }); - - it('should migrate textbox variable', () => { - const variable: TextBoxVariableModel = { - id: 'query0', - global: false, - index: 0, - state: LoadingState.Done, - error: null, - name: 'textboxVar', - label: 'Textbox Label', - description: 'Textbox Description', - type: 'textbox', - rootStateKey: 'N4XLmH5Vz', - current: {}, - hide: 0, - options: [], - query: 'defaultValue', - originalQuery: 'defaultValue', - skipUrlSync: false, - }; - - const migrated = createSceneVariableFromVariableModel(variable); - const { key, ...rest } = migrated.state; - expect(rest).toEqual({ - description: 'Textbox Description', - hide: 0, - label: 'Textbox Label', - name: 'textboxVar', - skipUrlSync: false, - type: 'textbox', - value: 'defaultValue', - }); - }); - - it('should migrate adhoc variable', () => { - const variable: TypedVariableModel = { - id: 'adhoc', - global: false, - index: 0, - state: LoadingState.Done, - error: null, - name: 'adhoc', - label: 'Adhoc Label', - description: 'Adhoc Description', - type: 'adhoc', - rootStateKey: 'N4XLmH5Vz', - datasource: { - uid: 'gdev-prometheus', - type: 'prometheus', - }, - filters: [ - { - key: 'filterTest', - operator: '=', - value: 'test', - }, - ], - baseFilters: [ - { - key: 'baseFilterTest', - operator: '=', - value: 'test', - }, - ], - hide: 0, - skipUrlSync: false, - }; - - const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable; - const filterVarState = migrated.state; - - expect(migrated).toBeInstanceOf(AdHocFiltersVariable); - expect(filterVarState).toEqual({ - key: expect.any(String), - description: 'Adhoc Description', - hide: 0, - label: 'Adhoc Label', - name: 'adhoc', - skipUrlSync: false, - type: 'adhoc', - filterExpression: 'filterTest="test"', - filters: [{ key: 'filterTest', operator: '=', value: 'test' }], - baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }], - datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, - applyMode: 'auto', - useQueriesAsFilterForOptions: true, - }); - }); - - it('should migrate adhoc variable with default keys', () => { - const variable: TypedVariableModel = { - id: 'adhoc', - global: false, - index: 0, - state: LoadingState.Done, - error: null, - name: 'adhoc', - label: 'Adhoc Label', - description: 'Adhoc Description', - type: 'adhoc', - rootStateKey: 'N4XLmH5Vz', - datasource: { - uid: 'gdev-prometheus', - type: 'prometheus', - }, - filters: [ - { - key: 'filterTest', - operator: '=', - value: 'test', - }, - ], - baseFilters: [ - { - key: 'baseFilterTest', - operator: '=', - value: 'test', - }, - ], - defaultKeys: [ - { - text: 'some', - value: '1', - }, - { - text: 'static', - value: '2', - }, - { - text: 'keys', - value: '3', - }, - ], - hide: 0, - skipUrlSync: false, - }; - - const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable; - const filterVarState = migrated.state; - - expect(migrated).toBeInstanceOf(AdHocFiltersVariable); - expect(filterVarState).toEqual({ - key: expect.any(String), - description: 'Adhoc Description', - hide: 0, - label: 'Adhoc Label', - name: 'adhoc', - skipUrlSync: false, - type: 'adhoc', - filterExpression: 'filterTest="test"', - filters: [{ key: 'filterTest', operator: '=', value: 'test' }], - baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }], - datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, - applyMode: 'auto', - defaultKeys: [ - { - text: 'some', - value: '1', - }, - { - text: 'static', - value: '2', - }, - { - text: 'keys', - value: '3', - }, - ], - useQueriesAsFilterForOptions: true, - }); - }); - - describe('when groupByVariable feature toggle is enabled', () => { - beforeAll(() => { - config.featureToggles.groupByVariable = true; - }); - - afterAll(() => { - config.featureToggles.groupByVariable = false; - }); - - it('should migrate groupby variable', () => { - const variable: GroupByVariableModel = { - id: 'groupby', - global: false, - index: 0, - state: LoadingState.Done, - error: null, - name: 'groupby', - label: 'GroupBy Label', - description: 'GroupBy Description', - type: 'groupby', - rootStateKey: 'N4XLmH5Vz', - datasource: { - uid: 'gdev-prometheus', - type: 'prometheus', - }, - multi: true, - options: [ - { - selected: false, - text: 'Foo', - value: 'foo', - }, - { - selected: false, - text: 'Bar', - value: 'bar', - }, - ], - current: {}, - query: '', - hide: 0, - skipUrlSync: false, - }; - - const migrated = createSceneVariableFromVariableModel(variable) as GroupByVariable; - const groupbyVarState = migrated.state; - - expect(migrated).toBeInstanceOf(GroupByVariable); - expect(groupbyVarState).toEqual({ - key: expect.any(String), - description: 'GroupBy Description', - hide: 0, - defaultOptions: [ - { - selected: false, - text: 'Foo', - value: 'foo', - }, - { - selected: false, - text: 'Bar', - value: 'bar', - }, - ], - isMulti: true, - layout: 'horizontal', - noValueOnClear: true, - label: 'GroupBy Label', - name: 'groupby', - skipUrlSync: false, - type: 'groupby', - baseFilters: [], - options: [], - text: [], - value: [], - datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, - applyMode: 'auto', - }); - }); - }); - - describe('when groupByVariable feature toggle is disabled', () => { - it('should not migrate groupby variable and throw an error instead', () => { - const variable: GroupByVariableModel = { - id: 'groupby', - global: false, - index: 0, - state: LoadingState.Done, - error: null, - name: 'groupby', - label: 'GroupBy Label', - description: 'GroupBy Description', - type: 'groupby', - rootStateKey: 'N4XLmH5Vz', - datasource: { - uid: 'gdev-prometheus', - type: 'prometheus', - }, - multi: true, - options: [], - current: {}, - query: '', - hide: 0, - skipUrlSync: false, - }; - - expect(() => createSceneVariableFromVariableModel(variable)).toThrow('Scenes: Unsupported variable type'); - }); - }); - - it.each(['system'])('should throw for unsupported (yet) variables', (type) => { - const variable = { - name: 'query0', - type: type as VariableType, - }; - - expect(() => createSceneVariableFromVariableModel(variable as TypedVariableModel)).toThrow(); - }); - - it('should handle variable without current', () => { - // @ts-expect-error - const variable: TypedVariableModel = { - id: 'query1', - name: 'query1', - type: 'datasource', - global: false, - regex: '/^gdev/', - options: [], - query: 'prometheus', - multi: true, - includeAll: true, - refresh: 1, - allValue: 'Custom all', - }; - - const migrated = createSceneVariableFromVariableModel(variable); - const { key, ...rest } = migrated.state; - - expect(migrated).toBeInstanceOf(DataSourceVariable); - expect(rest).toEqual({ - allValue: 'Custom all', - defaultToAll: true, - includeAll: true, - label: undefined, - name: 'query1', - options: [], - pluginId: 'prometheus', - regex: '/^gdev/', - text: '', - type: 'datasource', - value: '', - isMulti: true, - }); - }); - }); - describe('Repeating rows', () => { it('Should build correct scene model', () => { const scene = transformSaveModelToScene({ dashboard: repeatingRowsAndPanelsDashboardJson as any, meta: {} }); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index d03b55af774..54fe957d097 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -1,6 +1,6 @@ import { uniqueId } from 'lodash'; -import { DataFrameDTO, DataFrameJSON, TypedVariableModel } from '@grafana/data'; +import { DataFrameDTO, DataFrameJSON } from '@grafana/data'; import { config } from '@grafana/runtime'; import { VizPanel, @@ -10,12 +10,6 @@ import { SceneTimeRange, SceneVariableSet, VariableValueSelectors, - SceneVariable, - CustomVariable, - DataSourceVariable, - QueryVariable, - ConstantVariable, - IntervalVariable, SceneRefreshPicker, SceneObject, VizPanelMenu, @@ -24,10 +18,7 @@ import { SceneGridItemLike, SceneDataLayerProvider, SceneDataLayerControls, - TextBoxVariable, UserActionEvent, - GroupByVariable, - AdHocFiltersVariable, sceneGraph, } from '@grafana/scenes'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; @@ -52,12 +43,8 @@ import { setDashboardPanelContext } from '../scene/setDashboardPanelContext'; import { createPanelDataProvider } from '../utils/createPanelDataProvider'; import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState'; import { DashboardInteractions } from '../utils/interactions'; -import { - getCurrentValueForOldIntervalModel, - getDashboardSceneFor, - getIntervalsFromQueryString, - getVizPanelKeyForPanelId, -} from '../utils/utils'; +import { getDashboardSceneFor, getVizPanelKeyForPanelId } from '../utils/utils'; +import { createVariablesForDashboard, createVariablesForSnapshot } from '../utils/variables'; import { getAngularPanelMigrationHandler } from './angularMigration'; import { GRAFANA_DATASOURCE_REF } from './const'; @@ -198,22 +185,11 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel, let alertStatesLayer: AlertStatesDataLayer | undefined; if (oldModel.templating?.list?.length) { - const variableObjects = oldModel.templating.list - .map((v) => { - try { - return createSceneVariableFromVariableModel(v); - } catch (err) { - console.error(err); - return null; - } - }) - // TODO: Remove filter - // Added temporarily to allow skipping non-compatible variables - .filter((v): v is SceneVariable => Boolean(v)); - - variables = new SceneVariableSet({ - variables: variableObjects, - }); + if (oldModel.meta.isSnapshot) { + variables = createVariablesForSnapshot(oldModel); + } else { + variables = createVariablesForDashboard(oldModel); + } } else { // Create empty variable set variables = new SceneVariableSet({ @@ -303,128 +279,6 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel, return dashboardScene; } -export function createSceneVariableFromVariableModel(variable: TypedVariableModel): SceneVariable { - const commonProperties = { - name: variable.name, - label: variable.label, - description: variable.description, - }; - if (variable.type === 'adhoc') { - return new AdHocFiltersVariable({ - ...commonProperties, - description: variable.description, - skipUrlSync: variable.skipUrlSync, - hide: variable.hide, - datasource: variable.datasource, - applyMode: 'auto', - filters: variable.filters ?? [], - baseFilters: variable.baseFilters ?? [], - defaultKeys: variable.defaultKeys, - useQueriesAsFilterForOptions: true, - }); - } - if (variable.type === 'custom') { - return new CustomVariable({ - ...commonProperties, - value: variable.current?.value ?? '', - text: variable.current?.text ?? '', - - query: variable.query, - isMulti: variable.multi, - allValue: variable.allValue || undefined, - includeAll: variable.includeAll, - defaultToAll: Boolean(variable.includeAll), - skipUrlSync: variable.skipUrlSync, - hide: variable.hide, - }); - } else if (variable.type === 'query') { - return new QueryVariable({ - ...commonProperties, - value: variable.current?.value ?? '', - text: variable.current?.text ?? '', - - query: variable.query, - datasource: variable.datasource, - sort: variable.sort, - refresh: variable.refresh, - regex: variable.regex, - allValue: variable.allValue || undefined, - includeAll: variable.includeAll, - defaultToAll: Boolean(variable.includeAll), - isMulti: variable.multi, - skipUrlSync: variable.skipUrlSync, - hide: variable.hide, - definition: variable.definition, - }); - } else if (variable.type === 'datasource') { - return new DataSourceVariable({ - ...commonProperties, - value: variable.current?.value ?? '', - text: variable.current?.text ?? '', - regex: variable.regex, - pluginId: variable.query, - allValue: variable.allValue || undefined, - includeAll: variable.includeAll, - defaultToAll: Boolean(variable.includeAll), - skipUrlSync: variable.skipUrlSync, - isMulti: variable.multi, - hide: variable.hide, - }); - } else if (variable.type === 'interval') { - const intervals = getIntervalsFromQueryString(variable.query); - const currentInterval = getCurrentValueForOldIntervalModel(variable, intervals); - return new IntervalVariable({ - ...commonProperties, - value: currentInterval, - intervals: intervals, - autoEnabled: variable.auto, - autoStepCount: variable.auto_count, - autoMinInterval: variable.auto_min, - refresh: variable.refresh, - skipUrlSync: variable.skipUrlSync, - hide: variable.hide, - }); - } else if (variable.type === 'constant') { - return new ConstantVariable({ - ...commonProperties, - value: variable.query, - skipUrlSync: variable.skipUrlSync, - hide: variable.hide, - }); - } else if (variable.type === 'textbox') { - let val; - if (!variable?.current?.value) { - val = variable.query; - } else { - if (typeof variable.current.value === 'string') { - val = variable.current.value; - } else { - val = variable.current.value[0]; - } - } - - return new TextBoxVariable({ - ...commonProperties, - value: val, - skipUrlSync: variable.skipUrlSync, - hide: variable.hide, - }); - } else if (config.featureToggles.groupByVariable && variable.type === 'groupby') { - return new GroupByVariable({ - ...commonProperties, - datasource: variable.datasource, - value: variable.current?.value || [], - text: variable.current?.text || [], - skipUrlSync: variable.skipUrlSync, - hide: variable.hide, - // @ts-expect-error - defaultOptions: variable.options, - }); - } else { - throw new Error(`Scenes: Unsupported variable type ${variable.type}`); - } -} - export function buildGridItemForLibPanel(panel: PanelModel) { if (!panel.libraryPanel) { return null; diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts index c7f5fb69588..ab9bfb9fe00 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.ts @@ -36,7 +36,8 @@ interface EditableVariableConfig { editor: React.ComponentType; } -export type EditableVariableType = Exclude; +//exclude system variable type and snapshot variable type +export type EditableVariableType = Exclude; export function isEditableVariableType(type: VariableType): type is EditableVariableType { return type !== 'system'; diff --git a/public/app/features/dashboard-scene/utils/variables.test.ts b/public/app/features/dashboard-scene/utils/variables.test.ts new file mode 100644 index 00000000000..cc97261c95b --- /dev/null +++ b/public/app/features/dashboard-scene/utils/variables.test.ts @@ -0,0 +1,775 @@ +import { + ConstantVariableModel, + CustomVariableModel, + DataSourceVariableModel, + GroupByVariableModel, + IntervalVariableModel, + LoadingState, + QueryVariableModel, + TextBoxVariableModel, + TypedVariableModel, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + CustomVariable, + DataSourceVariable, + GroupByVariable, + QueryVariable, + SceneVariableSet, +} from '@grafana/scenes'; +import { defaultDashboard, defaultTimePickerConfig, VariableType } from '@grafana/schema'; +import { DashboardModel } from 'app/features/dashboard/state'; + +import { SnapshotVariable } from '../serialization/custom-variables/SnapshotVariable'; +import { NEW_LINK } from '../settings/links/utils'; + +import { createSceneVariableFromVariableModel, createVariablesForSnapshot } from './variables'; + +describe('when creating variables objects', () => { + it('should migrate custom variable', () => { + const variable: CustomVariableModel = { + current: { + selected: false, + text: 'a', + value: 'a', + }, + hide: 0, + includeAll: false, + multi: false, + name: 'query0', + options: [ + { + selected: true, + text: 'a', + value: 'a', + }, + { + selected: false, + text: 'b', + value: 'b', + }, + { + selected: false, + text: 'c', + value: 'c', + }, + { + selected: false, + text: 'd', + value: 'd', + }, + ], + query: 'a,b,c,d', + skipUrlSync: false, + type: 'custom', + rootStateKey: 'N4XLmH5Vz', + id: 'query0', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + description: null, + allValue: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(CustomVariable); + expect(rest).toEqual({ + allValue: undefined, + defaultToAll: false, + description: null, + includeAll: false, + isMulti: false, + label: undefined, + name: 'query0', + options: [], + query: 'a,b,c,d', + skipUrlSync: false, + text: 'a', + type: 'custom', + value: 'a', + hide: 0, + }); + }); + + it('should migrate query variable with definition', () => { + const variable: QueryVariableModel = { + allValue: null, + current: { + text: 'America', + value: 'America', + selected: false, + }, + datasource: { + uid: 'P15396BDD62B2BE29', + type: 'influxdb', + }, + definition: 'SHOW TAG VALUES WITH KEY = "datacenter"', + hide: 0, + includeAll: false, + label: 'Datacenter', + multi: false, + name: 'datacenter', + options: [ + { + text: 'America', + value: 'America', + selected: true, + }, + { + text: 'Africa', + value: 'Africa', + selected: false, + }, + { + text: 'Asia', + value: 'Asia', + selected: false, + }, + { + text: 'Europe', + value: 'Europe', + selected: false, + }, + ], + query: 'SHOW TAG VALUES WITH KEY = "datacenter" ', + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + type: 'query', + rootStateKey: '000000002', + id: 'datacenter', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + description: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(QueryVariable); + expect(rest).toEqual({ + allValue: undefined, + datasource: { + type: 'influxdb', + uid: 'P15396BDD62B2BE29', + }, + defaultToAll: false, + description: null, + includeAll: false, + isMulti: false, + label: 'Datacenter', + name: 'datacenter', + options: [], + query: 'SHOW TAG VALUES WITH KEY = "datacenter" ', + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + text: 'America', + type: 'query', + value: 'America', + hide: 0, + definition: 'SHOW TAG VALUES WITH KEY = "datacenter"', + }); + }); + + it('should migrate datasource variable', () => { + const variable: DataSourceVariableModel = { + id: 'query1', + rootStateKey: 'N4XLmH5Vz', + name: 'query1', + type: 'datasource', + global: false, + index: 1, + hide: 0, + skipUrlSync: false, + state: LoadingState.Done, + error: null, + description: null, + current: { + value: ['gdev-prometheus', 'gdev-slow-prometheus'], + text: ['gdev-prometheus', 'gdev-slow-prometheus'], + selected: true, + }, + regex: '/^gdev/', + options: [ + { + text: 'All', + value: '$__all', + selected: false, + }, + { + text: 'gdev-prometheus', + value: 'gdev-prometheus', + selected: true, + }, + { + text: 'gdev-slow-prometheus', + value: 'gdev-slow-prometheus', + selected: false, + }, + ], + query: 'prometheus', + multi: true, + includeAll: true, + refresh: 1, + allValue: 'Custom all', + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(DataSourceVariable); + expect(rest).toEqual({ + allValue: 'Custom all', + defaultToAll: true, + includeAll: true, + label: undefined, + name: 'query1', + options: [], + pluginId: 'prometheus', + regex: '/^gdev/', + skipUrlSync: false, + text: ['gdev-prometheus', 'gdev-slow-prometheus'], + type: 'datasource', + value: ['gdev-prometheus', 'gdev-slow-prometheus'], + isMulti: true, + description: null, + hide: 0, + }); + }); + + it('should migrate constant variable', () => { + const variable: ConstantVariableModel = { + hide: 2, + label: 'constant', + name: 'constant', + skipUrlSync: false, + type: 'constant', + rootStateKey: 'N4XLmH5Vz', + current: { + selected: true, + text: 'test', + value: 'test', + }, + options: [ + { + selected: true, + text: 'test', + value: 'test', + }, + ], + query: 'test', + id: 'constant', + global: false, + index: 3, + state: LoadingState.Done, + error: null, + description: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(rest).toEqual({ + description: null, + hide: 2, + label: 'constant', + name: 'constant', + skipUrlSync: true, + type: 'constant', + value: 'test', + }); + }); + + it('should migrate interval variable', () => { + const variable: IntervalVariableModel = { + name: 'intervalVar', + label: 'Interval Label', + type: 'interval', + rootStateKey: 'N4XLmH5Vz', + auto: false, + refresh: 2, + auto_count: 30, + auto_min: '10s', + current: { + selected: true, + text: '1m', + value: '1m', + }, + options: [ + { + selected: true, + text: '1m', + value: '1m', + }, + ], + query: '1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d, 7d, 14d, 30d', + id: 'intervalVar', + global: false, + index: 4, + hide: 0, + skipUrlSync: false, + state: LoadingState.Done, + error: null, + description: null, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + expect(rest).toEqual({ + label: 'Interval Label', + autoEnabled: false, + autoMinInterval: '10s', + autoStepCount: 30, + description: null, + refresh: 2, + intervals: ['1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d'], + hide: 0, + name: 'intervalVar', + skipUrlSync: false, + type: 'interval', + value: '1m', + }); + }); + + it('should migrate textbox variable', () => { + const variable: TextBoxVariableModel = { + id: 'query0', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'textboxVar', + label: 'Textbox Label', + description: 'Textbox Description', + type: 'textbox', + rootStateKey: 'N4XLmH5Vz', + current: {}, + hide: 0, + options: [], + query: 'defaultValue', + originalQuery: 'defaultValue', + skipUrlSync: false, + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + expect(rest).toEqual({ + description: 'Textbox Description', + hide: 0, + label: 'Textbox Label', + name: 'textboxVar', + skipUrlSync: false, + type: 'textbox', + value: 'defaultValue', + }); + }); + + it('should migrate adhoc variable', () => { + const variable: TypedVariableModel = { + id: 'adhoc', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'adhoc', + label: 'Adhoc Label', + description: 'Adhoc Description', + type: 'adhoc', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + hide: 0, + skipUrlSync: false, + }; + + const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable; + const filterVarState = migrated.state; + + expect(migrated).toBeInstanceOf(AdHocFiltersVariable); + expect(filterVarState).toEqual({ + key: expect.any(String), + description: 'Adhoc Description', + hide: 0, + label: 'Adhoc Label', + name: 'adhoc', + skipUrlSync: false, + type: 'adhoc', + filterExpression: 'filterTest="test"', + filters: [{ key: 'filterTest', operator: '=', value: 'test' }], + baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }], + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + applyMode: 'auto', + useQueriesAsFilterForOptions: true, + }); + }); + + it('should migrate adhoc variable with default keys', () => { + const variable: TypedVariableModel = { + id: 'adhoc', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'adhoc', + label: 'Adhoc Label', + description: 'Adhoc Description', + type: 'adhoc', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + defaultKeys: [ + { + text: 'some', + value: '1', + }, + { + text: 'static', + value: '2', + }, + { + text: 'keys', + value: '3', + }, + ], + hide: 0, + skipUrlSync: false, + }; + + const migrated = createSceneVariableFromVariableModel(variable) as AdHocFiltersVariable; + const filterVarState = migrated.state; + + expect(migrated).toBeInstanceOf(AdHocFiltersVariable); + expect(filterVarState).toEqual({ + key: expect.any(String), + description: 'Adhoc Description', + hide: 0, + label: 'Adhoc Label', + name: 'adhoc', + skipUrlSync: false, + type: 'adhoc', + filterExpression: 'filterTest="test"', + filters: [{ key: 'filterTest', operator: '=', value: 'test' }], + baseFilters: [{ key: 'baseFilterTest', operator: '=', value: 'test' }], + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + applyMode: 'auto', + defaultKeys: [ + { + text: 'some', + value: '1', + }, + { + text: 'static', + value: '2', + }, + { + text: 'keys', + value: '3', + }, + ], + useQueriesAsFilterForOptions: true, + }); + }); + + describe('when groupByVariable feature toggle is enabled', () => { + beforeAll(() => { + config.featureToggles.groupByVariable = true; + }); + + afterAll(() => { + config.featureToggles.groupByVariable = false; + }); + + it('should migrate groupby variable', () => { + const variable: GroupByVariableModel = { + id: 'groupby', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'groupby', + label: 'GroupBy Label', + description: 'GroupBy Description', + type: 'groupby', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + multi: true, + options: [ + { + selected: false, + text: 'Foo', + value: 'foo', + }, + { + selected: false, + text: 'Bar', + value: 'bar', + }, + ], + current: {}, + query: '', + hide: 0, + skipUrlSync: false, + }; + + const migrated = createSceneVariableFromVariableModel(variable) as GroupByVariable; + const groupbyVarState = migrated.state; + + expect(migrated).toBeInstanceOf(GroupByVariable); + expect(groupbyVarState).toEqual({ + key: expect.any(String), + description: 'GroupBy Description', + hide: 0, + defaultOptions: [ + { + selected: false, + text: 'Foo', + value: 'foo', + }, + { + selected: false, + text: 'Bar', + value: 'bar', + }, + ], + isMulti: true, + layout: 'horizontal', + noValueOnClear: true, + label: 'GroupBy Label', + name: 'groupby', + skipUrlSync: false, + type: 'groupby', + baseFilters: [], + options: [], + text: [], + value: [], + datasource: { uid: 'gdev-prometheus', type: 'prometheus' }, + applyMode: 'auto', + }); + }); + }); + + describe('when groupByVariable feature toggle is disabled', () => { + it('should not migrate groupby variable and throw an error instead', () => { + const variable: GroupByVariableModel = { + id: 'groupby', + global: false, + index: 0, + state: LoadingState.Done, + error: null, + name: 'groupby', + label: 'GroupBy Label', + description: 'GroupBy Description', + type: 'groupby', + rootStateKey: 'N4XLmH5Vz', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + multi: true, + options: [], + current: {}, + query: '', + hide: 0, + skipUrlSync: false, + }; + + expect(() => createSceneVariableFromVariableModel(variable)).toThrow('Scenes: Unsupported variable type'); + }); + }); + + it.each(['system'])('should throw for unsupported (yet) variables', (type) => { + const variable = { + name: 'query0', + type: type as VariableType, + }; + + expect(() => createSceneVariableFromVariableModel(variable as TypedVariableModel)).toThrow(); + }); + + it('should handle variable without current', () => { + // @ts-expect-error + const variable: TypedVariableModel = { + id: 'query1', + name: 'query1', + type: 'datasource', + global: false, + regex: '/^gdev/', + options: [], + query: 'prometheus', + multi: true, + includeAll: true, + refresh: 1, + allValue: 'Custom all', + }; + + const migrated = createSceneVariableFromVariableModel(variable); + const { key, ...rest } = migrated.state; + + expect(migrated).toBeInstanceOf(DataSourceVariable); + expect(rest).toEqual({ + allValue: 'Custom all', + defaultToAll: true, + includeAll: true, + label: undefined, + name: 'query1', + options: [], + pluginId: 'prometheus', + regex: '/^gdev/', + text: '', + type: 'datasource', + value: '', + isMulti: true, + }); + }); +}); + +describe('when creating snapshot variables from dashboard model', () => { + it('should create SnapshotVariables when required', () => { + const customVariable = { + current: { + selected: false, + text: 'a', + value: 'a', + }, + hide: 0, + includeAll: false, + multi: false, + name: 'custom0', + options: [], + query: 'a,b,c,d', + skipUrlSync: false, + type: 'custom' as VariableType, + rootStateKey: 'N4XLmH5Vz', + }; + + const intervalVariable = { + current: { + selected: false, + text: '10s', + value: '10s', + }, + hide: 0, + includeAll: false, + multi: false, + name: 'interval0', + options: [], + query: '10s,20s,30s', + skipUrlSync: false, + type: 'interval' as VariableType, + rootStateKey: 'N4XLmH5Vz', + }; + + const adHocVariable = { + global: false, + name: 'CoolFilters', + label: 'CoolFilters Label', + type: 'adhoc' as VariableType, + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + filters: [ + { + key: 'filterTest', + operator: '=', + value: 'test', + }, + ], + baseFilters: [ + { + key: 'baseFilterTest', + operator: '=', + value: 'test', + }, + ], + hide: 0, + index: 0, + }; + + const snapshot = { + ...defaultDashboard, + title: 'snapshot dash', + uid: 'test-uid', + time: { from: 'now-10h', to: 'now' }, + weekStart: 'saturday', + fiscalYearStartMonth: 2, + timezone: 'America/New_York', + timepicker: { + ...defaultTimePickerConfig, + hidden: true, + }, + links: [{ ...NEW_LINK, title: 'Link 1' }], + templating: { + list: [customVariable, adHocVariable, intervalVariable], + }, + }; + + const oldModel = new DashboardModel(snapshot, { isSnapshot: true }); + const variables = createVariablesForSnapshot(oldModel); + + // check variables were converted to snapshot variables + expect(variables).toBeInstanceOf(SceneVariableSet); + expect(variables.getByName('custom0')).toBeInstanceOf(SnapshotVariable); + expect(variables?.getByName('CoolFilters')).toBeInstanceOf(AdHocFiltersVariable); + expect(variables?.getByName('interval0')).toBeInstanceOf(SnapshotVariable); + // // custom snapshot + const customSnapshot = variables?.getByName('custom0') as SnapshotVariable; + expect(customSnapshot.state.value).toBe('a'); + expect(customSnapshot.state.text).toBe('a'); + expect(customSnapshot.state.isReadOnly).toBe(true); + // // adhoc snapshot + const adhocSnapshot = variables?.getByName('CoolFilters') as AdHocFiltersVariable; + expect(adhocSnapshot.state.filters).toEqual(adHocVariable.filters); + expect(adhocSnapshot.state.readOnly).toBe(true); + // + // // interval snapshot + const intervalSnapshot = variables?.getByName('interval0') as SnapshotVariable; + expect(intervalSnapshot.state.value).toBe('10s'); + expect(intervalSnapshot.state.text).toBe('10s'); + expect(intervalSnapshot.state.isReadOnly).toBe(true); + }); +}); diff --git a/public/app/features/dashboard-scene/utils/variables.ts b/public/app/features/dashboard-scene/utils/variables.ts new file mode 100644 index 00000000000..3eefdc84fc3 --- /dev/null +++ b/public/app/features/dashboard-scene/utils/variables.ts @@ -0,0 +1,238 @@ +import { TypedVariableModel } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + ConstantVariable, + CustomVariable, + DataSourceVariable, + GroupByVariable, + IntervalVariable, + QueryVariable, + SceneVariable, + SceneVariableSet, + TextBoxVariable, +} from '@grafana/scenes'; +import { DashboardModel } from 'app/features/dashboard/state'; + +import { SnapshotVariable } from '../serialization/custom-variables/SnapshotVariable'; + +import { getCurrentValueForOldIntervalModel, getIntervalsFromQueryString } from './utils'; + +export function createVariablesForDashboard(oldModel: DashboardModel) { + const variableObjects = oldModel.templating.list + .map((v) => { + try { + return createSceneVariableFromVariableModel(v); + } catch (err) { + console.error(err); + return null; + } + }) + // TODO: Remove filter + // Added temporarily to allow skipping non-compatible variables + .filter((v): v is SceneVariable => Boolean(v)); + + return new SceneVariableSet({ + variables: variableObjects, + }); +} + +export function createVariablesForSnapshot(oldModel: DashboardModel) { + const variableObjects = oldModel.templating.list + .map((v) => { + try { + // for adhoc we are using the AdHocFiltersVariable from scenes becuase of its complexity + if (v.type === 'adhoc') { + return new AdHocFiltersVariable({ + name: v.name, + label: v.label, + readOnly: true, + description: v.description, + skipUrlSync: v.skipUrlSync, + hide: v.hide, + datasource: v.datasource, + applyMode: 'auto', + filters: v.filters ?? [], + baseFilters: v.baseFilters ?? [], + defaultKeys: v.defaultKeys, + useQueriesAsFilterForOptions: true, + }); + } + // for other variable types we are using the SnapshotVariable + return createSnapshotVariable(v); + } catch (err) { + console.error(err); + return null; + } + }) + // TODO: Remove filter + // Added temporarily to allow skipping non-compatible variables + .filter((v): v is SceneVariable => Boolean(v)); + + return new SceneVariableSet({ + variables: variableObjects, + }); +} + +/** Snapshots variables are read-only and should not be updated */ +export function createSnapshotVariable(variable: TypedVariableModel): SceneVariable { + let snapshotVariable: SnapshotVariable; + let current: { value: string | string[]; text: string | string[] }; + if (variable.type === 'interval') { + const intervals = getIntervalsFromQueryString(variable.query); + const currentInterval = getCurrentValueForOldIntervalModel(variable, intervals); + snapshotVariable = new SnapshotVariable({ + name: variable.name, + label: variable.label, + description: variable.description, + value: currentInterval, + text: currentInterval, + hide: variable.hide, + }); + return snapshotVariable; + } + + if (variable.type === 'system' || variable.type === 'constant' || variable.type === 'adhoc') { + current = { + value: '', + text: '', + }; + } else { + current = { + value: variable.current?.value ?? '', + text: variable.current?.text ?? '', + }; + } + + snapshotVariable = new SnapshotVariable({ + name: variable.name, + label: variable.label, + description: variable.description, + value: current?.value ?? '', + text: current?.text ?? '', + hide: variable.hide, + }); + return snapshotVariable; +} + +export function createSceneVariableFromVariableModel(variable: TypedVariableModel): SceneVariable { + const commonProperties = { + name: variable.name, + label: variable.label, + description: variable.description, + }; + if (variable.type === 'adhoc') { + return new AdHocFiltersVariable({ + ...commonProperties, + description: variable.description, + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + datasource: variable.datasource, + applyMode: 'auto', + filters: variable.filters ?? [], + baseFilters: variable.baseFilters ?? [], + defaultKeys: variable.defaultKeys, + useQueriesAsFilterForOptions: true, + }); + } + if (variable.type === 'custom') { + return new CustomVariable({ + ...commonProperties, + value: variable.current?.value ?? '', + text: variable.current?.text ?? '', + + query: variable.query, + isMulti: variable.multi, + allValue: variable.allValue || undefined, + includeAll: variable.includeAll, + defaultToAll: Boolean(variable.includeAll), + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + }); + } else if (variable.type === 'query') { + return new QueryVariable({ + ...commonProperties, + value: variable.current?.value ?? '', + text: variable.current?.text ?? '', + + query: variable.query, + datasource: variable.datasource, + sort: variable.sort, + refresh: variable.refresh, + regex: variable.regex, + allValue: variable.allValue || undefined, + includeAll: variable.includeAll, + defaultToAll: Boolean(variable.includeAll), + isMulti: variable.multi, + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + definition: variable.definition, + }); + } else if (variable.type === 'datasource') { + return new DataSourceVariable({ + ...commonProperties, + value: variable.current?.value ?? '', + text: variable.current?.text ?? '', + regex: variable.regex, + pluginId: variable.query, + allValue: variable.allValue || undefined, + includeAll: variable.includeAll, + defaultToAll: Boolean(variable.includeAll), + skipUrlSync: variable.skipUrlSync, + isMulti: variable.multi, + hide: variable.hide, + }); + } else if (variable.type === 'interval') { + const intervals = getIntervalsFromQueryString(variable.query); + const currentInterval = getCurrentValueForOldIntervalModel(variable, intervals); + return new IntervalVariable({ + ...commonProperties, + value: currentInterval, + intervals: intervals, + autoEnabled: variable.auto, + autoStepCount: variable.auto_count, + autoMinInterval: variable.auto_min, + refresh: variable.refresh, + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + }); + } else if (variable.type === 'constant') { + return new ConstantVariable({ + ...commonProperties, + value: variable.query, + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + }); + } else if (variable.type === 'textbox') { + let val; + if (!variable?.current?.value) { + val = variable.query; + } else { + if (typeof variable.current.value === 'string') { + val = variable.current.value; + } else { + val = variable.current.value[0]; + } + } + + return new TextBoxVariable({ + ...commonProperties, + value: val, + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + }); + } else if (config.featureToggles.groupByVariable && variable.type === 'groupby') { + return new GroupByVariable({ + ...commonProperties, + datasource: variable.datasource, + value: variable.current?.value || [], + text: variable.current?.text || [], + skipUrlSync: variable.skipUrlSync, + hide: variable.hide, + // @ts-expect-error + defaultOptions: variable.options, + }); + } else { + throw new Error(`Scenes: Unsupported variable type ${variable.type}`); + } +} diff --git a/public/app/features/variables/guard.test.ts b/public/app/features/variables/guard.test.ts index cd65f21e621..7e57f8422ad 100644 --- a/public/app/features/variables/guard.test.ts +++ b/public/app/features/variables/guard.test.ts @@ -23,6 +23,7 @@ import { createIntervalVariable, createOrgVariable, createQueryVariable, + createSnapshotVariable, createTextBoxVariable, createUserVariable, } from './state/__tests__/fixtures'; @@ -163,18 +164,19 @@ describe('type guards', () => { type ExtraVariableTypes = 'org' | 'dashboard' | 'user'; // prettier-ignore const variableFactsObj: Record = { - query: { variable: createQueryVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, - adhoc: { variable: createAdhocVariable(), isMulti: false, hasOptions: false, hasCurrent: false }, - groupby: { variable: createGroupByVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, - constant: { variable: createConstantVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, - datasource: { variable: createDatasourceVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, - interval: { variable: createIntervalVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, - textbox: { variable: createTextBoxVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, - system: { variable: createUserVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, - user: { variable: createUserVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, - org: { variable: createOrgVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, - dashboard: { variable: createDashboardVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, - custom: { variable: createCustomVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, + query: { variable: createQueryVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, + adhoc: { variable: createAdhocVariable(), isMulti: false, hasOptions: false, hasCurrent: false }, + groupby: { variable: createGroupByVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, + constant: { variable: createConstantVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, + datasource: { variable: createDatasourceVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, + interval: { variable: createIntervalVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, + textbox: { variable: createTextBoxVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, + system: { variable: createUserVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, + user: { variable: createUserVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, + org: { variable: createOrgVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, + dashboard: { variable: createDashboardVariable(), isMulti: false, hasOptions: false, hasCurrent: true }, + custom: { variable: createCustomVariable(), isMulti: true, hasOptions: true, hasCurrent: true }, + snapshot: { variable: createSnapshotVariable(), isMulti: false, hasOptions: true, hasCurrent: true }, }; const variableFacts = Object.values(variableFactsObj); diff --git a/public/app/features/variables/state/__tests__/fixtures.ts b/public/app/features/variables/state/__tests__/fixtures.ts index d30052bc886..f72326cd79b 100644 --- a/public/app/features/variables/state/__tests__/fixtures.ts +++ b/public/app/features/variables/state/__tests__/fixtures.ts @@ -10,6 +10,7 @@ import { LoadingState, OrgVariableModel, QueryVariableModel, + SnapshotVariableModel, TextBoxVariableModel, UserVariableModel, VariableHide, @@ -198,3 +199,13 @@ export function createCustomVariable(input: Partial = {}): ...input, }; } + +export function createSnapshotVariable(input: Partial = {}): SnapshotVariableModel { + return { + ...createBaseVariableModel('snapshot'), + query: '', + current: createVariableOption('prom-prod', { text: 'Prometheus (main)', selected: true }), + options: [], + ...input, + }; +} diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index d66b2d7c07b..f843bdb5706 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -41,8 +41,8 @@ composableKinds: PanelCfg: { isFilterLabelActive?: _ onClickFilterString?: _ onClickFilterOutString?: _ - onClickShowField?: _ - onClickHideField?: _ + onClickShowField?: _ + onClickHideField?: _ displayedFields?: [...string] } @cuetsy(kind="interface") }