diff --git a/brian_dashboard_manager/grafana/provision.py b/brian_dashboard_manager/grafana/provision.py index 431d7c861c105c44b457f5624eb5fba084942dd8..1416daefae4adb02d50b0e5213e64a2e18a9f45d 100644 --- a/brian_dashboard_manager/grafana/provision.py +++ b/brian_dashboard_manager/grafana/provision.py @@ -5,6 +5,7 @@ entire provisioning lifecycle. import itertools import logging import time +from enum import Enum from concurrent.futures import Future from concurrent.futures import ThreadPoolExecutor from brian_dashboard_manager.config import DEFAULT_ORGANIZATIONS @@ -32,7 +33,7 @@ from brian_dashboard_manager.templating.helpers import \ get_nren_interface_data, get_dashboard_data, \ get_nren_dashboard_data, get_aggregate_interface_data, \ get_nren_interface_data_old, get_re_peer_dashboard_data, get_re_peer_interface_data, get_service_data, \ - get_service_dashboard_data + get_service_dashboard_data, get_aggregate_service_data from brian_dashboard_manager.templating.gws import generate_gws, generate_indirect from brian_dashboard_manager.templating.eumetsat import generate_eumetsat_multicast @@ -43,6 +44,11 @@ from brian_dashboard_manager.templating.render import ( logger = logging.getLogger(__name__) + +class REGIONS(Enum): + EAP = 'EAP' + + DASHBOARDS = { 'NRENLEGACY': { 'tag': ['customerslegacy'], @@ -55,6 +61,7 @@ DASHBOARDS = { 'interfaces': [] }, 'EAP': { + 'region': REGIONS.EAP.value, 'tag': ['eap'], 'folder_name': 'EAP NREN Access', 'interfaces': [] @@ -140,7 +147,8 @@ DASHBOARDS = { } SERVICE_DASHBOARDS = { - 'MWS': { + # service-based dashboards, the keys should be valid service types/products + 'GEANT MANAGED WAVELENGTH SERVICE': { 'tag': ['mws'], 'service_type': 'GEANT MANAGED WAVELENGTH SERVICE', 'folder_name': 'Managed Wavelength Service', @@ -150,11 +158,6 @@ SERVICE_DASHBOARDS = { } AGG_DASHBOARDS = { - 'CLS_PEERS': { - 'tag': 'cls_peers', - 'dashboard_name': 'CLS Peers', - 'interfaces': [] - }, 'IAS_PEERS': { 'tag': 'ias_peers', 'dashboard_name': 'IAS Peers', @@ -191,14 +194,37 @@ AGG_DASHBOARDS = { 'dashboard_name': 'ANA', 'interfaces': [] }, +} + +SERVICE_AGG_DASHBOARDS = { 'EAP': { + 'region': REGIONS.EAP.value, 'tag': 'eap', 'dashboard_name': 'EAP Aggregate', - 'interfaces': [] + 'services': [] } } +def get_service_region(service, regions): + for customer in service['customers']: + if customer in regions: + yield regions[customer].upper() + + +def get_customers_for_region(services, regions, region=None): + if not region: + return [] + customers = [] + for service in services: + service_customers = service.get('customers', []) + for cust in service_customers: + cust_region = regions.get(cust) + if cust_region == region: + customers.append(cust) + return list(set(customers)) + + def provision_folder(thread_executor: ThreadPoolExecutor, token_request, folder_name, dash, services, regions, ds_name, excluded_dashboards): """ @@ -236,17 +262,6 @@ def provision_folder(thread_executor: ThreadPoolExecutor, token_request, folder_ ) ) - def _get_customers_for_region(region=None): - customers = [] - region_lookup = {region['nren']: region['region'] for region in regions} - for service in services: - service_customers = service.get('customers', []) - for cust in service_customers: - cust_region = region_lookup.get(cust) - if cust_region == region: - customers.append(cust) - return customers - # dashboard should include error panels errors = dash.get('errors', False) @@ -261,7 +276,12 @@ def provision_folder(thread_executor: ThreadPoolExecutor, token_request, folder_ data = get_nren_interface_data_old(interfaces) dash_data = get_nren_dashboard_data(data, ds_name, tag) elif is_nren or is_eap: - region_customers = _get_customers_for_region("EAP" if is_eap else None) + dash_regions = dash.get('region') + region_customers = get_customers_for_region(services, regions, dash_regions) + if is_eap and not region_customers: + logger.info(f'No customers for region {dash_regions}, skipping EAP NREN Access dashboards') + delete_folder(token_request, uid=folder['uid']) + return data = get_nren_interface_data(services, interfaces, excluded_dashboards, region_customers) dash_data = get_nren_dashboard_data(data, ds_name, tag) elif is_re_peer: @@ -304,9 +324,15 @@ def provision_aggregate(token_request, folder, name = dash['dashboard_name'] tag = dash['tag'] - interfaces = dash['interfaces'] - group_field = dash.get('group_by', 'remote') - data = get_aggregate_interface_data(interfaces, name, group_field) + service_based = 'services' in dash + + if not service_based: + interfaces = dash['interfaces'] + group_field = dash.get('group_by', 'remote') + data = get_aggregate_interface_data(interfaces, group_field) + else: + services = dash['services'] + data = get_aggregate_service_data(services) dashboard = get_aggregate_dashboard_data( f'Aggregate - {name}', data, ds_name, tag) @@ -442,7 +468,7 @@ def _provision_interfaces(thread_executor: ThreadPoolExecutor, config, ifaces.append(iface) # provision dashboards and their folders - for folder in DASHBOARDS.values(): + for folder in itertools.chain(DASHBOARDS.values(), SERVICE_DASHBOARDS.values()): folder_name = folder['folder_name'] # boolean True means entire folder excluded @@ -600,14 +626,19 @@ def _provision_aggregates(thread_executor: ThreadPoolExecutor, config, org_confi folder_dashboards_by_name = list_folder_dashboards(token, agg_folder['uid']) - for dash in AGG_DASHBOARDS.values(): + for dash in itertools.chain(AGG_DASHBOARDS.values(), SERVICE_AGG_DASHBOARDS.values()): + + location = f'{org_config["name"]}/Aggregate {dash["dashboard_name"]}' + + if not dash.get('interfaces') and not dash.get('services'): + logger.info(f'No interfaces or services for {location}, skipping') + continue + excluded_dashboards = excluded_folder_dashboards(org_config, folder_name) if dash['dashboard_name'] in excluded_dashboards: - dash_name = {'title': f'Aggregate - {dash["dashboard_name"]}'} - delete_dashboard(token, dash_name, agg_folder['id']) continue - logger.info(f'Provisioning {org_config["name"]}/Aggregate {dash["dashboard_name"]} dashboards') + logger.info(f'Provisioning {location} dashboards') provisioned.append( thread_executor.submit(provision_aggregate, token, agg_folder, dash, ds_name, folder_dashboards_by_name) ) @@ -615,60 +646,6 @@ def _provision_aggregates(thread_executor: ThreadPoolExecutor, config, org_confi yield from provisioned -def _provision_service_dashboards(thread_executor: ThreadPoolExecutor, config, org_config, ds_name, token): - """ - This function is used to provision service-specific dashboards, - overwriting existing ones. - - :param thread_executor: a ThreadPoolExecutor for concurrent requests - :param config: the application config - :param org_config: the organisation config - :param ds_name: the name of the datasource to query in the dashboards - :param token: a token_request object - :return: generator of UIDs of dashboards that were created - """ - services = fetch_services(config['reporting_provider']) - regions = get_nren_regions(config['inventory_provider']) - excluded_folders = org_config.get('excluded_folders', {}) - - logger.info('Provisioning service-specific dashboards') - - # loop over service dashboards and get service types we care about - dash_service_types = {SERVICE_DASHBOARDS[dash]['service_type']: dash for dash in SERVICE_DASHBOARDS} - # loop over services and append to dashboards - for service in services: - if service['service_type'] in dash_service_types: - dash = dash_service_types[service['service_type']] - svcs = SERVICE_DASHBOARDS[dash]['services'] - svcs.append(service) - - # provision dashboards and their folders - provisioned = [] - for folder in SERVICE_DASHBOARDS.values(): - folder_name = folder['folder_name'] - - # boolean True means entire folder excluded - # if list, it is specific dashboard names not to provision - # so is handled at provision time. - if is_excluded_folder(excluded_folders, folder_name): - delete_folder(token, title=folder_name) - continue - - logger.info( - f'Provisioning {org_config["name"]}/{folder_name} dashboards') - res = thread_executor.submit( - provision_folder, thread_executor, token, - folder_name, folder, services, regions, ds_name, - excluded_folder_dashboards(org_config, folder_name)) - provisioned.append(res) - - for result in provisioned: - folder = result.result() - if folder is None: - continue - yield from folder - - def _provision_static_dashboards(thread_executor: ThreadPoolExecutor, config, org_config, ds_name, token): """ This function is used to provision static dashboards from json files, @@ -776,6 +753,49 @@ def _provision_orgs(config): return all_orgs +def _add_service_data(org_config, services, regions): + """ + This function is used to add service data to the aggregate dashboards. + + Services for customers that are listed in the excluded_nrens list are excluded. + """ + + # clean up the services in the datastructures from previous runs + for dash in SERVICE_AGG_DASHBOARDS.values(): + dash['services'] = [] + + for dash in SERVICE_DASHBOARDS.values(): + dash['services'] = [] + + excluded_nrens = [n.lower() for n in org_config['excluded_nrens']] + for service in services: + customers = service.get('customers', []) + if any(c.lower() in excluded_nrens for c in customers): + continue + + service_regions = list(get_service_region(service, regions)) + + for service_agg_dash in SERVICE_AGG_DASHBOARDS.values(): + # this block handles aggregate dashboards which are region-based + agg_dash_region = service_agg_dash.get('region') + if not agg_dash_region: + continue + + if agg_dash_region in service_regions: + service_agg_dash['services'].append(service) + + for service_agg_dash in SERVICE_AGG_DASHBOARDS.values(): + # this block handles aggregate dashboards which are not region-based + if service_agg_dash.get('region'): + continue + + # TODO: currently we only have region-based aggregate dashboards, TBD if we need to handle non-region-based + + service_type = service['service_type'] + if service_type in SERVICE_DASHBOARDS: + SERVICE_DASHBOARDS[service_type]['services'].append(service) + + def _provision_org(config, org, org_config, interfaces, services, regions): try: request = AdminRequest(**config) @@ -807,6 +827,10 @@ def _provision_org(config, org, org_config, interfaces, services, regions): args = (thread_executor, config, org_config, ds_name, token_request) + # initialise the aggregate dashboards with service data, to be used in the provisioning process + # it doesn't create the dashboards, just prepares the data + _add_service_data(org_config, services, regions) + # call to list is needed to queue up the futures managed_dashboards = list(itertools.chain( _provision_interfaces(*args, interfaces, services, regions), @@ -814,7 +838,6 @@ def _provision_org(config, org, org_config, interfaces, services, regions): _provision_gws_direct(*args), _provision_eumetsat_multicast(*args), _provision_aggregates(*args), - _provision_service_dashboards(*args), _provision_static_dashboards(*args), _get_ignored_dashboards(*args) )) diff --git a/brian_dashboard_manager/templating/helpers.py b/brian_dashboard_manager/templating/helpers.py index 00b5946f383e680b75bb05826eea1bdd19de7427..9c8d6e56623aa6b3d95b0f3004c37021aac8a82e 100644 --- a/brian_dashboard_manager/templating/helpers.py +++ b/brian_dashboard_manager/templating/helpers.py @@ -221,6 +221,45 @@ def get_re_peer_interface_data(interfaces): return result +def get_service_aggregate_targets(services): + for service in services: + _interfaces = service.get('endpoints') + name = service.get('name') + sid = service.get('sid') + scid = service.get('scid') + service_type = service.get('service_type', '') + + measurement = 'scid_rates' + + lag_service = 'GA-' in sid and service_type == 'ETHERNET' + + if len(_interfaces) == 0 or not lag_service: + continue + + if 'interface' in _interfaces[0]: + if_name = _interfaces[0].get('interface') + router = _interfaces[0].get('hostname') + else: + if_name = _interfaces[0].get('port') + router = _interfaces[0].get('equipment') + + router = router.replace('.geant.net', '') + target_alias = f'{router} - {if_name} - {name} ({sid})' + + if len(_interfaces) > 1: + logger.info( + f'{sid} {name} aggregate service has > 1 interface') + continue + + yield service, { + 'measurement': measurement, + 'alias': target_alias, + 'scid': scid, + # used when checking if an interface is already covered by a service in an aggregate panel for NREN access + 'interface_key': f'{router}:::{if_name}' + } + + def get_nren_interface_data(services, interfaces, excluded_dashboards, region_customers): """ Helper for grouping interface data to be used for generating @@ -263,6 +302,12 @@ def get_nren_interface_data(services, interfaces, excluded_dashboards, region_cu 'PHYSICAL': [] }) + # it's a tuple of (service, target) for each service, pick out the targets + aggregate_targets = (s[1] for s in get_service_aggregate_targets(services)) + for target in aggregate_targets: + aggregate_interfaces[target['interface_key']] = True + dashboard['AGGREGATES'].append(target) + for service in services: _interfaces = service.get('endpoints') name = service.get('name') @@ -290,17 +335,8 @@ def get_nren_interface_data(services, interfaces, excluded_dashboards, region_cu if lag_service: if len(_interfaces) > 1: - logger.info( - f'{sid} {name} aggregate service has > 1 interface') continue - aggregate_interfaces[f'{router}:::{if_name}'] = True - dashboard['AGGREGATES'].append({ - 'measurement': measurement, - 'alias': title.replace('- {} ', ''), # remove the format part for aggregate panels - 'scid': scid - }) - if 'MDVPN' in service['service_type']: # MDVPN type services don't have data in BRIAN continue @@ -450,6 +486,54 @@ def get_service_data(service_type, services, interfaces, excluded_dashboards): return result +def get_aggregate_service_data(services): + """ + Helper for grouping service data for generating aggregate dashboards. + Aggregate dashboards have panels with multiple targets (timeseries) that are grouped together. + + Groups the services by the customer and returns a dictionary of aggregate dashboards and their service data. + One of the panels is a special panel that has all the targets in a single panel, + as an aggregate of all data for that dashboard. + + + :param services: list of services + + :return: dictionary of targets for the aggregate panels, grouped by customer + """ + + targets = [] + + def get_reduce_func_for_field(field): + def reduce_func(remote_map, service): + value_to_group_by = service[field] + group = remote_map.get(value_to_group_by, []) + group.append(service) # contains all services for this group + all_agg = remote_map.get('EVERYSINGLETARGET', []) # contains all services regardless of group + all_agg.append(service) + remote_map[value_to_group_by] = group + remote_map['EVERYSINGLETARGET'] = all_agg + return remote_map + + return reduce_func + + aggregate_targets = get_service_aggregate_targets(services) + for service, target in aggregate_targets: + customers = service.get('customers') + if not customers: + continue + customer = customers[0].upper() # it's a list, but only one customer per service for now + targets.append({ + 'customer': customer, # used to group by customer + **target # contains the target data for the panel + }) + + targets = sorted(targets, key=lambda x: x['customer']) + result = reduce(get_reduce_func_for_field('customer'), targets, {}) + for key in result: + result[key] = sorted(result[key], key=lambda x: x['customer']) + return result + + def get_interface_data(interfaces): """ Helper for grouping interface data to be used for generating