From 2cc7d3d801a5c09178a13c3869e5de1a185f5780 Mon Sep 17 00:00:00 2001 From: Olha Kashyrina Date: Mon, 7 Aug 2023 14:44:52 +0200 Subject: [PATCH] Implement SLA functionality - add `calculate_sla()` function to `models.py` to calculate component unavailability on monthly basis for 6 months. Formula: `([number of minutes in month] - [number of minutes all outages open])/[number of minutes in month]` - adjust `get_history_by_months()` function for reusability - implement the component availability view --- app/models.py | 81 +++++++++++++++++++++++-- app/static/css/style.css | 30 ++++++++++ app/web/routes.py | 17 ++++++ app/web/templates/history.html | 6 +- app/web/templates/sla.html | 105 +++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 app/web/templates/sla.html diff --git a/app/models.py b/app/models.py index ff17841..3be5aa4 100644 --- a/app/models.py +++ b/app/models.py @@ -16,6 +16,8 @@ from app import db +from dateutil.relativedelta import relativedelta + from sqlalchemy import Column from sqlalchemy import ForeignKey from sqlalchemy import Index @@ -110,6 +112,19 @@ def get_attributes_as_dict(self): """Return component attributes as dicionary""" return {attr.name: attr.value for attr in self.attributes} + @staticmethod + def count_components_by_attributes(attr_dict): + """Return the number of components that match specific attributes""" + counter = 0 + for comp in Component.all(): + comp_attrs = comp.get_attributes_as_dict() + if set( + comp_attrs.items() + ).intersection(set( + attr_dict.items())) == set(attr_dict.items()): + counter += 1 + return counter + @staticmethod def find_by_name_and_attributes(name, attributes): """Find existing component by name and set of attributes @@ -141,6 +156,54 @@ def find_by_name_and_attributes(name, attributes): return comp return None + def calculate_sla(self): + """Calculate component availability on the month basis""" + + time_now = datetime.datetime.now() + this_month_start = datetime.datetime(time_now.year, time_now.month, 1) + + outages = [inc for inc in self.incidents + if inc.impact == 3 and inc.end_date is not None] + outages_dict = Incident.get_history_by_months(outages) + outages_dict_sorted = dict(sorted(outages_dict.items())) + + prev_month_minutes = 0 + + months = [this_month_start + relativedelta(months=-mon) + for mon in range(6)] + sla_dict = {month: 1 for month in months} + + for month_start, outage_group in outages_dict_sorted.items(): + minutes_in_month = prev_month_minutes + outages_minutes = 0 + prev_month_minutes = 0 + + if this_month_start.month == month_start.month: + minutes_in_month = ( + time_now - month_start + ).total_seconds() / 60 + else: + next_month_start = month_start + relativedelta(months=1) + minutes_in_month = ( + next_month_start - month_start + ).total_seconds() / 60 + + for outage in outage_group: + outage_start = outage.start_date + if outage_start < month_start: + diff = month_start - outage_start + prev_month_minutes += diff.total_seconds() / 60 + outage_start = month_start + + diff = outage.end_date - outage_start + outages_minutes += diff.total_seconds() / 60 + + sla_dict[month_start] = ( + minutes_in_month - outages_minutes + ) / minutes_in_month + + return sla_dict + class ComponentAttribute(Base): """Component Attribute model""" @@ -219,13 +282,19 @@ def get_all_closed(): ).all() @staticmethod - def get_history_by_months(): + def get_history_by_months(incident_list): + if incident_list is None: + incident_list = Incident.get_all_closed() incident_dict = {} - for incident in Incident.get_all_closed(): - incident_dict.setdefault(incident.end_date.month, []).append( - incident - ) - return incident_dict.values() + for incident in incident_list: + incident_dict.setdefault( + datetime.datetime( + incident.end_date.year, + incident.end_date.month, + 1), + [] + ).append(incident) + return incident_dict @staticmethod def get_active_maintenance(): diff --git a/app/static/css/style.css b/app/static/css/style.css index 7bd68d9..0ae001a 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -144,6 +144,36 @@ nav.navbar { justify-content: center; } +.table-header { + top:4; + background-color: var(--bs-blue); + color: var(--bs-white); +} + +.table thead > tr > th { + border: none; +} + +.table thead > tr { + border: none; +} + +tbody:nth-child(odd) { + background: var(--bs-gray) !important; +} + +.bg-0 { + background-color: var(--bs-green) !important; +} + +.bg-1 { + background-color: var(--bs-yellow) !important; +} + +.bg-2 { + background-color: var(--bs-red) !important; +} + /* Footer */ footer.footer { diff --git a/app/web/routes.py b/app/web/routes.py index cc00b1b..759c0f8 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -23,6 +23,8 @@ from app.web.forms import IncidentForm from app.web.forms import IncidentUpdateForm +from dateutil.relativedelta import relativedelta + from flask import abort from flask import current_app from flask import flash @@ -127,6 +129,21 @@ def history(): ) +@bp.route("/sla", methods=["GET"]) +def sla(): + time_now = datetime.now() + months = [time_now + relativedelta(months=-mon) for mon in range(6)] + + return render_template( + "sla.html", + title="Component Availability", + components=Component, + component_attributes=ComponentAttribute, + incidents=Incident, + months=months, + ) + + @bp.route("/login/") def login(name): """Login diff --git a/app/web/templates/history.html b/app/web/templates/history.html index 1dd0f91..86a07f8 100644 --- a/app/web/templates/history.html +++ b/app/web/templates/history.html @@ -8,13 +8,13 @@

