Skip to content

Commit 8203b64

Browse files
committed
Add Python implementation of Grafana's DashboardExporter (WIP)
1 parent 6db756a commit 8203b64

File tree

3 files changed

+372
-0
lines changed

3 files changed

+372
-0
lines changed

grafana_client/export/__init__.py

Whitespace-only changes.

grafana_client/export/dashboard.py

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
# ruff: noqa: ERA001, W293, T201
2+
"""
3+
About
4+
=====
5+
Python implementation of Grafana's `DashboardExporter.ts`.
6+
7+
State of the onion
8+
==================
9+
It has been started on 2022-02-15. It is a work in progress. Contributions are very much welcome!
10+
11+
Synopsis
12+
========
13+
::
14+
15+
python grafana_client/model/dashboard.py play.grafana.org 000000012 | jq
16+
17+
Parameters
18+
==========
19+
- `host`: The Grafana host name to connect to.
20+
- `dashboard uid`: The UID of the Grafana dashboard to export.
21+
22+
References
23+
==========
24+
25+
- https://community.grafana.com/t/export-dashboard-for-external-use-via-http-api/50716
26+
- https://github.com/panodata/grafana-client/issues/8
27+
- https://play.grafana.org/d/000000012
28+
"""
29+
30+
import dataclasses
31+
import operator
32+
from typing import Any, Dict, List, Optional
33+
34+
35+
@dataclasses.dataclass
36+
class AnnotationQuery:
37+
pass
38+
39+
40+
@dataclasses.dataclass
41+
class DashboardLink:
42+
pass
43+
44+
45+
@dataclasses.dataclass
46+
class PanelModel:
47+
pass
48+
49+
50+
@dataclasses.dataclass
51+
class Subscription:
52+
pass
53+
54+
55+
@dataclasses.dataclass
56+
class DashboardModel:
57+
"""
58+
https://github.com/grafana/grafana/blob/v8.3.6/public/app/features/dashboard/state/DashboardModel.ts
59+
"""
60+
61+
id: Any
62+
uid: str
63+
title: str
64+
# autoUpdate: Any
65+
# description: Any
66+
tags: Any
67+
style: Any
68+
timezone: Any
69+
editable: Any
70+
# graphTooltip: DashboardCursorSync;
71+
graphTooltip: Any
72+
time: Any
73+
liveNow: bool
74+
# private originalTime: Any
75+
timepicker: Any
76+
templating: List[Any]
77+
# private originalTemplating: Any
78+
annotations: List[AnnotationQuery]
79+
refresh: Any
80+
# snapshot: Any
81+
schemaVersion: int
82+
version: int
83+
# revision: int
84+
links: List[DashboardLink]
85+
gnetId: Any
86+
panels: List[PanelModel]
87+
# panelInEdit?: PanelModel;
88+
# panelInView?: PanelModel;
89+
fiscalYearStartMonth: int
90+
# private panelsAffectedByVariableChange: number[] | null;
91+
# private appEventsSubscription: Subscription;
92+
# private lastRefresh: int
93+
94+
# Not in dashboard payload from API, but should be exported.
95+
weekStart: Any = ""
96+
97+
def cleanUpRepeats(self):
98+
pass
99+
100+
def getSaveModelClone(self):
101+
return self
102+
103+
def processRepeats(self):
104+
pass
105+
106+
def getVariables(self):
107+
return self.templating["list"]
108+
109+
def asdict(self):
110+
return dataclasses.asdict(self)
111+
112+
113+
@dataclasses.dataclass
114+
class DashboardExporter:
115+
"""
116+
https://github.com/grafana/grafana/blob/v8.3.6/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts
117+
"""
118+
119+
inputs: Optional[List] = None
120+
requires: Optional[Dict] = None
121+
datasources: Optional[Dict] = None
122+
promises: Optional[List[Any]] = None
123+
variableLookup: Optional[Dict[str, Any]] = None
124+
libraryPanels: Optional[Dict[str, Any]] = None
125+
126+
def makeExportable(self, dashboard: DashboardModel):
127+
# clean up repeated rows and panels,
128+
# this is done on the live real dashboard instance, not on a clone
129+
# so we need to undo this
130+
# this is pretty hacky and needs to be changed
131+
dashboard.cleanUpRepeats()
132+
133+
saveModel = dashboard.getSaveModelClone()
134+
saveModel.id = None
135+
136+
# undo repeat cleanup
137+
dashboard.processRepeats()
138+
139+
self.inputs = []
140+
self.requires = {}
141+
self.datasources = {}
142+
self.promises = []
143+
self.variableLookup = {}
144+
self.libraryPanels = {}
145+
146+
for variable in saveModel.getVariables():
147+
self.variableLookup[variable.name] = variable
148+
149+
"""
150+
const templateizeDatasourceUsage = (obj: any) => {
151+
let datasource: string = obj.datasource;
152+
let datasourceVariable: any = null;
153+
154+
// ignore data source properties that contain a variable
155+
if (datasource && (datasource as any).uid) {
156+
const uid = (datasource as any).uid as string;
157+
if (uid.indexOf('$') === 0) {
158+
datasourceVariable = variableLookup[uid.substring(1)];
159+
if (datasourceVariable && datasourceVariable.current) {
160+
datasource = datasourceVariable.current.value;
161+
}
162+
}
163+
}
164+
"""
165+
166+
"""
167+
promises.push(
168+
getDataSourceSrv()
169+
.get(datasource)
170+
.then((ds) => {
171+
if (ds.meta?.builtIn) {
172+
return;
173+
}
174+
175+
// add data source type to require list
176+
requires['datasource' + ds.meta?.id] = {
177+
type: 'datasource',
178+
id: ds.meta.id,
179+
name: ds.meta.name,
180+
version: ds.meta.info.version || '1.0.0',
181+
};
182+
183+
// if used via variable we can skip templatizing usage
184+
if (datasourceVariable) {
185+
return;
186+
}
187+
188+
const refName = 'DS_' + ds.name.replace(' ', '_').toUpperCase();
189+
datasources[refName] = {
190+
name: refName,
191+
label: ds.name,
192+
description: '',
193+
type: 'datasource',
194+
pluginId: ds.meta?.id,
195+
pluginName: ds.meta?.name,
196+
};
197+
198+
if (!obj.datasource || typeof obj.datasource === 'string') {
199+
obj.datasource = '${' + refName + '}';
200+
} else {
201+
obj.datasource.uid = '${' + refName + '}';
202+
}
203+
})
204+
);
205+
};
206+
207+
const processPanel = (panel: PanelModel) => {
208+
if (panel.datasource !== undefined && panel.datasource !== null) {
209+
templateizeDatasourceUsage(panel);
210+
}
211+
212+
if (panel.targets) {
213+
for (const target of panel.targets) {
214+
if (target.datasource !== undefined) {
215+
templateizeDatasourceUsage(target);
216+
}
217+
}
218+
}
219+
220+
const panelDef: PanelPluginMeta = config.panels[panel.type];
221+
if (panelDef) {
222+
requires['panel' + panelDef.id] = {
223+
type: 'panel',
224+
id: panelDef.id,
225+
name: panelDef.name,
226+
version: panelDef.info.version,
227+
};
228+
}
229+
};
230+
231+
const processLibraryPanels = (panel: any) => {
232+
if (isPanelModelLibraryPanel(panel)) {
233+
const { libraryPanel, ...model } = panel;
234+
const { name, uid } = libraryPanel;
235+
if (!libraryPanels.has(uid)) {
236+
libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model });
237+
}
238+
}
239+
};
240+
241+
// check up panel data sources
242+
for (const panel of saveModel.panels) {
243+
processPanel(panel);
244+
245+
// handle collapsed rows
246+
if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) {
247+
for (const rowPanel of panel.panels) {
248+
processPanel(rowPanel);
249+
}
250+
}
251+
}
252+
253+
// templatize template vars
254+
for (const variable of saveModel.getVariables()) {
255+
if (isQuery(variable)) {
256+
templateizeDatasourceUsage(variable);
257+
variable.options = [];
258+
variable.current = {} as unknown as VariableOption;
259+
variable.refresh =
260+
variable.refresh !== VariableRefresh.never ? variable.refresh : VariableRefresh.onDashboardLoad;
261+
}
262+
}
263+
264+
// templatize annotations vars
265+
for (const annotationDef of saveModel.annotations.list) {
266+
templateizeDatasourceUsage(annotationDef);
267+
}
268+
"""
269+
270+
# add grafana version
271+
self.requires["grafana"] = {
272+
"type": "grafana",
273+
"id": "grafana",
274+
"name": "Grafana",
275+
# FIXME: "version": config.buildInfo.version,
276+
"version": "8.4.0-beta1",
277+
}
278+
279+
"""
280+
return Promise.all(promises)
281+
.then(() => {
282+
each(datasources, (value: any) => {
283+
inputs.push(value);
284+
});
285+
286+
// we need to process all panels again after all the promises are resolved
287+
// so all data sources, variables and targets have been templateized when we process library panels
288+
for (const panel of saveModel.panels) {
289+
processLibraryPanels(panel);
290+
if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) {
291+
for (const rowPanel of panel.panels) {
292+
processLibraryPanels(rowPanel);
293+
}
294+
}
295+
}
296+
297+
// templatize constants
298+
for (const variable of saveModel.getVariables()) {
299+
if (isConstant(variable)) {
300+
const refName = 'VAR_' + variable.name.replace(' ', '_').toUpperCase();
301+
inputs.push({
302+
name: refName,
303+
type: 'constant',
304+
label: variable.label || variable.name,
305+
value: variable.query,
306+
description: '',
307+
});
308+
// update current and option
309+
variable.query = '${' + refName + '}';
310+
variable.current = {
311+
value: variable.query,
312+
text: variable.query,
313+
selected: false,
314+
};
315+
variable.options = [variable.current];
316+
}
317+
}
318+
319+
})
320+
"""
321+
322+
# make inputs and requires a top thing
323+
newObj = dict(
324+
__inputs=self.inputs,
325+
__elements=list(self.libraryPanels.values()),
326+
__requires=sorted(self.requires.values(), key=operator.itemgetter("id")),
327+
)
328+
329+
# purge some attributes.
330+
blocklist = ["gnetId"]
331+
dashboard_data = dashboard.asdict()
332+
for blockitem in blocklist:
333+
if blockitem in dashboard_data:
334+
del dashboard_data[blockitem]
335+
newObj.update(dashboard_data)
336+
337+
return newObj
338+
339+
340+
def main():
341+
import json
342+
import sys
343+
from typing import Dict
344+
345+
from grafana_client import GrafanaApi
346+
from grafana_client.model.dashboard import DashboardExporter, DashboardModel
347+
348+
def jdump(data):
349+
print(json.dumps(data, indent=4, sort_keys=True))
350+
351+
grafana_host = sys.argv[1]
352+
dashboard_uid = sys.argv[2]
353+
354+
# Fetch dashboard.
355+
grafana: GrafanaApi = GrafanaApi(None, host=grafana_host)
356+
dashboard_raw: Dict = grafana.dashboard.get_dashboard(dashboard_uid)
357+
# jdump(dashboard_raw)
358+
359+
# Converge to model class.
360+
dashboard_data: Dict = dashboard_raw["dashboard"]
361+
dashboard_model: DashboardModel = DashboardModel(**dashboard_data)
362+
# jdump(dashboard_model.asdict())
363+
364+
# Represent.
365+
exporter: DashboardExporter = DashboardExporter()
366+
exported: Dict = exporter.makeExportable(dashboard_model)
367+
# assert exported.templating.list[0].datasource == '${DS_GFDB}'
368+
jdump(exported)
369+
370+
371+
if __name__ == "__main__":
372+
main()

0 commit comments

Comments
 (0)