DashboardScene: Data source and query options edit for a panel (#79267)

* Scenes bump

* Refactor query group components so that they are reusable

* Basic queries rendering and datasource change

* Store last used ds in local storage

* maxDataPoints and minInterval queries options

* Progress on query options

* Query options tests

* Allow panel inspection / queries inspection from panel edit

* Add counters to data pane tabs

* Betterer update

* Handle data source changes

* Minor fixes

* Data source change tests

* Handle scenario of data source change when transformations are in place

* Review comment

* Fix test
This commit is contained in:
Dominik Prokop 2023-12-19 14:51:19 +01:00 committed by GitHub
parent 96b5b10e20
commit 73eab8fcd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1818 additions and 193 deletions

View File

@ -2438,6 +2438,20 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
],
"public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/features/dashboard-scene/scene/DashboardScene.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@ -4176,14 +4190,14 @@ exports[`better eslint`] = {
],
"public/app/features/query/components/QueryGroup.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"]
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "8"]
],
"public/app/features/query/components/QueryGroupOptions.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]

View File

@ -255,7 +255,7 @@
"@grafana/lezer-traceql": "0.0.12",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "1.27.0",
"@grafana/scenes": "1.28.0",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@kusto/monaco-kusto": "^7.4.0",

View File

@ -3,16 +3,32 @@ import React from 'react';
import { IconName } from '@grafana/data';
import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
import { VizPanelManager } from '../VizPanelManager';
import { PanelDataPaneTabState, PanelDataPaneTab } from './types';
export class PanelDataAlertingTab extends SceneObjectBase<PanelDataPaneTabState> implements PanelDataPaneTab {
static Component = PanelDataAlertingTabRendered;
tabId = 'alert';
icon: IconName = 'bell';
private _panelManager: VizPanelManager;
constructor(panelManager: VizPanelManager) {
super({});
this._panelManager = panelManager;
}
getTabLabel() {
return 'Alert';
}
getItemsCount() {
return 0;
}
get panelManager() {
return this._panelManager;
}
}
function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDataAlertingTab>) {

View File

@ -1,24 +1,29 @@
import React from 'react';
import { Unsubscribable } from 'rxjs';
import {
SceneComponentProps,
SceneDataTransformer,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
SceneQueryRunner,
VizPanel,
sceneGraph,
} from '@grafana/scenes';
import { Tab, TabContent, TabsBar } from '@grafana/ui';
import { shouldShowAlertingTab } from 'app/features/dashboard/components/PanelEditor/state/selectors';
import { ShareQueryDataProvider } from '../../scene/ShareQueryDataProvider';
import { VizPanelManager } from '../VizPanelManager';
import { PanelDataAlertingTab } from './PanelDataAlertingTab';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { PanelDataPaneTab } from './types';
export interface PanelDataPaneState extends SceneObjectState {
panelRef: SceneObjectRef<VizPanel>;
tabs?: PanelDataPaneTab[];
tab?: string;
}
@ -26,6 +31,9 @@ export interface PanelDataPaneState extends SceneObjectState {
export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
static Component = PanelDataPaneRendered;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab'] });
private _initialTabsBuilt = false;
private panelSubscription: Unsubscribable | undefined;
public panelManager: VizPanelManager;
getUrlState() {
return {
@ -42,36 +50,81 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
}
}
constructor(state: Omit<PanelDataPaneState, 'tab'> & { tab?: string }) {
constructor(panelMgr: VizPanelManager) {
super({
tab: 'queries',
...state,
});
const { panelRef } = this.state;
const panel = panelRef.resolve();
this.panelManager = panelMgr;
this.addActivationHandler(() => this.onActivate());
}
if (panel) {
// The subscription below is needed because the plugin may not be loaded when this pane is mounted.
// This can happen i.e. when the user opens the panel editor directly via an URL.
this._subs.add(
panel.subscribeToState((n, p) => {
if (n.pluginVersion || p.pluginId !== n.pluginId) {
this.buildTabs();
}
})
);
private onActivate() {
const panel = this.panelManager.state.panel;
this.setupPanelSubscription(panel);
this.buildTabs();
this._subs.add(
// Setup subscription for the case when panel type changed
this.panelManager.subscribeToState((n, p) => {
if (n.panel !== p.panel) {
this.buildTabs();
this.setupPanelSubscription(n.panel);
}
})
);
return () => {
if (this.panelSubscription) {
this.panelSubscription.unsubscribe();
this.panelSubscription = undefined;
}
};
}
private setupPanelSubscription(panel: VizPanel) {
if (this.panelSubscription) {
this._initialTabsBuilt = false;
this.panelSubscription.unsubscribe();
}
this.addActivationHandler(() => this.buildTabs());
this.panelSubscription = panel.subscribeToState(() => {
if (panel.getPlugin() && !this._initialTabsBuilt) {
this.buildTabs();
this._initialTabsBuilt = true;
}
});
}
private getDataObjects(): [SceneQueryRunner | ShareQueryDataProvider | undefined, SceneDataTransformer | undefined] {
const dataObj = sceneGraph.getData(this.panelManager.state.panel);
let runner: SceneQueryRunner | ShareQueryDataProvider | undefined;
let transformer: SceneDataTransformer | undefined;
if (dataObj instanceof SceneQueryRunner || dataObj instanceof ShareQueryDataProvider) {
runner = dataObj;
}
if (dataObj instanceof SceneDataTransformer) {
transformer = dataObj;
if (transformer.state.$data instanceof SceneQueryRunner) {
runner = transformer.state.$data;
}
}
return [runner, transformer];
}
private buildTabs() {
const { panelRef } = this.state;
const panelManager = this.panelManager;
const panel = panelManager.state.panel;
const [runner] = this.getDataObjects();
const tabs: PanelDataPaneTab[] = [];
if (panelRef) {
const plugin = panelRef.resolve().getPlugin();
if (panel) {
const plugin = panel.getPlugin();
if (!plugin) {
this.setState({ tabs });
return;
@ -80,11 +133,14 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
this.setState({ tabs });
return;
} else {
tabs.push(new PanelDataQueriesTab({}));
tabs.push(new PanelDataTransformationsTab({}));
if (runner) {
tabs.push(new PanelDataQueriesTab(this.panelManager));
}
tabs.push(new PanelDataTransformationsTab(this.panelManager));
if (shouldShowAlertingTab(plugin)) {
tabs.push(new PanelDataAlertingTab({}));
tabs.push(new PanelDataAlertingTab(this.panelManager));
}
}
}
@ -115,7 +171,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
key={`${t.getTabLabel()}-${index}`}
label={t.getTabLabel()}
icon={t.icon}
// suffix={}
counter={t.getItemsCount?.()}
active={t.tabId === tab}
onChangeTab={() => model.onChangeTab(t)}
/>

View File

@ -1,20 +1,172 @@
import React from 'react';
import { IconName } from '@grafana/data';
import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
import { DataSourceApi, DataSourceInstanceSettings, IconName } from '@grafana/data';
import { SceneObjectBase, SceneComponentProps, SceneQueryRunner, sceneGraph } from '@grafana/scenes';
import { DataQuery } from '@grafana/schema';
import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows';
import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup';
import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { QueryGroupOptions } from 'app/types';
import { PanelTimeRange } from '../../scene/PanelTimeRange';
import { ShareQueryDataProvider } from '../../scene/ShareQueryDataProvider';
import { VizPanelManager } from '../VizPanelManager';
import { PanelDataPaneTabState, PanelDataPaneTab } from './types';
export class PanelDataQueriesTab extends SceneObjectBase<PanelDataPaneTabState> implements PanelDataPaneTab {
interface PanelDataQueriesTabState extends PanelDataPaneTabState {
// dataRef: SceneObjectRef<SceneQueryRunner | ShareQueryDataProvider>;
datasource?: DataSourceApi;
dsSettings?: DataSourceInstanceSettings;
}
export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabState> implements PanelDataPaneTab {
static Component = PanelDataQueriesTabRendered;
tabId = 'queries';
icon: IconName = 'database';
private _panelManager: VizPanelManager;
getTabLabel() {
return 'Queries';
}
getItemsCount() {
const dataObj = this._panelManager.state.panel.state.$data!;
if (dataObj instanceof ShareQueryDataProvider) {
return 1;
}
if (dataObj instanceof SceneQueryRunner) {
return dataObj.state.queries.length;
}
return null;
}
constructor(panelManager: VizPanelManager) {
super({});
this._panelManager = panelManager;
}
buildQueryOptions(): QueryGroupOptions {
const panelManager = this._panelManager;
const panelObj = this._panelManager.state.panel;
const dataObj = panelObj.state.$data!;
const queryRunner = this._panelManager.queryRunner;
const timeRangeObj = sceneGraph.getTimeRange(panelObj);
let timeRangeOpts: QueryGroupOptions['timeRange'] = {
from: undefined,
shift: undefined,
hide: undefined,
};
if (timeRangeObj instanceof PanelTimeRange) {
timeRangeOpts = {
from: timeRangeObj.state.timeFrom,
shift: timeRangeObj.state.timeShift,
hide: timeRangeObj.state.hideTimeOverride,
};
}
let queries: QueryGroupOptions['queries'] = [];
if (dataObj instanceof ShareQueryDataProvider) {
queries = [dataObj.state.query];
}
if (dataObj instanceof SceneQueryRunner) {
queries = dataObj.state.queries;
}
return {
// TODO
// cacheTimeout: dsSettings?.meta.queryOptions?.cacheTimeout ? panel.cacheTimeout : undefined,
// queryCachingTTL: dsSettings?.cachingConfig?.enabled ? panel.queryCachingTTL : undefined,
dataSource: {
default: panelManager.state.dsSettings?.isDefault,
type: panelManager.state.dsSettings?.type,
uid: panelManager.state.dsSettings?.uid,
},
queries,
maxDataPoints: queryRunner.state.maxDataPoints,
minInterval: queryRunner.state.minInterval,
timeRange: timeRangeOpts,
};
}
onOpenInspector = () => {
this._panelManager.inspectPanel();
};
onChangeDataSource = async (
newSettings: DataSourceInstanceSettings,
defaultQueries?: DataQuery[] | GrafanaQuery[]
) => {
this._panelManager.changePanelDataSource(newSettings, defaultQueries);
};
onQueryOptionsChange = (options: QueryGroupOptions) => {
this._panelManager.changeQueryOptions(options);
};
onQueriesChange = (queries: DataQuery[]) => {
this._panelManager.changeQueries(queries);
};
getQueries() {
const dataObj = this._panelManager.state.panel.state.$data!;
if (dataObj instanceof ShareQueryDataProvider) {
return [dataObj.state.query];
}
return this._panelManager.queryRunner.state.queries;
}
get panelManager() {
return this._panelManager;
}
}
function PanelDataQueriesTabRendered(props: SceneComponentProps<PanelDataQueriesTab>) {
return <div>TODO Queries</div>;
function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) {
const { panel, datasource, dsSettings } = model.panelManager.useState();
const { $data: dataObj } = panel.useState();
if (!dataObj) {
return;
}
const { data } = dataObj!.useState();
if (!datasource || !dsSettings || !data) {
return null;
}
return (
<>
<QueryGroupTopSection
data={data}
dsSettings={dsSettings}
dataSource={datasource}
options={model.buildQueryOptions()}
onDataSourceChange={model.onChangeDataSource}
onOptionsChange={model.onQueryOptionsChange}
onOpenQueryInspector={model.onOpenInspector}
/>
{dataObj instanceof ShareQueryDataProvider ? (
<h1>TODO: DashboardQueryEditor</h1>
) : (
<QueryEditorRows
data={data}
queries={model.getQueries()}
dsSettings={dsSettings}
onAddQuery={() => {}}
onQueriesChange={model.onQueriesChange}
onRunQueries={() => {}}
/>
)}
</>
);
}

View File

@ -3,18 +3,44 @@ import React from 'react';
import { IconName } from '@grafana/data';
import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
import { VizPanelManager } from '../VizPanelManager';
import { PanelDataPaneTabState, PanelDataPaneTab } from './types';
export class PanelDataTransformationsTab extends SceneObjectBase<PanelDataPaneTabState> implements PanelDataPaneTab {
interface PanelDataTransformationsTabState extends PanelDataPaneTabState {}
export class PanelDataTransformationsTab
extends SceneObjectBase<PanelDataTransformationsTabState>
implements PanelDataPaneTab
{
static Component = PanelDataTransformationsTabRendered;
tabId = 'transformations';
icon: IconName = 'process';
private _panelManager: VizPanelManager;
getTabLabel() {
return 'Transformations';
}
getItemsCount() {
return 0;
}
constructor(panelManager: VizPanelManager) {
super({});
this._panelManager = panelManager;
}
get panelManager() {
return this._panelManager;
}
}
function PanelDataTransformationsTabRendered(props: SceneComponentProps<PanelDataTransformationsTab>) {
function PanelDataTransformationsTabRendered({ model }: SceneComponentProps<PanelDataTransformationsTab>) {
// const { dataRef } = model.useState();
// const dataObj = dataRef.resolve();
// // const { transformations } = dataObj.useState();
return <div>TODO Transformations</div>;
}

View File

@ -5,6 +5,7 @@ export interface PanelDataPaneTabState extends SceneObjectState {}
export interface PanelDataPaneTab extends SceneObject {
getTabLabel(): string;
getItemsCount?(): number | null;
tabId: string;
icon: IconName;
}

View File

@ -14,12 +14,15 @@ import {
SplitLayout,
VizPanel,
} from '@grafana/scenes';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper';
import { getDashboardUrl } from '../utils/urlBuilders';
import { PanelDataPane } from './PanelDataPane/PanelDataPane';
import { PanelEditorRenderer } from './PanelEditorRenderer';
import { PanelEditorUrlSync } from './PanelEditorUrlSync';
import { PanelOptionsPane } from './PanelOptionsPane';
import { PanelVizTypePicker } from './PanelVizTypePicker';
import { VizPanelManager } from './VizPanelManager';
@ -29,9 +32,9 @@ export interface PanelEditorState extends SceneObjectState {
controls?: SceneObject[];
isDirty?: boolean;
/** Panel to inspect */
inspectPanelId?: string;
inspectPanelKey?: string;
/** Scene object that handles the current drawer */
drawer?: SceneObject;
overlay?: SceneObject;
dashboardRef: SceneObjectRef<DashboardScene>;
sourcePanelRef: SceneObjectRef<VizPanel>;
@ -41,6 +44,11 @@ export interface PanelEditorState extends SceneObjectState {
export class PanelEditor extends SceneObjectBase<PanelEditorState> {
static Component = PanelEditorRenderer;
/**
* Handles url sync
*/
protected _urlSync = new PanelEditorUrlSync(this);
public constructor(state: PanelEditorState) {
super(state);
@ -48,6 +56,10 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
}
private _activationHandler() {
const oldDashboardWrapper = new DashboardModelCompatibilityWrapper(this.state.dashboardRef.resolve());
// @ts-expect-error
getDashboardSrv().setCurrent(oldDashboardWrapper);
// Deactivation logic
return () => {
getUrlSyncManager().cleanUp(this);
@ -85,13 +97,15 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
private _commitChanges() {
const dashboard = this.state.dashboardRef.resolve();
const sourcePanel = this.state.sourcePanelRef.resolve();
const panel = this.state.panelRef.resolve();
const panelMngr = this.state.panelRef.resolve();
if (!dashboard.state.isEditing) {
dashboard.onEnterEditMode();
}
const newState = sceneUtils.cloneSceneObjectState(panel.state);
const newState = sceneUtils.cloneSceneObjectState(panelMngr.state.panel.state);
sourcePanel.setState(newState);
// preserve time range and variables state
@ -107,6 +121,7 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
getDashboardUrl({
uid: this.state.dashboardRef.resolve().state.uid,
currentQueryParams: locationService.getLocation().search,
useExperimentalURL: true,
})
);
}
@ -114,7 +129,8 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
export function buildPanelEditScene(dashboard: DashboardScene, panel: VizPanel): PanelEditor {
const panelClone = panel.clone();
const vizPanelMgr = new VizPanelManager(panelClone);
const vizPanelMgr = new VizPanelManager(panelClone, dashboard.getRef());
const dashboardStateCloned = sceneUtils.cloneSceneObjectState(dashboard.state);
return new PanelEditor({
@ -130,10 +146,10 @@ export function buildPanelEditScene(dashboard: DashboardScene, panel: VizPanel):
direction: 'column',
primary: new SceneFlexLayout({
direction: 'column',
children: [panelClone],
children: [vizPanelMgr],
}),
secondary: new SceneFlexItem({
body: new PanelDataPane({ panelRef: panelClone.getRef() }),
body: new PanelDataPane(vizPanelMgr),
}),
}),
secondary: new SceneFlexLayout({

View File

@ -13,7 +13,7 @@ import { useSelector } from 'app/types/store';
import { PanelEditor } from './PanelEditor';
export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) {
const { body, controls, drawer } = model.useState();
const { body, controls, overlay } = model.useState();
const styles = useStyles2(getStyles);
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
@ -34,7 +34,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
<body.Component model={body} />
</div>
</div>
{drawer && <drawer.Component model={drawer} />}
{overlay && <overlay.Component model={overlay} />}
</Page>
);
}

View File

@ -0,0 +1,49 @@
import { AppEvents } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes';
import appEvents from 'app/core/app_events';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { findVizPanelByKey } from '../utils/utils';
import { PanelEditor, PanelEditorState } from './PanelEditor';
export class PanelEditorUrlSync implements SceneObjectUrlSyncHandler {
constructor(private _scene: PanelEditor) {}
getKeys(): string[] {
return ['inspect'];
}
getUrlState(): SceneObjectUrlValues {
const state = this._scene.state;
return {
inspect: state.inspectPanelKey,
};
}
updateFromUrl(values: SceneObjectUrlValues): void {
const { inspectPanelKey } = this._scene.state;
const update: Partial<PanelEditorState> = {};
// Handle inspect object state
if (typeof values.inspect === 'string') {
const panel = findVizPanelByKey(this._scene, values.inspect);
if (!panel) {
appEvents.emit(AppEvents.alertError, ['Panel not found']);
locationService.partial({ inspect: null });
return;
}
update.inspectPanelKey = values.inspect;
update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() });
} else if (inspectPanelKey) {
update.inspectPanelKey = undefined;
update.overlay = undefined;
}
if (Object.keys(update).length > 0) {
this._scene.setState(update);
}
}
}

View File

@ -1,31 +1,178 @@
import { FieldConfigSource } from '@grafana/data';
import { DeepPartial, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { map, of } from 'rxjs';
import { DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneDataTransformer, SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { InspectTab } from 'app/features/inspector/types';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { DashboardScene } from '../scene/DashboardScene';
import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange';
import { ShareQueryDataProvider } from '../scene/ShareQueryDataProvider';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper';
import { findVizPanelByKey } from '../utils/utils';
import { VizPanelManager } from './VizPanelManager';
import testDashboard from './testfiles/testDashboard.json';
const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
const result: PanelData = {
state: LoadingState.Loading,
series: [],
timeRange: request.range,
};
return of([]).pipe(
map(() => {
result.state = LoadingState.Done;
result.series = [];
return result;
})
);
});
const ds1Mock: DataSourceApi = {
meta: {
id: 'grafana-testdata-datasource',
},
name: 'grafana-testdata-datasource',
type: 'grafana-testdata-datasource',
uid: 'gdev-testdata',
getRef: () => {
return { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' };
},
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>;
const ds2Mock: DataSourceApi = {
meta: {
id: 'grafana-prometheus-datasource',
},
name: 'grafana-prometheus-datasource',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
getRef: () => {
return { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' };
},
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>;
const ds3Mock: DataSourceApi = {
meta: {
id: DASHBOARD_DATASOURCE_PLUGIN_ID,
},
name: SHARED_DASHBOARD_QUERY,
type: SHARED_DASHBOARD_QUERY,
uid: SHARED_DASHBOARD_QUERY,
getRef: () => {
return { type: SHARED_DASHBOARD_QUERY, uid: SHARED_DASHBOARD_QUERY };
},
} as DataSourceApi<DataQuery, DataSourceJsonData, {}>;
const instance1SettingsMock = {
id: 1,
uid: 'gdev-testdata',
name: 'testDs1',
type: 'grafana-testdata-datasource',
meta: {
id: 'grafana-testdata-datasource',
},
};
const instance2SettingsMock = {
id: 1,
uid: 'gdev-prometheus',
name: 'testDs2',
type: 'grafana-prometheus-datasource',
meta: {
id: 'grafana-prometheus-datasource',
},
};
// Mock the store module
jest.mock('app/core/store', () => ({
exists: jest.fn(),
get: jest.fn(),
getObject: jest.fn(),
setObject: jest.fn(),
}));
const store = jest.requireMock('app/core/store');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
return runRequestMock(ds, request);
},
getDataSourceSrv: () => ({
get: async (ref: DataSourceRef) => {
if (ref.uid === 'gdev-testdata') {
return ds1Mock;
}
if (ref.uid === 'gdev-prometheus') {
return ds2Mock;
}
if (ref.uid === SHARED_DASHBOARD_QUERY) {
return ds3Mock;
}
return null;
},
getInstanceSettings: (ref: DataSourceRef) => {
if (ref.uid === 'gdev-testdata') {
return instance1SettingsMock;
}
if (ref.uid === 'gdev-prometheus') {
return instance2SettingsMock;
}
return null;
},
}),
locationService: {
partial: jest.fn(),
},
}));
describe('VizPanelManager', () => {
describe('changePluginType', () => {
it('Should successfully change from one viz type to another', () => {
const vizPanelManager = getVizPanelManager();
expect(vizPanelManager.state.panel.state.pluginId).toBe('table');
vizPanelManager.changePluginType('timeseries');
const vizPanelManager = setupTest('panel-1');
expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries');
vizPanelManager.changePluginType('table');
expect(vizPanelManager.state.panel.state.pluginId).toBe('table');
});
it('Should clear custom options', () => {
const dashboardSceneMock = new DashboardScene({});
const overrides = [
{
matcher: { id: 'matcherOne' },
properties: [{ id: 'custom.propertyOne' }, { id: 'custom.propertyTwo' }, { id: 'standardProperty' }],
},
];
const vizPanelManager = getVizPanelManager(undefined, {
defaults: {
custom: 'Custom',
const vizPanel = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
options: undefined,
fieldConfig: {
defaults: {
custom: 'Custom',
},
overrides,
},
overrides,
});
const vizPanelManager = new VizPanelManager(vizPanel, dashboardSceneMock.getRef());
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toBe('Custom');
expect(vizPanelManager.state.panel.state.fieldConfig.overrides).toBe(overrides);
@ -37,14 +184,21 @@ describe('VizPanelManager', () => {
});
it('Should restore cached options/fieldConfig if they exist', () => {
const vizPanelManager = getVizPanelManager(
{
const dashboardSceneMock = new DashboardScene({});
const vizPanel = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
options: {
customOption: 'A',
},
{ defaults: { custom: 'Custom' }, overrides: [] }
);
fieldConfig: { defaults: { custom: 'Custom' }, overrides: [] },
});
vizPanelManager.changePluginType('timeseries');
const vizPanelManager = new VizPanelManager(vizPanel, dashboardSceneMock.getRef());
vizPanelManager.changePluginType('timeseties');
//@ts-ignore
expect(vizPanelManager.state.panel.state.options['customOption']).toBeUndefined();
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toStrictEqual({});
@ -56,22 +210,412 @@ describe('VizPanelManager', () => {
expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toBe('Custom');
});
});
describe('query options', () => {
beforeEach(() => {
store.setObject.mockClear();
});
describe('activation', () => {
it('should load data source', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
expect(vizPanelManager.state.datasource).toEqual(ds1Mock);
expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock);
});
it('should store loaded data source in local storage', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', {
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-testdata',
});
});
});
describe('data source change', () => {
it('should load new data source', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const dataObj = vizPanelManager.queryRunner;
dataObj.setState({
datasource: {
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
},
});
await Promise.resolve();
expect(store.setObject).toHaveBeenCalledTimes(2);
expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', {
dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f',
datasourceUid: 'gdev-prometheus',
});
expect(vizPanelManager.state.datasource).toEqual(ds2Mock);
expect(vizPanelManager.state.dsSettings).toEqual(instance2SettingsMock);
});
});
describe('query options change', () => {
describe('time overrides', () => {
it('should create PanelTimeRange object', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$timeRange).toBeUndefined();
vizPanelManager.changeQueryOptions({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
timeRange: {
from: '1h',
},
});
expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange);
});
it('should update PanelTimeRange object on time options update', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$timeRange).toBeUndefined();
vizPanelManager.changeQueryOptions({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
timeRange: {
from: '1h',
},
});
expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange);
expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h');
vizPanelManager.changeQueryOptions({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
timeRange: {
from: '2h',
},
});
expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h');
});
it('should remove PanelTimeRange object on time options cleared', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$timeRange).toBeUndefined();
vizPanelManager.changeQueryOptions({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
timeRange: {
from: '1h',
},
});
expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange);
vizPanelManager.changeQueryOptions({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
timeRange: {
from: null,
},
});
expect(panel.state.$timeRange).toBeUndefined();
});
});
describe('max data points and interval', () => {
it('max data points', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const dataObj = vizPanelManager.queryRunner;
expect(dataObj.state.maxDataPoints).toBeUndefined();
vizPanelManager.changeQueryOptions({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
maxDataPoints: 100,
});
expect(dataObj.state.maxDataPoints).toBe(100);
});
it('max data points', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const dataObj = vizPanelManager.queryRunner;
expect(dataObj.state.maxDataPoints).toBeUndefined();
vizPanelManager.changeQueryOptions({
dataSource: {
name: 'grafana-testdata',
type: 'grafana-testdata-datasource',
default: true,
},
queries: [],
minInterval: '1s',
});
expect(dataObj.state.minInterval).toBe('1s');
});
});
});
describe('query inspection', () => {
it('allows query inspection from the tab', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.inspectPanel();
expect(locationService.partial).toHaveBeenCalledWith({ inspect: 1, inspectTab: InspectTab.Query });
});
});
describe('data source change', () => {
it('changing from one plugin to another', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$data).toBeInstanceOf(SceneQueryRunner);
expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await vizPanelManager.changePanelDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as any);
expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
it('changing from a plugin to a dashboard data source', async () => {
const vizPanelManager = setupTest('panel-1');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$data).toBeInstanceOf(SceneQueryRunner);
expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await vizPanelManager.changePanelDataSource({
name: SHARED_DASHBOARD_QUERY,
type: 'datasource',
uid: SHARED_DASHBOARD_QUERY,
meta: {
name: 'Prometheus',
module: 'prometheus',
id: DASHBOARD_DATASOURCE_PLUGIN_ID,
},
} as any);
expect(panel.state.$data).toBeInstanceOf(ShareQueryDataProvider);
});
it('changing from dashboard data source to a plugin', async () => {
const vizPanelManager = setupTest('panel-3');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$data).toBeInstanceOf(ShareQueryDataProvider);
await vizPanelManager.changePanelDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as any);
expect(panel.state.$data).toBeInstanceOf(SceneQueryRunner);
expect((panel.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
describe('with transformations', () => {
it('changing from one plugin to another', async () => {
const vizPanelManager = setupTest('panel-2');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer);
expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await vizPanelManager.changePanelDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as any);
expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer);
expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
});
it('changing from a plugin to dashboard data source', async () => {
const vizPanelManager = setupTest('panel-2');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer);
expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-testdata',
type: 'grafana-testdata-datasource',
});
await vizPanelManager.changePanelDataSource({
name: SHARED_DASHBOARD_QUERY,
type: 'datasource',
uid: SHARED_DASHBOARD_QUERY,
meta: {
name: 'Prometheus',
module: 'prometheus',
id: DASHBOARD_DATASOURCE_PLUGIN_ID,
},
} as any);
expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer);
expect(panel.state.$data?.state.$data).toBeInstanceOf(ShareQueryDataProvider);
});
it('changing from a dashboard data source to a plugin', async () => {
const vizPanelManager = setupTest('panel-4');
vizPanelManager.activate();
await Promise.resolve();
const panel = vizPanelManager.state.panel;
expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer);
expect(panel.state.$data?.state.$data).toBeInstanceOf(ShareQueryDataProvider);
await vizPanelManager.changePanelDataSource({
name: 'grafana-prometheus',
type: 'grafana-prometheus-datasource',
uid: 'gdev-prometheus',
meta: {
name: 'Prometheus',
module: 'prometheus',
id: 'grafana-prometheus-datasource',
},
} as any);
expect(panel.state.$data).toBeInstanceOf(SceneDataTransformer);
expect(panel.state.$data?.state.$data).toBeInstanceOf(SceneQueryRunner);
expect((panel.state.$data?.state.$data as SceneQueryRunner).state.datasource).toEqual({
uid: 'gdev-prometheus',
type: 'grafana-prometheus-datasource',
});
});
});
});
});
function getVizPanelManager(
options: {} = {},
fieldConfig: FieldConfigSource<DeepPartial<{}>> = { overrides: [], defaults: {} }
) {
const vizPanel = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
options,
fieldConfig,
});
const setupTest = (panelId: string) => {
const scene = transformSaveModelToScene({ dashboard: testDashboard as any, meta: {} });
const vizPanelManager = new VizPanelManager(vizPanel);
// The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it
// @ts-expect-error
getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene));
const panel = findVizPanelByKey(scene, panelId)!;
const vizPanelManager = new VizPanelManager(panel.clone(), scene.getRef());
return vizPanelManager;
}
};