Incident History

- {% set past_incidents = incidents.get_history_by_months() %} - {% if past_incidents | length == 0 %} + {% set past_incidents = incidents.get_history_by_months(None) %} + {% if past_incidents.values() | length == 0 %} {% else %} - {% for incident_group in past_incidents + {% for incident_group in past_incidents.values() | sort(attribute='0.end_date', reverse = True) %}

{{ incident_group[0].end_date.strftime('%B %Y') }}

    diff --git a/app/web/templates/sla.html b/app/web/templates/sla.html new file mode 100644 index 0000000..111f89b --- /dev/null +++ b/app/web/templates/sla.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} + +{% block content %} + +
    + +
    +

    Component Availability

    +
    + + {% set regions = component_attributes.get_unique_values('region') %} + {% set color_value = 1 %} + + + + +
    + {% for region in regions %} + {% if loop.first %} +
    +
    + + + + + + + + + {% for month in months | reverse %} + + {% endfor %} + + + {% set categories = component_attributes.get_unique_values("category") %} + {% for cat in categories|sort %} + + {% set attr_dict = ({'region':region,'category':cat}) %} + {% set cat_length = components.count_components_by_attributes(attr_dict) %} + {% set ns = namespace(first=true) %} + {% for component in components.all() %} + {% with attrs = component.get_attributes_as_dict() %} + {% if 'region' in attrs and attrs['region'] == region and 'category' in attrs and attrs['category'] == cat %} + + {% if ns.first %} + + {% set ns.first = false %} + {% endif %} + + {% with sla_dict = component.calculate_sla() %} + {% for (m, sla) in sla_dict | dictsort %} + {% if sla > 0.9995 %} + {% set color_value = 0 %} + {% elif sla < 0.9 %} + {% set color_value = 2 %} + {% endif %} + + {% endfor %} + {% endwith %} + + {% endif %} + {% endwith %} + {% endfor %} + + {% endfor %} +
    CategoryServiceAvailability, %
    {{ month.strftime('%B %Y') }}
    {{ cat }}{{ component.name }}{{ '%0.2f'| format(sla * 100|float) }}
    +
    +
    + {% endfor %} +
    +
    + + +{% endblock %}