diff --git a/brian_dashboard_manager/grafana/provision.py b/brian_dashboard_manager/grafana/provision.py index 8acabc7fa8ad6e54cdad6bd0a5c0036e414c43e7..57b85d05b4502012bedd2b11fa4866be583568ab 100644 --- a/brian_dashboard_manager/grafana/provision.py +++ b/brian_dashboard_manager/grafana/provision.py @@ -18,11 +18,12 @@ from brian_dashboard_manager.inventory_provider.interfaces import \ from brian_dashboard_manager.templating.nren_access import generate_nrens from brian_dashboard_manager.templating.helpers import is_re_customer, \ - is_cls, is_ias_customer, is_ias_private, is_ias_public, is_ias_upstream, \ - is_lag_backbone, is_nren, is_phy_upstream, is_re_peer, is_gcs, \ - is_geantopen, is_l2circuit, is_lhcone_peer, is_lhcone_customer, is_mdvpn,\ + is_cls_peer, is_cls, is_ias_customer, is_ias_private, is_ias_public, \ + is_ias_upstream, is_ias_peer, is_lag_backbone, is_nren, is_phy_upstream, \ + is_re_peer, is_gcs, is_geantopen, is_l2circuit, is_lhcone_peer, \ + is_lhcone_customer, is_lhcone, is_mdvpn, get_aggregate_dashboard_data, \ get_interface_data, parse_backbone_name, parse_phy_upstream_name, \ - get_dashboard_data + get_dashboard_data, get_aggregate_interface_data from brian_dashboard_manager.templating.render import render_dashboard @@ -61,6 +62,21 @@ def provision_folder(token_request, folder_name, rendered, folder['id']) +def provision_aggregate(token_request, agg_type, aggregate_folder, + dash, excluded_interfaces, datasource_name): + predicate = dash['predicate'] + tag = dash['tag'] + + relevant_interfaces = filter(predicate, excluded_interfaces) + data = get_aggregate_interface_data(relevant_interfaces, agg_type) + + dashboard = get_aggregate_dashboard_data( + f'Aggregate - {agg_type}', data, datasource_name, tag) + + rendered = render_dashboard(dashboard) + create_dashboard(token_request, rendered, aggregate_folder['id']) + + def provision(config): request = AdminRequest(**config) @@ -200,6 +216,39 @@ def provision(config): folder_name, dash, excluded_interfaces, datasource_name) + aggregate_dashboards = { + 'CLS PEERS': { + 'predicate': is_cls_peer, + 'tag': 'cls_peers', + }, + 'IAS PEERS': { + 'predicate': is_ias_peer, + 'tag': 'ias_peers', + }, + 'GWS UPSTREAMS': { + 'predicate': is_ias_upstream, + 'tag': 'gws_upstreams', + }, + 'LHCONE': { + 'predicate': is_lhcone, + 'tag': 'lhcone', + }, + # 'CAE1': { + # 'predicate': is_cae1, + # 'tag': 'cae', + # } + } + + with ProcessPoolExecutor(max_workers=4) as executor: + aggregate_folder = find_folder(token_request, 'Aggregates') + for agg_type, dash in aggregate_dashboards.items(): + logger.info( + f'Provisioning {org["name"]}' + + f'/Aggregate {agg_type} dashboards') + executor.submit(provision_aggregate, token_request, agg_type, + aggregate_folder, dash, + excluded_interfaces, datasource_name) + # NREN Access dashboards # uses a different template than the above. logger.info('Provisioning NREN Access dashboards') diff --git a/brian_dashboard_manager/templating/helpers.py b/brian_dashboard_manager/templating/helpers.py index 46e141c5eb2282444c7458f45bdfc231f051945c..9f9adf29cb3d2094dcaa2943b5e72bf729a2d5c7 100644 --- a/brian_dashboard_manager/templating/helpers.py +++ b/brian_dashboard_manager/templating/helpers.py @@ -1,8 +1,11 @@ import re import logging +import json from itertools import product +from functools import reduce from string import ascii_uppercase -from brian_dashboard_manager.templating.render import create_panel +from brian_dashboard_manager.templating.render import create_panel, \ + create_panel_target PANEL_HEIGHT = 12 PANEL_WIDTH = 24 @@ -35,6 +38,10 @@ def is_cls(interface): return 'SRV_CLS' in get_description(interface) +def is_cls_peer(interface): + return 'SRV_CLS PRIVATE' in get_description(interface) + + def is_ias_public(interface): return 'SRV_IAS PUBLIC' in get_description(interface) @@ -51,6 +58,10 @@ def is_ias_upstream(interface): return 'SRV_IAS UPSTREAM' in get_description(interface) +def is_ias_peer(interface): + return is_ias_public(interface) or is_ias_private(interface) + + def is_re_peer(interface): return 'SRV_GLOBAL RE_INTERCONNECT' in get_description(interface) @@ -82,6 +93,11 @@ def is_lhcone_customer(interface): return 'LHCONE' in description and 'SRV_L3VPN CUSTOMER' in description +def is_lhcone(interface): + regex = 'SRV_L3VPN (CUSTOMER|RE_INTERCONNECT)' + return re.match(regex, get_description(interface)) + + def is_mdvpn(interface): return re.match('^SRV_MDVPN CUSTOMER', get_description(interface)) @@ -96,6 +112,10 @@ def is_lag_backbone(interface): return is_infrastructure_backbone(interface) and is_lag +def is_cae1(interface): + return interface.get('router', '').lower() == 'mx1.lon.uk.geant.net' + + def parse_backbone_name(description, *args, **kwargs): link = description.split('|')[1].strip() link = link.replace('( ', '(') @@ -119,16 +139,24 @@ def num_generator(start=1): num += 1 -def gridPos_generator(id_generator, start=0): +def gridPos_generator(id_generator, start=0, agg=False): num = start while True: yield { "height": PANEL_HEIGHT, - "width": PANEL_WIDTH, + "width": PANEL_WIDTH if not agg else PANEL_WIDTH // 2, "x": 0, "y": num * PANEL_HEIGHT, "id": next(id_generator) } + if agg: + yield { + "height": PANEL_HEIGHT, + "width": PANEL_WIDTH // 2, + "x": PANEL_WIDTH // 2, + "y": num * PANEL_HEIGHT, + "id": next(id_generator) + } num += 1 @@ -155,12 +183,14 @@ def letter_generator(): # parse_func receives interface information and returns a peer name. def get_interface_data(interfaces, name_parse_func=None): result = {} + + if not name_parse_func: + # Most (but not all) descriptions use a format + # which has the peer name as the third element. + def name_parse_func(desc, *args, **kwargs): + return desc.split(' ')[2].upper() + for interface in interfaces: - if not name_parse_func: - # Most (but not all) descriptions use a format - # which has the peer name as the third element. - def name_parse_func(desc, *args, **kwargs): - return desc.split(' ')[2].upper() description = interface.get('description', '').strip() interface_name = interface.get('name') @@ -182,6 +212,67 @@ def get_interface_data(interfaces, name_parse_func=None): return result +def get_aggregate_interface_data(interfaces, agg_type): + result = [] + + def reduce_func(prev, curr): + remotes = prev.get(curr['remote'], []) + remotes.append(curr) + all_agg = prev.get('EVERYSINGLEPANEL', []) + all_agg.append(curr) + prev[curr['remote']] = remotes + prev['EVERYSINGLEPANEL'] = all_agg + return prev + + for interface in interfaces: + + description = interface.get('description', '').strip() + interface_name = interface.get('name') + host = interface.get('router', '') + + remote = description.split(' ')[2].upper() + + result.append({ + 'type': agg_type, + 'interface': interface_name, + 'hostname': host, + 'remote': remote, + 'alias': f"{host.split('.')[1].upper()} - {remote}" + }) + return reduce(reduce_func, result, {}) + + +# Helper used for generating stacked aggregate panels +# with multiple target fields (ingress/egress) +def get_aggregate_targets(targets): + ingress = [] + egress = [] + + # used to generate refIds + letters = letter_generator() + + for target in targets: + ref_id = next(letters) + in_data = { + **target, + 'alias': f"{target['alias']} - Ingress Traffic", + 'refId': ref_id, + 'select_field': 'ingress' + } + out_data = { + **target, + 'alias': f"{target['alias']} - Egress Traffic", + 'refId': ref_id, + 'select_field': 'egress' + } + ingress_target = create_panel_target(in_data) + egress_target = create_panel_target(out_data) + ingress.append(ingress_target) + egress.append(egress_target) + + return ingress, egress + + # Helper used for generating all traffic/error panels # with a single target field (ingress/egress or err/s) def get_panel_fields(panel, panel_type, datasource): @@ -214,6 +305,7 @@ def get_panel_fields(panel, panel_type, datasource): return create_panel({ **panel, 'datasource': datasource, + 'linewidth': 1, 'title': panel['title'].format(panel_type), 'panel_targets': [get_target_data(*target) for target in targets], 'y_axis_type': 'errors' if is_error else 'bits', @@ -244,3 +336,82 @@ def get_dashboard_data(data, datasource, tag, errors=False): 'panels': get_panel_definitions(panels, datasource), 'tag': tag } + + +def create_aggregate_panel(title, gridpos, targets, datasource): + + ingress_targets, egress_targets = get_aggregate_targets(targets) + result = [] + + ingress_pos = next(gridpos) + egress_pos = next(gridpos) + + is_total = 'totals' in title.lower() + + def reduce_alias(prev, curr): + d = json.loads(curr) + alias = d['alias'] + if 'egress' in alias.lower(): + prev[alias] = '#0000FF' + else: + prev[alias] = '#00FF00' + return prev + + ingress_colors = reduce(reduce_alias, ingress_targets, {}) + egress_colors = reduce(reduce_alias, egress_targets, {}) + + result.append(create_panel({ + **ingress_pos, + 'stack': True, + 'linewidth': 0 if is_total else 1, + 'datasource': datasource, + 'title': title + ' - ingress', + 'targets': ingress_targets, + 'y_axis_type': 'bits', + 'alias_colors': json.dumps(ingress_colors) if is_total else {} + })) + + result.append(create_panel({ + **egress_pos, + 'stack': True, + 'linewidth': 0 if is_total else 1, + 'datasource': datasource, + 'title': title + ' - egress', + 'targets': egress_targets, + 'y_axis_type': 'bits', + 'alias_colors': json.dumps(egress_colors) if is_total else {} + })) + + return result + + +def get_aggregate_dashboard_data(title, targets, datasource, tag): + id_gen = num_generator() + gridPos = gridPos_generator(id_gen, agg=True) + + panels = [] + all_targets = targets.get('EVERYSINGLEPANEL', []) + + ingress, egress = create_aggregate_panel( + title, gridPos, all_targets, datasource) + panels.extend([ingress, egress]) + + totals_title = title + ' - Totals' + t_in, t_eg = create_aggregate_panel( + totals_title, gridPos, all_targets, datasource) + panels.extend([t_in, t_eg]) + + if 'EVERYSINGLEPANEL' in targets: + del targets['EVERYSINGLEPANEL'] + + for target in targets: + _in, _out = create_aggregate_panel( + title + f' - {target}', gridPos, targets[target], datasource) + panels.extend([_in, _out]) + + return { + 'title': title, + 'datasource': datasource, + 'panels': panels, + 'tag': tag + } diff --git a/brian_dashboard_manager/templating/nren_access.py b/brian_dashboard_manager/templating/nren_access.py index 1f0a8194ca74f124f730342d978d732a2448526a..bf258ea240618a9127d8d184a8808ac9162fa27d 100644 --- a/brian_dashboard_manager/templating/nren_access.py +++ b/brian_dashboard_manager/templating/nren_access.py @@ -2,11 +2,10 @@ import json import os import jinja2 from concurrent.futures import ProcessPoolExecutor -from brian_dashboard_manager.templating.render import create_dropdown_panel, \ - create_panel_target +from brian_dashboard_manager.templating.render import create_dropdown_panel from brian_dashboard_manager.templating.helpers import \ is_aggregate_interface, is_logical_interface, is_physical_interface, \ - num_generator, gridPos_generator, letter_generator, \ + num_generator, gridPos_generator, get_aggregate_targets, \ get_panel_fields @@ -66,37 +65,6 @@ id_gen = num_generator(start=3) gridPos = gridPos_generator(id_gen, start=1) -# Aggregate panels have unique targets, -# handle those here. -def get_aggregate_targets(aggregates): - ingress = [] - egress = [] - - # used to generate refIds - letters = letter_generator() - - for target in aggregates: - ref_id = next(letters) - in_data = { - **target, - 'alias': f"{target['alias']} - Ingress Traffic", - 'refId': ref_id, - 'select_field': 'ingress' - } - out_data = { - **target, - 'alias': f"{target['alias']} - Egress Traffic", - 'refId': ref_id, - 'select_field': 'egress' - } - ingress_target = create_panel_target(in_data) - egress_target = create_panel_target(out_data) - ingress.append(ingress_target) - egress.append(egress_target) - - return ingress, egress - - def get_panel_definitions(panels, datasource, errors=False): result = [] for panel in panels: diff --git a/brian_dashboard_manager/templating/render.py b/brian_dashboard_manager/templating/render.py index cf263decc3f2facc483164588158a874ebd52eda..dede30c87a9d9869551a1680e183e73a2060d1e4 100644 --- a/brian_dashboard_manager/templating/render.py +++ b/brian_dashboard_manager/templating/render.py @@ -46,7 +46,7 @@ def create_panel(data): with open(file) as f: template = jinja2.Template(f.read()) yaxes = create_yaxes(data.get('y_axis_type', 'bits')) - targets = [] + targets = data.get('targets', []) for target in data.get('panel_targets', []): targets.append(create_panel_target(target)) return template.render({**data, 'yaxes': yaxes, 'targets': targets}) diff --git a/brian_dashboard_manager/templating/templates/shared/panel.json.j2 b/brian_dashboard_manager/templating/templates/shared/panel.json.j2 index f3e2389ffbf47896e1bb04afa3a3d5c806964098..005e069176752e00082ad58c14348d5edd185b09 100644 --- a/brian_dashboard_manager/templating/templates/shared/panel.json.j2 +++ b/brian_dashboard_manager/templating/templates/shared/panel.json.j2 @@ -1,10 +1,15 @@ -{ +{ + {% if alias_colors %} + "aliasColors": {{ alias_colors }}, + {% else %} "aliasColors": {}, + {% endif %} "bars": false, "collapsed": null, "dashLength": 10, "dashes": false, "datasource": "{{ datasource }}", + "decimals": 2, "fieldConfig": { "defaults": { "custom": {} @@ -12,15 +17,20 @@ "overrides": [] }, "fill": 1, - "fillGradient": 5, + "fillGradient": 10, "gridPos": { "h": {{ height }}, "w": {{ width }}, + {% if x %} + "x": {{ x }}, + {% else %} "x": 0, + {% endif %} "y": {{ y }} }, "hiddenSeries": false, "id": {{ id }}, + {% if not disable_legend %} "legend": { "alignAsTable": true, "avg": true, @@ -32,8 +42,9 @@ "total": false, "values": true }, + {% endif %} "lines": true, - "linewidth": 1, + "linewidth": {{ linewidth }}, "nullPointMode": "null", "options": { "alertThreshold": true @@ -45,7 +56,11 @@ "search": null, "seriesOverrides": [], "spaceLength": 10, - "stack": null, + {% if stack %} + "stack": true, + {% else %} + "stack": false, + {% endif %} "steppedLine": false, "tags": null, "thresholds": [], diff --git a/changelog.md b/changelog.md index dfb7dd9d0ab9c8015752ccbd89f42350c6ffc50f..be7cc37267d0b6c649279409a661a15be5800f05 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. +## [0.5] - 2021-03-10 +- Added provisioning of aggregate dashboards with many targets + ## [0.4] - 2021-03-03 - Provisioning is now limited to a single org at once. diff --git a/setup.py b/setup.py index 297888f8d6a7cc8c9a5567591ea8146306314cee..a3584f43e836f93f6269a0714ee08f177725d994 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name='brian-dashboard-manager', - version="0.4", + version="0.5", author='GEANT', author_email='swd@geant.org', description='', diff --git a/test/test_aggregrate.py b/test/test_aggregrate.py new file mode 100644 index 0000000000000000000000000000000000000000..ac0df399fbcda6c2997770980f2df137db06261a --- /dev/null +++ b/test/test_aggregrate.py @@ -0,0 +1,177 @@ +from brian_dashboard_manager.grafana.utils.request import TokenRequest +import responses +from brian_dashboard_manager.grafana.provision import provision_aggregate, \ + is_cls_peer + +DEFAULT_REQUEST_HEADERS = { + "Content-type": "application/json", + "Accept": ["application/json"] +} + + +TEST_INTERFACES = [ + { + "router": "mx1.ath2.gr.geant.net", + "name": "xe-1/0/1", + "bundle": [], + "bundle-parents": [], + "snmp-index": 569, + "description": "PHY RESERVED | New OTEGLOBE ATH2-VIE 10Gb LS", + "circuits": [] + }, + { + "router": "mx1.ath2.gr.geant.net", + "name": "ge-1/3/7", + "bundle": [], + "bundle-parents": [], + "snmp-index": 543, + "description": "PHY SPARE", + "circuits": [] + }, + { + "router": "mx1.ham.de.geant.net", + "name": "xe-2/2/0.13", + "bundle": [], + "bundle-parents": [], + "snmp-index": 721, + "description": "SRV_L2CIRCUIT CUSTOMER WP6T3 WP6T3 #ham_lon2-WP6-GTS_20063 |", # noqa: E501 + "circuits": [ + { + "id": 52382, + "name": "ham_lon2-WP6-GTS_20063_L2c", + "type": "", + "status": "operational" + } + ] + }, + { + "router": "mx1.fra.de.geant.net", + "name": "ae27", + "bundle": [], + "bundle-parents": [ + "xe-10/0/2", + "xe-10/3/2", + "xe-10/3/3" + ], + "snmp-index": 760, + "description": "LAG CUSTOMER ULAKBIM SRF9940983 |", + "circuits": [ + { + "id": 40983, + "name": "ULAKBIM AP2 LAG", + "type": "", + "status": "operational" + } + ] + }, + { + "router": "mx2.zag.hr.geant.net", + "name": "xe-2/1/0", + "bundle": [], + "bundle-parents": [], + "snmp-index": 739, + "description": "PHY SPARE", + "circuits": [] + }, + { + "router": "rt1.rig.lv.geant.net", + "name": "xe-0/1/5", + "bundle": [], + "bundle-parents": [], + "snmp-index": 539, + "description": "PHY SPARE", + "circuits": [] + }, + { + "router": "srx1.ch.office.geant.net", + "name": "ge-0/0/0", + "bundle": [], + "bundle-parents": [], + "snmp-index": 513, + "description": "Reserved for GEANT OC to test Virgin Media link", + "circuits": [] + }, + { + "router": "mx1.par.fr.geant.net", + "name": "xe-4/1/4.1", + "bundle": [], + "bundle-parents": [], + "snmp-index": 1516, + "description": "SRV_L2CIRCUIT INFRASTRUCTURE JRA1 JRA1 | #SDX-L2_PILOT-Br52 OF-P3_par ", # noqa: E501 + "circuits": [] + }, + { + "router": "mx1.lon.uk.geant.net", + "name": "lt-1/3/0.61", + "bundle": [], + "bundle-parents": [], + "snmp-index": 1229, + "description": "SRV_IAS INFRASTRUCTURE ACCESS GLOBAL #LON-IAS-RE-Peering | BGP Peering - IAS Side", # noqa: E501 + "circuits": [] + }, + { + "router": "mx1.sof.bg.geant.net", + "name": "xe-2/0/5", + "bundle": [], + "bundle-parents": [], + "snmp-index": 694, + "description": "PHY RESERVED | Prime Telecom Sofia-Bucharest 3_4", + "circuits": [] + }, + { + "router": "mx1.sof.bg.geant.net", + "name": "xe-2/0/5", + "bundle": [], + "bundle-parents": [], + "snmp-index": 694, + "description": "SRV_GLOBAL CUSTOMER HEANET TESTDESCRIPTION |", + "circuits": [] + } +] + + +def generate_folder(data): + return { + "id": 555, + "uid": data['uid'], + "title": data['title'], + "url": f"/dashboards/f/{data['uid']}/{data['title'].lower()}", + "hasAcl": False, + "canSave": True, + "canEdit": True, + "canAdmin": True, + "createdBy": "Anonymous", + "created": "2021-02-23T15:33:46Z", + "updatedBy": "Anonymous", + "updated": "2021-02-23T15:33:46Z", + "version": 1 + } + + +@responses.activate +def test_provision_aggregate(data_config, mocker, client): + + TEST_DATASOURCE = [{ + "name": "brian-influx-datasource", + "type": "influxdb", + "access": "proxy", + "url": "http://test-brian-datasource.geant.org:8086", + "database": "test-db", + "basicAuth": False, + "isDefault": True, + "readOnly": False + }] + + _mocked_create_dashboard = mocker.patch( + 'brian_dashboard_manager.grafana.provision.create_dashboard') + # we dont care about this, tested separately + _mocked_create_dashboard.return_value = None + + request = TokenRequest(**data_config, token='test') + fake_folder = generate_folder({'uid': 'aggtest', 'title': 'aggtest'}) + dash = { + 'predicate': is_cls_peer, + 'tag': 'cls_peers', + } + provision_aggregate(request, 'MY FAKE PEERS', fake_folder, + dash, TEST_INTERFACES, TEST_DATASOURCE[0]['name'])