View File

@ -1,12 +1,18 @@
import React from 'react';
import { Unsubscribable } from 'rxjs';
import {
DataSourceApi,
DataSourceInstanceSettings,
FieldConfigSource,
LoadingState,
PanelModel,
filterFieldConfigOverrides,
getDefaultTimeRange,
isStandardFieldProp,
restoreCustomOverrideRules,
} from '@grafana/data';
import { getDataSourceSrv, locationService } from '@grafana/runtime';
import {
SceneObjectState,
VizPanel,
@ -14,21 +20,143 @@ import {
SceneComponentProps,
sceneUtils,
DeepPartial,
SceneObjectRef,
SceneObject,
SceneQueryRunner,
sceneGraph,
SceneDataTransformer,
SceneDataProvider,
} from '@grafana/scenes';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
import { updateQueries } from 'app/features/query/state/updateQueries';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types';
import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { QueryGroupOptions } from 'app/types';
import { DashboardScene } from '../scene/DashboardScene';
import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange';
import { ShareQueryDataProvider, findObjectInScene } from '../scene/ShareQueryDataProvider';
import { getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../utils/utils';
interface VizPanelManagerState extends SceneObjectState {
panel: VizPanel;
datasource?: DataSourceApi;
dsSettings?: DataSourceInstanceSettings;
}
// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data maniulation.
export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => {
const { panel } = model.useState();
return <panel.Component model={panel} />;
};
private _cachedPluginOptions: Record<
string,
{ options: DeepPartial<{}>; fieldConfig: FieldConfigSource<DeepPartial<{}>> } | undefined
> = {};
public constructor(panel: VizPanel) {
private _dataObjectSubscription: Unsubscribable | undefined;
public constructor(panel: VizPanel, dashboardRef: SceneObjectRef<DashboardScene>) {
super({ panel });
/**
* If the panel uses a shared query, we clone the source runner and attach it as a data provider for the shared one.
* This way the source panel does not to be present in the edit scene hierarchy.
*/
if (panel.state.$data instanceof ShareQueryDataProvider) {
const sharedProvider = panel.state.$data;
if (sharedProvider.state.query.panelId) {
const keyToFind = getVizPanelKeyForPanelId(sharedProvider.state.query.panelId);
const source = findObjectInScene(dashboardRef.resolve(), (scene: SceneObject) => scene.state.key === keyToFind);
if (source) {
sharedProvider.setState({
$data: source.state.$data!.clone(),
});
}
}
}
this.addActivationHandler(() => this._onActivate());
}
private _onActivate() {
this.setupDataObjectSubscription();
this.loadDataSource();
return () => {
this._dataObjectSubscription?.unsubscribe();
};
}
/**
* The subscription is updated whenever the data source type is changed so that we can update manager's stored
* data source and data source instance settings, which are needed for the query options and editors
*/
private setupDataObjectSubscription() {
const runner = this.queryRunner;
if (this._dataObjectSubscription) {
this._dataObjectSubscription.unsubscribe();
}
this._dataObjectSubscription = runner.subscribeToState((n, p) => {
if (n.datasource !== p.datasource) {
this.loadDataSource();
}
});
}
private async loadDataSource() {
const dataObj = this.state.panel.state.$data;
if (!dataObj) {
return;
}
let datasourceToLoad: DataSourceRef | undefined;
if (dataObj instanceof ShareQueryDataProvider) {
datasourceToLoad = {
uid: SHARED_DASHBOARD_QUERY,
type: DASHBOARD_DATASOURCE_PLUGIN_ID,
};
} else {
datasourceToLoad = this.queryRunner.state.datasource;
}
if (!datasourceToLoad) {
return;
}
try {
// TODO: Handle default/last used datasource selection for new panel
// Ref: PanelEditorQueries / componentDidMount
const datasource = await getDataSourceSrv().get(datasourceToLoad);
const dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad);
if (datasource && dsSettings) {
this.setState({
datasource,
dsSettings,
});
storeLastUsedDataSourceInLocalStorage(
{
type: dsSettings.type,
uid: dsSettings.uid,
} || { default: true }
);
}
} catch (err) {
console.error(err);
}
}
public changePluginType(pluginType: string) {
@ -79,11 +207,172 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
}
this.setState({ panel: newPanel });
this.setupDataObjectSubscription();
}
public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => {
const { panel } = model.useState();
public async changePanelDataSource(
newSettings: DataSourceInstanceSettings,
defaultQueries?: DataQuery[] | GrafanaQuery[]
) {
const { panel, dsSettings } = this.state;
const dataObj = panel.state.$data;
if (!dataObj) {
return;
}
return <panel.Component model={panel} />;
};
const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined;
const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid });
const currentQueries = [];
if (dataObj instanceof SceneQueryRunner) {
currentQueries.push(...dataObj.state.queries);
} else if (dataObj instanceof ShareQueryDataProvider) {
currentQueries.push(dataObj.state.query);
}
// We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value
const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS));
if (dataObj instanceof SceneQueryRunner) {
// Changing to Dashboard data source
if (newSettings.uid === SHARED_DASHBOARD_QUERY) {
// Changing from one plugin to another
const sharedProvider = new ShareQueryDataProvider({
query: queries[0],
$data: new SceneQueryRunner({
queries: [],
}),
data: {
series: [],
state: LoadingState.NotStarted,
timeRange: getDefaultTimeRange(),
},
});
panel.setState({ $data: sharedProvider });
this.setupDataObjectSubscription();
this.loadDataSource();
} else {
dataObj.setState({
datasource: {
type: newSettings.type,
uid: newSettings.uid,
},
queries,
});
if (defaultQueries) {
dataObj.runQueries();
}
}
} else if (dataObj instanceof ShareQueryDataProvider && newSettings.uid !== SHARED_DASHBOARD_QUERY) {
const dataProvider = new SceneQueryRunner({
datasource: {
type: newSettings.type,
uid: newSettings.uid,
},
queries,
});
panel.setState({ $data: dataProvider });
this.setupDataObjectSubscription();
this.loadDataSource();
} else if (dataObj instanceof SceneDataTransformer) {
const data = dataObj.clone();
let provider: SceneDataProvider = new SceneQueryRunner({
datasource: {
type: newSettings.type,
uid: newSettings.uid,
},
queries,
});
if (newSettings.uid === SHARED_DASHBOARD_QUERY) {
provider = new ShareQueryDataProvider({
query: queries[0],
$data: new SceneQueryRunner({
queries: [],
}),
data: {
series: [],
state: LoadingState.NotStarted,
timeRange: getDefaultTimeRange(),
},
});
}
data.setState({
$data: provider,
});
panel.setState({ $data: data });
this.setupDataObjectSubscription();
this.loadDataSource();
}
}
public changeQueryOptions(options: QueryGroupOptions) {
const panelObj = this.state.panel;
const dataObj = this.queryRunner;
let timeRangeObj = sceneGraph.getTimeRange(panelObj);
const dataObjStateUpdate: Partial<SceneQueryRunner['state']> = {};
const timeRangeObjStateUpdate: Partial<PanelTimeRangeState> = {};
if (options.maxDataPoints !== dataObj.state.maxDataPoints) {
dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined;
}
if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) {
dataObjStateUpdate.minInterval = options.minInterval;
}
if (options.timeRange) {
timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined;
timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined;
timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide;
}
if (timeRangeObj instanceof PanelTimeRange) {
if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) {
// update time override
timeRangeObj.setState(timeRangeObjStateUpdate);
} else {
// remove time override
panelObj.setState({ $timeRange: undefined });
}
} else {
// no time override present on the panel, let's create one first
panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) });
}
dataObj.setState(dataObjStateUpdate);
dataObj.runQueries();
}
public changeQueries(queries: DataQuery[]) {
const dataObj = this.queryRunner;
dataObj.setState({ queries });
// TODO: Handle dashboard query
}
public inspectPanel() {
const panel = this.state.panel;
const panelId = getPanelIdForVizPanel(panel);
locationService.partial({
inspect: panelId,
inspectTab: 'query',
});
}
get queryRunner(): SceneQueryRunner {
const dataObj = this.state.panel.state.$data;
if (dataObj instanceof ShareQueryDataProvider) {
return dataObj.state.$data as SceneQueryRunner;
}
if (dataObj instanceof SceneDataTransformer) {
return dataObj.state.$data as SceneQueryRunner;
}
return dataObj as SceneQueryRunner;
}
}

