2023-09-22 22:06:49 +08:00
|
|
|
import { cloneDeep, defaultsDeep, isArray, isEqual } from 'lodash';
|
2021-10-13 14:53:36 +08:00
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
2022-04-22 21:33:13 +08:00
|
|
|
|
2019-10-31 17:48:05 +08:00
|
|
|
import {
|
2020-03-16 21:26:03 +08:00
|
|
|
DataConfigSource,
|
2021-03-02 23:37:36 +08:00
|
|
|
DataFrameDTO,
|
2020-03-10 15:53:41 +08:00
|
|
|
DataLink,
|
2019-10-31 17:48:05 +08:00
|
|
|
DataQuery,
|
|
|
|
|
DataTransformerConfig,
|
2021-03-02 23:37:36 +08:00
|
|
|
EventBusSrv,
|
2020-04-28 02:50:33 +08:00
|
|
|
FieldConfigSource,
|
2020-03-10 15:53:41 +08:00
|
|
|
PanelPlugin,
|
2021-04-27 13:39:02 +08:00
|
|
|
PanelPluginDataSupport,
|
2019-10-31 17:48:05 +08:00
|
|
|
ScopedVars,
|
2021-08-11 15:23:41 +08:00
|
|
|
PanelModel as IPanelModel,
|
2021-10-30 01:57:24 +08:00
|
|
|
DataSourceRef,
|
2022-12-08 18:52:28 +08:00
|
|
|
CoreApp,
|
2022-12-29 21:48:22 +08:00
|
|
|
filterFieldConfigOverrides,
|
|
|
|
|
getPanelOptionsWithDefaults,
|
|
|
|
|
isStandardFieldProp,
|
|
|
|
|
restoreCustomOverrideRules,
|
2019-10-31 17:48:05 +08:00
|
|
|
} from '@grafana/data';
|
2022-04-22 21:33:13 +08:00
|
|
|
import { getTemplateSrv, RefreshEvent } from '@grafana/runtime';
|
2023-01-30 12:14:12 +08:00
|
|
|
import { LibraryPanel, LibraryPanelRef } from '@grafana/schema';
|
2019-03-25 21:10:23 +08:00
|
|
|
import config from 'app/core/config';
|
2022-07-22 23:10:10 +08:00
|
|
|
import { safeStringifyValue } from 'app/core/utils/explore';
|
2022-04-22 21:33:13 +08:00
|
|
|
import { getNextRefIdChar } from 'app/core/utils/query';
|
|
|
|
|
import { QueryGroupOptions } from 'app/types';
|
2020-12-23 17:45:31 +08:00
|
|
|
import {
|
|
|
|
|
PanelOptionsChangedEvent,
|
|
|
|
|
PanelQueriesChangedEvent,
|
|
|
|
|
PanelTransformationsChangedEvent,
|
|
|
|
|
RenderEvent,
|
|
|
|
|
} from 'app/types/events';
|
2022-04-22 21:33:13 +08:00
|
|
|
|
|
|
|
|
import { PanelQueryRunner } from '../../query/state/PanelQueryRunner';
|
|
|
|
|
import { TimeOverrideResult } from '../utils/panel';
|
|
|
|
|
|
2017-10-10 20:20:53 +08:00
|
|
|
export interface GridPos {
|
2017-10-10 15:34:14 +08:00
|
|
|
x: number;
|
|
|
|
|
y: number;
|
2017-10-10 20:20:53 +08:00
|
|
|
w: number;
|
|
|
|
|
h: number;
|
2017-10-16 15:55:55 +08:00
|
|
|
static?: boolean;
|
2017-10-10 20:20:53 +08:00
|
|
|
}
|
|
|
|
|
|
2022-08-10 16:36:19 +08:00
|
|
|
type RunPanelQueryOptions = {
|
|
|
|
|
dashboardUID: string;
|
|
|
|
|
dashboardTimezone: string;
|
|
|
|
|
timeData: TimeOverrideResult;
|
|
|
|
|
width: number;
|
|
|
|
|
publicDashboardAccessToken?: string;
|
|
|
|
|
};
|
2017-12-19 23:06:54 +08:00
|
|
|
const notPersistedProperties: { [str: string]: boolean } = {
|
|
|
|
|
events: true,
|
2020-04-10 22:37:26 +08:00
|
|
|
isViewing: true,
|
2017-12-20 19:33:33 +08:00
|
|
|
isEditing: true,
|
2019-05-04 03:55:22 +08:00
|
|
|
isInView: true,
|
2018-08-25 23:49:39 +08:00
|
|
|
hasRefreshed: true,
|
2018-12-04 21:19:55 +08:00
|
|
|
cachedPluginOptions: true,
|
2019-03-24 22:56:32 +08:00
|
|
|
plugin: true,
|
2019-04-18 14:22:14 +08:00
|
|
|
queryRunner: true,
|
2020-03-16 21:26:03 +08:00
|
|
|
replaceVariables: true,
|
2021-04-20 01:24:39 +08:00
|
|
|
configRev: true,
|
2022-09-07 01:00:32 +08:00
|
|
|
hasSavedPanelEditChange: true,
|
2021-03-15 15:44:13 +08:00
|
|
|
getDisplayTitle: true,
|
2021-04-27 13:39:02 +08:00
|
|
|
dataSupport: true,
|
2021-09-01 23:03:56 +08:00
|
|
|
key: true,
|
2023-04-25 22:18:58 +08:00
|
|
|
isNew: true,
|
2023-11-09 22:41:47 +08:00
|
|
|
refreshWhenInView: true,
|
2017-10-10 20:20:53 +08:00
|
|
|
};
|
|
|
|
|
|
2018-11-16 17:00:13 +08:00
|
|
|
// For angular panels we need to clean up properties when changing type
|
|
|
|
|
// To make sure the change happens without strange bugs happening when panels use same
|
|
|
|
|
// named property with different type / value expectations
|
|
|
|
|
// This is not required for react panels
|
|
|
|
|
const mustKeepProps: { [str: string]: boolean } = {
|
|
|
|
|
id: true,
|
|
|
|
|
gridPos: true,
|
|
|
|
|
type: true,
|
|
|
|
|
title: true,
|
|
|
|
|
scopedVars: true,
|
|
|
|
|
repeat: true,
|
|
|
|
|
repeatPanelId: true,
|
|
|
|
|
repeatDirection: true,
|
|
|
|
|
repeatedByRow: true,
|
|
|
|
|
minSpan: true,
|
|
|
|
|
collapsed: true,
|
|
|
|
|
panels: true,
|
|
|
|
|
targets: true,
|
|
|
|
|
datasource: true,
|
|
|
|
|
timeFrom: true,
|
|
|
|
|
timeShift: true,
|
|
|
|
|
hideTimeOverride: true,
|
|
|
|
|
description: true,
|
|
|
|
|
links: true,
|
|
|
|
|
fullscreen: true,
|
|
|
|
|
isEditing: true,
|
|
|
|
|
hasRefreshed: true,
|
|
|
|
|
events: true,
|
|
|
|
|
cacheTimeout: true,
|
2023-02-03 12:39:54 +08:00
|
|
|
queryCachingTTL: true,
|
2018-12-04 21:19:55 +08:00
|
|
|
cachedPluginOptions: true,
|
2018-12-06 17:34:27 +08:00
|
|
|
transparent: true,
|
2019-03-23 04:45:09 +08:00
|
|
|
pluginVersion: true,
|
2019-05-03 16:29:22 +08:00
|
|
|
queryRunner: true,
|
2019-09-09 14:58:57 +08:00
|
|
|
transformations: true,
|
2020-03-19 18:50:31 +08:00
|
|
|
fieldConfig: true,
|
2020-05-12 04:03:43 +08:00
|
|
|
maxDataPoints: true,
|
|
|
|
|
interval: true,
|
2021-01-20 23:06:29 +08:00
|
|
|
replaceVariables: true,
|
2021-02-25 18:26:28 +08:00
|
|
|
libraryPanel: true,
|
2021-03-15 15:44:13 +08:00
|
|
|
getDisplayTitle: true,
|
2021-04-21 03:22:58 +08:00
|
|
|
configRev: true,
|
2021-10-13 14:53:36 +08:00
|
|
|
key: true,
|
2018-11-16 17:00:13 +08:00
|
|
|
};
|
|
|
|
|
|
2018-10-15 03:14:11 +08:00
|
|
|
const defaults: any = {
|
|
|
|
|
gridPos: { x: 0, y: 0, h: 3, w: 6 },
|
2019-01-22 03:35:24 +08:00
|
|
|
targets: [{ refId: 'A' }],
|
2018-12-04 21:19:55 +08:00
|
|
|
cachedPluginOptions: {},
|
2018-12-06 17:34:27 +08:00
|
|
|
transparent: false,
|
2020-02-13 23:06:45 +08:00
|
|
|
options: {},
|
2024-02-05 19:27:44 +08:00
|
|
|
links: [],
|
|
|
|
|
transformations: [],
|
2021-04-27 16:09:37 +08:00
|
|
|
fieldConfig: {
|
|
|
|
|
defaults: {},
|
|
|
|
|
overrides: [],
|
|
|
|
|
},
|
2021-02-22 17:06:07 +08:00
|
|
|
title: '',
|
2018-10-15 03:14:11 +08:00
|
|
|
};
|
|
|
|
|
|
2024-02-15 00:06:25 +08:00
|
|
|
export const explicitlyControlledMigrationPanels = [
|
|
|
|
|
'graph',
|
|
|
|
|
'table-old',
|
|
|
|
|
'grafana-piechart-panel',
|
|
|
|
|
'grafana-worldmap-panel',
|
|
|
|
|
'singlestat',
|
|
|
|
|
'grafana-singlestat-panel',
|
|
|
|
|
];
|
2024-02-09 06:00:48 +08:00
|
|
|
|
2023-03-27 23:11:45 +08:00
|
|
|
export const autoMigrateAngular: Record<string, string> = {
|
|
|
|
|
graph: 'timeseries',
|
|
|
|
|
'table-old': 'table',
|
|
|
|
|
singlestat: 'stat', // also automigrated if dashboard schemaVerion < 27
|
|
|
|
|
'grafana-singlestat-panel': 'stat',
|
|
|
|
|
'grafana-piechart-panel': 'piechart',
|
|
|
|
|
'grafana-worldmap-panel': 'geomap',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const autoMigratePanelType: Record<string, string> = {
|
|
|
|
|
'heatmap-new': 'heatmap', // this was a temporary development panel that is now standard
|
|
|
|
|
};
|
|
|
|
|
|
2021-08-11 15:23:41 +08:00
|
|
|
export class PanelModel implements DataConfigSource, IPanelModel {
|
2020-02-10 21:23:54 +08:00
|
|
|
/* persisted id, used in URL to identify a panel */
|
2021-02-22 17:06:07 +08:00
|
|
|
id!: number;
|
|
|
|
|
gridPos!: GridPos;
|
|
|
|
|
type!: string;
|
|
|
|
|
title!: string;
|
2017-10-11 22:08:56 +08:00
|
|
|
alert?: any;
|
2019-03-04 17:42:59 +08:00
|
|
|
scopedVars?: ScopedVars;
|
2017-10-13 20:50:16 +08:00
|
|
|
repeat?: string;
|
|
|
|
|
repeatIteration?: number;
|
|
|
|
|
repeatPanelId?: number;
|
|
|
|
|
repeatDirection?: string;
|
2018-01-10 21:30:43 +08:00
|
|
|
repeatedByRow?: boolean;
|
2018-08-20 21:33:49 +08:00
|
|
|
maxPerRow?: number;
|
2017-10-17 20:53:52 +08:00
|
|
|
collapsed?: boolean;
|
2020-08-04 00:50:36 +08:00
|
|
|
|
2022-05-04 16:39:41 +08:00
|
|
|
panels?: PanelModel[];
|
2021-04-15 20:21:06 +08:00
|
|
|
declare targets: DataQuery[];
|
2019-09-09 14:58:57 +08:00
|
|
|
transformations?: DataTransformerConfig[];
|
2021-10-30 01:57:24 +08:00
|
|
|
datasource: DataSourceRef | null = null;
|
2018-08-26 03:22:50 +08:00
|
|
|
thresholds?: any;
|
2019-03-23 04:45:09 +08:00
|
|
|
pluginVersion?: string;
|
2020-11-17 17:25:36 +08:00
|
|
|
snapshotData?: DataFrameDTO[];
|
2018-11-08 21:44:12 +08:00
|
|
|
timeFrom?: any;
|
|
|
|
|
timeShift?: any;
|
|
|
|
|
hideTimeOverride?: any;
|
2021-04-15 20:21:06 +08:00
|
|
|
declare options: {
|
2019-02-18 18:40:25 +08:00
|
|
|
[key: string]: any;
|
|
|
|
|
};
|
2021-04-15 20:21:06 +08:00
|
|
|
declare fieldConfig: FieldConfigSource;
|
2018-11-08 21:44:12 +08:00
|
|
|
|
2020-12-02 22:42:54 +08:00
|
|
|
maxDataPoints?: number | null;
|
|
|
|
|
interval?: string | null;
|
2018-11-14 15:07:16 +08:00
|
|
|
description?: string;
|
2019-06-25 17:38:51 +08:00
|
|
|
links?: DataLink[];
|
2021-04-15 20:21:06 +08:00
|
|
|
declare transparent: boolean;
|
2018-11-12 16:32:55 +08:00
|
|
|
|
2023-01-30 12:14:12 +08:00
|
|
|
libraryPanel?: LibraryPanelRef | LibraryPanel;
|
2021-02-25 18:26:28 +08:00
|
|
|
|
2022-06-11 08:12:56 +08:00
|
|
|
autoMigrateFrom?: string;
|
|
|
|
|
|
2017-10-11 17:42:49 +08:00
|
|
|
// non persisted
|
2021-04-15 20:21:06 +08:00
|
|
|
isViewing = false;
|
|
|
|
|
isEditing = false;
|
|
|
|
|
isInView = false;
|
2021-04-20 01:24:39 +08:00
|
|
|
configRev = 0; // increments when configs change
|
2022-09-07 01:00:32 +08:00
|
|
|
hasSavedPanelEditChange?: boolean;
|
2021-04-15 20:21:06 +08:00
|
|
|
hasRefreshed?: boolean;
|
2021-11-24 21:24:33 +08:00
|
|
|
cacheTimeout?: string | null;
|
2023-02-03 12:39:54 +08:00
|
|
|
queryCachingTTL?: number | null;
|
2023-04-25 22:18:58 +08:00
|
|
|
isNew?: boolean;
|
2023-11-09 22:41:47 +08:00
|
|
|
refreshWhenInView = false;
|
2023-02-03 12:39:54 +08:00
|
|
|
|
2021-04-27 16:09:37 +08:00
|
|
|
cachedPluginOptions: Record<string, PanelOptionsCache> = {};
|
2020-03-10 15:53:41 +08:00
|
|
|
legend?: { show: boolean; sort?: string; sortDesc?: boolean };
|
2019-05-01 13:36:46 +08:00
|
|
|
plugin?: PanelPlugin;
|
2021-10-13 14:53:36 +08:00
|
|
|
/**
|
|
|
|
|
* Unique in application state, this is used as redux key for panel and for redux panel state
|
|
|
|
|
* Change will cause unmount and re-init of panel
|
|
|
|
|
*/
|
|
|
|
|
key: string;
|
2020-02-09 17:53:34 +08:00
|
|
|
|
2021-05-01 04:33:29 +08:00
|
|
|
/**
|
|
|
|
|
* The PanelModel event bus only used for internal and legacy angular support.
|
|
|
|
|
* The EventBus passed to panels is based on the dashboard event model.
|
|
|
|
|
*/
|
|
|
|
|
events: EventBusSrv;
|
|
|
|
|
|
2019-04-19 05:10:18 +08:00
|
|
|
private queryRunner?: PanelQueryRunner;
|
2018-12-04 21:19:55 +08:00
|
|
|
|
2019-03-20 01:24:09 +08:00
|
|
|
constructor(model: any) {
|
2020-11-03 20:08:54 +08:00
|
|
|
this.events = new EventBusSrv();
|
2020-02-07 21:59:04 +08:00
|
|
|
this.restoreModel(model);
|
2020-03-16 21:26:03 +08:00
|
|
|
this.replaceVariables = this.replaceVariables.bind(this);
|
2021-10-13 14:53:36 +08:00
|
|
|
this.key = uuidv4();
|
2020-02-07 21:59:04 +08:00
|
|
|
}
|
2019-09-24 18:15:35 +08:00
|
|
|
|
2020-02-07 21:59:04 +08:00
|
|
|
/** Given a persistened PanelModel restores property values */
|
2020-02-28 18:04:40 +08:00
|
|
|
restoreModel(model: any) {
|
2020-04-28 02:50:33 +08:00
|
|
|
// Start with clean-up
|
2020-05-12 04:03:43 +08:00
|
|
|
for (const property in this) {
|
|
|
|
|
if (notPersistedProperties[property] || !this.hasOwnProperty(property)) {
|
2020-04-28 02:50:33 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (model[property]) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-22 22:06:49 +08:00
|
|
|
if (typeof this[property] === 'function') {
|
2020-04-28 02:50:33 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-22 22:06:49 +08:00
|
|
|
if (typeof this[property] === 'symbol') {
|
2020-04-28 02:50:33 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-22 22:06:49 +08:00
|
|
|
delete this[property];
|
2020-04-28 02:50:33 +08:00
|
|
|
}
|
|
|
|
|
|
2017-10-10 20:20:53 +08:00
|
|
|
// copy properties from persisted model
|
2018-08-29 20:26:50 +08:00
|
|
|
for (const property in model) {
|
2019-03-20 01:24:09 +08:00
|
|
|
(this as any)[property] = model[property];
|
2017-10-10 20:20:53 +08:00
|
|
|
}
|
2017-10-13 03:37:27 +08:00
|
|
|
|
2023-03-27 23:11:45 +08:00
|
|
|
const newType = autoMigratePanelType[this.type];
|
|
|
|
|
if (newType) {
|
|
|
|
|
this.autoMigrateFrom = this.type;
|
|
|
|
|
this.type = newType;
|
2022-06-11 08:12:56 +08:00
|
|
|
}
|
|
|
|
|
|
2018-06-26 22:32:01 +08:00
|
|
|
// defaults
|
2021-04-21 15:38:00 +08:00
|
|
|
defaultsDeep(this, cloneDeep(defaults));
|
2019-05-07 12:07:33 +08:00
|
|
|
|
2019-01-22 03:35:24 +08:00
|
|
|
// queries must have refId
|
|
|
|
|
this.ensureQueryIds();
|
2020-02-28 18:04:40 +08:00
|
|
|
}
|
2019-01-22 03:35:24 +08:00
|
|
|
|
2021-10-13 14:53:36 +08:00
|
|
|
generateNewKey() {
|
|
|
|
|
this.key = uuidv4();
|
|
|
|
|
}
|
|
|
|
|
|
2019-01-22 03:35:24 +08:00
|
|
|
ensureQueryIds() {
|
2021-04-21 15:38:00 +08:00
|
|
|
if (this.targets && isArray(this.targets)) {
|
2019-01-22 03:35:24 +08:00
|
|
|
for (const query of this.targets) {
|
|
|
|
|
if (!query.refId) {
|
2019-03-18 18:17:58 +08:00
|
|
|
query.refId = getNextRefIdChar(this.targets);
|
2019-01-22 03:35:24 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2017-10-10 20:20:53 +08:00
|
|
|
}
|
|
|
|
|
|
2019-05-21 19:19:19 +08:00
|
|
|
getOptions() {
|
|
|
|
|
return this.options;
|
2018-11-06 00:46:09 +08:00
|
|
|
}
|
2020-08-31 23:53:21 +08:00
|
|
|
|
2021-04-20 01:24:39 +08:00
|
|
|
get hasChanged(): boolean {
|
|
|
|
|
return this.configRev > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2018-11-06 00:46:09 +08:00
|
|
|
updateOptions(options: object) {
|
2019-02-18 18:41:14 +08:00
|
|
|
this.options = options;
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2020-11-26 22:14:57 +08:00
|
|
|
this.events.publish(new PanelOptionsChangedEvent());
|
2020-03-19 18:50:31 +08:00
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateFieldConfig(config: FieldConfigSource) {
|
|
|
|
|
this.fieldConfig = config;
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2020-12-17 20:30:55 +08:00
|
|
|
this.events.publish(new PanelOptionsChangedEvent());
|
2020-03-19 18:50:31 +08:00
|
|
|
|
2020-03-16 21:26:03 +08:00
|
|
|
this.resendLastResult();
|
2018-11-06 00:46:09 +08:00
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
|
2017-10-10 20:20:53 +08:00
|
|
|
getSaveModel() {
|
2017-10-10 23:57:53 +08:00
|
|
|
const model: any = {};
|
2020-05-12 04:03:43 +08:00
|
|
|
|
2018-08-29 20:26:50 +08:00
|
|
|
for (const property in this) {
|
2017-10-10 20:20:53 +08:00
|
|
|
if (notPersistedProperties[property] || !this.hasOwnProperty(property)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-21 15:38:00 +08:00
|
|
|
if (isEqual(this[property], defaults[property])) {
|
2018-10-15 03:14:11 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-21 15:38:00 +08:00
|
|
|
model[property] = cloneDeep(this[property]);
|
2017-10-10 20:20:53 +08:00
|
|
|
}
|
2020-05-12 04:03:43 +08:00
|
|
|
|
2023-05-16 19:44:19 +08:00
|
|
|
// clean libraryPanel from collapsed rows
|
|
|
|
|
if (this.type === 'row' && this.panels && this.panels.length > 0) {
|
|
|
|
|
model.panels = this.panels.map((panel) => {
|
|
|
|
|
if (panel.libraryPanel) {
|
|
|
|
|
const { id, title, libraryPanel, gridPos } = panel;
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
title,
|
|
|
|
|
gridPos,
|
|
|
|
|
libraryPanel: {
|
|
|
|
|
uid: libraryPanel.uid,
|
|
|
|
|
name: libraryPanel.name,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return panel;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2017-10-10 23:57:53 +08:00
|
|
|
return model;
|
2017-10-10 20:20:53 +08:00
|
|
|
}
|
|
|
|
|
|
2020-04-10 22:37:26 +08:00
|
|
|
setIsViewing(isViewing: boolean) {
|
|
|
|
|
this.isViewing = isViewing;
|
2017-10-11 17:42:49 +08:00
|
|
|
}
|
|
|
|
|
|
2022-06-27 19:30:59 +08:00
|
|
|
updateGridPos(newPos: GridPos, manuallyUpdated = true) {
|
2022-06-17 14:58:53 +08:00
|
|
|
if (
|
|
|
|
|
newPos.x === this.gridPos.x &&
|
|
|
|
|
newPos.y === this.gridPos.y &&
|
|
|
|
|
newPos.h === this.gridPos.h &&
|
|
|
|
|
newPos.w === this.gridPos.w
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2017-10-10 20:20:53 +08:00
|
|
|
this.gridPos.x = newPos.x;
|
|
|
|
|
this.gridPos.y = newPos.y;
|
|
|
|
|
this.gridPos.w = newPos.w;
|
|
|
|
|
this.gridPos.h = newPos.h;
|
2022-06-27 19:30:59 +08:00
|
|
|
if (manuallyUpdated) {
|
|
|
|
|
this.configRev++;
|
|
|
|
|
}
|
2023-02-03 01:53:18 +08:00
|
|
|
|
|
|
|
|
// Maybe a bit heavy. Could add a "GridPosChanged" event instead?
|
|
|
|
|
this.render();
|
2017-10-12 03:36:03 +08:00
|
|
|
}
|
2017-10-16 22:09:23 +08:00
|
|
|
|
2023-08-26 02:56:02 +08:00
|
|
|
runAllPanelQueries({ dashboardUID, dashboardTimezone, timeData, width }: RunPanelQueryOptions) {
|
2021-06-01 19:52:08 +08:00
|
|
|
this.getQueryRunner().run({
|
|
|
|
|
datasource: this.datasource,
|
|
|
|
|
queries: this.targets,
|
2021-10-13 14:53:36 +08:00
|
|
|
panelId: this.id,
|
2024-02-21 16:38:42 +08:00
|
|
|
panelPluginId: this.type,
|
2022-08-10 16:36:19 +08:00
|
|
|
dashboardUID: dashboardUID,
|
2021-06-01 19:52:08 +08:00
|
|
|
timezone: dashboardTimezone,
|
|
|
|
|
timeRange: timeData.timeRange,
|
|
|
|
|
timeInfo: timeData.timeInfo,
|
2021-11-29 18:49:53 +08:00
|
|
|
maxDataPoints: this.maxDataPoints || Math.floor(width),
|
2021-06-01 19:52:08 +08:00
|
|
|
minInterval: this.interval,
|
|
|
|
|
scopedVars: this.scopedVars,
|
|
|
|
|
cacheTimeout: this.cacheTimeout,
|
2023-02-03 12:39:54 +08:00
|
|
|
queryCachingTTL: this.queryCachingTTL,
|
2021-06-01 19:52:08 +08:00
|
|
|
transformations: this.transformations,
|
2022-12-08 18:52:28 +08:00
|
|
|
app: this.isEditing ? CoreApp.PanelEditor : this.isViewing ? CoreApp.PanelViewer : CoreApp.Dashboard,
|
2021-06-01 19:52:08 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2018-08-25 23:49:39 +08:00
|
|
|
refresh() {
|
|
|
|
|
this.hasRefreshed = true;
|
2020-12-23 17:45:31 +08:00
|
|
|
this.events.publish(new RefreshEvent());
|
2018-08-25 23:49:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render() {
|
|
|
|
|
if (!this.hasRefreshed) {
|
|
|
|
|
this.refresh();
|
|
|
|
|
} else {
|
2020-12-23 17:45:31 +08:00
|
|
|
this.events.publish(new RenderEvent());
|
2018-08-25 23:49:39 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-10 20:33:58 +08:00
|
|
|
public getOptionsToRemember(): any {
|
2018-12-04 21:19:55 +08:00
|
|
|
return Object.keys(this).reduce((acc, property) => {
|
2018-12-05 14:33:21 +08:00
|
|
|
if (notPersistedProperties[property] || mustKeepProps[property]) {
|
2018-12-04 21:19:55 +08:00
|
|
|
return acc;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...acc,
|
2019-03-20 01:24:09 +08:00
|
|
|
[property]: (this as any)[property],
|
2018-12-04 21:19:55 +08:00
|
|
|
};
|
|
|
|
|
}, {});
|
|
|
|
|
}
|
|
|
|
|
|
2018-12-05 14:33:21 +08:00
|
|
|
private restorePanelOptions(pluginId: string) {
|
2021-01-20 23:06:29 +08:00
|
|
|
const prevOptions = this.cachedPluginOptions[pluginId];
|
2018-12-04 21:19:55 +08:00
|
|
|
|
2021-01-20 23:06:29 +08:00
|
|
|
if (!prevOptions) {
|
2019-05-21 19:19:19 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2020-08-31 23:53:21 +08:00
|
|
|
|
2021-01-20 23:06:29 +08:00
|
|
|
Object.keys(prevOptions.properties).map((property) => {
|
|
|
|
|
(this as any)[property] = prevOptions.properties[property];
|
2019-11-19 21:59:39 +08:00
|
|
|
});
|
2020-03-19 18:50:31 +08:00
|
|
|
|
2021-01-20 23:06:29 +08:00
|
|
|
this.fieldConfig = restoreCustomOverrideRules(this.fieldConfig, prevOptions.fieldConfig);
|
2020-11-09 20:11:09 +08:00
|
|
|
}
|
|
|
|
|
|
2021-02-13 18:35:39 +08:00
|
|
|
applyPluginOptionDefaults(plugin: PanelPlugin, isAfterPluginChange: boolean) {
|
2021-01-20 23:06:29 +08:00
|
|
|
const options = getPanelOptionsWithDefaults({
|
|
|
|
|
plugin,
|
|
|
|
|
currentOptions: this.options,
|
|
|
|
|
currentFieldConfig: this.fieldConfig,
|
2021-02-13 18:35:39 +08:00
|
|
|
isAfterPluginChange: isAfterPluginChange,
|
2021-01-20 23:06:29 +08:00
|
|
|
});
|
2020-11-09 20:11:09 +08:00
|
|
|
|
2021-01-20 23:06:29 +08:00
|
|
|
this.fieldConfig = options.fieldConfig;
|
|
|
|
|
this.options = options.options;
|
2019-05-21 19:19:19 +08:00
|
|
|
}
|
|
|
|
|
|
2023-08-28 18:06:55 +08:00
|
|
|
async pluginLoaded(plugin: PanelPlugin) {
|
2019-03-24 22:56:32 +08:00
|
|
|
this.plugin = plugin;
|
2023-04-19 19:47:55 +08:00
|
|
|
|
2021-04-12 18:47:17 +08:00
|
|
|
const version = getPluginVersion(plugin);
|
2019-03-24 22:56:32 +08:00
|
|
|
|
2022-06-11 08:12:56 +08:00
|
|
|
if (this.autoMigrateFrom) {
|
2023-03-27 23:11:45 +08:00
|
|
|
const wasAngular = autoMigrateAngular[this.autoMigrateFrom] != null;
|
2023-04-19 19:47:55 +08:00
|
|
|
const oldOptions = this.getOptionsToRemember();
|
|
|
|
|
const prevPluginId = this.autoMigrateFrom;
|
|
|
|
|
const newPluginId = this.type;
|
|
|
|
|
|
|
|
|
|
this.clearPropertiesBeforePluginChange();
|
|
|
|
|
|
|
|
|
|
// Need to set these again as they get cleared by the above function
|
|
|
|
|
this.type = newPluginId;
|
|
|
|
|
this.plugin = plugin;
|
|
|
|
|
|
|
|
|
|
this.callPanelTypeChangeHandler(plugin, prevPluginId, oldOptions, wasAngular);
|
2022-06-11 08:12:56 +08:00
|
|
|
}
|
|
|
|
|
|
2020-07-01 15:39:06 +08:00
|
|
|
if (plugin.onPanelMigration) {
|
2019-03-25 21:10:23 +08:00
|
|
|
if (version !== this.pluginVersion) {
|
2023-08-28 18:06:55 +08:00
|
|
|
const newPanelOptions = plugin.onPanelMigration(this);
|
|
|
|
|
this.options = await newPanelOptions;
|
2019-03-25 21:10:23 +08:00
|
|
|
this.pluginVersion = version;
|
|
|
|
|
}
|
2019-03-24 22:56:32 +08:00
|
|
|
}
|
2019-08-06 14:26:11 +08:00
|
|
|
|
2021-02-13 18:35:39 +08:00
|
|
|
this.applyPluginOptionDefaults(plugin, false);
|
2020-03-16 21:26:03 +08:00
|
|
|
this.resendLastResult();
|
2019-03-24 22:56:32 +08:00
|
|
|
}
|
|
|
|
|
|
2021-01-20 23:06:29 +08:00
|
|
|
clearPropertiesBeforePluginChange() {
|
2019-02-18 18:41:14 +08:00
|
|
|
// remove panel type specific options
|
2023-09-22 22:06:49 +08:00
|
|
|
for (const key in this) {
|
2019-02-18 18:41:14 +08:00
|
|
|
if (mustKeepProps[key]) {
|
|
|
|
|
continue;
|
2018-11-16 17:00:13 +08:00
|
|
|
}
|
2023-09-22 22:06:49 +08:00
|
|
|
delete this[key];
|
2018-11-16 17:00:13 +08:00
|
|
|
}
|
2018-12-04 21:19:55 +08:00
|
|
|
|
2021-01-20 23:06:29 +08:00
|
|
|
this.options = {};
|
|
|
|
|
|
|
|
|
|
// clear custom options
|
|
|
|
|
this.fieldConfig = {
|
|
|
|
|
defaults: {
|
|
|
|
|
...this.fieldConfig.defaults,
|
|
|
|
|
custom: {},
|
|
|
|
|
},
|
|
|
|
|
// filter out custom overrides
|
|
|
|
|
overrides: filterFieldConfigOverrides(this.fieldConfig.overrides, isStandardFieldProp),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2022-06-11 08:12:56 +08:00
|
|
|
// Let panel plugins inspect options from previous panel and keep any that it can use
|
|
|
|
|
private callPanelTypeChangeHandler(
|
|
|
|
|
newPlugin: PanelPlugin,
|
|
|
|
|
oldPluginId: string,
|
|
|
|
|
oldOptions: any,
|
|
|
|
|
wasAngular: boolean
|
|
|
|
|
) {
|
|
|
|
|
if (newPlugin.onPanelTypeChanged) {
|
|
|
|
|
const prevOptions = wasAngular ? { angular: oldOptions } : oldOptions.options;
|
|
|
|
|
Object.assign(this.options, newPlugin.onPanelTypeChanged(this, oldPluginId, prevOptions, this.fieldConfig));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2021-01-20 23:06:29 +08:00
|
|
|
changePlugin(newPlugin: PanelPlugin) {
|
|
|
|
|
const pluginId = newPlugin.meta.id;
|
|
|
|
|
const oldOptions: any = this.getOptionsToRemember();
|
2021-01-21 01:28:27 +08:00
|
|
|
const prevFieldConfig = this.fieldConfig;
|
2021-01-20 23:06:29 +08:00
|
|
|
const oldPluginId = this.type;
|
2023-04-29 03:20:10 +08:00
|
|
|
const wasAngular = this.isAngularPlugin() || Boolean(autoMigrateAngular[oldPluginId]);
|
2021-01-20 23:06:29 +08:00
|
|
|
this.cachedPluginOptions[oldPluginId] = {
|
|
|
|
|
properties: oldOptions,
|
2021-01-21 01:28:27 +08:00
|
|
|
fieldConfig: prevFieldConfig,
|
2021-01-20 23:06:29 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.clearPropertiesBeforePluginChange();
|
2018-12-04 21:19:55 +08:00
|
|
|
this.restorePanelOptions(pluginId);
|
2019-02-21 20:43:36 +08:00
|
|
|
|
2022-06-11 08:12:56 +08:00
|
|
|
// Potentially modify current options
|
|
|
|
|
this.callPanelTypeChangeHandler(newPlugin, oldPluginId, oldOptions, wasAngular);
|
2019-04-05 00:30:15 +08:00
|
|
|
|
2019-08-19 06:01:07 +08:00
|
|
|
// switch
|
|
|
|
|
this.type = pluginId;
|
|
|
|
|
this.plugin = newPlugin;
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2020-03-16 21:26:03 +08:00
|
|
|
|
2021-02-13 18:35:39 +08:00
|
|
|
this.applyPluginOptionDefaults(newPlugin, true);
|
2019-08-19 06:01:07 +08:00
|
|
|
|
2019-05-01 13:36:46 +08:00
|
|
|
if (newPlugin.onPanelMigration) {
|
|
|
|
|
this.pluginVersion = getPluginVersion(newPlugin);
|
2019-02-21 20:43:36 +08:00
|
|
|
}
|
2018-07-10 00:17:51 +08:00
|
|
|
}
|
|
|
|
|
|
2021-03-03 22:16:54 +08:00
|
|
|
updateQueries(options: QueryGroupOptions) {
|
2021-10-30 01:57:24 +08:00
|
|
|
const { dataSource } = options;
|
2022-03-08 15:56:12 +08:00
|
|
|
this.datasource = {
|
|
|
|
|
uid: dataSource.uid,
|
|
|
|
|
type: dataSource.type,
|
|
|
|
|
};
|
2022-12-01 07:33:40 +08:00
|
|
|
|
2021-11-24 21:24:33 +08:00
|
|
|
this.cacheTimeout = options.cacheTimeout;
|
2023-02-03 12:39:54 +08:00
|
|
|
this.queryCachingTTL = options.queryCachingTTL;
|
2021-03-03 22:16:54 +08:00
|
|
|
this.timeFrom = options.timeRange?.from;
|
|
|
|
|
this.timeShift = options.timeRange?.shift;
|
|
|
|
|
this.hideTimeOverride = options.timeRange?.hide;
|
|
|
|
|
this.interval = options.minInterval;
|
|
|
|
|
this.maxDataPoints = options.maxDataPoints;
|
|
|
|
|
this.targets = options.queries;
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2021-03-03 22:16:54 +08:00
|
|
|
|
2020-11-26 22:14:57 +08:00
|
|
|
this.events.publish(new PanelQueriesChangedEvent());
|
2020-09-28 15:06:46 +08:00
|
|
|
}
|
|
|
|
|
|
2018-12-12 15:54:12 +08:00
|
|
|
addQuery(query?: Partial<DataQuery>) {
|
2018-12-11 20:36:44 +08:00
|
|
|
query = query || { refId: 'A' };
|
2019-03-18 18:17:58 +08:00
|
|
|
query.refId = getNextRefIdChar(this.targets);
|
2019-01-22 03:35:24 +08:00
|
|
|
this.targets.push(query as DataQuery);
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2018-12-11 20:36:44 +08:00
|
|
|
}
|
|
|
|
|
|
2019-01-29 16:39:23 +08:00
|
|
|
changeQuery(query: DataQuery, index: number) {
|
|
|
|
|
// ensure refId is maintained
|
|
|
|
|
query.refId = this.targets[index].refId;
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2019-01-29 16:39:23 +08:00
|
|
|
|
|
|
|
|
// update query in array
|
|
|
|
|
this.targets = this.targets.map((item, itemIndex) => {
|
|
|
|
|
if (itemIndex === index) {
|
|
|
|
|
return query;
|
|
|
|
|
}
|
|
|
|
|
return item;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-07 21:59:04 +08:00
|
|
|
getEditClone() {
|
2020-02-13 23:06:45 +08:00
|
|
|
const sourceModel = this.getSaveModel();
|
|
|
|
|
|
|
|
|
|
const clone = new PanelModel(sourceModel);
|
2020-04-10 22:37:26 +08:00
|
|
|
clone.isEditing = true;
|
2022-08-24 14:35:44 +08:00
|
|
|
clone.plugin = this.plugin;
|
2021-10-13 14:53:36 +08:00
|
|
|
|
2020-02-11 21:57:16 +08:00
|
|
|
const sourceQueryRunner = this.getQueryRunner();
|
2020-02-08 20:23:16 +08:00
|
|
|
|
2020-05-06 22:06:21 +08:00
|
|
|
// Copy last query result
|
|
|
|
|
clone.getQueryRunner().useLastResultFrom(sourceQueryRunner);
|
2020-02-08 20:23:16 +08:00
|
|
|
|
2020-02-07 21:59:04 +08:00
|
|
|
return clone;
|
|
|
|
|
}
|
|
|
|
|
|
2020-03-16 21:26:03 +08:00
|
|
|
getTransformations() {
|
|
|
|
|
return this.transformations;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getFieldOverrideOptions() {
|
|
|
|
|
if (!this.plugin) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2020-04-06 22:24:41 +08:00
|
|
|
fieldConfig: this.fieldConfig,
|
2020-03-16 21:26:03 +08:00
|
|
|
replaceVariables: this.replaceVariables,
|
2020-04-06 22:24:41 +08:00
|
|
|
fieldConfigRegistry: this.plugin.fieldConfigRegistry,
|
2021-04-29 18:44:06 +08:00
|
|
|
theme: config.theme2,
|
2020-03-16 21:26:03 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2021-05-03 14:52:05 +08:00
|
|
|
getDataSupport(): PanelPluginDataSupport {
|
|
|
|
|
return this.plugin?.dataSupport ?? { annotations: false, alertStates: false };
|
|
|
|
|
}
|
|
|
|
|
|
2019-04-19 05:10:18 +08:00
|
|
|
getQueryRunner(): PanelQueryRunner {
|
|
|
|
|
if (!this.queryRunner) {
|
2020-03-16 21:26:03 +08:00
|
|
|
this.queryRunner = new PanelQueryRunner(this);
|
2019-04-19 05:10:18 +08:00
|
|
|
}
|
|
|
|
|
return this.queryRunner;
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-06 21:26:09 +08:00
|
|
|
hasTitle() {
|
2019-05-08 23:21:46 +08:00
|
|
|
return this.title && this.title.length > 0;
|
2019-05-06 21:26:09 +08:00
|
|
|
}
|
|
|
|
|
|
2019-09-12 23:28:46 +08:00
|
|
|
isAngularPlugin(): boolean {
|
2023-11-10 18:44:54 +08:00
|
|
|
return (
|
|
|
|
|
(this.plugin && this.plugin.angularPanelCtrl) !== undefined || (this.plugin?.meta?.angular?.detected ?? false)
|
|
|
|
|
);
|
2019-09-12 23:28:46 +08:00
|
|
|
}
|
|
|
|
|
|
2017-10-16 22:09:23 +08:00
|
|
|
destroy() {
|
|
|
|
|
this.events.removeAllListeners();
|
2019-04-19 12:56:27 +08:00
|
|
|
|
|
|
|
|
if (this.queryRunner) {
|
|
|
|
|
this.queryRunner.destroy();
|
|
|
|
|
}
|
2017-10-16 22:09:23 +08:00
|
|
|
}
|
2019-09-09 14:58:57 +08:00
|
|
|
|
|
|
|
|
setTransformations(transformations: DataTransformerConfig[]) {
|
|
|
|
|
this.transformations = transformations;
|
2020-04-08 01:54:06 +08:00
|
|
|
this.resendLastResult();
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2020-11-26 22:14:57 +08:00
|
|
|
this.events.publish(new PanelTransformationsChangedEvent());
|
2020-03-16 21:26:03 +08:00
|
|
|
}
|
|
|
|
|
|
2021-03-03 22:16:54 +08:00
|
|
|
setProperty(key: keyof this, value: any) {
|
|
|
|
|
this[key] = value;
|
2021-04-20 01:24:39 +08:00
|
|
|
this.configRev++;
|
2021-03-25 15:33:13 +08:00
|
|
|
|
|
|
|
|
// Custom handling of repeat dependent options, handled here as PanelEditor can
|
|
|
|
|
// update one key at a time right now
|
|
|
|
|
if (key === 'repeat') {
|
|
|
|
|
if (this.repeat && !this.repeatDirection) {
|
|
|
|
|
this.repeatDirection = 'h';
|
|
|
|
|
} else if (!this.repeat) {
|
|
|
|
|
delete this.repeatDirection;
|
|
|
|
|
delete this.maxPerRow;
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-03-03 22:16:54 +08:00
|
|
|
}
|
|
|
|
|
|
2020-12-15 20:29:37 +08:00
|
|
|
replaceVariables(value: string, extraVars: ScopedVars | undefined, format?: string | Function) {
|
2021-12-16 19:05:00 +08:00
|
|
|
const lastRequest = this.getQueryRunner().getLastRequest();
|
|
|
|
|
const vars: ScopedVars = Object.assign({}, this.scopedVars, lastRequest?.scopedVars, extraVars);
|
2020-10-02 01:51:23 +08:00
|
|
|
return getTemplateSrv().replace(value, vars, format);
|
2020-03-16 21:26:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resendLastResult() {
|
|
|
|
|
if (!this.plugin) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.getQueryRunner().resendLastResult();
|
2019-09-09 14:58:57 +08:00
|
|
|
}
|
2020-04-10 22:37:26 +08:00
|
|
|
|
2021-03-15 15:44:13 +08:00
|
|
|
/*
|
|
|
|
|
* This is the title used when displaying the title in the UI so it will include any interpolated variables.
|
|
|
|
|
* If you need the raw title without interpolation use title property instead.
|
|
|
|
|
* */
|
|
|
|
|
getDisplayTitle(): string {
|
2021-12-16 19:05:00 +08:00
|
|
|
return this.replaceVariables(this.title, undefined, 'text');
|
2021-03-15 15:44:13 +08:00
|
|
|
}
|
2022-10-27 06:38:20 +08:00
|
|
|
|
2023-01-30 12:14:12 +08:00
|
|
|
initLibraryPanel(libPanel: LibraryPanel) {
|
2022-10-27 06:38:20 +08:00
|
|
|
for (const [key, val] of Object.entries(libPanel.model)) {
|
|
|
|
|
switch (key) {
|
|
|
|
|
case 'id':
|
|
|
|
|
case 'gridPos':
|
|
|
|
|
case 'libraryPanel': // recursive?
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
(this as any)[key] = val; // :grimmice:
|
|
|
|
|
}
|
|
|
|
|
this.libraryPanel = libPanel;
|
|
|
|
|
}
|
2022-11-02 18:38:48 +08:00
|
|
|
|
|
|
|
|
unlinkLibraryPanel() {
|
|
|
|
|
delete this.libraryPanel;
|
|
|
|
|
this.configRev++;
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
2017-10-10 15:34:14 +08:00
|
|
|
}
|
2019-05-01 13:36:46 +08:00
|
|
|
|
2023-12-07 00:14:54 +08:00
|
|
|
export function getPluginVersion(plugin: PanelPlugin): string {
|
2019-05-01 13:36:46 +08:00
|
|
|
return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version;
|
|
|
|
|
}
|
2021-01-20 23:06:29 +08:00
|
|
|
|
|
|
|
|
interface PanelOptionsCache {
|
|
|
|
|
properties: any;
|
|
|
|
|
fieldConfig: FieldConfigSource;
|
|
|
|
|
}
|
2022-07-22 23:10:10 +08:00
|
|
|
|
|
|
|
|
// For cases where we immediately want to stringify the panel model without cloning each property
|
|
|
|
|
export function stringifyPanelModel(panel: PanelModel) {
|
|
|
|
|
const model: any = {};
|
|
|
|
|
|
|
|
|
|
Object.entries(panel)
|
|
|
|
|
.filter(
|
|
|
|
|
([prop, val]) => !notPersistedProperties[prop] && panel.hasOwnProperty(prop) && !isEqual(val, defaults[prop])
|
|
|
|
|
)
|
|
|
|
|
.forEach(([k, v]) => {
|
|
|
|
|
model[k] = v;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return safeStringifyValue(model);
|
|
|
|
|
}
|