blob: 63090ab1410303f277f43c7503c2055e55ee0fb8 [file] [log] [blame]
Ales Komarek3f044b22016-10-30 00:27:24 +02001# -*- coding: utf-8 -*-
2'''
3Manage Grafana v3.0 Dashboards
4
5.. versionadded:: 2016.3.0
6
7.. code-block:: yaml
8
9 grafana:
10 grafana_timeout: 3
11 grafana_token: qwertyuiop
12 grafana_url: 'https://url.com'
13
14.. code-block:: yaml
15
16 Ensure minimum dashboard is managed:
17 grafana_dashboard.present:
18 - name: insightful-dashboard
19 - base_dashboards_from_pillar:
20 - default_dashboard
21 - base_rows_from_pillar:
22 - default_row
23 - base_panels_from_pillar:
24 - default_panel
25 - dashboard:
26 rows:
27 - title: Usage
28 panels:
29 - targets:
30 - target: alias(constantLine(50), 'max')
31 title: Imaginary
32 type: graph
33
34
35The behavior of this module is to create dashboards if they do not exist, to
36add rows if they do not exist in existing dashboards, and to update rows if
37they exist in dashboards. The module will not manage rows that are not defined,
38allowing users to manage their own custom rows.
39'''
40
41# Import Python libs
42from __future__ import absolute_import
43import copy
44import json
45import requests
46
47# Import Salt libs
48import salt.ext.six as six
49from salt.utils.dictdiffer import DictDiffer
50
51
52def __virtual__():
53 '''Only load if grafana v2.0 is configured.'''
54 return __salt__['config.get']('grafana_version', 1) == 3
55
56
57_DEFAULT_DASHBOARD_PILLAR = 'grafana_dashboards:default'
58_DEFAULT_PANEL_PILLAR = 'grafana_panels:default'
59_DEFAULT_ROW_PILLAR = 'grafana_rows:default'
60_PINNED_ROWS_PILLAR = 'grafana_pinned_rows'
61
62
63def present(name,
64 base_dashboards_from_pillar=None,
65 base_panels_from_pillar=None,
66 base_rows_from_pillar=None,
67 dashboard=None,
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +010068 dashboard_format='yaml',
Ales Komarek3f044b22016-10-30 00:27:24 +020069 profile='grafana'):
70 '''
71 Ensure the grafana dashboard exists and is managed.
72
73 name
74 Name of the grafana dashboard.
75
76 base_dashboards_from_pillar
77 A pillar key that contains a list of dashboards to inherit from
78
79 base_panels_from_pillar
80 A pillar key that contains a list of panels to inherit from
81
82 base_rows_from_pillar
83 A pillar key that contains a list of rows to inherit from
84
85 dashboard
86 A dict that defines a dashboard that should be managed.
87
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +010088 dashboard_format
89 You can use two formats for dashboards. You can use the JSON format
90 if you provide a complete dashboard in raw JSON or you can use the YAML
91 format (this is the default) and provide a description of the
92 dashboard in YAML.
93
Ales Komarek3f044b22016-10-30 00:27:24 +020094 profile
95 A pillar key or dict that contains grafana information
96 '''
97 ret = {'name': name, 'result': True, 'comment': '', 'changes': {}}
Ales Komarek3f044b22016-10-30 00:27:24 +020098 dashboard = dashboard or {}
99
100 if isinstance(profile, six.string_types):
101 profile = __salt__['config.option'](profile)
102
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100103 if dashboard_format == 'json':
104 # In this case, a raw JSON of the full dashboard is provided.
105 response = _update(dashboard, profile)
106
107 if response.get('status') == 'success':
108 ret['comment'] = 'Dashboard {0} created.'.format(name)
109 ret['changes']['new'] = 'Dashboard {0} created.'.format(name)
110 else:
111 ret['result'] = False
112 ret['comment'] = ("Failed to create dashboard {0}, "
113 "response={1}").format(name, response)
114
115 return ret
116
117 base_dashboards_from_pillar = base_dashboards_from_pillar or []
118 base_panels_from_pillar = base_panels_from_pillar or []
119 base_rows_from_pillar = base_rows_from_pillar or []
120
Ales Komarek3f044b22016-10-30 00:27:24 +0200121 # Add pillar keys for default configuration
122 base_dashboards_from_pillar = ([_DEFAULT_DASHBOARD_PILLAR] +
123 base_dashboards_from_pillar)
124 base_panels_from_pillar = ([_DEFAULT_PANEL_PILLAR] +
125 base_panels_from_pillar)
126 base_rows_from_pillar = [_DEFAULT_ROW_PILLAR] + base_rows_from_pillar
127
128 # Build out all dashboard fields
129 new_dashboard = _inherited_dashboard(
130 dashboard, base_dashboards_from_pillar, ret)
131 new_dashboard['title'] = name
132 rows = new_dashboard.get('rows', [])
133 for i, row in enumerate(rows):
134 rows[i] = _inherited_row(row, base_rows_from_pillar, ret)
135 for row in rows:
136 panels = row.get('panels', [])
137 for i, panel in enumerate(panels):
138 panels[i] = _inherited_panel(panel, base_panels_from_pillar, ret)
139 _auto_adjust_panel_spans(new_dashboard)
140 _ensure_panel_ids(new_dashboard)
141 _ensure_annotations(new_dashboard)
142
143 # Create dashboard if it does not exist
144 url = 'db/{0}'.format(name)
145 old_dashboard = _get(url, profile)
146 if not old_dashboard:
147 if __opts__['test']:
148 ret['result'] = None
149 ret['comment'] = 'Dashboard {0} is set to be created.'.format(name)
150 return ret
151
152 response = _update(new_dashboard, profile)
153 if response.get('status') == 'success':
154 ret['comment'] = 'Dashboard {0} created.'.format(name)
155 ret['changes']['new'] = 'Dashboard {0} created.'.format(name)
156 else:
157 ret['result'] = False
158 ret['comment'] = ("Failed to create dashboard {0}, "
159 "response={1}").format(name, response)
160 return ret
161
162 # Add unmanaged rows to the dashboard. They appear at the top if they are
163 # marked as pinned. They appear at the bottom otherwise.
164 managed_row_titles = [row.get('title')
165 for row in new_dashboard.get('rows', [])]
166 new_rows = new_dashboard.get('rows', [])
167 for old_row in old_dashboard.get('rows', []):
168 if old_row.get('title') not in managed_row_titles:
169 new_rows.append(copy.deepcopy(old_row))
170 _ensure_pinned_rows(new_dashboard)
171 _ensure_panel_ids(new_dashboard)
172
173 # Update dashboard if it differs
174 dashboard_diff = DictDiffer(_cleaned(new_dashboard),
175 _cleaned(old_dashboard))
176 updated_needed = (dashboard_diff.changed() or
177 dashboard_diff.added() or
178 dashboard_diff.removed())
179 if updated_needed:
180 if __opts__['test']:
181 ret['result'] = None
182 ret['comment'] = ('Dashboard {0} is set to be updated, '
183 'changes={1}').format(
184 name,
185 json.dumps(
186 _dashboard_diff(
187 _cleaned(new_dashboard),
188 _cleaned(old_dashboard)
189 ),
190 indent=4
191 ))
192 return ret
193
194 response = _update(new_dashboard, profile)
195 if response.get('status') == 'success':
196 updated_dashboard = _get(url, profile)
197 dashboard_diff = DictDiffer(_cleaned(updated_dashboard),
198 _cleaned(old_dashboard))
199 ret['comment'] = 'Dashboard {0} updated.'.format(name)
200 ret['changes'] = _dashboard_diff(_cleaned(new_dashboard),
201 _cleaned(old_dashboard))
202 else:
203 ret['result'] = False
204 ret['comment'] = ("Failed to update dashboard {0}, "
205 "response={1}").format(name, response)
206 return ret
207
208 ret['comment'] = 'Dashboard present'
209 return ret
210
211
212def absent(name, profile='grafana'):
213 '''
214 Ensure the named grafana dashboard is absent.
215
216 name
217 Name of the grafana dashboard.
218
219 profile
220 A pillar key or dict that contains grafana information
221 '''
222 ret = {'name': name, 'result': True, 'comment': '', 'changes': {}}
223
224 if isinstance(profile, six.string_types):
225 profile = __salt__['config.option'](profile)
226
227 url = 'db/{0}'.format(name)
228 existing_dashboard = _get(url, profile)
229 if existing_dashboard:
230 if __opts__['test']:
231 ret['result'] = None
232 ret['comment'] = 'Dashboard {0} is set to be deleted.'.format(name)
233 return ret
234
235 _delete(url, profile)
236 ret['comment'] = 'Dashboard {0} deleted.'.format(name)
237 ret['changes']['new'] = 'Dashboard {0} deleted.'.format(name)
238 return ret
239
240 ret['comment'] = 'Dashboard absent'
241 return ret
242
243
244_IGNORED_DASHBOARD_FIELDS = [
245 'id',
246 'originalTitle',
247 'version',
248]
249_IGNORED_ROW_FIELDS = []
250_IGNORED_PANEL_FIELDS = [
251 'grid',
252 'mode',
253 'tooltip',
254]
255_IGNORED_TARGET_FIELDS = [
256 'textEditor',
257]
258
259
260def _cleaned(_dashboard):
261 '''Return a copy without fields that can differ.'''
262 dashboard = copy.deepcopy(_dashboard)
263
264 for ignored_dashboard_field in _IGNORED_DASHBOARD_FIELDS:
265 dashboard.pop(ignored_dashboard_field, None)
266 for row in dashboard.get('rows', []):
267 for ignored_row_field in _IGNORED_ROW_FIELDS:
268 row.pop(ignored_row_field, None)
269 for i, panel in enumerate(row.get('panels', [])):
270 for ignored_panel_field in _IGNORED_PANEL_FIELDS:
271 panel.pop(ignored_panel_field, None)
272 for target in panel.get('targets', []):
273 for ignored_target_field in _IGNORED_TARGET_FIELDS:
274 target.pop(ignored_target_field, None)
275 row['panels'][i] = _stripped(panel)
276
277 return dashboard
278
279
280def _inherited_dashboard(dashboard, base_dashboards_from_pillar, ret):
281 '''Return a dashboard with properties from parents.'''
282 base_dashboards = []
283 for base_dashboard_from_pillar in base_dashboards_from_pillar:
284 base_dashboard = __salt__['pillar.get'](base_dashboard_from_pillar)
285 if base_dashboard:
286 base_dashboards.append(base_dashboard)
287 elif base_dashboard_from_pillar != _DEFAULT_DASHBOARD_PILLAR:
288 ret.setdefault('warnings', [])
289 warning_message = 'Cannot find dashboard pillar "{0}".'.format(
290 base_dashboard_from_pillar)
291 if warning_message not in ret['warnings']:
292 ret['warnings'].append(warning_message)
293 base_dashboards.append(dashboard)
294
295 result_dashboard = {}
296 tags = set()
297 for dashboard in base_dashboards:
298 tags.update(dashboard.get('tags', []))
299 result_dashboard.update(dashboard)
300 result_dashboard['tags'] = list(tags)
301 return result_dashboard
302
303
304def _inherited_row(row, base_rows_from_pillar, ret):
305 '''Return a row with properties from parents.'''
306 base_rows = []
307 for base_row_from_pillar in base_rows_from_pillar:
308 base_row = __salt__['pillar.get'](base_row_from_pillar)
309 if base_row:
310 base_rows.append(base_row)
311 elif base_row_from_pillar != _DEFAULT_ROW_PILLAR:
312 ret.setdefault('warnings', [])
313 warning_message = 'Cannot find row pillar "{0}".'.format(
314 base_row_from_pillar)
315 if warning_message not in ret['warnings']:
316 ret['warnings'].append(warning_message)
317 base_rows.append(row)
318
319 result_row = {}
320 for row in base_rows:
321 result_row.update(row)
322 return result_row
323
324
325def _inherited_panel(panel, base_panels_from_pillar, ret):
326 '''Return a panel with properties from parents.'''
327 base_panels = []
328 for base_panel_from_pillar in base_panels_from_pillar:
329 base_panel = __salt__['pillar.get'](base_panel_from_pillar)
330 if base_panel:
331 base_panels.append(base_panel)
332 elif base_panel_from_pillar != _DEFAULT_PANEL_PILLAR:
333 ret.setdefault('warnings', [])
334 warning_message = 'Cannot find panel pillar "{0}".'.format(
335 base_panel_from_pillar)
336 if warning_message not in ret['warnings']:
337 ret['warnings'].append(warning_message)
338 base_panels.append(panel)
339
340 result_panel = {}
341 for panel in base_panels:
342 result_panel.update(panel)
343 return result_panel
344
345
346_FULL_LEVEL_SPAN = 12
347_DEFAULT_PANEL_SPAN = 2.5
348
349
350def _auto_adjust_panel_spans(dashboard):
351 '''Adjust panel spans to take up the available width.
352
353 For each group of panels that would be laid out on the same level, scale up
354 the unspecified panel spans to fill up the level.
355 '''
356 for row in dashboard.get('rows', []):
357 levels = []
358 current_level = []
359 levels.append(current_level)
360 for panel in row.get('panels', []):
361 current_level_span = sum(panel.get('span', _DEFAULT_PANEL_SPAN)
362 for panel in current_level)
363 span = panel.get('span', _DEFAULT_PANEL_SPAN)
364 if current_level_span + span > _FULL_LEVEL_SPAN:
365 current_level = [panel]
366 levels.append(current_level)
367 else:
368 current_level.append(panel)
369
370 for level in levels:
371 specified_panels = [panel for panel in level if 'span' in panel]
372 unspecified_panels = [panel for panel in level
373 if 'span' not in panel]
374 if not unspecified_panels:
375 continue
376
377 specified_span = sum(panel['span'] for panel in specified_panels)
378 available_span = _FULL_LEVEL_SPAN - specified_span
379 auto_span = float(available_span) / len(unspecified_panels)
380 for panel in unspecified_panels:
381 panel['span'] = auto_span
382
383
384def _ensure_pinned_rows(dashboard):
385 '''Pin rows to the top of the dashboard.'''
386 pinned_row_titles = __salt__['pillar.get'](_PINNED_ROWS_PILLAR)
387 if not pinned_row_titles:
388 return
389
390 pinned_row_titles_lower = []
391 for title in pinned_row_titles:
392 pinned_row_titles_lower.append(title.lower())
393 rows = dashboard.get('rows', [])
394 pinned_rows = []
395 for i, row in enumerate(rows):
396 if row.get('title', '').lower() in pinned_row_titles_lower:
397 del rows[i]
398 pinned_rows.append(row)
399 rows = pinned_rows + rows
400
401
402def _ensure_panel_ids(dashboard):
403 '''Assign panels auto-incrementing IDs.'''
404 panel_id = 1
405 for row in dashboard.get('rows', []):
406 for panel in row.get('panels', []):
407 panel['id'] = panel_id
408 panel_id += 1
409
410
411def _ensure_annotations(dashboard):
412 '''Explode annotation_tags into annotations.'''
413 if 'annotation_tags' not in dashboard:
414 return
415 tags = dashboard['annotation_tags']
416 annotations = {
417 'enable': True,
418 'list': [],
419 }
420 for tag in tags:
421 annotations['list'].append({
422 'datasource': "graphite",
423 'enable': False,
424 'iconColor': "#C0C6BE",
425 'iconSize': 13,
426 'lineColor': "rgba(255, 96, 96, 0.592157)",
427 'name': tag,
428 'showLine': True,
429 'tags': tag,
430 })
431 del dashboard['annotation_tags']
432 dashboard['annotations'] = annotations
433
434
435def _get(url, profile):
436 '''Get a specific dashboard.'''
437 request_url = "{0}/api/dashboards/{1}".format(profile.get('grafana_url'),
438 url)
439 if profile.get('grafana_token', False):
440 response = requests.get(
441 request_url,
442 headers=_get_headers(profile),
443 timeout=profile.get('grafana_timeout', 3),
444 )
445 else:
446 response = requests.get(
447 request_url,
448 auth=_get_auth(profile),
449 timeout=profile.get('grafana_timeout', 3),
450 )
451 data = response.json()
452 if data.get('message') == 'Not found':
453 return None
454 if 'dashboard' not in data:
455 return None
456 return data['dashboard']
457
458
459def _delete(url, profile):
460 '''Delete a specific dashboard.'''
461 request_url = "{0}/api/dashboards/{1}".format(profile.get('grafana_url'),
462 url)
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100463 response = requests.delete(
464 request_url,
465 auth=_get_auth(profile),
466 headers=_get_headers(profile),
467 timeout=profile.get('grafana_timeout'),
468 )
Ales Komarek3f044b22016-10-30 00:27:24 +0200469 data = response.json()
470 return data
471
472
473def _update(dashboard, profile):
474 '''Update a specific dashboard.'''
475 payload = {
476 'dashboard': dashboard,
477 'overwrite': True
478 }
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100479 response = requests.post(
480 "{0}/api/dashboards/db".format(profile.get('grafana_url')),
481 auth=_get_auth(profile),
482 headers=_get_headers(profile),
483 json=payload
484 )
Ales Komarek3f044b22016-10-30 00:27:24 +0200485 return response.json()
486
487
488def _get_headers(profile):
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100489 headers = {'Content-type': 'application/json'}
490
491 if profile.get('grafana_token', False):
492 headers['Authorization'] = 'Bearer {0}'.format(profile['grafana_token'])
493
494 return headers
Ales Komarek3f044b22016-10-30 00:27:24 +0200495
496
497def _get_auth(profile):
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100498 if profile.get('grafana_token', False):
499 return None
500
Ales Komarek3f044b22016-10-30 00:27:24 +0200501 return requests.auth.HTTPBasicAuth(
502 profile['grafana_user'],
503 profile['grafana_password']
504 )
505
506
507def _dashboard_diff(_new_dashboard, _old_dashboard):
508 '''Return a dictionary of changes between dashboards.'''
509 diff = {}
510
511 # Dashboard diff
512 new_dashboard = copy.deepcopy(_new_dashboard)
513 old_dashboard = copy.deepcopy(_old_dashboard)
514 dashboard_diff = DictDiffer(new_dashboard, old_dashboard)
515 diff['dashboard'] = _stripped({
516 'changed': list(dashboard_diff.changed()) or None,
517 'added': list(dashboard_diff.added()) or None,
518 'removed': list(dashboard_diff.removed()) or None,
519 })
520
521 # Row diff
522 new_rows = new_dashboard.get('rows', [])
523 old_rows = old_dashboard.get('rows', [])
524 new_rows_by_title = {}
525 old_rows_by_title = {}
526 for row in new_rows:
527 if 'title' in row:
528 new_rows_by_title[row['title']] = row
529 for row in old_rows:
530 if 'title' in row:
531 old_rows_by_title[row['title']] = row
532 rows_diff = DictDiffer(new_rows_by_title, old_rows_by_title)
533 diff['rows'] = _stripped({
534 'added': list(rows_diff.added()) or None,
535 'removed': list(rows_diff.removed()) or None,
536 })
537 for changed_row_title in rows_diff.changed():
538 old_row = old_rows_by_title[changed_row_title]
539 new_row = new_rows_by_title[changed_row_title]
540 row_diff = DictDiffer(new_row, old_row)
541 diff['rows'].setdefault('changed', {})
542 diff['rows']['changed'][changed_row_title] = _stripped({
543 'changed': list(row_diff.changed()) or None,
544 'added': list(row_diff.added()) or None,
545 'removed': list(row_diff.removed()) or None,
546 })
547
548 # Panel diff
549 old_panels_by_id = {}
550 new_panels_by_id = {}
551 for row in old_dashboard.get('rows', []):
552 for panel in row.get('panels', []):
553 if 'id' in panel:
554 old_panels_by_id[panel['id']] = panel
555 for row in new_dashboard.get('rows', []):
556 for panel in row.get('panels', []):
557 if 'id' in panel:
558 new_panels_by_id[panel['id']] = panel
559 panels_diff = DictDiffer(new_panels_by_id, old_panels_by_id)
560 diff['panels'] = _stripped({
561 'added': list(panels_diff.added()) or None,
562 'removed': list(panels_diff.removed()) or None,
563 })
564 for changed_panel_id in panels_diff.changed():
565 old_panel = old_panels_by_id[changed_panel_id]
566 new_panel = new_panels_by_id[changed_panel_id]
567 panels_diff = DictDiffer(new_panel, old_panel)
568 diff['panels'].setdefault('changed', {})
569 diff['panels']['changed'][changed_panel_id] = _stripped({
570 'changed': list(panels_diff.changed()) or None,
571 'added': list(panels_diff.added()) or None,
572 'removed': list(panels_diff.removed()) or None,
573 })
574
575 return diff
576
577
578def _stripped(d):
579 '''Strip falsey entries.'''
580 ret = {}
581 for k, v in six.iteritems(d):
582 if v:
583 ret[k] = v
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100584 return ret