diff --git a/grafana_client/export/DashboardExporter.ts b/grafana_client/export/DashboardExporter.ts new file mode 100644 index 0000000..98ecab8 --- /dev/null +++ b/grafana_client/export/DashboardExporter.ts @@ -0,0 +1,256 @@ +// https://github.com/grafana/grafana/blob/v8.3.6/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +// License: AGPL-3.0 + +import { defaults, each, sortBy } from 'lodash'; + +import config from 'app/core/config'; +import { DashboardModel } from '../../state/DashboardModel'; +import { PanelModel } from 'app/features/dashboard/state'; +import { PanelPluginMeta } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { VariableOption, VariableRefresh } from '../../../variables/types'; +import { isConstant, isQuery } from '../../../variables/guard'; +import { LibraryElementKind } from '../../../library-panels/types'; +import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; + +interface Input { + name: string; + type: string; + label: string; + value: any; + description: string; +} + +interface Requires { + [key: string]: { + type: string; + id: string; + name: string; + version: string; + }; +} + +interface DataSources { + [key: string]: { + name: string; + label: string; + description: string; + type: string; + pluginId: string; + pluginName: string; + }; +} + +export interface LibraryElementExport { + name: string; + uid: string; + model: any; + kind: LibraryElementKind; +} + +export class DashboardExporter { + makeExportable(dashboard: DashboardModel) { + // clean up repeated rows and panels, + // this is done on the live real dashboard instance, not on a clone + // so we need to undo this + // this is pretty hacky and needs to be changed + dashboard.cleanUpRepeats(); + + const saveModel = dashboard.getSaveModelClone(); + saveModel.id = null; + + // undo repeat cleanup + dashboard.processRepeats(); + + const inputs: Input[] = []; + const requires: Requires = {}; + const datasources: DataSources = {}; + const promises: Array> = []; + const variableLookup: { [key: string]: any } = {}; + const libraryPanels: Map = new Map(); + + for (const variable of saveModel.getVariables()) { + variableLookup[variable.name] = variable; + } + + const templateizeDatasourceUsage = (obj: any) => { + let datasource: string = obj.datasource; + let datasourceVariable: any = null; + + // ignore data source properties that contain a variable + if (datasource && (datasource as any).uid) { + const uid = (datasource as any).uid as string; + if (uid.indexOf('$') === 0) { + datasourceVariable = variableLookup[uid.substring(1)]; + if (datasourceVariable && datasourceVariable.current) { + datasource = datasourceVariable.current.value; + } + } + } + + promises.push( + getDataSourceSrv() + .get(datasource) + .then((ds) => { + if (ds.meta?.builtIn) { + return; + } + + // add data source type to require list + requires['datasource' + ds.meta?.id] = { + type: 'datasource', + id: ds.meta.id, + name: ds.meta.name, + version: ds.meta.info.version || '1.0.0', + }; + + // if used via variable we can skip templatizing usage + if (datasourceVariable) { + return; + } + + const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); + datasources[refName] = { + name: refName, + label: ds.name, + description: '', + type: 'datasource', + pluginId: ds.meta?.id, + pluginName: ds.meta?.name, + }; + + if (!obj.datasource || typeof obj.datasource === 'string') { + obj.datasource = '${' + refName + '}'; + } else { + obj.datasource.uid = '${' + refName + '}'; + } + }) + ); + }; + + const processPanel = (panel: PanelModel) => { + if (panel.datasource !== undefined && panel.datasource !== null) { + templateizeDatasourceUsage(panel); + } + + if (panel.targets) { + for (const target of panel.targets) { + if (target.datasource !== undefined) { + templateizeDatasourceUsage(target); + } + } + } + + const panelDef: PanelPluginMeta = config.panels[panel.type]; + if (panelDef) { + requires['panel' + panelDef.id] = { + type: 'panel', + id: panelDef.id, + name: panelDef.name, + version: panelDef.info.version, + }; + } + }; + + const processLibraryPanels = (panel: any) => { + if (isPanelModelLibraryPanel(panel)) { + const { libraryPanel, ...model } = panel; + const { name, uid } = libraryPanel; + if (!libraryPanels.has(uid)) { + libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model }); + } + } + }; + + // check up panel data sources + for (const panel of saveModel.panels) { + processPanel(panel); + + // handle collapsed rows + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + processPanel(rowPanel); + } + } + } + + // templatize template vars + for (const variable of saveModel.getVariables()) { + if (isQuery(variable)) { + templateizeDatasourceUsage(variable); + variable.options = []; + variable.current = {} as unknown as VariableOption; + variable.refresh = + variable.refresh !== VariableRefresh.never ? variable.refresh : VariableRefresh.onDashboardLoad; + } + } + + // templatize annotations vars + for (const annotationDef of saveModel.annotations.list) { + templateizeDatasourceUsage(annotationDef); + } + + // add grafana version + requires['grafana'] = { + type: 'grafana', + id: 'grafana', + name: 'Grafana', + version: config.buildInfo.version, + }; + + return Promise.all(promises) + .then(() => { + each(datasources, (value: any) => { + inputs.push(value); + }); + + // we need to process all panels again after all the promises are resolved + // so all data sources, variables and targets have been templateized when we process library panels + for (const panel of saveModel.panels) { + processLibraryPanels(panel); + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + processLibraryPanels(rowPanel); + } + } + } + + // templatize constants + for (const variable of saveModel.getVariables()) { + if (isConstant(variable)) { + const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase(); + inputs.push({ + name: refName, + type: 'constant', + label: variable.label || variable.name, + value: variable.query, + description: '', + }); + // update current and option + variable.query = '${' + refName + '}'; + variable.current = { + value: variable.query, + text: variable.query, + selected: false, + }; + variable.options = [variable.current]; + } + } + + // make inputs and requires a top thing + const newObj: { [key: string]: {} } = {}; + newObj['__inputs'] = inputs; + newObj['__elements'] = [...libraryPanels.values()]; + newObj['__requires'] = sortBy(requires, ['id']); + + defaults(newObj, saveModel); + return newObj; + }) + .catch((err) => { + console.error('Export failed:', err); + return { + error: err, + }; + }); + } +} diff --git a/grafana_client/export/__init__.py b/grafana_client/export/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grafana_client/export/dashboard.py b/grafana_client/export/dashboard.py new file mode 100644 index 0000000..b2b796d --- /dev/null +++ b/grafana_client/export/dashboard.py @@ -0,0 +1,372 @@ +# ruff: noqa: ERA001, W293, T201 +""" +About +===== +Python implementation of Grafana's `DashboardExporter.ts`. + +State of the onion +================== +It has been started on 2022-02-15. It is a work in progress. Contributions are very much welcome! + +Synopsis +======== +:: + + python grafana_client/model/dashboard.py play.grafana.org 000000012 | jq + +Parameters +========== +- `host`: The Grafana host name to connect to. +- `dashboard uid`: The UID of the Grafana dashboard to export. + +References +========== + +- https://community.grafana.com/t/export-dashboard-for-external-use-via-http-api/50716 +- https://github.com/panodata/grafana-client/issues/8 +- https://play.grafana.org/d/000000012 +""" + +import dataclasses +import operator +from typing import Any, Dict, List, Optional + + +@dataclasses.dataclass +class AnnotationQuery: + pass + + +@dataclasses.dataclass +class DashboardLink: + pass + + +@dataclasses.dataclass +class PanelModel: + pass + + +@dataclasses.dataclass +class Subscription: + pass + + +@dataclasses.dataclass +class DashboardModel: + """ + https://github.com/grafana/grafana/blob/v8.3.6/public/app/features/dashboard/state/DashboardModel.ts + """ + + id: Any + uid: str + title: str + # autoUpdate: Any + # description: Any + tags: Any + style: Any + timezone: Any + editable: Any + # graphTooltip: DashboardCursorSync; + graphTooltip: Any + time: Any + liveNow: bool + # private originalTime: Any + timepicker: Any + templating: List[Any] + # private originalTemplating: Any + annotations: List[AnnotationQuery] + refresh: Any + # snapshot: Any + schemaVersion: int + version: int + # revision: int + links: List[DashboardLink] + gnetId: Any + panels: List[PanelModel] + # panelInEdit?: PanelModel; + # panelInView?: PanelModel; + fiscalYearStartMonth: int + # private panelsAffectedByVariableChange: number[] | null; + # private appEventsSubscription: Subscription; + # private lastRefresh: int + + # Not in dashboard payload from API, but should be exported. + weekStart: Any = "" + + def cleanUpRepeats(self): + pass + + def getSaveModelClone(self): + return self + + def processRepeats(self): + pass + + def getVariables(self): + return self.templating["list"] + + def asdict(self): + return dataclasses.asdict(self) + + +@dataclasses.dataclass +class DashboardExporter: + """ + https://github.com/grafana/grafana/blob/v8.3.6/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts + """ + + inputs: Optional[List] = None + requires: Optional[Dict] = None + datasources: Optional[Dict] = None + promises: Optional[List[Any]] = None + variableLookup: Optional[Dict[str, Any]] = None + libraryPanels: Optional[Dict[str, Any]] = None + + def makeExportable(self, dashboard: DashboardModel): + # clean up repeated rows and panels, + # this is done on the live real dashboard instance, not on a clone + # so we need to undo this + # this is pretty hacky and needs to be changed + dashboard.cleanUpRepeats() + + saveModel = dashboard.getSaveModelClone() + saveModel.id = None + + # undo repeat cleanup + dashboard.processRepeats() + + self.inputs = [] + self.requires = {} + self.datasources = {} + self.promises = [] + self.variableLookup = {} + self.libraryPanels = {} + + for variable in saveModel.getVariables(): + self.variableLookup[variable.name] = variable + + """ + const templateizeDatasourceUsage = (obj: any) => { + let datasource: string = obj.datasource; + let datasourceVariable: any = null; + + // ignore data source properties that contain a variable + if (datasource && (datasource as any).uid) { + const uid = (datasource as any).uid as string; + if (uid.indexOf('$') === 0) { + datasourceVariable = variableLookup[uid.substring(1)]; + if (datasourceVariable && datasourceVariable.current) { + datasource = datasourceVariable.current.value; + } + } + } + """ + + """ + promises.push( + getDataSourceSrv() + .get(datasource) + .then((ds) => { + if (ds.meta?.builtIn) { + return; + } + + // add data source type to require list + requires['datasource' + ds.meta?.id] = { + type: 'datasource', + id: ds.meta.id, + name: ds.meta.name, + version: ds.meta.info.version || '1.0.0', + }; + + // if used via variable we can skip templatizing usage + if (datasourceVariable) { + return; + } + + const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase(); + datasources[refName] = { + name: refName, + label: ds.name, + description: '', + type: 'datasource', + pluginId: ds.meta?.id, + pluginName: ds.meta?.name, + }; + + if (!obj.datasource || typeof obj.datasource === 'string') { + obj.datasource = '${' + refName + '}'; + } else { + obj.datasource.uid = '${' + refName + '}'; + } + }) + ); + }; + + const processPanel = (panel: PanelModel) => { + if (panel.datasource !== undefined && panel.datasource !== null) { + templateizeDatasourceUsage(panel); + } + + if (panel.targets) { + for (const target of panel.targets) { + if (target.datasource !== undefined) { + templateizeDatasourceUsage(target); + } + } + } + + const panelDef: PanelPluginMeta = config.panels[panel.type]; + if (panelDef) { + requires['panel' + panelDef.id] = { + type: 'panel', + id: panelDef.id, + name: panelDef.name, + version: panelDef.info.version, + }; + } + }; + + const processLibraryPanels = (panel: any) => { + if (isPanelModelLibraryPanel(panel)) { + const { libraryPanel, ...model } = panel; + const { name, uid } = libraryPanel; + if (!libraryPanels.has(uid)) { + libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model }); + } + } + }; + + // check up panel data sources + for (const panel of saveModel.panels) { + processPanel(panel); + + // handle collapsed rows + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + processPanel(rowPanel); + } + } + } + + // templatize template vars + for (const variable of saveModel.getVariables()) { + if (isQuery(variable)) { + templateizeDatasourceUsage(variable); + variable.options = []; + variable.current = {} as unknown as VariableOption; + variable.refresh = + variable.refresh !== VariableRefresh.never ? variable.refresh : VariableRefresh.onDashboardLoad; + } + } + + // templatize annotations vars + for (const annotationDef of saveModel.annotations.list) { + templateizeDatasourceUsage(annotationDef); + } + """ + + # add grafana version + self.requires["grafana"] = { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + # FIXME: "version": config.buildInfo.version, + "version": "8.4.0-beta1", + } + + """ + return Promise.all(promises) + .then(() => { + each(datasources, (value: any) => { + inputs.push(value); + }); + + // we need to process all panels again after all the promises are resolved + // so all data sources, variables and targets have been templateized when we process library panels + for (const panel of saveModel.panels) { + processLibraryPanels(panel); + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + processLibraryPanels(rowPanel); + } + } + } + + // templatize constants + for (const variable of saveModel.getVariables()) { + if (isConstant(variable)) { + const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase(); + inputs.push({ + name: refName, + type: 'constant', + label: variable.label || variable.name, + value: variable.query, + description: '', + }); + // update current and option + variable.query = '${' + refName + '}'; + variable.current = { + value: variable.query, + text: variable.query, + selected: false, + }; + variable.options = [variable.current]; + } + } + + }) + """ + + # make inputs and requires a top thing + newObj = dict( + __inputs=self.inputs, + __elements=list(self.libraryPanels.values()), + __requires=sorted(self.requires.values(), key=operator.itemgetter("id")), + ) + + # purge some attributes. + blocklist = ["gnetId"] + dashboard_data = dashboard.asdict() + for blockitem in blocklist: + if blockitem in dashboard_data: + del dashboard_data[blockitem] + newObj.update(dashboard_data) + + return newObj + + +def main(): + import json + import sys + from typing import Dict + + from grafana_client import GrafanaApi + from grafana_client.model.dashboard import DashboardExporter, DashboardModel + + def jdump(data): + print(json.dumps(data, indent=4, sort_keys=True)) + + grafana_host = sys.argv[1] + dashboard_uid = sys.argv[2] + + # Fetch dashboard. + grafana: GrafanaApi = GrafanaApi(None, host=grafana_host) + dashboard_raw: Dict = grafana.dashboard.get_dashboard(dashboard_uid) + # jdump(dashboard_raw) + + # Converge to model class. + dashboard_data: Dict = dashboard_raw["dashboard"] + dashboard_model: DashboardModel = DashboardModel(**dashboard_data) + # jdump(dashboard_model.asdict()) + + # Represent. + exporter: DashboardExporter = DashboardExporter() + exported: Dict = exporter.makeExportable(dashboard_model) + # assert exported.templating.list[0].datasource == '${DS_GFDB}' + jdump(exported) + + +if __name__ == "__main__": + main() diff --git a/test/model/DashboardExporter.test.ts b/test/model/DashboardExporter.test.ts new file mode 100644 index 0000000..a390d5a --- /dev/null +++ b/test/model/DashboardExporter.test.ts @@ -0,0 +1,477 @@ +// https://github.com/grafana/grafana/blob/v8.3.6/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +// License: AGPL-3.0 + +import { find } from 'lodash'; +import config from 'app/core/config'; +import { DashboardExporter, LibraryElementExport } from './DashboardExporter'; +import { DashboardModel } from '../../state/DashboardModel'; +import { DataSourceInstanceSettings, DataSourceRef, PanelPluginMeta } from '@grafana/data'; +import { variableAdapters } from '../../../variables/adapters'; +import { createConstantVariableAdapter } from '../../../variables/constant/adapter'; +import { createQueryVariableAdapter } from '../../../variables/query/adapter'; +import { createDataSourceVariableAdapter } from '../../../variables/datasource/adapter'; +import { LibraryElementKind } from '../../../library-panels/types'; + +jest.mock('app/core/store', () => { + return { + getBool: jest.fn(), + getObject: jest.fn(), + }; +}); + +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as unknown as object), + getDataSourceSrv: () => { + return { + get: (v: any) => { + const s = getStubInstanceSettings(v); + // console.log('GET', v, s); + return Promise.resolve(s); + }, + getInstanceSettings: getStubInstanceSettings, + }; + }, + config: { + buildInfo: {}, + panels: {}, + featureToggles: { + newVariables: false, + }, + }, +})); + +variableAdapters.register(createQueryVariableAdapter()); +variableAdapters.register(createConstantVariableAdapter()); +variableAdapters.register(createDataSourceVariableAdapter()); + +it('handles a default datasource in a template variable', async () => { + const dashboard: any = { + annotations: { + list: [ + { + builtIn: 1, + datasource: '-- Grafana --', + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + target: { + limit: 100, + matchAny: false, + tags: [], + type: 'dashboard', + }, + type: 'dashboard', + }, + ], + }, + editable: true, + fiscalYearStartMonth: 0, + graphTooltip: 0, + id: 331, + iteration: 1642157860116, + links: [], + liveNow: false, + panels: [ + { + fieldConfig: { + defaults: { + color: { + mode: 'palette-classic', + }, + custom: { + axisLabel: '', + axisPlacement: 'auto', + barAlignment: 0, + drawStyle: 'line', + fillOpacity: 0, + gradientMode: 'none', + hideFrom: { + legend: false, + tooltip: false, + viz: 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: 9, + w: 12, + x: 0, + y: 0, + }, + id: 2, + options: { + legend: { + calcs: [], + displayMode: 'list', + placement: 'bottom', + }, + tooltip: { + mode: 'single', + sort: 'none', + }, + }, + targets: [ + { + datasource: { + type: 'testdata', + uid: 'PD8C576611E62080A', + }, + expr: '{filename="/var/log/system.log"}', + refId: 'A', + }, + ], + title: 'Panel Title', + type: 'timeseries', + }, + ], + templating: { + list: [ + { + current: {}, + definition: 'test', + error: {}, + hide: 0, + includeAll: false, + multi: false, + name: 'query0', + options: [], + query: { + query: 'test', + refId: 'StandardVariableQuery', + }, + refresh: 1, + regex: '', + skipUrlSync: false, + sort: 0, + type: 'query', + }, + ], + }, + }; + const dashboardModel = new DashboardModel(dashboard, {}, () => dashboard.templating.list); + const exporter = new DashboardExporter(); + const exported: any = await exporter.makeExportable(dashboardModel); + expect(exported.templating.list[0].datasource).toBe('${DS_GFDB}'); +}); + +describe('given dashboard with repeated panels', () => { + let dash: any, exported: any; + + beforeEach((done) => { + dash = { + templating: { + list: [ + { + name: 'apps', + type: 'query', + datasource: { uid: 'gfdb', type: 'testdb' }, + current: { value: 'Asd', text: 'Asd' }, + options: [{ value: 'Asd', text: 'Asd' }], + }, + { + name: 'prefix', + type: 'constant', + current: { value: 'collectd', text: 'collectd' }, + options: [], + query: 'collectd', + }, + { + name: 'ds', + type: 'datasource', + query: 'other2', + current: { value: 'other2', text: 'other2' }, + options: [], + }, + ], + }, + annotations: { + list: [ + { + name: 'logs', + datasource: 'gfdb', + }, + ], + }, + panels: [ + { id: 6, datasource: { uid: 'gfdb', type: 'testdb' }, type: 'graph' }, + { id: 7 }, + { + id: 8, + datasource: { uid: '-- Mixed --', type: 'mixed' }, + targets: [{ datasource: { uid: 'other', type: 'other' } }], + }, + { id: 9, datasource: { uid: '$ds', type: 'other2' } }, + { + id: 17, + datasource: { uid: '$ds', type: 'other2' }, + type: 'graph', + libraryPanel: { + name: 'Library Panel 2', + uid: 'ah8NqyDPs', + }, + }, + { + id: 2, + repeat: 'apps', + datasource: { uid: 'gfdb', type: 'testdb' }, + type: 'graph', + }, + { id: 3, repeat: null, repeatPanelId: 2 }, + { + id: 4, + collapsed: true, + panels: [ + { id: 10, datasource: { uid: 'gfdb', type: 'testdb' }, type: 'table' }, + { id: 11 }, + { + id: 12, + datasource: { uid: '-- Mixed --', type: 'mixed' }, + targets: [{ datasource: { uid: 'other', type: 'other' } }], + }, + { id: 13, datasource: { uid: '$uid', type: 'other' } }, + { + id: 14, + repeat: 'apps', + datasource: { uid: 'gfdb', type: 'testdb' }, + type: 'heatmap', + }, + { id: 15, repeat: null, repeatPanelId: 14 }, + { + id: 16, + datasource: { uid: 'gfdb', type: 'testdb' }, + type: 'graph', + libraryPanel: { + name: 'Library Panel', + uid: 'jL6MrxCMz', + }, + }, + ], + }, + ], + }; + + config.buildInfo.version = '3.0.2'; + + config.panels['graph'] = { + id: 'graph', + name: 'Graph', + info: { version: '1.1.0' }, + } as PanelPluginMeta; + + config.panels['table'] = { + id: 'table', + name: 'Table', + info: { version: '1.1.1' }, + } as PanelPluginMeta; + + config.panels['heatmap'] = { + id: 'heatmap', + name: 'Heatmap', + info: { version: '1.1.2' }, + } as PanelPluginMeta; + + dash = new DashboardModel(dash, {}, () => dash.templating.list); + const exporter = new DashboardExporter(); + exporter.makeExportable(dash).then((clean) => { + exported = clean; + done(); + }); + }); + + it('should replace datasource refs', () => { + const panel = exported.panels[0]; + expect(panel.datasource.uid).toBe('${DS_GFDB}'); + }); + + it('should replace datasource refs in collapsed row', () => { + const panel = exported.panels[6].panels[0]; + expect(panel.datasource.uid).toBe('${DS_GFDB}'); + }); + + it('should replace datasource in variable query', () => { + expect(exported.templating.list[0].datasource.uid).toBe('${DS_GFDB}'); + expect(exported.templating.list[0].options.length).toBe(0); + expect(exported.templating.list[0].current.value).toBe(undefined); + expect(exported.templating.list[0].current.text).toBe(undefined); + }); + + it('should replace datasource in annotation query', () => { + expect(exported.annotations.list[1].datasource).toBe('${DS_GFDB}'); + }); + + it('should add datasource as input', () => { + expect(exported.__inputs[0].name).toBe('DS_GFDB'); + expect(exported.__inputs[0].pluginId).toBe('testdb'); + expect(exported.__inputs[0].type).toBe('datasource'); + }); + + it('should add datasource to required', () => { + const require: any = find(exported.__requires, { name: 'TestDB' }); + expect(require.name).toBe('TestDB'); + expect(require.id).toBe('testdb'); + expect(require.type).toBe('datasource'); + expect(require.version).toBe('1.2.1'); + }); + + it('should not add built in datasources to required', () => { + const require: any = find(exported.__requires, { name: 'Mixed' }); + expect(require).toBe(undefined); + }); + + it('should add datasources used in mixed mode', () => { + const require: any = find(exported.__requires, { name: 'OtherDB' }); + expect(require).not.toBe(undefined); + }); + + it('should add graph panel to required', () => { + const require: any = find(exported.__requires, { name: 'Graph' }); + expect(require.name).toBe('Graph'); + expect(require.id).toBe('graph'); + expect(require.version).toBe('1.1.0'); + }); + + it('should add table panel to required', () => { + const require: any = find(exported.__requires, { name: 'Table' }); + expect(require.name).toBe('Table'); + expect(require.id).toBe('table'); + expect(require.version).toBe('1.1.1'); + }); + + it('should add heatmap panel to required', () => { + const require: any = find(exported.__requires, { name: 'Heatmap' }); + expect(require.name).toBe('Heatmap'); + expect(require.id).toBe('heatmap'); + expect(require.version).toBe('1.1.2'); + }); + + it('should add grafana version', () => { + const require: any = find(exported.__requires, { name: 'Grafana' }); + expect(require.type).toBe('grafana'); + expect(require.id).toBe('grafana'); + expect(require.version).toBe('3.0.2'); + }); + + it('should add constant template variables as inputs', () => { + const input: any = find(exported.__inputs, { name: 'VAR_PREFIX' }); + expect(input.type).toBe('constant'); + expect(input.label).toBe('prefix'); + expect(input.value).toBe('collectd'); + }); + + it('should templatize constant variables', () => { + const variable: any = find(exported.templating.list, { name: 'prefix' }); + expect(variable.query).toBe('${VAR_PREFIX}'); + expect(variable.current.text).toBe('${VAR_PREFIX}'); + expect(variable.current.value).toBe('${VAR_PREFIX}'); + expect(variable.options[0].text).toBe('${VAR_PREFIX}'); + expect(variable.options[0].value).toBe('${VAR_PREFIX}'); + }); + + it('should add datasources only use via datasource variable to requires', () => { + const require: any = find(exported.__requires, { name: 'OtherDB_2' }); + expect(require.id).toBe('other2'); + }); + + it('should add library panels as elements', () => { + const element: LibraryElementExport = exported.__elements.find( + (element: LibraryElementExport) => element.uid === 'ah8NqyDPs' + ); + expect(element.name).toBe('Library Panel 2'); + expect(element.kind).toBe(LibraryElementKind.Panel); + expect(element.model).toEqual({ + id: 17, + datasource: { type: 'other2', uid: '$ds' }, + type: 'graph', + }); + }); + + it('should add library panels in collapsed rows as elements', () => { + const element: LibraryElementExport = exported.__elements.find( + (element: LibraryElementExport) => element.uid === 'jL6MrxCMz' + ); + expect(element.name).toBe('Library Panel'); + expect(element.kind).toBe(LibraryElementKind.Panel); + expect(element.model).toEqual({ + id: 16, + type: 'graph', + datasource: { + type: 'testdb', + uid: '${DS_GFDB}', + }, + }); + }); +}); + +function getStubInstanceSettings(v: string | DataSourceRef): DataSourceInstanceSettings { + let key = (v as DataSourceRef)?.type ?? v; + return (stubs[(key as any) ?? 'gfdb'] ?? stubs['gfdb']) as any; +} + +// Stub responses +const stubs: { [key: string]: {} } = {}; +stubs['gfdb'] = { + name: 'gfdb', + meta: { id: 'testdb', info: { version: '1.2.1' }, name: 'TestDB' }, +}; + +stubs['other'] = { + name: 'other', + meta: { id: 'other', info: { version: '1.2.1' }, name: 'OtherDB' }, +}; + +stubs['other2'] = { + name: 'other2', + meta: { id: 'other2', info: { version: '1.2.1' }, name: 'OtherDB_2' }, +}; + +stubs['-- Mixed --'] = { + name: 'mixed', + meta: { + id: 'mixed', + info: { version: '1.2.1' }, + name: 'Mixed', + builtIn: true, + }, +}; + +stubs['-- Grafana --'] = { + name: '-- Grafana --', + meta: { + id: 'grafana', + info: { version: '1.2.1' }, + name: 'grafana', + builtIn: true, + }, +}; diff --git a/test/model/play-000000012.json b/test/model/play-000000012.json new file mode 100644 index 0000000..4a56c03 --- /dev/null +++ b/test/model/play-000000012.json @@ -0,0 +1,927 @@ +{ + "__inputs": [ + { + "name": "DS_GRAPHITE", + "label": "graphite", + "description": "", + "type": "datasource", + "pluginId": "graphite", + "pluginName": "Graphite" + } + ], + "__elements": [], + "__requires": [ + { + "type": "panel", + "id": "dashlist", + "name": "Dashboard list", + "version": "" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "8.4.0-beta1" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph (old)", + "version": "" + }, + { + "type": "datasource", + "id": "graphite", + "name": "Graphite", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "enable": false, + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [ + { + "icon": "external link", + "tags": [], + "targetBlank": true, + "title": "Learn more at Grafana.com", + "tooltip": "", + "type": "link", + "url": "https://grafana.com?pg=play&plcmt=home" + }, + { + "icon": "cloud", + "tags": [], + "targetBlank": true, + "title": "Grafana Cloud now free with 50gb logs, 10K metric series", + "tooltip": "", + "type": "link", + "url": "https://grafana.com/signup/cloud/connect-account?pg=play&plcmt=home" + } + ], + "liveNow": false, + "panels": [ + { + "editable": true, + "error": false, + "gridPos": { + "h": 11, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 7, + "links": [], + "options": { + "maxItems": 100, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "demo" + ] + }, + "pluginVersion": "8.4.0-beta1", + "tags": [ + "demo" + ], + "title": "Feature showcases", + "type": "dashlist" + }, + { + "editable": true, + "error": false, + "gridPos": { + "h": 11, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 9, + "links": [], + "options": { + "maxItems": 10, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "misc" + ] + }, + "pluginVersion": "8.4.0-beta1", + "tags": [ + "misc" + ], + "title": "Data source demos", + "type": "dashlist" + }, + { + "editable": true, + "error": false, + "gridPos": { + "h": 11, + "w": 8, + "x": 16, + "y": 0 + }, + "id": 8, + "links": [], + "options": { + "maxItems": 10, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "whatsnew" + ] + }, + "pluginVersion": "8.4.0-beta1", + "tags": [ + "whatsnew" + ], + "title": "What's New", + "type": "dashlist" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 61, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "web_server_01" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#70dbed", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "web_server_02" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#5195ce", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "web_server_03" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#0a50a1", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "web_server_04" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#0a437c", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 11 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.0.0-beta3", + "targets": [ + { + "refId": "A", + "target": "aliasByNode(movingAverage(scaleToSeconds(apps.fakesite.*.counters.requests.count, 1), 2), 2)" + } + ], + "title": "server requests", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 54, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 0, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "cpu" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#E24D42", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "memory" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "statsd.fakesite.counters.session_start.desktop.count" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#6ED0E0", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "cpu" + }, + "properties": [ + { + "id": "custom.fillOpacity", + "value": 0 + }, + { + "id": "custom.lineWidth", + "value": 2 + }, + { + "id": "unit", + "value": "percent" + }, + { + "id": "min", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "memory" + }, + "properties": [ + { + "id": "custom.pointSize", + "value": 6 + }, + { + "id": "custom.showPoints", + "value": "always" + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 11 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "hide": false, + "refId": "A", + "target": "alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.request_status.code_302.count, 10), 20), 'cpu')" + }, + { + "refId": "B", + "target": "alias(statsd.fakesite.counters.session_start.desktop.count, 'memory')" + } + ], + "title": "Memory / CPU", + "type": "timeseries" + }, + { + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 91, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "upper_25" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F9E2D2", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "upper_50" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F2C96D", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "upper_75" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#EAB839", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 16, + "x": 0, + "y": 18 + }, + "id": 5, + "links": [], + "options": { + "legend": { + "calcs": [ + "mean" + ], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.0.0-beta3", + "targets": [ + { + "refId": "A", + "target": "aliasByNode(summarize(statsd.fakesite.timers.ads_timer.*, '4min', 'avg'), 4)" + } + ], + "title": "client side full page load", + "type": "timeseries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 41, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "logins" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#5195ce", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "logins (-1 day)" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#447EBC", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "logins (-1 hour)" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#e24d42", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 18 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "8.0.0-beta3", + "targets": [ + { + "refId": "A", + "target": "alias(movingAverage(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), 2), 'logins')" + }, + { + "refId": "B", + "target": "alias(movingAverage(timeShift(scaleToSeconds(apps.fakesite.web_server_01.counters.requests.count, 1), '1h'), 2), 'logins (-1 hour)')" + } + ], + "title": "logins", + "type": "timeseries" + }, + { + "aliasColors": { + "cpu1": "#82b5d8", + "cpu2": "#1f78c1", + "upper_25": "#B7DBAB", + "upper_50": "#7EB26D", + "upper_75": "#629E51", + "upper_90": "#629E51", + "upper_95": "#508642" + }, + "annotate": { + "enable": false + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": { + "type": "graphite", + "uid": "${DS_GRAPHITE}" + }, + "editable": true, + "fieldConfig": { + "defaults": { + "links": [] + }, + "overrides": [] + }, + "fill": 3, + "fillGradient": 0, + "grid": {}, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 25 + }, + "hiddenSeries": false, + "id": 11, + "interactive": true, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "legendSideLastValue": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "legend_counts": true, + "lines": true, + "linewidth": 2, + "links": [], + "nullPointMode": "connected", + "options": { + "alertThreshold": true + }, + "paceLength": 10, + "percentage": false, + "pluginVersion": "8.4.0-beta1", + "pointradius": 1, + "points": false, + "renderer": "flot", + "resolution": 100, + "scale": 1, + "seriesOverrides": [ + { + "alias": "this is test of brekaing", + "yaxis": 1 + } + ], + "spaceLength": 10, + "spyable": true, + "stack": false, + "steppedLine": false, + "targets": [ + { + "refId": "A", + "target": "aliasByNode(statsd.fakesite.timers.ads_timer.*,4)" + }, + { + "refId": "B", + "target": "alias(scale(statsd.fakesite.timers.ads_timer.upper_95,-1),'cpu1')" + }, + { + "refId": "C", + "target": "alias(scale(statsd.fakesite.timers.ads_timer.upper_75,-1),'cpu2')" + } + ], + "thresholds": [], + "timeRegions": [], + "timezone": "browser", + "title": "Traffic In/Out", + "tooltip": { + "query_as_alias": true, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "decbytes", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": false + } + ], + "yaxis": { + "align": false + }, + "zerofill": true + } + ], + "refresh": false, + "schemaVersion": 35, + "style": "dark", + "tags": [ + "startpage", + "home", + "Home" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "collapse": false, + "enable": true, + "notice": false, + "now": true, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "status": "Stable", + "time_options": [ + "5m", + "15m", + "1h", + "2h", + " 6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ], + "type": "timepicker" + }, + "timezone": "browser", + "title": "Grafana Play Home", + "uid": "000000012", + "version": 37, + "weekStart": "" +} \ No newline at end of file