diff --git a/.betterer.results b/.betterer.results index b5d57820a1c..b05e872e997 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4554,6 +4554,9 @@ exports[`better eslint`] = { "public/app/features/sandbox/TestStuffPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/scenes/components/layout/SceneFlexLayout.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/panel/components/PanelRenderer.tsx b/public/app/features/panel/components/PanelRenderer.tsx index f05db6fac24..b841e210c00 100644 --- a/public/app/features/panel/components/PanelRenderer.tsx +++ b/public/app/features/panel/components/PanelRenderer.tsx @@ -38,7 +38,7 @@ export function PanelRenderer

(pr const [plugin, setPlugin] = useState(syncGetPanelPlugin(pluginId)); const [error, setError] = useState(); const optionsWithDefaults = useOptionDefaults(plugin, options, fieldConfig); - const dataWithOverrides = useFieldOverrides(plugin, optionsWithDefaults, data, timeZone); + const dataWithOverrides = useFieldOverrides(plugin, optionsWithDefaults?.fieldConfig, data, timeZone); useEffect(() => { // If we already have a plugin and it's correct one do nothing @@ -117,13 +117,12 @@ function useOptionDefaults

( }, [plugin, fieldConfig, options]); } -function useFieldOverrides( +export function useFieldOverrides( plugin: PanelPlugin | undefined, - defaultOptions: OptionDefaults | undefined, + fieldConfig: FieldConfigSource | undefined, data: PanelData | undefined, timeZone: string ): PanelData | undefined { - const fieldConfig = defaultOptions?.fieldConfig; const fieldConfigRegistry = plugin?.fieldConfigRegistry; const theme = useTheme2(); const structureRev = useRef(0); diff --git a/public/app/features/plugins/__mocks__/pluginMocks.ts b/public/app/features/plugins/__mocks__/pluginMocks.ts index 372e2f49c2e..b59d49777b3 100644 --- a/public/app/features/plugins/__mocks__/pluginMocks.ts +++ b/public/app/features/plugins/__mocks__/pluginMocks.ts @@ -63,7 +63,7 @@ export function getPanelPlugin( version: '', }, hideFromList: options.hideFromList === true, - module: '', + module: options.module ?? '', baseUrl: '', }; return plugin; diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx deleted file mode 100644 index 2ffb21bfd90..00000000000 --- a/public/app/features/scenes/components/VizPanel.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import AutoSizer from 'react-virtualized-auto-sizer'; - -import { AbsoluteTimeRange, FieldConfigSource, toUtc } from '@grafana/data'; -import { PanelRenderer } from '@grafana/runtime'; -import { Field, PanelChrome, Input } from '@grafana/ui'; - -import { SceneObjectBase } from '../core/SceneObjectBase'; -import { sceneGraph } from '../core/sceneGraph'; -import { SceneComponentProps, SceneLayoutChildState } from '../core/types'; -import { VariableDependencyConfig } from '../variables/VariableDependencyConfig'; - -import { SceneDragHandle } from './SceneDragHandle'; - -export interface VizPanelState extends SceneLayoutChildState { - title?: string; - pluginId: string; - options?: object; - fieldConfig?: FieldConfigSource; -} - -export class VizPanel extends SceneObjectBase { - public static Component = ScenePanelRenderer; - public static Editor = VizPanelEditor; - - protected _variableDependency = new VariableDependencyConfig(this, { - statePaths: ['title'], - }); - - public onSetTimeRange = (timeRange: AbsoluteTimeRange) => { - const sceneTimeRange = sceneGraph.getTimeRange(this); - sceneTimeRange.setState({ - raw: { - from: toUtc(timeRange.from), - to: toUtc(timeRange.to), - }, - from: toUtc(timeRange.from), - to: toUtc(timeRange.to), - }); - }; -} - -function ScenePanelRenderer({ model }: SceneComponentProps) { - const { title, pluginId, options, fieldConfig, ...state } = model.useState(); - const { data } = sceneGraph.getData(model).useState(); - - const layout = sceneGraph.getLayout(model); - const isDraggable = layout.state.isDraggable ? state.isDraggable : false; - const dragHandle = ; - - const titleInterpolated = sceneGraph.interpolate(model, title); - - return ( - - {({ width, height }) => { - if (width < 3 || height < 3) { - return null; - } - - return ( - - {(innerWidth, innerHeight) => ( - <> - {}} - onChangeTimeRange={model.onSetTimeRange} - /> - - )} - - ); - }} - - ); -} - -ScenePanelRenderer.displayName = 'ScenePanelRenderer'; - -function VizPanelEditor({ model }: SceneComponentProps) { - const { title } = model.useState(); - - return ( - - model.setState({ title: evt.currentTarget.value })} /> - - ); -} diff --git a/public/app/features/scenes/components/VizPanel/VizPanel.test.tsx b/public/app/features/scenes/components/VizPanel/VizPanel.test.tsx new file mode 100644 index 00000000000..cdbe87edff6 --- /dev/null +++ b/public/app/features/scenes/components/VizPanel/VizPanel.test.tsx @@ -0,0 +1,136 @@ +import React from 'react'; + +import { FieldConfigProperty, PanelPlugin } from '@grafana/data'; +import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; + +import { VizPanel } from './VizPanel'; + +let pluginToLoad: PanelPlugin | undefined; + +jest.mock('app/features/plugins/importPanelPlugin', () => ({ + syncGetPanelPlugin: jest.fn(() => pluginToLoad), +})); + +interface OptionsPlugin1 { + showThresholds: boolean; + option2?: string; +} + +interface FieldConfigPlugin1 { + customProp?: boolean; + customProp2?: boolean; + junkProp?: boolean; +} + +function getTestPlugin1() { + const pluginToLoad = getPanelPlugin( + { + id: 'custom-plugin-id', + }, + () =>

My custom panel
+ ); + + pluginToLoad.meta.info.version = '1.0.0'; + pluginToLoad.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds', + path: 'showThresholds', + defaultValue: true, + }); + builder.addTextInput({ + name: 'option2', + path: 'option2', + defaultValue: undefined, + }); + }); + + pluginToLoad.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Unit]: { + defaultValue: 'flop', + }, + [FieldConfigProperty.Decimals]: { + defaultValue: 2, + }, + }, + useCustomConfig: (builder) => { + builder.addBooleanSwitch({ + name: 'CustomProp', + path: 'customProp', + defaultValue: false, + }); + builder.addBooleanSwitch({ + name: 'customProp2', + path: 'customProp2', + defaultValue: false, + }); + }, + }); + + pluginToLoad.setMigrationHandler((panel) => { + if (panel.fieldConfig.defaults.custom) { + panel.fieldConfig.defaults.custom.customProp2 = true; + } + + return { option2: 'hello' }; + }); + + return pluginToLoad; +} + +describe('VizPanel', () => { + describe('when activated', () => { + let panel: VizPanel; + + beforeAll(async () => { + panel = new VizPanel({ + pluginId: 'custom-plugin-id', + fieldConfig: { + defaults: { custom: { junkProp: true } }, + overrides: [], + }, + }); + + pluginToLoad = getTestPlugin1(); + panel.activate(); + }); + + it('load plugin', () => { + expect(panel.getPlugin()).toBe(pluginToLoad); + }); + + it('should call panel migration handler', () => { + expect(panel.state.options.option2).toEqual('hello'); + expect(panel.state.fieldConfig.defaults.custom?.customProp2).toEqual(true); + }); + + it('should apply option defaults', () => { + expect(panel.state.options.showThresholds).toEqual(true); + }); + + it('should apply fieldConfig defaults', () => { + expect(panel.state.fieldConfig.defaults.unit).toBe('flop'); + expect(panel.state.fieldConfig.defaults.custom!.customProp).toBe(false); + }); + + it('should should remove props that are not defined for plugin', () => { + expect(panel.state.fieldConfig.defaults.custom?.junkProp).toEqual(undefined); + }); + }); + + describe('When calling on onPanelMigration', () => { + const onPanelMigration = jest.fn(); + let panel: VizPanel; + + beforeAll(async () => { + panel = new VizPanel({ pluginId: 'custom-plugin-id' }); + pluginToLoad = getTestPlugin1(); + pluginToLoad.onPanelMigration = onPanelMigration; + panel.activate(); + }); + + it('should call onPanelMigration with pluginVersion set to initial state (undefined)', () => { + expect(onPanelMigration.mock.calls[0][0].pluginVersion).toBe(undefined); + }); + }); +}); diff --git a/public/app/features/scenes/components/VizPanel/VizPanel.tsx b/public/app/features/scenes/components/VizPanel/VizPanel.tsx new file mode 100644 index 00000000000..c468b27709c --- /dev/null +++ b/public/app/features/scenes/components/VizPanel/VizPanel.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import { AbsoluteTimeRange, FieldConfigSource, PanelModel, PanelPlugin, toUtc } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Field, Input } from '@grafana/ui'; +import { importPanelPlugin, syncGetPanelPlugin } from 'app/features/plugins/importPanelPlugin'; + +import { getPanelOptionsWithDefaults } from '../../../dashboard/state/getPanelOptionsWithDefaults'; +import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { sceneGraph } from '../../core/sceneGraph'; +import { SceneComponentProps, SceneLayoutChildState } from '../../core/types'; + +import { VizPanelRenderer } from './VizPanelRenderer'; + +export interface VizPanelState extends SceneLayoutChildState { + title: string; + pluginId: string; + options: TOptions; + fieldConfig: FieldConfigSource; + pluginVersion?: string; + // internal state + pluginLoadError?: string; +} + +export class VizPanel extends SceneObjectBase< + VizPanelState, TFieldConfig> +> { + public static Component = VizPanelRenderer; + public static Editor = VizPanelEditor; + + // Not part of state as this is not serializable + private _plugin?: PanelPlugin; + + public constructor(state: Partial, TFieldConfig>>) { + super({ + options: {}, + fieldConfig: { defaults: {}, overrides: [] }, + title: 'Title', + pluginId: 'timeseries', + ...state, + }); + } + + public activate() { + super.activate(); + + const plugin = syncGetPanelPlugin(this.state.pluginId); + + if (plugin) { + this.pluginLoaded(plugin); + } else { + importPanelPlugin(this.state.pluginId) + .then((result) => this.pluginLoaded(result)) + .catch((err: Error) => { + this.setState({ pluginLoadError: err.message }); + }); + } + } + + private pluginLoaded(plugin: PanelPlugin) { + const { options, fieldConfig, title, pluginId, pluginVersion } = this.state; + + const panel: PanelModel = { title, options, fieldConfig, id: 1, type: pluginId, pluginVersion: pluginVersion }; + const currentVersion = this.getPluginVersion(plugin); + + if (plugin.onPanelMigration) { + if (currentVersion !== this.state.pluginVersion) { + // These migration handlers also mutate panel.fieldConfig to migrate fieldConfig + panel.options = plugin.onPanelMigration(panel); + } + } + + const withDefaults = getPanelOptionsWithDefaults({ + plugin, + currentOptions: panel.options, + currentFieldConfig: panel.fieldConfig, + isAfterPluginChange: false, + }); + + this._plugin = plugin; + this.setState({ + options: withDefaults.options, + fieldConfig: withDefaults.fieldConfig, + pluginVersion: currentVersion, + }); + } + + private getPluginVersion(plugin: PanelPlugin): string { + return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version; + } + + public getPlugin(): PanelPlugin | undefined { + return this._plugin; + } + + public onChangeTimeRange = (timeRange: AbsoluteTimeRange) => { + const sceneTimeRange = sceneGraph.getTimeRange(this); + sceneTimeRange.setState({ + raw: { + from: toUtc(timeRange.from), + to: toUtc(timeRange.to), + }, + from: toUtc(timeRange.from), + to: toUtc(timeRange.to), + }); + }; + + public onOptionsChange = (options: TOptions) => { + this.setState({ options }); + }; + + public onFieldConfigChange = (fieldConfig: FieldConfigSource) => { + this.setState({ fieldConfig }); + }; +} + +function VizPanelEditor({ model }: SceneComponentProps) { + const { title } = model.useState(); + + return ( + + model.setState({ title: evt.currentTarget.value })} /> + + ); +} diff --git a/public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx b/public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx new file mode 100644 index 00000000000..a120f3fda44 --- /dev/null +++ b/public/app/features/scenes/components/VizPanel/VizPanelRenderer.tsx @@ -0,0 +1,94 @@ +import React, { RefCallback } from 'react'; +import { useMeasure } from 'react-use'; + +import { PluginContextProvider } from '@grafana/data'; +import { PanelChrome, ErrorBoundaryAlert } from '@grafana/ui'; +import { appEvents } from 'app/core/core'; +import { useFieldOverrides } from 'app/features/panel/components/PanelRenderer'; + +import { sceneGraph } from '../../core/sceneGraph'; +import { SceneComponentProps } from '../../core/types'; +import { SceneQueryRunner } from '../../querying/SceneQueryRunner'; +import { SceneDragHandle } from '../SceneDragHandle'; + +import { VizPanel } from './VizPanel'; + +export function VizPanelRenderer({ model }: SceneComponentProps) { + const { title, options, fieldConfig, pluginId, pluginLoadError, $data, ...state } = model.useState(); + const [ref, { width, height }] = useMeasure(); + const plugin = model.getPlugin(); + const { data } = sceneGraph.getData(model).useState(); + const layout = sceneGraph.getLayout(model); + + const isDraggable = layout.state.isDraggable ? state.isDraggable : false; + const dragHandle = ; + + const titleInterpolated = sceneGraph.interpolate(model, title); + + // Not sure we need to subscribe to this state + const timeZone = sceneGraph.getTimeRange(model).state.timeZone; + + const dataWithOverrides = useFieldOverrides(plugin, fieldConfig, data, timeZone); + + if (pluginLoadError) { + return
Failed to load plugin: {pluginLoadError}
; + } + + if (!plugin || !plugin.hasPluginId(pluginId)) { + return
Loading plugin panel...
; + } + + if (!plugin.panel) { + return
Panel plugin has no panel component
; + } + + const PanelComponent = plugin.panel; + + // Query runner needs to with for auto maxDataPoints + if ($data instanceof SceneQueryRunner) { + $data.setContainerWidth(width); + } + + return ( +
} style={{ width: '100%', height: '100%' }}> + + {(innerWidth, innerHeight) => ( + <> + {!dataWithOverrides &&
No data...
} + {dataWithOverrides && ( + + + str} + onOptionsChange={model.onOptionsChange} + onFieldConfigChange={model.onFieldConfigChange} + onChangeTimeRange={model.onChangeTimeRange} + eventBus={appEvents} + /> + + + )} + + )} +
+
+ ); +} + +VizPanelRenderer.displayName = 'ScenePanelRenderer'; diff --git a/public/app/features/scenes/components/index.ts b/public/app/features/scenes/components/index.ts new file mode 100644 index 00000000000..89aafc6064f --- /dev/null +++ b/public/app/features/scenes/components/index.ts @@ -0,0 +1,10 @@ +export { VizPanel } from './VizPanel/VizPanel'; +export { NestedScene } from './NestedScene'; +export { Scene } from './Scene'; +export { SceneCanvasText } from './SceneCanvasText'; +export { SceneToolbarButton, SceneToolbarInput } from './SceneToolbarButton'; +export { SceneTimePicker } from './SceneTimePicker'; +export { ScenePanelRepeater } from './ScenePanelRepeater'; +export { SceneSubMenu } from './SceneSubMenu'; +export { SceneFlexLayout } from './layout/SceneFlexLayout'; +export { SceneGridLayout, SceneGridRow } from './layout/SceneGridLayout'; diff --git a/public/app/features/scenes/core/SceneTimeRange.tsx b/public/app/features/scenes/core/SceneTimeRange.tsx index 596b2337637..c790d5f739c 100644 --- a/public/app/features/scenes/core/SceneTimeRange.tsx +++ b/public/app/features/scenes/core/SceneTimeRange.tsx @@ -1,9 +1,17 @@ -import { TimeRange, UrlQueryMap } from '@grafana/data'; +import { getDefaultTimeRange, getTimeZone, TimeRange, UrlQueryMap } from '@grafana/data'; import { SceneObjectBase } from './SceneObjectBase'; import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types'; export class SceneTimeRange extends SceneObjectBase implements SceneObjectWithUrlSync { + public constructor(state: Partial = {}) { + super({ + ...getDefaultTimeRange(), + timeZone: getTimeZone(), + ...state, + }); + } + public onTimeRangeChange = (timeRange: TimeRange) => { this.setState(timeRange); }; diff --git a/public/app/features/scenes/core/sceneGraph.ts b/public/app/features/scenes/core/sceneGraph.ts index 6cd7e4606d4..0cee433477e 100644 --- a/public/app/features/scenes/core/sceneGraph.ts +++ b/public/app/features/scenes/core/sceneGraph.ts @@ -108,7 +108,7 @@ export const EmptyDataNode = new SceneDataNode({ }, }); -export const DefaultTimeRange = new SceneTimeRangeImpl(getDefaultTimeRange()); +export const DefaultTimeRange = new SceneTimeRangeImpl(); export const sceneGraph = { getVariables, diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index d818e5bba4b..3363da75d0d 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -1,7 +1,7 @@ import React from 'react'; import { Observer, Subscription, Unsubscribable } from 'rxjs'; -import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, UrlQueryMap } from '@grafana/data'; +import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, TimeZone, UrlQueryMap } from '@grafana/data'; import { SceneVariableDependencyConfigLike, SceneVariables } from '../variables/types'; @@ -128,7 +128,9 @@ interface SceneComponentEditWrapperProps { children: React.ReactNode; } -export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange {} +export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange { + timeZone: TimeZone; +} export interface SceneTimeRange extends SceneObject { onTimeRangeChange(timeRange: TimeRange): void; diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts index 7d56b5a78d5..94197ca42d3 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -1,12 +1,9 @@ -import { getDefaultTimeRange } from '@grafana/data'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { DashboardDTO } from 'app/types'; -import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; -import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { VizPanel, SceneTimePicker, SceneGridLayout, SceneGridRow } from '../components'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneObject } from '../core/types'; import { SceneQueryRunner } from '../querying/SceneQueryRunner'; @@ -54,7 +51,7 @@ export class DashboardLoader extends StateManagerBase { layout: new SceneGridLayout({ children: this.buildSceneObjectsFromDashboard(oldModel), }), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), actions: [new SceneTimePicker({})], }); @@ -83,26 +80,7 @@ export class DashboardLoader extends StateManagerBase { size: { y: panel.gridPos.y, }, - children: panel.panels - ? panel.panels.map( - (p) => - new VizPanel({ - title: p.title, - pluginId: p.type, - size: { - x: p.gridPos.x, - y: p.gridPos.y, - width: p.gridPos.w, - height: p.gridPos.h, - }, - options: p.options, - fieldConfig: p.fieldConfig, - $data: new SceneQueryRunner({ - queries: p.targets, - }), - }) - ) - : [], + children: panel.panels ? panel.panels.map(createVizPanelFromPanelModel) : [], }) ); } else { @@ -128,21 +106,7 @@ export class DashboardLoader extends StateManagerBase { } } } else { - const panelObject = new VizPanel({ - title: panel.title, - pluginId: panel.type, - size: { - x: panel.gridPos.x, - y: panel.gridPos.y, - width: panel.gridPos.w, - height: panel.gridPos.h, - }, - options: panel.options, - fieldConfig: panel.fieldConfig, - $data: new SceneQueryRunner({ - queries: panel.targets, - }), - }); + const panelObject = createVizPanelFromPanelModel(panel); // when processing an expanded row, collect its panels if (currentRow) { @@ -170,6 +134,25 @@ export class DashboardLoader extends StateManagerBase { } } +function createVizPanelFromPanelModel(panel: PanelModel) { + return new VizPanel({ + title: panel.title, + pluginId: panel.type, + size: { + x: panel.gridPos.x, + y: panel.gridPos.y, + width: panel.gridPos.w, + height: panel.gridPos.h, + }, + options: panel.options, + fieldConfig: panel.fieldConfig, + pluginVersion: panel.pluginVersion, + $data: new SceneQueryRunner({ + queries: panel.targets, + }), + }); +} + let loader: DashboardLoader | null = null; export function getDashboardLoader(): DashboardLoader { diff --git a/public/app/features/scenes/querying/SceneQueryRunner.test.ts b/public/app/features/scenes/querying/SceneQueryRunner.test.ts new file mode 100644 index 00000000000..2e3de13a862 --- /dev/null +++ b/public/app/features/scenes/querying/SceneQueryRunner.test.ts @@ -0,0 +1,83 @@ +import { of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, getDefaultTimeRange, LoadingState, PanelData } from '@grafana/data'; + +import { SceneTimeRange } from '../core/SceneTimeRange'; + +import { SceneQueryRunner } from './SceneQueryRunner'; + +const getDatasource = () => { + return { + getRef: () => ({ uid: 'test' }), + }; +}; + +jest.mock('app/features/plugins/datasource_srv', () => ({ + getDatasourceSrv: jest.fn(() => ({ + get: getDatasource, + })), +})); + +const runRequest = jest.fn().mockReturnValue( + of({ + state: LoadingState.Done, + series: [], + timeRange: getDefaultTimeRange(), + }) +); + +let sentRequest: DataQueryRequest | undefined; + +jest.mock('app/features/query/state/runRequest', () => ({ + runRequest: (ds: DataSourceApi, request: DataQueryRequest) => { + sentRequest = request; + return runRequest(ds, request); + }, +})); + +describe('SceneQueryRunner', () => { + describe('when activated and got no data', () => { + it('should run queries', async () => { + const queryRunner = new SceneQueryRunner({ + queries: [{ refId: 'A' }], + $timeRange: new SceneTimeRange(), + }); + + expect(queryRunner.state.data).toBeUndefined(); + + queryRunner.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(queryRunner.state.data?.state).toBe(LoadingState.Done); + // Default max data points + expect(sentRequest?.maxDataPoints).toBe(500); + }); + }); + + describe('when activated and maxDataPointsFromWidth set to true', () => { + it('should run queries', async () => { + const queryRunner = new SceneQueryRunner({ + queries: [{ refId: 'A' }], + $timeRange: new SceneTimeRange(), + maxDataPointsFromWidth: true, + }); + + expect(queryRunner.state.data).toBeUndefined(); + + queryRunner.activate(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(queryRunner.state.data?.state).toBeUndefined(); + + queryRunner.setContainerWidth(1000); + + expect(queryRunner.state.data?.state).toBeUndefined(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(queryRunner.state.data?.state).toBe(LoadingState.Done); + }); + }); +}); diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts index ba9312f6c43..bd93f611f42 100644 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -24,6 +24,11 @@ import { VariableDependencyConfig } from '../variables/VariableDependencyConfig' export interface QueryRunnerState extends SceneObjectStatePlain { data?: PanelData; queries: DataQueryExtended[]; + datasource?: DataSourceRef; + minInterval?: string; + maxDataPoints?: number; + // Non persisted state + maxDataPointsFromWidth?: boolean; } export interface DataQueryExtended extends DataQuery { @@ -31,7 +36,8 @@ export interface DataQueryExtended extends DataQuery { } export class SceneQueryRunner extends SceneObjectBase { - private querySub?: Unsubscribable; + private _querySub?: Unsubscribable; + private _containerWidth?: number; protected _variableDependency = new VariableDependencyConfig(this, { statePaths: ['queries'], @@ -51,17 +57,52 @@ export class SceneQueryRunner extends SceneObjectBase { }) ); - if (!this.state.data) { + if (this.shouldRunQueriesOnActivate()) { this.runQueries(); } } + private shouldRunQueriesOnActivate() { + // If we already have data, no need + // TODO validate that time range is similar and if not we should run queries again + if (this.state.data) { + return false; + } + + // If no maxDataPoints specified we need might to wait for container width to be set from the outside + if (!this.state.maxDataPoints && this.state.maxDataPointsFromWidth && !this._containerWidth) { + return false; + } + + return true; + } + public deactivate(): void { super.deactivate(); - if (this.querySub) { - this.querySub.unsubscribe(); - this.querySub = undefined; + if (this._querySub) { + this._querySub.unsubscribe(); + this._querySub = undefined; + } + } + + public setContainerWidth(width: number) { + // If we don't have a width we should run queries + if (!this._containerWidth) { + this._containerWidth = width; + + // If we don't have maxDataPoints specifically set and maxDataPointsFromWidth is true + if (this.state.maxDataPointsFromWidth && !this.state.maxDataPoints) { + // As this is called from render path we need to wait for next tick before running queries + setTimeout(() => { + if (this.isActive && !this._querySub) { + this.runQueries(); + } + }, 0); + } + } else { + // let's just remember the width until next query issue + this._containerWidth = width; } } @@ -70,8 +111,12 @@ export class SceneQueryRunner extends SceneObjectBase { this.runWithTimeRange(timeRange.state); } + private getMaxDataPoints() { + return this.state.maxDataPoints ?? this._containerWidth ?? 500; + } + private async runWithTimeRange(timeRange: TimeRange) { - const queries = cloneDeep(this.state.queries); + const { datasource, minInterval, queries } = this.state; const request: DataQueryRequest = { app: CoreApp.Dashboard, @@ -82,14 +127,14 @@ export class SceneQueryRunner extends SceneObjectBase { range: timeRange, interval: '1s', intervalMs: 1000, - targets: cloneDeep(this.state.queries), - maxDataPoints: 500, + targets: cloneDeep(queries), + maxDataPoints: this.getMaxDataPoints(), scopedVars: {}, startTime: Date.now(), }; try { - const ds = await getDataSource(queries[0].datasource!, request.scopedVars); + const ds = await getDataSource(datasource, request.scopedVars); // Attach the data source name to each query request.targets = request.targets.map((query) => { @@ -99,8 +144,9 @@ export class SceneQueryRunner extends SceneObjectBase { return query; }); - const lowerIntervalLimit = ds.interval; - const norm = rangeUtil.calculateInterval(timeRange, request.maxDataPoints ?? 1000, lowerIntervalLimit); + // TODO interpolate minInterval + const lowerIntervalLimit = minInterval ? minInterval : ds.interval; + const norm = rangeUtil.calculateInterval(timeRange, request.maxDataPoints!, lowerIntervalLimit); // make shallow copy of scoped vars, // and add built in variables interval and interval_ms @@ -112,22 +158,20 @@ export class SceneQueryRunner extends SceneObjectBase { request.interval = norm.interval; request.intervalMs = norm.intervalMs; - this.querySub = runRequest(ds, request).subscribe({ - next: (data) => { - console.log('set data', data, data.state); - this.setState({ data }); - }, + this._querySub = runRequest(ds, request).subscribe({ + next: this.onDataReceived, }); } catch (err) { console.error('PanelQueryRunner Error', err); } } + + private onDataReceived = (data: PanelData) => { + this.setState({ data }); + }; } -async function getDataSource( - datasource: DataSourceRef | string | DataSourceApi | null, - scopedVars: ScopedVars -): Promise { +async function getDataSource(datasource: DataSourceRef | undefined, scopedVars: ScopedVars): Promise { if (datasource && (datasource as any).query) { return datasource as DataSourceApi; } diff --git a/public/app/features/scenes/scenes/demo.tsx b/public/app/features/scenes/scenes/demo.tsx index a4e96b97a70..bbc6bbc09f2 100644 --- a/public/app/features/scenes/scenes/demo.tsx +++ b/public/app/features/scenes/scenes/demo.tsx @@ -1,12 +1,12 @@ -import { getDefaultTimeRange } from '@grafana/data'; - -import { Scene } from '../components/Scene'; -import { SceneCanvasText } from '../components/SceneCanvasText'; -import { ScenePanelRepeater } from '../components/ScenePanelRepeater'; -import { SceneTimePicker } from '../components/SceneTimePicker'; -import { SceneToolbarInput } from '../components/SceneToolbarButton'; -import { VizPanel } from '../components/VizPanel'; -import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; +import { + Scene, + SceneCanvasText, + ScenePanelRepeater, + SceneTimePicker, + SceneToolbarInput, + SceneFlexLayout, + VizPanel, +} from '../components'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -22,8 +22,8 @@ export function getFlexLayoutTest(): Scene { size: { minWidth: '70%' }, pluginId: 'timeseries', title: 'Dynamic height and width', + $data: getQueryRunnerWithRandomWalkQuery({}, { maxDataPointsFromWidth: true }), }), - new SceneFlexLayout({ direction: 'column', children: [ @@ -51,7 +51,7 @@ export function getFlexLayoutTest(): Scene { ], }), $editor: new SceneEditManager({}), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); @@ -97,7 +97,7 @@ export function getScenePanelRepeaterTest(): Scene { }), }), $editor: new SceneEditManager({}), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: queryRunner, actions: [ new SceneToolbarInput({ diff --git a/public/app/features/scenes/scenes/grid.tsx b/public/app/features/scenes/scenes/grid.tsx index 4cf629fade4..3516ff8969c 100644 --- a/public/app/features/scenes/scenes/grid.tsx +++ b/public/app/features/scenes/scenes/grid.tsx @@ -1,8 +1,6 @@ -import { getDefaultTimeRange } from '@grafana/data'; - +import { VizPanel } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneGridLayout } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; @@ -57,7 +55,7 @@ export function getGridLayoutTest(): Scene { ], }), $editor: new SceneEditManager({}), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx index cb730ce1dbb..8ed865aa812 100644 --- a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx +++ b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx @@ -1,8 +1,8 @@ -import { dateTime, getDefaultTimeRange } from '@grafana/data'; +import { dateTime } from '@grafana/data'; +import { VizPanel } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -10,7 +10,7 @@ import { SceneEditManager } from '../editor/SceneEditManager'; import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getGridWithMultipleTimeRanges(): Scene { - const globalTimeRange = new SceneTimeRange(getDefaultTimeRange()); + const globalTimeRange = new SceneTimeRange(); const now = dateTime(); const row1TimeRange = new SceneTimeRange({ diff --git a/public/app/features/scenes/scenes/gridMultiple.tsx b/public/app/features/scenes/scenes/gridMultiple.tsx index 087081e576b..b16e2eb6bde 100644 --- a/public/app/features/scenes/scenes/gridMultiple.tsx +++ b/public/app/features/scenes/scenes/gridMultiple.tsx @@ -1,8 +1,6 @@ -import { getDefaultTimeRange } from '@grafana/data'; - +import { VizPanel } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneGridLayout } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; @@ -101,7 +99,7 @@ export function getMultipleGridLayoutTest(): Scene { }), $editor: new SceneEditManager({}), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx index f4752793079..43960d9b6da 100644 --- a/public/app/features/scenes/scenes/gridWithMultipleData.tsx +++ b/public/app/features/scenes/scenes/gridWithMultipleData.tsx @@ -1,8 +1,6 @@ -import { getDefaultTimeRange } from '@grafana/data'; - +import { VizPanel } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -15,7 +13,7 @@ export function getGridWithMultipleData(): Scene { layout: new SceneGridLayout({ children: [ new SceneGridRow({ - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery({ scenarioId: 'random_walk_table' }), title: 'Row A - has its own query', key: 'Row A', @@ -95,7 +93,7 @@ export function getGridWithMultipleData(): Scene { ], }), $editor: new SceneEditManager({}), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridWithRow.tsx b/public/app/features/scenes/scenes/gridWithRow.tsx index 38dadce9cf1..85826d52b5b 100644 --- a/public/app/features/scenes/scenes/gridWithRow.tsx +++ b/public/app/features/scenes/scenes/gridWithRow.tsx @@ -1,8 +1,6 @@ -import { getDefaultTimeRange } from '@grafana/data'; - +import { VizPanel } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -78,7 +76,7 @@ export function getGridWithRowLayoutTest(): Scene { ], }), $editor: new SceneEditManager({}), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/gridWithRows.tsx b/public/app/features/scenes/scenes/gridWithRows.tsx index 1f47a22e708..1aef68fb5d4 100644 --- a/public/app/features/scenes/scenes/gridWithRows.tsx +++ b/public/app/features/scenes/scenes/gridWithRows.tsx @@ -1,8 +1,6 @@ -import { getDefaultTimeRange } from '@grafana/data'; - +import { VizPanel } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; @@ -83,7 +81,7 @@ export function getGridWithRowsTest(): Scene { children: [cell1, cell2, row1, row2], }), $editor: new SceneEditManager({}), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx index cd4e60889fa..728346f4138 100644 --- a/public/app/features/scenes/scenes/nested.tsx +++ b/public/app/features/scenes/scenes/nested.tsx @@ -1,9 +1,7 @@ -import { getDefaultTimeRange } from '@grafana/data'; - +import { VizPanel } from '../components'; import { NestedScene } from '../components/NestedScene'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { VizPanel } from '../components/VizPanel'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; @@ -23,7 +21,7 @@ export function getNestedScene(): Scene { }), ], }), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); @@ -46,7 +44,7 @@ export function getInnerScene(title: string) { }), ], }), - $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $timeRange: new SceneTimeRange(), $data: getQueryRunnerWithRandomWalkQuery(), actions: [new SceneTimePicker({})], }); diff --git a/public/app/features/scenes/scenes/queries.ts b/public/app/features/scenes/scenes/queries.ts index 66f411c8b74..2aab365b2ad 100644 --- a/public/app/features/scenes/scenes/queries.ts +++ b/public/app/features/scenes/scenes/queries.ts @@ -1,8 +1,11 @@ import { TestDataQuery } from 'app/plugins/datasource/testdata/types'; -import { SceneQueryRunner } from '../querying/SceneQueryRunner'; +import { QueryRunnerState, SceneQueryRunner } from '../querying/SceneQueryRunner'; -export function getQueryRunnerWithRandomWalkQuery(overrides?: Partial) { +export function getQueryRunnerWithRandomWalkQuery( + overrides?: Partial, + queryRunnerOverrides?: Partial +) { return new SceneQueryRunner({ queries: [ { @@ -15,5 +18,6 @@ export function getQueryRunnerWithRandomWalkQuery(overrides?: Partial