blob: edd535955e85996e6520b01a2a9ec3a13f9525f6 [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)
Guillaume Thouvenin885c5fc2017-01-10 17:08:26 +0100131 if not new_dashboard.get('title'):
132 new_dashboard['title'] = name
Ales Komarek3f044b22016-10-30 00:27:24 +0200133 rows = new_dashboard.get('rows', [])
134 for i, row in enumerate(rows):
135 rows[i] = _inherited_row(row, base_rows_from_pillar, ret)
136 for row in rows:
137 panels = row.get('panels', [])
Guillaume Thouveninab1a01b2017-01-25 16:01:40 +0100138 for i, panel in enumerate(sorted(panels)):
Ales Komarek3f044b22016-10-30 00:27:24 +0200139 panels[i] = _inherited_panel(panel, base_panels_from_pillar, ret)
140 _auto_adjust_panel_spans(new_dashboard)
141 _ensure_panel_ids(new_dashboard)
142 _ensure_annotations(new_dashboard)
143
144 # Create dashboard if it does not exist
145 url = 'db/{0}'.format(name)
146 old_dashboard = _get(url, profile)
147 if not old_dashboard:
148 if __opts__['test']:
149 ret['result'] = None
150 ret['comment'] = 'Dashboard {0} is set to be created.'.format(name)
151 return ret
152
153 response = _update(new_dashboard, profile)
154 if response.get('status') == 'success':
155 ret['comment'] = 'Dashboard {0} created.'.format(name)
156 ret['changes']['new'] = 'Dashboard {0} created.'.format(name)
157 else:
158 ret['result'] = False
159 ret['comment'] = ("Failed to create dashboard {0}, "
160 "response={1}").format(name, response)
161 return ret
162
163 # Add unmanaged rows to the dashboard. They appear at the top if they are
164 # marked as pinned. They appear at the bottom otherwise.
165 managed_row_titles = [row.get('title')
166 for row in new_dashboard.get('rows', [])]
167 new_rows = new_dashboard.get('rows', [])
168 for old_row in old_dashboard.get('rows', []):
169 if old_row.get('title') not in managed_row_titles:
170 new_rows.append(copy.deepcopy(old_row))
171 _ensure_pinned_rows(new_dashboard)
172 _ensure_panel_ids(new_dashboard)
173
174 # Update dashboard if it differs
175 dashboard_diff = DictDiffer(_cleaned(new_dashboard),
176 _cleaned(old_dashboard))
177 updated_needed = (dashboard_diff.changed() or
178 dashboard_diff.added() or
179 dashboard_diff.removed())
180 if updated_needed:
181 if __opts__['test']:
182 ret['result'] = None
183 ret['comment'] = ('Dashboard {0} is set to be updated, '
184 'changes={1}').format(
185 name,
186 json.dumps(
187 _dashboard_diff(
188 _cleaned(new_dashboard),
189 _cleaned(old_dashboard)
190 ),
191 indent=4
192 ))
193 return ret
194
195 response = _update(new_dashboard, profile)
196 if response.get('status') == 'success':
197 updated_dashboard = _get(url, profile)
198 dashboard_diff = DictDiffer(_cleaned(updated_dashboard),
199 _cleaned(old_dashboard))
200 ret['comment'] = 'Dashboard {0} updated.'.format(name)
201 ret['changes'] = _dashboard_diff(_cleaned(new_dashboard),
202 _cleaned(old_dashboard))
203 else:
204 ret['result'] = False
205 ret['comment'] = ("Failed to update dashboard {0}, "
206 "response={1}").format(name, response)
207 return ret
208
209 ret['comment'] = 'Dashboard present'
210 return ret
211
212
213def absent(name, profile='grafana'):
214 '''
215 Ensure the named grafana dashboard is absent.
216
217 name
218 Name of the grafana dashboard.
219
220 profile
221 A pillar key or dict that contains grafana information
222 '''
223 ret = {'name': name, 'result': True, 'comment': '', 'changes': {}}
224
225 if isinstance(profile, six.string_types):
226 profile = __salt__['config.option'](profile)
227
228 url = 'db/{0}'.format(name)
229 existing_dashboard = _get(url, profile)
230 if existing_dashboard:
231 if __opts__['test']:
232 ret['result'] = None
233 ret['comment'] = 'Dashboard {0} is set to be deleted.'.format(name)
234 return ret
235
236 _delete(url, profile)
237 ret['comment'] = 'Dashboard {0} deleted.'.format(name)
238 ret['changes']['new'] = 'Dashboard {0} deleted.'.format(name)
239 return ret
240
241 ret['comment'] = 'Dashboard absent'
242 return ret
243
244
245_IGNORED_DASHBOARD_FIELDS = [
246 'id',
247 'originalTitle',
248 'version',
249]
250_IGNORED_ROW_FIELDS = []
251_IGNORED_PANEL_FIELDS = [
252 'grid',
253 'mode',
254 'tooltip',
255]
256_IGNORED_TARGET_FIELDS = [
257 'textEditor',
258]
259
260
261def _cleaned(_dashboard):
262 '''Return a copy without fields that can differ.'''
263 dashboard = copy.deepcopy(_dashboard)
264
265 for ignored_dashboard_field in _IGNORED_DASHBOARD_FIELDS:
266 dashboard.pop(ignored_dashboard_field, None)
267 for row in dashboard.get('rows', []):
268 for ignored_row_field in _IGNORED_ROW_FIELDS:
269 row.pop(ignored_row_field, None)
270 for i, panel in enumerate(row.get('panels', [])):
271 for ignored_panel_field in _IGNORED_PANEL_FIELDS:
272 panel.pop(ignored_panel_field, None)
273 for target in panel.get('targets', []):
274 for ignored_target_field in _IGNORED_TARGET_FIELDS:
275 target.pop(ignored_target_field, None)
276 row['panels'][i] = _stripped(panel)
277
278 return dashboard
279
280
281def _inherited_dashboard(dashboard, base_dashboards_from_pillar, ret):
282 '''Return a dashboard with properties from parents.'''
283 base_dashboards = []
284 for base_dashboard_from_pillar in base_dashboards_from_pillar:
285 base_dashboard = __salt__['pillar.get'](base_dashboard_from_pillar)
286 if base_dashboard:
287 base_dashboards.append(base_dashboard)
288 elif base_dashboard_from_pillar != _DEFAULT_DASHBOARD_PILLAR:
289 ret.setdefault('warnings', [])
290 warning_message = 'Cannot find dashboard pillar "{0}".'.format(
291 base_dashboard_from_pillar)
292 if warning_message not in ret['warnings']:
293 ret['warnings'].append(warning_message)
294 base_dashboards.append(dashboard)
295
296 result_dashboard = {}
297 tags = set()
298 for dashboard in base_dashboards:
299 tags.update(dashboard.get('tags', []))
300 result_dashboard.update(dashboard)
301 result_dashboard['tags'] = list(tags)
302 return result_dashboard
303
304
305def _inherited_row(row, base_rows_from_pillar, ret):
306 '''Return a row with properties from parents.'''
307 base_rows = []
308 for base_row_from_pillar in base_rows_from_pillar:
309 base_row = __salt__['pillar.get'](base_row_from_pillar)
310 if base_row:
311 base_rows.append(base_row)
312 elif base_row_from_pillar != _DEFAULT_ROW_PILLAR:
313 ret.setdefault('warnings', [])
314 warning_message = 'Cannot find row pillar "{0}".'.format(
315 base_row_from_pillar)
316 if warning_message not in ret['warnings']:
317 ret['warnings'].append(warning_message)
318 base_rows.append(row)
319
320 result_row = {}
321 for row in base_rows:
322 result_row.update(row)
323 return result_row
324
325
326def _inherited_panel(panel, base_panels_from_pillar, ret):
327 '''Return a panel with properties from parents.'''
328 base_panels = []
329 for base_panel_from_pillar in base_panels_from_pillar:
330 base_panel = __salt__['pillar.get'](base_panel_from_pillar)
331 if base_panel:
332 base_panels.append(base_panel)
333 elif base_panel_from_pillar != _DEFAULT_PANEL_PILLAR:
334 ret.setdefault('warnings', [])
335 warning_message = 'Cannot find panel pillar "{0}".'.format(
336 base_panel_from_pillar)
337 if warning_message not in ret['warnings']:
338 ret['warnings'].append(warning_message)
339 base_panels.append(panel)
340
341 result_panel = {}
342 for panel in base_panels:
343 result_panel.update(panel)
344 return result_panel
345
346
347_FULL_LEVEL_SPAN = 12
348_DEFAULT_PANEL_SPAN = 2.5
349
350
351def _auto_adjust_panel_spans(dashboard):
352 '''Adjust panel spans to take up the available width.
353
354 For each group of panels that would be laid out on the same level, scale up
355 the unspecified panel spans to fill up the level.
356 '''
357 for row in dashboard.get('rows', []):
358 levels = []
359 current_level = []
360 levels.append(current_level)
361 for panel in row.get('panels', []):
362 current_level_span = sum(panel.get('span', _DEFAULT_PANEL_SPAN)
363 for panel in current_level)
364 span = panel.get('span', _DEFAULT_PANEL_SPAN)
365 if current_level_span + span > _FULL_LEVEL_SPAN:
366 current_level = [panel]
367 levels.append(current_level)
368 else:
369 current_level.append(panel)
370
371 for level in levels:
372 specified_panels = [panel for panel in level if 'span' in panel]
373 unspecified_panels = [panel for panel in level
374 if 'span' not in panel]
375 if not unspecified_panels:
376 continue
377
378 specified_span = sum(panel['span'] for panel in specified_panels)
379 available_span = _FULL_LEVEL_SPAN - specified_span
380 auto_span = float(available_span) / len(unspecified_panels)
381 for panel in unspecified_panels:
382 panel['span'] = auto_span
383
384
385def _ensure_pinned_rows(dashboard):
386 '''Pin rows to the top of the dashboard.'''
387 pinned_row_titles = __salt__['pillar.get'](_PINNED_ROWS_PILLAR)
388 if not pinned_row_titles:
389 return
390
391 pinned_row_titles_lower = []
392 for title in pinned_row_titles:
393 pinned_row_titles_lower.append(title.lower())
394 rows = dashboard.get('rows', [])
395 pinned_rows = []
396 for i, row in enumerate(rows):
397 if row.get('title', '').lower() in pinned_row_titles_lower:
398 del rows[i]
399 pinned_rows.append(row)
400 rows = pinned_rows + rows
401
402
403def _ensure_panel_ids(dashboard):
404 '''Assign panels auto-incrementing IDs.'''
405 panel_id = 1
406 for row in dashboard.get('rows', []):
407 for panel in row.get('panels', []):
408 panel['id'] = panel_id
409 panel_id += 1
410
411
412def _ensure_annotations(dashboard):
413 '''Explode annotation_tags into annotations.'''
414 if 'annotation_tags' not in dashboard:
415 return
416 tags = dashboard['annotation_tags']
417 annotations = {
418 'enable': True,
419 'list': [],
420 }
421 for tag in tags:
422 annotations['list'].append({
423 'datasource': "graphite",
424 'enable': False,
425 'iconColor': "#C0C6BE",
426 'iconSize': 13,
427 'lineColor': "rgba(255, 96, 96, 0.592157)",
428 'name': tag,
429 'showLine': True,
430 'tags': tag,
431 })
432 del dashboard['annotation_tags']
433 dashboard['annotations'] = annotations
434
435
436def _get(url, profile):
437 '''Get a specific dashboard.'''
438 request_url = "{0}/api/dashboards/{1}".format(profile.get('grafana_url'),
439 url)
440 if profile.get('grafana_token', False):
441 response = requests.get(
442 request_url,
443 headers=_get_headers(profile),
444 timeout=profile.get('grafana_timeout', 3),
445 )
446 else:
447 response = requests.get(
448 request_url,
449 auth=_get_auth(profile),
450 timeout=profile.get('grafana_timeout', 3),
451 )
452 data = response.json()
453 if data.get('message') == 'Not found':
454 return None
455 if 'dashboard' not in data:
456 return None
457 return data['dashboard']
458
459
460def _delete(url, profile):
461 '''Delete a specific dashboard.'''
462 request_url = "{0}/api/dashboards/{1}".format(profile.get('grafana_url'),
463 url)
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100464 response = requests.delete(
465 request_url,
466 auth=_get_auth(profile),
467 headers=_get_headers(profile),
468 timeout=profile.get('grafana_timeout'),
469 )
Ales Komarek3f044b22016-10-30 00:27:24 +0200470 data = response.json()
471 return data
472
473
474def _update(dashboard, profile):
475 '''Update a specific dashboard.'''
476 payload = {
477 'dashboard': dashboard,
478 'overwrite': True
479 }
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100480 response = requests.post(
481 "{0}/api/dashboards/db".format(profile.get('grafana_url')),
482 auth=_get_auth(profile),
483 headers=_get_headers(profile),
484 json=payload
485 )
Ales Komarek3f044b22016-10-30 00:27:24 +0200486 return response.json()
487
488
489def _get_headers(profile):
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100490 headers = {'Content-type': 'application/json'}
491
492 if profile.get('grafana_token', False):
493 headers['Authorization'] = 'Bearer {0}'.format(profile['grafana_token'])
494
495 return headers
Ales Komarek3f044b22016-10-30 00:27:24 +0200496
497
498def _get_auth(profile):
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100499 if profile.get('grafana_token', False):
500 return None
501
Ales Komarek3f044b22016-10-30 00:27:24 +0200502 return requests.auth.HTTPBasicAuth(
503 profile['grafana_user'],
504 profile['grafana_password']
505 )
506
507
508def _dashboard_diff(_new_dashboard, _old_dashboard):
509 '''Return a dictionary of changes between dashboards.'''
510 diff = {}
511
512 # Dashboard diff
513 new_dashboard = copy.deepcopy(_new_dashboard)
514 old_dashboard = copy.deepcopy(_old_dashboard)
515 dashboard_diff = DictDiffer(new_dashboard, old_dashboard)
516 diff['dashboard'] = _stripped({
517 'changed': list(dashboard_diff.changed()) or None,
518 'added': list(dashboard_diff.added()) or None,
519 'removed': list(dashboard_diff.removed()) or None,
520 })
521
522 # Row diff
523 new_rows = new_dashboard.get('rows', [])
524 old_rows = old_dashboard.get('rows', [])
525 new_rows_by_title = {}
526 old_rows_by_title = {}
527 for row in new_rows:
528 if 'title' in row:
529 new_rows_by_title[row['title']] = row
530 for row in old_rows:
531 if 'title' in row:
532 old_rows_by_title[row['title']] = row
533 rows_diff = DictDiffer(new_rows_by_title, old_rows_by_title)
534 diff['rows'] = _stripped({
535 'added': list(rows_diff.added()) or None,
536 'removed': list(rows_diff.removed()) or None,
537 })
538 for changed_row_title in rows_diff.changed():
539 old_row = old_rows_by_title[changed_row_title]
540 new_row = new_rows_by_title[changed_row_title]
541 row_diff = DictDiffer(new_row, old_row)
542 diff['rows'].setdefault('changed', {})
543 diff['rows']['changed'][changed_row_title] = _stripped({
544 'changed': list(row_diff.changed()) or None,
545 'added': list(row_diff.added()) or None,
546 'removed': list(row_diff.removed()) or None,
547 })
548
549 # Panel diff
550 old_panels_by_id = {}
551 new_panels_by_id = {}
552 for row in old_dashboard.get('rows', []):
553 for panel in row.get('panels', []):
554 if 'id' in panel:
555 old_panels_by_id[panel['id']] = panel
556 for row in new_dashboard.get('rows', []):
557 for panel in row.get('panels', []):
558 if 'id' in panel:
559 new_panels_by_id[panel['id']] = panel
560 panels_diff = DictDiffer(new_panels_by_id, old_panels_by_id)
561 diff['panels'] = _stripped({
562 'added': list(panels_diff.added()) or None,
563 'removed': list(panels_diff.removed()) or None,
564 })
565 for changed_panel_id in panels_diff.changed():
566 old_panel = old_panels_by_id[changed_panel_id]
567 new_panel = new_panels_by_id[changed_panel_id]
568 panels_diff = DictDiffer(new_panel, old_panel)
569 diff['panels'].setdefault('changed', {})
570 diff['panels']['changed'][changed_panel_id] = _stripped({
571 'changed': list(panels_diff.changed()) or None,
572 'added': list(panels_diff.added()) or None,
573 'removed': list(panels_diff.removed()) or None,
574 })
575
576 return diff
577
578
579def _stripped(d):
580 '''Strip falsey entries.'''
581 ret = {}
582 for k, v in six.iteritems(d):
583 if v:
584 ret[k] = v
Guillaume Thouvenin2958bda2016-11-08 11:55:55 +0100585 return ret