View File

@ -0,0 +1,372 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 2378,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Source panel",
"type": "timeseries"
},
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true
},
"pluginVersion": "10.3.0-pre",
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "gdev-testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Panel with transforms",
"transformations": [
{
"id": "reduce",
"options": {}
}
],
"type": "table"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A"
}
],
"title": "Dashboard query",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 4,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true
},
"pluginVersion": "10.3.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A"
}
],
"title": "Dashboard query with transformations",
"transformations": [
{
"id": "reduce",
"options": {}
}
],
"type": "table"
}
],
"refresh": "",
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Scenes/PanelEdit/Queries: Edit",
"uid": "ffbe00e2-803c-4d49-adb7-41aad336234f",
"version": 6,
"weekStart": ""
}

View File

@ -12,7 +12,7 @@ import {
import { Icon, PanelChrome, TimePickerTooltip, Tooltip, useStyles2 } from '@grafana/ui';
import { TimeOverrideResult } from 'app/features/dashboard/utils/panel';
interface PanelTimeRangeState extends SceneTimeRangeState {
export interface PanelTimeRangeState extends SceneTimeRangeState {
timeFrom?: string;
timeShift?: string;
hideTimeOverride?: boolean;
@ -30,8 +30,21 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
to: 'now',
value: getDefaultTimeRange(),
});
this.addActivationHandler(() => this._onActivate());
}
private _onActivate() {
this._subs.add(
this.subscribeToState((n, p) => {
// Listen to own changes and update time info when required
if (n.timeFrom !== p.timeFrom || n.timeShift !== p.timeShift) {
const { timeInfo } = this.getTimeOverride(this.getAncestorTimeRange().state.value);
this.setState({ timeInfo });
}
})
);
}
protected ancestorTimeRangeChanged(timeRange: SceneTimeRangeState): void {
const overrideResult = this.getTimeOverride(timeRange.value);
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });

