// Libraries import { each, find, findIndex, flattenDeep, isArray, isBoolean, isNumber, isString, map, max, some } from 'lodash'; // Utils import getFactors from 'app/core/utils/factors'; import kbn from 'app/core/utils/kbn'; // Types import { PanelModel } from './PanelModel'; import { DashboardModel } from './DashboardModel'; import { DataLink, DataLinkBuiltInVars, MappingType, SpecialValueMatch, PanelPlugin, standardEditorsRegistry, standardFieldConfigEditorRegistry, ThresholdsConfig, urlUtil, ValueMap, ValueMapping, getActiveThreshold, } from '@grafana/data'; // Constants import { DEFAULT_PANEL_SPAN, DEFAULT_ROW_HEIGHT, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, MIN_PANEL_HEIGHT, } from 'app/core/constants'; import { isConstant, isMulti } from 'app/features/variables/guard'; import { alignCurrentWithMulti } from 'app/features/variables/shared/multiOptions'; import { VariableHide } from '../../variables/types'; import { config } from 'app/core/config'; import { plugin as statPanelPlugin } from 'app/plugins/panel/stat/module'; import { plugin as gaugePanelPlugin } from 'app/plugins/panel/gauge/module'; import { getStandardFieldConfigs, getStandardOptionEditors } from '@grafana/ui'; standardEditorsRegistry.setInit(getStandardOptionEditors); standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs); type PanelSchemeUpgradeHandler = (panel: PanelModel) => PanelModel; export class DashboardMigrator { dashboard: DashboardModel; constructor(dashboardModel: DashboardModel) { this.dashboard = dashboardModel; } updateSchema(old: any) { let i, j, k, n; const oldVersion = this.dashboard.schemaVersion; const panelUpgrades: PanelSchemeUpgradeHandler[] = []; this.dashboard.schemaVersion = 30; if (oldVersion === this.dashboard.schemaVersion) { return; } // version 2 schema changes if (oldVersion < 2) { if (old.services) { if (old.services.filter) { this.dashboard.time = old.services.filter.time; this.dashboard.templating.list = old.services.filter.list || []; } } panelUpgrades.push((panel: any) => { // rename panel type if (panel.type === 'graphite') { panel.type = 'graph'; } if (panel.type !== 'graph') { return panel; } if (isBoolean(panel.legend)) { panel.legend = { show: panel.legend }; } if (panel.grid) { if (panel.grid.min) { panel.grid.leftMin = panel.grid.min; delete panel.grid.min; } if (panel.grid.max) { panel.grid.leftMax = panel.grid.max; delete panel.grid.max; } } if (panel.y_format) { if (!panel.y_formats) { panel.y_formats = []; } panel.y_formats[0] = panel.y_format; delete panel.y_format; } if (panel.y2_format) { if (!panel.y_formats) { panel.y_formats = []; } panel.y_formats[1] = panel.y2_format; delete panel.y2_format; } return panel; }); } // schema version 3 changes if (oldVersion < 3) { // ensure panel IDs let maxId = this.dashboard.getNextPanelId(); panelUpgrades.push((panel: any) => { if (!panel.id) { panel.id = maxId; maxId += 1; } return panel; }); } // schema version 4 changes if (oldVersion < 4) { // move aliasYAxis changes panelUpgrades.push((panel: any) => { if (panel.type !== 'graph') { return panel; } each(panel.aliasYAxis, (value, key) => { panel.seriesOverrides = [{ alias: key, yaxis: value }]; }); delete panel.aliasYAxis; return panel; }); } if (oldVersion < 6) { // move drop-downs to new schema const annotations: any = find(old.pulldowns, { type: 'annotations' }); if (annotations) { this.dashboard.annotations = { list: annotations.annotations || [], }; } // update template variables for (i = 0; i < this.dashboard.templating.list.length; i++) { const variable = this.dashboard.templating.list[i]; if (variable.datasource === void 0) { variable.datasource = null; } if (variable.type === 'filter') { variable.type = 'query'; } if (variable.type === void 0) { variable.type = 'query'; } if (variable.allFormat === void 0) { variable.allFormat = 'glob'; } } } if (oldVersion < 7) { if (old.nav && old.nav.length) { this.dashboard.timepicker = old.nav[0]; } // ensure query refIds panelUpgrades.push((panel: any) => { each(panel.targets, (target) => { if (!target.refId) { target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter(); } }); return panel; }); } if (oldVersion < 8) { panelUpgrades.push((panel: any) => { each(panel.targets, (target) => { // update old influxdb query schema if (target.fields && target.tags && target.groupBy) { if (target.rawQuery) { delete target.fields; delete target.fill; } else { target.select = map(target.fields, (field) => { const parts = []; parts.push({ type: 'field', params: [field.name] }); parts.push({ type: field.func, params: [] }); if (field.mathExpr) { parts.push({ type: 'math', params: [field.mathExpr] }); } if (field.asExpr) { parts.push({ type: 'alias', params: [field.asExpr] }); } return parts; }); delete target.fields; each(target.groupBy, (part) => { if (part.type === 'time' && part.interval) { part.params = [part.interval]; delete part.interval; } if (part.type === 'tag' && part.key) { part.params = [part.key]; delete part.key; } }); if (target.fill) { target.groupBy.push({ type: 'fill', params: [target.fill] }); delete target.fill; } } } }); return panel; }); } // schema version 9 changes if (oldVersion < 9) { // move aliasYAxis changes panelUpgrades.push((panel: any) => { if (panel.type !== 'singlestat' && panel.thresholds !== '') { return panel; } if (panel.thresholds) { const k = panel.thresholds.split(','); if (k.length >= 3) { k.shift(); panel.thresholds = k.join(','); } } return panel; }); } // schema version 10 changes if (oldVersion < 10) { // move aliasYAxis changes panelUpgrades.push((panel: any) => { if (panel.type !== 'table') { return panel; } each(panel.styles, (style) => { if (style.thresholds && style.thresholds.length >= 3) { const k = style.thresholds; k.shift(); style.thresholds = k; } }); return panel; }); } if (oldVersion < 12) { // update template variables each(this.dashboard.getVariables(), (templateVariable: any) => { if (templateVariable.refresh) { templateVariable.refresh = 1; } if (!templateVariable.refresh) { templateVariable.refresh = 0; } if (templateVariable.hideVariable) { templateVariable.hide = 2; } else if (templateVariable.hideLabel) { templateVariable.hide = 1; } }); } if (oldVersion < 12) { // update graph yaxes changes panelUpgrades.push((panel: any) => { if (panel.type !== 'graph') { return panel; } if (!panel.grid) { return panel; } if (!panel.yaxes) { panel.yaxes = [ { show: panel['y-axis'], min: panel.grid.leftMin, max: panel.grid.leftMax, logBase: panel.grid.leftLogBase, format: panel.y_formats[0], label: panel.leftYAxisLabel, }, { show: panel['y-axis'], min: panel.grid.rightMin, max: panel.grid.rightMax, logBase: panel.grid.rightLogBase, format: panel.y_formats[1], label: panel.rightYAxisLabel, }, ]; panel.xaxis = { show: panel['x-axis'], }; delete panel.grid.leftMin; delete panel.grid.leftMax; delete panel.grid.leftLogBase; delete panel.grid.rightMin; delete panel.grid.rightMax; delete panel.grid.rightLogBase; delete panel.y_formats; delete panel.leftYAxisLabel; delete panel.rightYAxisLabel; delete panel['y-axis']; delete panel['x-axis']; } return panel; }); } if (oldVersion < 13) { // update graph yaxes changes panelUpgrades.push((panel: any) => { if (panel.type !== 'graph') { return panel; } if (!panel.grid) { return panel; } if (!panel.thresholds) { panel.thresholds = []; } const t1: any = {}, t2: any = {}; if (panel.grid.threshold1 !== null) { t1.value = panel.grid.threshold1; if (panel.grid.thresholdLine) { t1.line = true; t1.lineColor = panel.grid.threshold1Color; t1.colorMode = 'custom'; } else { t1.fill = true; t1.fillColor = panel.grid.threshold1Color; t1.colorMode = 'custom'; } } if (panel.grid.threshold2 !== null) { t2.value = panel.grid.threshold2; if (panel.grid.thresholdLine) { t2.line = true; t2.lineColor = panel.grid.threshold2Color; t2.colorMode = 'custom'; } else { t2.fill = true; t2.fillColor = panel.grid.threshold2Color; t2.colorMode = 'custom'; } } if (isNumber(t1.value)) { if (isNumber(t2.value)) { if (t1.value > t2.value) { t1.op = t2.op = 'lt'; panel.thresholds.push(t1); panel.thresholds.push(t2); } else { t1.op = t2.op = 'gt'; panel.thresholds.push(t1); panel.thresholds.push(t2); } } else { t1.op = 'gt'; panel.thresholds.push(t1); } } delete panel.grid.threshold1; delete panel.grid.threshold1Color; delete panel.grid.threshold2; delete panel.grid.threshold2Color; delete panel.grid.thresholdLine; return panel; }); } if (oldVersion < 14) { this.dashboard.graphTooltip = old.sharedCrosshair ? 1 : 0; } if (oldVersion < 16) { this.upgradeToGridLayout(old); } if (oldVersion < 17) { panelUpgrades.push((panel: any) => { if (panel.minSpan) { const max = GRID_COLUMN_COUNT / panel.minSpan; const factors = getFactors(GRID_COLUMN_COUNT); // find the best match compared to factors // (ie. [1,2,3,4,6,12,24] for 24 columns) panel.maxPerRow = factors[ findIndex(factors, (o) => { return o > max; }) - 1 ]; } delete panel.minSpan; return panel; }); } if (oldVersion < 18) { // migrate change to gauge options panelUpgrades.push((panel: any) => { if (panel['options-gauge']) { panel.options = panel['options-gauge']; panel.options.valueOptions = { unit: panel.options.unit, stat: panel.options.stat, decimals: panel.options.decimals, prefix: panel.options.prefix, suffix: panel.options.suffix, }; // correct order if (panel.options.thresholds) { panel.options.thresholds.reverse(); } // this options prop was due to a bug delete panel.options.options; delete panel.options.unit; delete panel.options.stat; delete panel.options.decimals; delete panel.options.prefix; delete panel.options.suffix; delete panel['options-gauge']; } return panel; }); } if (oldVersion < 19) { // migrate change to gauge options panelUpgrades.push((panel: any) => { if (panel.links && isArray(panel.links)) { panel.links = panel.links.map(upgradePanelLink); } return panel; }); } if (oldVersion < 20) { const updateLinks = (link: DataLink) => { return { ...link, url: updateVariablesSyntax(link.url), }; }; panelUpgrades.push((panel: any) => { // For graph panel if (panel.options && panel.options.dataLinks && isArray(panel.options.dataLinks)) { panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); } // For panel with fieldOptions if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { if (panel.options.fieldOptions.defaults.links && isArray(panel.options.fieldOptions.defaults.links)) { panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); } if (panel.options.fieldOptions.defaults.title) { panel.options.fieldOptions.defaults.title = updateVariablesSyntax( panel.options.fieldOptions.defaults.title ); } } return panel; }); } if (oldVersion < 21) { const updateLinks = (link: DataLink) => { return { ...link, url: link.url.replace(/__series.labels/g, '__field.labels'), }; }; panelUpgrades.push((panel: any) => { // For graph panel if (panel.options && panel.options.dataLinks && isArray(panel.options.dataLinks)) { panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); } // For panel with fieldOptions if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { if (panel.options.fieldOptions.defaults.links && isArray(panel.options.fieldOptions.defaults.links)) { panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); } } return panel; }); } if (oldVersion < 22) { panelUpgrades.push((panel: any) => { if (panel.type !== 'table') { return panel; } each(panel.styles, (style) => { style.align = 'auto'; }); return panel; }); } if (oldVersion < 23) { for (const variable of this.dashboard.templating.list) { if (!isMulti(variable)) { continue; } const { multi, current } = variable; variable.current = alignCurrentWithMulti(current, multi); } } if (oldVersion < 24) { // 7.0 // - migrate existing tables to 'table-old' panelUpgrades.push((panel: any) => { const wasAngularTable = panel.type === 'table'; if (wasAngularTable && !panel.styles) { return panel; // styles are missing so assumes default settings } const wasReactTable = panel.table === 'table2'; if (!wasAngularTable || wasReactTable) { return panel; } panel.type = wasAngularTable ? 'table-old' : 'table'; return panel; }); } if (oldVersion < 25) { // tags are removed in version 28 } if (oldVersion < 26) { panelUpgrades.push((panel: any) => { const wasReactText = panel.type === 'text2'; if (!wasReactText) { return panel; } panel.type = 'text'; delete panel.options.angular; return panel; }); } if (oldVersion < 27) { for (const variable of this.dashboard.templating.list) { if (!isConstant(variable)) { continue; } if (variable.hide === VariableHide.dontHide || variable.hide === VariableHide.hideLabel) { variable.type = 'textbox'; } variable.current = { selected: true, text: variable.query ?? '', value: variable.query ?? '' }; variable.options = [variable.current]; } } if (oldVersion < 28) { panelUpgrades.push((panel: PanelModel) => { if (panel.type === 'singlestat') { return migrateSinglestat(panel); } return panel; }); for (const variable of this.dashboard.templating.list) { if (variable.tags) { delete variable.tags; } if (variable.tagsQuery) { delete variable.tagsQuery; } if (variable.tagValuesQuery) { delete variable.tagValuesQuery; } if (variable.useTags) { delete variable.useTags; } } } if (oldVersion < 29) { for (const variable of this.dashboard.templating.list) { if (variable.type !== 'query') { continue; } if (variable.refresh !== 1 && variable.refresh !== 2) { variable.refresh = 1; } if (variable.options?.length) { variable.options = []; } } } if (oldVersion < 30) { panelUpgrades.push(upgradeValueMappingsForPanel); panelUpgrades.push(migrateTooltipOptions); } if (panelUpgrades.length === 0) { return; } for (j = 0; j < this.dashboard.panels.length; j++) { for (k = 0; k < panelUpgrades.length; k++) { this.dashboard.panels[j] = panelUpgrades[k].call(this, this.dashboard.panels[j]); if (this.dashboard.panels[j].panels) { for (n = 0; n < this.dashboard.panels[j].panels.length; n++) { this.dashboard.panels[j].panels[n] = panelUpgrades[k].call(this, this.dashboard.panels[j].panels[n]); } } } } } upgradeToGridLayout(old: any) { let yPos = 0; const widthFactor = GRID_COLUMN_COUNT / 12; const maxPanelId = max( flattenDeep( map(old.rows, (row) => { return map(row.panels, 'id'); }) ) ); let nextRowId = maxPanelId + 1; if (!old.rows) { return; } // Add special "row" panels if even one row is collapsed, repeated or has visible title const showRows = some(old.rows, (row) => row.collapse || row.showTitle || row.repeat); for (const row of old.rows) { if (row.repeatIteration) { continue; } const height: any = row.height || DEFAULT_ROW_HEIGHT; const rowGridHeight = getGridHeight(height); const rowPanel: any = {}; let rowPanelModel: PanelModel | undefined; if (showRows) { // add special row panel rowPanel.id = nextRowId; rowPanel.type = 'row'; rowPanel.title = row.title; rowPanel.collapsed = row.collapse; rowPanel.repeat = row.repeat; rowPanel.panels = []; rowPanel.gridPos = { x: 0, y: yPos, w: GRID_COLUMN_COUNT, h: rowGridHeight, }; rowPanelModel = new PanelModel(rowPanel); nextRowId++; yPos++; } const rowArea = new RowArea(rowGridHeight, GRID_COLUMN_COUNT, yPos); for (const panel of row.panels) { panel.span = panel.span || DEFAULT_PANEL_SPAN; if (panel.minSpan) { panel.minSpan = Math.min(GRID_COLUMN_COUNT, (GRID_COLUMN_COUNT / 12) * panel.minSpan); } const panelWidth = Math.floor(panel.span) * widthFactor; const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight; const panelPos = rowArea.getPanelPosition(panelHeight, panelWidth); yPos = rowArea.yPos; panel.gridPos = { x: panelPos.x, y: yPos + panelPos.y, w: panelWidth, h: panelHeight, }; rowArea.addPanel(panel.gridPos); delete panel.span; if (rowPanelModel && rowPanel.collapsed) { rowPanelModel.panels.push(panel); } else { this.dashboard.panels.push(new PanelModel(panel)); } } if (rowPanelModel) { this.dashboard.panels.push(rowPanelModel); } if (!(rowPanelModel && rowPanel.collapsed)) { yPos += rowGridHeight; } } } } function getGridHeight(height: number | string) { if (isString(height)) { height = parseInt(height.replace('px', ''), 10); } if (height < MIN_PANEL_HEIGHT) { height = MIN_PANEL_HEIGHT; } const gridHeight = Math.ceil(height / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); return gridHeight; } /** * RowArea represents dashboard row filled by panels * area is an array of numbers represented filled column's cells like * ----------------------- * |******** **** * |******** **** * |******** * ----------------------- * 33333333 2222 00000 ... */ class RowArea { area: number[]; yPos: number; height: number; constructor(height: number, width = GRID_COLUMN_COUNT, rowYPos = 0) { this.area = new Array(width).fill(0); this.yPos = rowYPos; this.height = height; } reset() { this.area.fill(0); } /** * Update area after adding the panel. */ addPanel(gridPos: any) { for (let i = gridPos.x; i < gridPos.x + gridPos.w; i++) { if (!this.area[i] || gridPos.y + gridPos.h - this.yPos > this.area[i]) { this.area[i] = gridPos.y + gridPos.h - this.yPos; } } return this.area; } /** * Calculate position for the new panel in the row. */ getPanelPosition(panelHeight: number, panelWidth: number, callOnce = false): any { let startPlace, endPlace; let place; for (let i = this.area.length - 1; i >= 0; i--) { if (this.height - this.area[i] > 0) { if (endPlace === undefined) { endPlace = i; } else { if (i < this.area.length - 1 && this.area[i] <= this.area[i + 1]) { startPlace = i; } else { break; } } } else { break; } } if (startPlace !== undefined && endPlace !== undefined && endPlace - startPlace >= panelWidth - 1) { const yPos = max(this.area.slice(startPlace)); place = { x: startPlace, y: yPos, }; } else if (!callOnce) { // wrap to next row this.yPos += this.height; this.reset(); return this.getPanelPosition(panelHeight, panelWidth, true); } else { return null; } return place; } } function upgradePanelLink(link: any): DataLink { let url = link.url; if (!url && link.dashboard) { url = `dashboard/db/${kbn.slugifyForUrl(link.dashboard)}`; } if (!url && link.dashUri) { url = `dashboard/${link.dashUri}`; } // some models are incomplete and have no dashboard or dashUri if (!url) { url = '/'; } if (link.keepTime) { url = urlUtil.appendQueryToUrl(url, `$${DataLinkBuiltInVars.keepTime}`); } if (link.includeVars) { url = urlUtil.appendQueryToUrl(url, `$${DataLinkBuiltInVars.includeVars}`); } if (link.params) { url = urlUtil.appendQueryToUrl(url, link.params); } return { url: url, title: link.title, targetBlank: link.targetBlank, }; } function updateVariablesSyntax(text: string) { const legacyVariableNamesRegex = /(__series_name)|(\$__series_name)|(__value_time)|(__field_name)|(\$__field_name)/g; return text.replace(legacyVariableNamesRegex, (match, seriesName, seriesName1, valueTime, fieldName, fieldName1) => { if (seriesName) { return '__series.name'; } if (seriesName1) { return '${__series.name}'; } if (valueTime) { return '__value.time'; } if (fieldName) { return '__field.name'; } if (fieldName1) { return '${__field.name}'; } return match; }); } function migrateSinglestat(panel: PanelModel) { // If 'grafana-singlestat-panel' exists, move to that if (config.panels['grafana-singlestat-panel']) { panel.type = 'grafana-singlestat-panel'; return panel; } let returnSaveModel = false; if (!panel.changePlugin) { returnSaveModel = true; panel = new PanelModel(panel); } // To make sure PanelModel.isAngularPlugin logic thinks the current panel is angular // And since this plugin no longer exist we just fake it here panel.plugin = { angularPanelCtrl: {} } as PanelPlugin; // Otheriwse use gauge or stat panel if ((panel as any).gauge?.show) { gaugePanelPlugin.meta = config.panels['gauge']; panel.changePlugin(gaugePanelPlugin); } else { statPanelPlugin.meta = config.panels['stat']; panel.changePlugin(statPanelPlugin); } if (returnSaveModel) { return panel.getSaveModel(); } return panel; } function upgradeValueMappingsForPanel(panel: PanelModel) { const fieldConfig = panel.fieldConfig; if (!fieldConfig) { return panel; } fieldConfig.defaults.mappings = upgradeValueMappings(fieldConfig.defaults.mappings, fieldConfig.defaults.thresholds); // Protect against no overrides if (Array.isArray(fieldConfig.overrides)) { for (const override of fieldConfig.overrides) { for (const prop of override.properties) { if (prop.id === 'mappings') { prop.value = upgradeValueMappings(prop.value); } } } } return panel; } function upgradeValueMappings(oldMappings: any, thresholds?: ThresholdsConfig): ValueMapping[] | undefined { if (!oldMappings) { return undefined; } const valueMaps: ValueMap = { type: MappingType.ValueToText, options: {} }; const newMappings: ValueMapping[] = []; for (const old of oldMappings) { // when migrating singlestat to stat/gauge, mappings are handled by panel type change handler used in that migration if (old.type && old.options) { // collect al value->text mappings in a single value map object. These are migrated by panel change handler as a separate value maps if (old.type === MappingType.ValueToText) { valueMaps.options = { ...valueMaps.options, ...old.options, }; } else { newMappings.push(old); } continue; } // Use the color we would have picked from thesholds let color: string | undefined = undefined; const numeric = parseFloat(old.text); if (thresholds && !isNaN(numeric)) { const level = getActiveThreshold(numeric, thresholds.steps); if (level && level.color) { color = level.color; } } switch (old.type) { case 1: // MappingType.ValueToText: if (old.value != null) { if (old.value === 'null') { newMappings.push({ type: MappingType.SpecialValue, options: { match: SpecialValueMatch.Null, result: { text: old.text, color }, }, }); } else { valueMaps.options[String(old.value)] = { text: old.text, color, }; } } break; case 2: // MappingType.RangeToText: newMappings.push({ type: MappingType.RangeToText, options: { from: +old.from, to: +old.to, result: { text: old.text, color }, }, }); break; } } if (Object.keys(valueMaps.options).length > 0) { newMappings.unshift(valueMaps); } return newMappings; } function migrateTooltipOptions(panel: PanelModel) { if (panel.type === 'timeseries' || panel.type === 'xychart') { if (panel.options.tooltipOptions) { panel.options = { ...panel.options, tooltip: panel.options.tooltipOptions, }; delete panel.options.tooltipOptions; } } return panel; }