The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx

527 lines
16 KiB

import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useEffect } from 'react';
import {
DataSourceApi,
DataSourceInstanceSettings,
FieldConfigSource,
GrafanaTheme2,
PanelModel,
filterFieldConfigOverrides,
isStandardFieldProp,
restoreCustomOverrideRules,
} from '@grafana/data';
import { config, getDataSourceSrv, locationService } from '@grafana/runtime';
import {
DeepPartial,
PanelBuilders,
SceneComponentProps,
SceneDataTransformer,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectStateChangedEvent,
SceneQueryRunner,
SceneVariables,
VizPanel,
sceneUtils,
} from '@grafana/scenes';
import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard';
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
import { updateLibraryVizPanel } from 'app/features/library-panels/state/api';
import { updateQueries } from 'app/features/query/state/updateQueries';
import { GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { QueryGroupOptions } from 'app/types';
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
import { getPanelChanges } from '../saving/getDashboardChanges';
import { DashboardGridItem, RepeatDirection } from '../scene/DashboardGridItem';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange';
import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
export interface VizPanelManagerState extends SceneObjectState {
panel: VizPanel;
sourcePanel: SceneObjectRef<VizPanel>;
datasource?: DataSourceApi;
dsSettings?: DataSourceInstanceSettings;
tableView?: VizPanel;
repeat?: string;
repeatDirection?: RepeatDirection;
maxPerRow?: number;
isDirty?: boolean;
}
export enum DisplayMode {
Fill = 0,
Fit = 1,
Exact = 2,
}
// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data manipulation.
export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
private _cachedPluginOptions: Record<
string,
{ options: DeepPartial<{}>; fieldConfig: FieldConfigSource<DeepPartial<{}>> } | undefined
> = {};
public constructor(state: VizPanelManagerState) {
super(state);
this.addActivationHandler(() => this._onActivate());
}
/**
* Will clone the source panel and move the data provider to
* live on the VizPanelManager level instead of the VizPanel level
*/
public static createFor(sourcePanel: VizPanel) {
let repeatOptions: Pick<VizPanelManagerState, 'repeat' | 'repeatDirection' | 'maxPerRow'> = {};
const gridItem = sourcePanel.parent instanceof LibraryVizPanel ? sourcePanel.parent.parent : sourcePanel.parent;
if (!(gridItem instanceof DashboardGridItem)) {
console.error('VizPanel is not a child of a dashboard grid item');
throw new Error('VizPanel is not a child of a dashboard grid item');
}
const { variableName: repeat, repeatDirection, maxPerRow } = gridItem.state;
repeatOptions = { repeat, repeatDirection, maxPerRow };
let variables: SceneVariables | undefined;
if (gridItem.parent?.state.$variables) {
variables = gridItem.parent.state.$variables.clone();
}
return new VizPanelManager({
$variables: variables,
panel: sourcePanel.clone(),
sourcePanel: sourcePanel.getRef(),
...repeatOptions,
});
}
private _onActivate() {
this.loadDataSource();
const changesSub = this.subscribeToEvent(SceneObjectStateChangedEvent, this._handleStateChange);
return () => {
changesSub.unsubscribe();
};
}
private _detectPanelModelChanges = debounce(() => {
const { hasChanges } = getPanelChanges(
vizPanelToPanel(this.state.sourcePanel.resolve()),
vizPanelToPanel(this.state.panel)
);
this.setState({ isDirty: hasChanges });
}, 250);
private _handleStateChange = (event: SceneObjectStateChangedEvent) => {
if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) {
this._detectPanelModelChanges();
}
};
private async loadDataSource() {
const dataObj = this.state.panel.state.$data;
if (!dataObj) {
return;
}
let datasourceToLoad = this.queryRunner.state.datasource;
try {
let datasource: DataSourceApi | undefined;
let dsSettings: DataSourceInstanceSettings | undefined;
if (!datasourceToLoad) {
const dashboardScene = getDashboardSceneFor(this);
const dashboardUid = dashboardScene.state.uid ?? '';
const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!);
// do we have a last used datasource for this dashboard
if (lastUsedDatasource?.datasourceUid !== null) {
// get datasource from dashbopard uid
dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid });
if (dsSettings) {
datasource = await getDataSourceSrv().get({
uid: lastUsedDatasource?.datasourceUid,
type: dsSettings.type,
});
this.queryRunner.setState({
datasource: {
uid: lastUsedDatasource?.datasourceUid,
type: dsSettings.type,
},
});
}
}
} else {
datasource = await getDataSourceSrv().get(datasourceToLoad);
dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad);
}
if (datasource && dsSettings) {
this.setState({ datasource, dsSettings });
storeLastUsedDataSourceInLocalStorage(
{
type: dsSettings.type,
uid: dsSettings.uid,
} || { default: true }
);
}
} catch (err) {
//set default datasource if we fail to load the datasource
const datasource = await getDataSourceSrv().get(config.defaultDatasource);
const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource);
if (datasource && dsSettings) {
this.setState({
datasource,
dsSettings,
});
this.queryRunner.setState({
datasource: {
uid: dsSettings.uid,
type: dsSettings.type,
},
});
}
console.error(err);
}
}
public changePluginType(pluginId: string) {
const {
options: prevOptions,
fieldConfig: prevFieldConfig,
pluginId: prevPluginId,
...restOfOldState
} = sceneUtils.cloneSceneObjectState(this.state.panel.state);
// clear custom options
let newFieldConfig: FieldConfigSource = {
defaults: {
...prevFieldConfig.defaults,
custom: {},
},
overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp),
};
this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig };
const cachedOptions = this._cachedPluginOptions[pluginId]?.options;
const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig;
if (cachedFieldConfig) {
newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig);
}
const newPanel = new VizPanel({
options: cachedOptions ?? {},
fieldConfig: newFieldConfig,
pluginId: pluginId,
...restOfOldState,
});
// When changing from non-data to data panel, we need to add a new data provider
if (!this.state.panel.state.$data && !config.panels[pluginId].skipDataQuery) {
let ds = getLastUsedDatasourceFromStorage(getDashboardSceneFor(this).state.uid!)?.datasourceUid;
if (!ds) {
ds = config.defaultDatasource;
}
newPanel.setState({
$data: new SceneDataTransformer({
$data: new SceneQueryRunner({
datasource: {
uid: ds,
},
queries: [{ refId: 'A' }],
}),
transformations: [],
}),
});
}
const newPlugin = newPanel.getPlugin();
const panel: PanelModel = {
title: newPanel.state.title,
options: newPanel.state.options,
fieldConfig: newPanel.state.fieldConfig,
id: 1,
type: pluginId,
};
const newOptions = newPlugin?.onPanelTypeChanged?.(panel, prevPluginId, prevOptions, prevFieldConfig);
if (newOptions) {
newPanel.onOptionsChange(newOptions, true, true);
}
if (newPlugin?.onPanelMigration) {
newPanel.setState({ pluginVersion: getPluginVersion(newPlugin) });
}
this.setState({ panel: newPanel });
this.loadDataSource();
}
public async changePanelDataSource(
newSettings: DataSourceInstanceSettings,
defaultQueries?: DataQuery[] | GrafanaQuery[]
) {
const { dsSettings } = this.state;
const queryRunner = this.queryRunner;
const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined;
const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid });
const currentQueries = queryRunner.state.queries;
// 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));
queryRunner.setState({
datasource: {
type: newSettings.type,
uid: newSettings.uid,
},
queries,
});
if (defaultQueries) {
queryRunner.runQueries();
}
this.loadDataSource();
}
public changeQueryOptions(options: QueryGroupOptions) {
const panelObj = this.state.panel;
const dataObj = this.queryRunner;
const timeRangeObj = panelObj.state.$timeRange;
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) });
}
if (options.cacheTimeout !== dataObj?.state.cacheTimeout) {
dataObjStateUpdate.cacheTimeout = options.cacheTimeout;
}
if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) {
dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL;
}
dataObj.setState(dataObjStateUpdate);
dataObj.runQueries();
}
public changeQueries<T extends DataQuery>(queries: T[]) {
const runner = this.queryRunner;
runner.setState({ queries });
}
public changeTransformations(transformations: DataTransformerConfig[]) {
const dataprovider = this.dataTransformer;
dataprovider.setState({ transformations });
dataprovider.reprocessTransformations();
}
public inspectPanel() {
const panel = this.state.panel;
const panelId = getPanelIdForVizPanel(panel);
locationService.partial({
inspect: panelId,
inspectTab: 'query',
});
}
get queryRunner(): SceneQueryRunner {
// Panel data object is always SceneQueryRunner wrapped in a SceneDataTransformer
const runner = getQueryRunnerFor(this.state.panel);
if (!runner) {
throw new Error('Query runner not found');
}
return runner;
}
get dataTransformer(): SceneDataTransformer {
const provider = this.state.panel.state.$data;
if (!provider || !(provider instanceof SceneDataTransformer)) {
throw new Error('Could not find SceneDataTransformer for panel');
}
return provider;
}
public toggleTableView() {
if (this.state.tableView) {
this.setState({ tableView: undefined });
return;
}
this.setState({
tableView: PanelBuilders.table()
.setTitle('')
.setOption('showTypeIcons', true)
.setOption('showHeader', true)
// Here we are breaking a scene rule and changing the parent of the main panel data provider
// But we need to share this same instance as the queries tab is subscribing to it
.setData(this.dataTransformer)
.build(),
});
}
public unlinkLibraryPanel() {
const sourcePanel = this.state.sourcePanel.resolve();
if (!(sourcePanel.parent instanceof LibraryVizPanel)) {
throw new Error('VizPanel is not a child of a library panel');
}
const gridItem = sourcePanel.parent.parent;
if (!(gridItem instanceof DashboardGridItem)) {
throw new Error('Library panel not a child of a grid item');
}
const newSourcePanel = this.state.panel.clone({ $data: this.state.$data?.clone() });
gridItem.setState({
body: newSourcePanel,
});
this.setState({ sourcePanel: newSourcePanel.getRef() });
}
public commitChanges() {
const sourcePanel = this.state.sourcePanel.resolve();
this.commitChangesTo(sourcePanel);
}
public commitChangesTo(sourcePanel: VizPanel) {
const repeatUpdate = {
variableName: this.state.repeat,
repeatDirection: this.state.repeatDirection,
maxPerRow: this.state.maxPerRow,
};
if (sourcePanel.parent instanceof DashboardGridItem) {
sourcePanel.parent.setState({
...repeatUpdate,
body: this.state.panel.clone(),
});
}
if (sourcePanel.parent instanceof LibraryVizPanel) {
if (sourcePanel.parent.parent instanceof DashboardGridItem) {
const newLibPanel = sourcePanel.parent.clone({
panel: this.state.panel.clone(),
});
sourcePanel.parent.parent.setState({
body: newLibPanel,
...repeatUpdate,
});
updateLibraryVizPanel(newLibPanel!).then((p) => {
if (sourcePanel.parent instanceof LibraryVizPanel) {
newLibPanel.setPanelFromLibPanel(p);
}
});
}
}
}
/**
* Used from inspect json tab to view the current persisted model
*/
public getPanelSaveModel(): Panel | object {
const sourcePanel = this.state.sourcePanel.resolve();
const isLibraryPanel = sourcePanel.parent instanceof LibraryVizPanel;
const gridItem = isLibraryPanel ? sourcePanel.parent.parent : sourcePanel.parent;
if (!(gridItem instanceof DashboardGridItem)) {
return { error: 'Unsupported panel parent' };
}
const parentClone = gridItem.clone({
body: this.state.panel.clone(),
});
return gridItemToPanel(parentClone);
}
public setPanelTitle(newTitle: string) {
this.state.panel.setState({ title: newTitle, hoverHeader: newTitle === '' });
}
public static Component = ({ model }: SceneComponentProps<VizPanelManager>) => {
const { panel, tableView } = model.useState();
const styles = useStyles2(getStyles);
const panelToShow = tableView ?? panel;
const dataProvider = panelToShow.state.$data;
// This is to preserve SceneQueryRunner stays alive when switching between visualizations and table view
useEffect(() => {
return dataProvider?.activate();
}, [dataProvider]);
return (
<>
<div className={styles.wrapper}>{<panelToShow.Component model={panelToShow} />}</div>
</>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
height: '100%',
width: '100%',
paddingLeft: theme.spacing(2),
}),
};
}