View File

@ -56,22 +56,27 @@ export class ShareQueryDataProvider extends SceneObjectBase<ShareQueryDataProvid
this._querySub.unsubscribe();
}
if (!query.panelId) {
return;
}
if (this.state.$data) {
this._sourceProvider = this.state.$data;
this._passContainerWidth = true;
} else {
if (!query.panelId) {
return;
}
const keyToFind = getVizPanelKeyForPanelId(query.panelId);
const source = findObjectInScene(this.getRoot(), (scene: SceneObject) => scene.state.key === keyToFind);
const keyToFind = getVizPanelKeyForPanelId(query.panelId);
const source = findObjectInScene(this.getRoot(), (scene: SceneObject) => scene.state.key === keyToFind);
if (!source) {
console.log('Shared dashboard query refers to a panel that does not exist in the scene');
return;
}
if (!source) {
console.log('Shared dashboard query refers to a panel that does not exist in the scene');
return;
}
this._sourceProvider = source.state.$data;
if (!this._sourceProvider) {
console.log('No source data found for shared dashboard query');
return;
this._sourceProvider = source.state.$data;
if (!this._sourceProvider) {
console.log('No source data found for shared dashboard query');
return;
}
}
// If the source is not active we need to pass the container width

View File

@ -2,18 +2,14 @@ import React, { PureComponent } from 'react';
import { DataQuery, getDataSourceRef } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { QueryGroup } from 'app/features/query/components/QueryGroup';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { QueryGroupDataSource, QueryGroupOptions } from 'app/types';
import { getDashboardSrv } from '../../services/DashboardSrv';
import { PanelModel } from '../../state';
import {
getLastUsedDatasourceFromStorage,
initLastUsedDatasourceKeyForDashboard,
setLastUsedDatasourceKeyForDashboard,
} from '../../utils/dashboard';
import { getLastUsedDatasourceFromStorage } from '../../utils/dashboard';
interface Props {
/** Current panel */
@ -29,16 +25,7 @@ export class PanelEditorQueries extends PureComponent<Props> {
// store last used datasource in local storage
updateLastUsedDatasource = (datasource: QueryGroupDataSource) => {
if (!datasource.uid) {
return;
}
const dashboardUid = getDashboardSrv().getCurrent()?.uid ?? '';
// if datasource is MIXED reset datasource uid in storage, because Mixed datasource can contain multiple ds
if (datasource.uid === MIXED_DATASOURCE_NAME) {
return initLastUsedDatasourceKeyForDashboard(dashboardUid!);
}
setLastUsedDatasourceKeyForDashboard(dashboardUid, datasource.uid);
storeLastUsedDataSourceInLocalStorage(datasource);
};
buildQueryOptions(panel: PanelModel): QueryGroupOptions {

View File

@ -1,4 +1,11 @@
import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef } from '@grafana/data';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import {
initLastUsedDatasourceKeyForDashboard,
setLastUsedDatasourceKeyForDashboard,
} from 'app/features/dashboard/utils/dashboard';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { QueryGroupDataSource } from 'app/types';
export function isDataSourceMatch(
ds: DataSourceInstanceSettings | undefined,
@ -97,3 +104,17 @@ export function getDataSourceCompareFn(
export function matchDataSourceWithSearch(ds: DataSourceInstanceSettings, searchTerm = '') {
return ds.name.toLowerCase().includes(searchTerm.toLowerCase());
}
export function storeLastUsedDataSourceInLocalStorage(datasource: QueryGroupDataSource) {
if (!datasource.uid) {
return;
}
const dashboardUid = getDashboardSrv().getCurrent()?.uid ?? '';
// if datasource is MIXED reset datasource uid in storage, because Mixed datasource can contain multiple ds
if (datasource.uid === MIXED_DATASOURCE_NAME) {
return initLastUsedDatasourceKeyForDashboard(dashboardUid!);
}
setLastUsedDatasourceKeyForDashboard(dashboardUid, datasource.uid);
}

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import React, { PureComponent, useEffect, useState } from 'react';
import { Unsubscribable } from 'rxjs';
import {
@ -84,11 +84,6 @@ export class QueryGroup extends PureComponent<Props, State> {
});
this.setNewQueriesAndDatasource(options);
// Clean up the first panel flag since the modal is now open
if (!!locationService.getSearchObject().firstPanel) {
locationService.partial({ firstPanel: null }, true);
}
}
componentWillUnmount() {
@ -213,58 +208,21 @@ export class QueryGroup extends PureComponent<Props, State> {
renderTopSection(styles: QueriesTabStyles) {
const { onOpenQueryInspector, options } = this.props;
const { dataSource, data } = this.state;
const { dataSource, data, dsSettings } = this.state;
if (!dsSettings || !dataSource) {
return null;
}
return (
<div>
<div className={styles.dataSourceRow}>
<InlineFormLabel htmlFor="data-source-picker" width={'auto'}>
Data source
</InlineFormLabel>
<div className={styles.dataSourceRowItem}>{this.renderDataSourcePickerWithPrompt()}</div>
{dataSource && (
<>
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
icon="question-circle"
title="Open data source help"
onClick={this.onOpenHelp}
data-testid="query-tab-help-button"
/>
</div>
<div className={styles.dataSourceRowItemOptions}>
<QueryGroupOptionsEditor
options={options}
dataSource={dataSource}
data={data}
onChange={this.onUpdateAndRun}
/>
</div>
{onOpenQueryInspector && (
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
onClick={onOpenQueryInspector}
aria-label={selectors.components.QueryTab.queryInspectorButton}
>
Query inspector
</Button>
</div>
)}
</>
)}
</div>
{dataSource && isAngularDatasourcePluginAndNotHidden(dataSource.uid) && (
<AngularDeprecationPluginNotice
pluginId={dataSource.type}
pluginType={PluginType.datasource}
angularSupportEnabled={config?.angularSupportEnabled}
showPluginDetailsLink={true}
interactionElementId="datasource-query"
/>
)}
</div>
<QueryGroupTopSection
data={data}
dataSource={dataSource}
options={options}
dsSettings={dsSettings}
onOptionsChange={this.onUpdateAndRun}
onDataSourceChange={this.onChangeDataSource}
onOpenQueryInspector={onOpenQueryInspector}
/>
);
}
@ -280,32 +238,6 @@ export class QueryGroup extends PureComponent<Props, State> {
this.setState({ isDataSourceModalOpen: false });
};
renderDataSourcePickerWithPrompt = () => {
const { isDataSourceModalOpen } = this.state;
const commonProps = {
metrics: true,
mixed: true,
dashboard: true,
variables: true,
current: this.props.options.dataSource,
uploadFile: true,
onChange: async (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => {
await this.onChangeDataSource(ds, defaultQueries);
this.onCloseDataSourceModal();
},
};
return (
<>
{isDataSourceModalOpen && config.featureToggles.advancedDataSourcePicker && (
<DataSourceModal {...commonProps} onDismiss={this.onCloseDataSourceModal}></DataSourceModal>
)}
<DataSourcePicker {...commonProps} />
</>
);
};
onAddQuery = (query: Partial<DataQuery>) => {
const { dsSettings, queries } = this.state;
this.onQueriesChange(addQuery(queries, query, { type: dsSettings?.type, uid: dsSettings?.uid }));
@ -452,3 +384,134 @@ const getStyles = stylesFactory(() => {
});
type QueriesTabStyles = ReturnType<typeof getStyles>;
interface QueryGroupTopSectionProps {
data: PanelData;
dataSource: DataSourceApi;
dsSettings: DataSourceInstanceSettings;
options: QueryGroupOptions;
onOpenQueryInspector?: () => void;
onOptionsChange?: (options: QueryGroupOptions) => void;
onDataSourceChange?: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => Promise<void>;
}
export function QueryGroupTopSection({
dataSource,
options,
data,
dsSettings,
onDataSourceChange,
onOptionsChange,
onOpenQueryInspector,
}: QueryGroupTopSectionProps) {
const styles = getStyles();
const [isHelpOpen, setIsHelpOpen] = useState(false);
return (
<>
<div>
<div className={styles.dataSourceRow}>
<InlineFormLabel htmlFor="data-source-picker" width={'auto'}>
Data source
</InlineFormLabel>
<div className={styles.dataSourceRowItem}>
<DataSourcePickerWithPrompt
options={options}
onChange={async (ds, defaultQueries) => {
return await onDataSourceChange?.(ds, defaultQueries);
}}
isDataSourceModalOpen={Boolean(locationService.getSearchObject().firstPanel)}
/>
</div>
{dataSource && (
<>
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
icon="question-circle"
title="Open data source help"
onClick={() => setIsHelpOpen(true)}
data-testid="query-tab-help-button"
/>
</div>
<div className={styles.dataSourceRowItemOptions}>
<QueryGroupOptionsEditor
options={options}
dataSource={dataSource}
data={data}
onChange={(opts) => {
onOptionsChange?.(opts);
}}
/>
</div>
{onOpenQueryInspector && (
<div className={styles.dataSourceRowItem}>
<Button
variant="secondary"
onClick={onOpenQueryInspector}
aria-label={selectors.components.QueryTab.queryInspectorButton}
>
Query inspector
</Button>
</div>
)}
</>
)}
</div>
{dataSource && isAngularDatasourcePluginAndNotHidden(dataSource.uid) && (
<AngularDeprecationPluginNotice
pluginId={dataSource.type}
pluginType={PluginType.datasource}
angularSupportEnabled={config?.angularSupportEnabled}
showPluginDetailsLink={true}
interactionElementId="datasource-query"
/>
)}
</div>
{isHelpOpen && (
<Modal title="Data source help" isOpen={true} onDismiss={() => setIsHelpOpen(false)}>
<PluginHelp pluginId={dsSettings.meta.id} />
</Modal>
)}
</>
);
}
interface DataSourcePickerWithPromptProps {
isDataSourceModalOpen?: boolean;
options: QueryGroupOptions;
onChange: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => Promise<void>;
}
function DataSourcePickerWithPrompt({ options, onChange, ...otherProps }: DataSourcePickerWithPromptProps) {
const [isDataSourceModalOpen, setIsDataSourceModalOpen] = useState(Boolean(otherProps.isDataSourceModalOpen));
useEffect(() => {
// Clean up the first panel flag since the modal is now open
if (!!locationService.getSearchObject().firstPanel) {
locationService.partial({ firstPanel: null }, true);
}
}, []);
const commonProps = {
metrics: true,
mixed: true,
dashboard: true,
variables: true,
current: options.dataSource,
uploadFile: true,
onChange: async (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => {
await onChange(ds, defaultQueries);
setIsDataSourceModalOpen(false);
},
};
return (
<>
{isDataSourceModalOpen && config.featureToggles.advancedDataSourcePicker && (
<DataSourceModal {...commonProps} onDismiss={() => setIsDataSourceModalOpen(false)}></DataSourceModal>
)}
<DataSourcePicker {...commonProps} />
</>
);
}

View File

@ -19,6 +19,7 @@ jest.mock('@grafana/runtime', () => ({
jest.mock('app/core/store', () => {
return {
set() {},
get() {},
getBool(key: string, defaultValue?: boolean) {
const item = window.localStorage.getItem(key);
if (item === null) {

View File

@ -3290,9 +3290,9 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes@npm:1.27.0":
version: 1.27.0
resolution: "@grafana/scenes@npm:1.27.0"
"@grafana/scenes@npm:1.28.0":
version: 1.28.0
resolution: "@grafana/scenes@npm:1.28.0"
dependencies:
"@grafana/e2e-selectors": "npm:10.0.2"
react-grid-layout: "npm:1.3.4"
@ -3304,7 +3304,7 @@ __metadata:
"@grafana/runtime": 10.0.3
"@grafana/schema": 10.0.3
"@grafana/ui": 10.0.3
checksum: e19aa28d6297316676cb4805dde9bc3a52d2d28a3231e2fcdedf610356fc5c1b24eecfec36e8089cf4defc388705d47fce32736f8a86c9176e5bd4e4b7da9acb
checksum: 0973206c4485cad15ceb41f031e96e0f1f075be24570f527bbcb17dd56d5cd362385c04acef8f7aa240c3bb8b045d2270fab2dbb2f18e7e2850ab67a13a3d268
languageName: node
linkType: hard
@ -17317,7 +17317,7 @@ __metadata:
"@grafana/lezer-traceql": "npm:0.0.12"
"@grafana/monaco-logql": "npm:^0.0.7"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:1.27.0"
"@grafana/scenes": "npm:1.28.0"
"@grafana/schema": "workspace:*"
"@grafana/tsconfig": "npm:^1.3.0-rc1"
"@grafana/ui": "workspace:*"