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