From ba5b2f0744bb9b52179e5518f5eaa869af49fa41 Mon Sep 17 00:00:00 2001 From: sbgap Date: Tue, 24 Mar 2026 13:30:06 +0100 Subject: [PATCH] feat: add filter tabs for alert and history views --- alerta/database/backends/postgres/base.py | 85 +++++++ alerta/database/base.py | 26 +++ alerta/models/filter_tab.py | 151 +++++++++++++ alerta/sql/schema.sql | 6 + alerta/views/__init__.py | 2 +- alerta/views/filter_tabs.py | 258 ++++++++++++++++++++++ 6 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 alerta/models/filter_tab.py create mode 100644 alerta/views/filter_tabs.py diff --git a/alerta/database/backends/postgres/base.py b/alerta/database/backends/postgres/base.py index b3270a332..3a26a58dd 100644 --- a/alerta/database/backends/postgres/base.py +++ b/alerta/database/backends/postgres/base.py @@ -945,6 +945,91 @@ def get_alert_tags(self, query=None, topn=1000): """.format(where=query.where) return [{'environment': t.environment, 'tag': t.tag, 'count': t.count} for t in self._fetchall(select, query.vars, limit=topn)] + # FILTER TABS + + def get_filter_tabs(self): + select = """SELECT * FROM filter_tabs ORDER BY index""" + return self._fetchall(select, {}, 'ALL') + + def get_filter_tab(self, name): + select = """SELECT * FROM filter_tabs WHERE name=%(name)s""" + return self._fetchone(select, {'name': name}) + + def update_filter_tabs(self, tabs): + tab_updates = ','.join([ + f""" + (%(name{i})s, %(index{i})s, %(filter{i})s) + """ for i in range(len(tabs)) + ]) + update = f""" + UPDATE filter_tabs as tab + SET name=update.name, index=update.index, filter=update.filter::jsonb + FROM (VALUES + {tab_updates} + ) AS update(name, index, filter) + WHERE tab.name = update.name + RETURNING tab.* + """ + objs = {} + for i, tab in enumerate(tabs): + objs.update({f'{key}{i}': value for key, value in tab.items()}) + + return self._updateall(update, objs, True) + + def update_filter_tab_indexes(self, tabs): + tab_updates = ','.join([ + f""" + (%(name{i})s, %(index{i})s) + """ for i in range(len(tabs)) + ]) + update = f""" + UPDATE filter_tabs as tab + SET index=update.index + FROM (VALUES + {tab_updates} + ) AS update(name, index) + WHERE tab.name = update.name + RETURNING tab.* + """ + objs = {} + for i, tab in enumerate(tabs): + objs.update({f'{key}{i}': value for key, value in tab.items()}) + + return self._updateall(update, objs, True) + + def delete_filter_tab(self, name): + select = """DELETE FROM filter_tabs WHERE name=%(name)s RETURNING name""" + return self._deleteone(select, {'name': name}) + + def delete_filter_tabs(self, names): + select = """DELETE FROM filter_tabs WHERE name=ANY(%(names)s) RETURNING name""" + return self._deleteall(select, {'names': names}, returning=True) + + def create_filter_tab(self, filter): + insert = """ + INSERT INTO filter_tabs (name, index, filter) + VALUES (%(name)s, %(index)s, %(filter)s) + RETURNING * + """ + return self._insert(insert, vars(filter)) + + def create_filter_tabs(self, tabs): + tab_values = ','.join([ + f""" + (%(name{i})s, %(index{i})s, %(filter{i})s) + """ for i in range(len(tabs)) + ]) + insert = f""" + INSERT INTO filter_tabs (name, index, filter) + VALUES {tab_values} + RETURNING * + """ + + objs = {} + for i, tab in enumerate(tabs): + objs.update({f'{key}{i}': value for key, value in tab.items()}) + return self._insert_all(insert, objs) + # BLACKOUTS def create_blackout(self, blackout): diff --git a/alerta/database/base.py b/alerta/database/base.py index 6f0e4c7c6..e8e73eceb 100644 --- a/alerta/database/base.py +++ b/alerta/database/base.py @@ -244,6 +244,32 @@ def get_services(self, query=None, topn=1000): def get_alert_tags(self, query=None, topn=1000): raise NotImplementedError + # FILTER TABS + + def get_filter_tabs(self): + raise NotImplementedError + + def get_filter_tab(self): + raise NotImplementedError + + def update_filter_tabs(self, tabs): + raise NotImplementedError + + def update_filter_tab_indexes(self, tabs): + raise NotImplementedError + + def create_filter_tab(self): + raise NotImplementedError + + def create_filter_tabs(self): + raise NotImplementedError + + def delete_filter_tab(self, id): + raise NotImplementedError + + def delete_filter_tabs(self, ids: list[str]): + raise NotImplementedError + # BLACKOUTS def create_blackout(self, blackout): diff --git a/alerta/models/filter_tab.py b/alerta/models/filter_tab.py new file mode 100644 index 000000000..bf0d644ae --- /dev/null +++ b/alerta/models/filter_tab.py @@ -0,0 +1,151 @@ +from datetime import UTC, datetime, timedelta + +from werkzeug.datastructures import MultiDict + +from alerta.app import db + +VALID_PARAMS = [ + 'id', + 'resource', + 'event', + 'environment', + 'severity', + 'status', + 'service', + 'value', + 'text', + 'tag', + 'tags', + 'customTags', + 'attributes', + 'origin', + 'createTime', + 'timeout', + 'rawData', + 'customer', + 'duplicateCount', + 'previousSeverity', + 'receiveTime', + 'lastReceiveId', + 'lastReceiveTime', + 'updateTime', +] + + +class FilterTab: + + def __init__(self,name: str, index: int, **kwargs) -> None: + if name is None: + raise ValueError('Missing mandatory value for "name"') + if index is None: + raise ValueError('Missing mandatory value for "index"') + + self.name = name + self.index = index + self.filter = kwargs.get('filter') or {} + + @classmethod + def parse(cls, json: dict[str, str | int | dict[str, str]]) -> 'FilterTab': + if not isinstance(json.get('index'), int): + raise ValueError('index must be an int') + if not isinstance(json.get('name'), str): + raise ValueError('name must be a string') + + return FilterTab( + name=json['name'], + index=json['index'], + filter=json.get('filter', None), + ) + + @property + def serialize(self): + return { + 'name': self.name, + 'index': self.index, + 'filter': self.filter, + } + + @property + def filter_args(self): + def to_isoformat(date: datetime): + return date.isoformat(timespec='milliseconds').replace('+00:00', 'Z') + data = [] + for key, value in self.filter.items(): + if key == 'dateRange': + if value == {}: + continue + if 'from' in value: + if value.get('select'): + from_time = datetime.fromtimestamp(value['from'], tz=UTC) + else: + from_time = (datetime.now(UTC) + timedelta(seconds=int(value['from']))) + data.append(('from-date', to_isoformat(from_time))) + if 'to' in value: + to_time = datetime.fromtimestamp(value['to'], tz=UTC) + data.append(('to-date', to_isoformat(to_time))) + if key == 'attributes': + for attr_key, attr_value in value.items(): + if isinstance(attr_value, list): + for item in attr_value: + data.append((f'attributes.{attr_key}', item)) + else: + data.append((f'attributes.{attr_key}', attr_value)) + elif key not in VALID_PARAMS or isinstance(value, dict): + continue + elif isinstance(value, list): + for val in value: + data.append((key, val)) + else: + data.append(key, value) + + return MultiDict(data) + + def __repr__(self) -> str: + return f'AlertTab(name={self.name}, index={self.index}, filter={self.filter},' + + @ classmethod + def from_db(cls, rec) -> 'FilterTab': + return FilterTab( + name=rec.name, + index=rec.index, + filter=rec.filter + ) + + # create a filter tab + def create(self) -> 'FilterTab': + return FilterTab.from_db(db.create_filter_tab(self)) + + # create a filter tabs + @staticmethod + def create_all(tabs: list['FilterTab']) -> list['FilterTab']: + return [FilterTab.from_db(tab) for tab in db.create_filter_tabs(tabs)] + + # get a filter tab + @ staticmethod + def find_by_id(id: str): + return FilterTab.from_db(db.get_filter_tab(id)) + + @staticmethod + def delete_all(ids: list[str]): + return db.delete_filter_tabs(ids) + + @staticmethod + def update_all(tabs: list['FilterTab']): + return db.update_filter_tabs(tabs) + + @staticmethod + def update_indexes(tabs): + return db.update_filter_tab_indexes(tabs) + + @ staticmethod + def find_all() -> list['FilterTab']: + return [ + FilterTab.from_db(notification_channel) + for notification_channel in db.get_filter_tabs() + ] + + # def update(self, **kwargs) -> 'FilterTab': + # return FilterTab.from_db(db.update(self.id, **kwargs)) + + def delete(self) -> bool: + return db.delete_filter_tab(self.id) diff --git a/alerta/sql/schema.sql b/alerta/sql/schema.sql index 3ae857d72..bd8bf7aa7 100644 --- a/alerta/sql/schema.sql +++ b/alerta/sql/schema.sql @@ -628,3 +628,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS env_res_evt_cust_key ON alerts USING btree (en CREATE UNIQUE INDEX IF NOT EXISTS org_cust_key ON heartbeats USING btree (origin, (COALESCE(customer, ''::text))); + +CREATE TABLE IF NOT EXISTS filter_tabs ( + name text PRIMARY KEY, + "index" integer NOT NULL, + "filter" jsonb +); diff --git a/alerta/views/__init__.py b/alerta/views/__init__.py index 0ffc9c0e6..677766141 100644 --- a/alerta/views/__init__.py +++ b/alerta/views/__init__.py @@ -7,7 +7,7 @@ api = Blueprint('api', __name__) -from . import alerta, alerts, blackouts, config, customers, groups, keys, oembed, permissions, users, notification_rules, notification_channels, notification_history, on_call, escalation_rules, notification_groups, notification_delays, notification_sends # noqa isort:skip +from . import alerta, alerts, blackouts, config, customers, groups, keys, oembed, permissions, users, notification_rules, notification_channels, notification_history, on_call, escalation_rules, notification_groups, notification_delays, notification_sends, filter_tabs # noqa isort:skip if get_config('HEARTBEAT_URL') is None: from . import heartbeats # noqa else: diff --git a/alerta/views/filter_tabs.py b/alerta/views/filter_tabs.py new file mode 100644 index 000000000..7ac853ad1 --- /dev/null +++ b/alerta/views/filter_tabs.py @@ -0,0 +1,258 @@ +import logging + +from flask import current_app, g, jsonify, request +from flask_cors import cross_origin + +from alerta.app import qb +from alerta.auth.decorators import permission +from alerta.exceptions import ApiError +from alerta.models.alert import Alert +from alerta.models.enums import Scope +from alerta.models.filter_tab import FilterTab +from alerta.utils.audit import write_audit_trail +from alerta.utils.response import absolute_url, jsonp + +from . import api + +LOGGER = logging.getLogger('alerta/views/filter_tabs') + + +@api.route('/filtertabs/', methods=['OPTIONS', 'DELETE']) +@cross_origin() +@permission(Scope.admin_alerts) +@jsonp +def delete_filter_tab(filter_tab_id): + filter_tab = FilterTab.find_by_id(filter_tab_id) + + if not filter_tab: + raise ApiError('not found', 404) + + write_audit_trail.send( + current_app._get_current_object(), + event='filter_tab-deleted', + message='', + user=g.login, + customers=g.customers, + scopes=g.scopes, + resource_id=filter_tab.name, + type='filter_tab', + request=request, + ) + + if filter_tab.delete(): + return jsonify(status='ok') + else: + raise ApiError('failed to delete filter tab', 500) + + +@api.route('/filtertabs', methods=['DELETE']) +@cross_origin() +@permission(Scope.admin_alerts) +@jsonp +def delete_filter_tabs(): + requested_ids = request.args.getlist('id[]', None) + if requested_ids is None: + raise ApiError('Missing required param id as list of ids to delete', 400) + elif len(requested_ids) == 0: + raise ApiError('Id list is emtpy', 400) + + deleted_ids = FilterTab.delete_all(requested_ids) + + write_audit_trail.send( + current_app._get_current_object(), + event='filter_tabs-deleted', + message='', + user=g.login, + customers=g.customers, + scopes=g.scopes, + resource_id=deleted_ids, + type='filter_tab', + request=request, + ) + + if deleted_ids == requested_ids: + return jsonify(status='ok') + elif len(deleted_ids): + return jsonify(status='warning', deleted=deleted_ids, not_found=[id for id in requested_ids if id not in deleted_ids]) + else: + raise ApiError('failed to delete filtertabs', 500) + + +@api.route('/filtertab', methods=['OPTIONS', 'POST']) +@cross_origin() +@permission(Scope.admin_alerts) +@jsonp +def create_filter_tab(): + try: + filter_tab = FilterTab.parse(request.json) + except ValueError as e: + LOGGER.info('Got illegal input for tabs %s', e) + raise ApiError(str(e), 400) + except Exception as e: + LOGGER.error('Failed to parse filter tab with error message: %s', e) + raise ApiError('parse of data for filter tabs failed', 500) + + try: + filter_tab = filter_tab.create() + except Exception as e: + LOGGER.error('Failed to create filter tab with error message: %s', e) + raise ApiError('create filter tab failed', 500) + + write_audit_trail.send( + current_app._get_current_object(), + event='filter_tab-created', + message='', + user=g.login, + customers=g.customers, + scopes=g.scopes, + resource_id=filter_tab.name, + type='filter_tab', + request=request, + ) + + if filter_tab: + return ( + jsonify(status='ok', id=filter_tab.name, filterTab=filter_tab.serialize), + 201, + {'Location': absolute_url('/filtertabs/' + filter_tab.name)}, + ) + else: + raise ApiError('insert filter tab failed', 500) + + +@api.route('/filtertabs', methods=['OPTIONS', 'POST']) +@cross_origin() +@permission(Scope.admin_alerts) +@jsonp +def create_filter_tabs(): + try: + tabs = request.json + filter_tabs = [FilterTab.parse(tab).serialize for tab in tabs] + except ValueError as e: + LOGGER.info('Got illegal input for tabs %s', e) + raise ApiError(str(e), 400) + except Exception as e: + LOGGER.error('Failed to parse filter tab with error message: %s', e) + raise ApiError('parse of data for filter tabs failed', 500) + try: + filter_tabs = FilterTab.create_all(filter_tabs) + except Exception as e: + LOGGER.error('Failed to create filter tab with error message: %s', e) + raise ApiError('create filter tab failed', 500) + + write_audit_trail.send( + current_app._get_current_object(), + event='filter_tabs-created', + message='', + user=g.login, + customers=g.customers, + scopes=g.scopes, + resource_id=[tab.name for tab in filter_tabs], + type='filter_tab', + request=request, + ) + + if len(filter_tabs) > 0: + return ( + jsonify(status='ok', filterTabs=[tab.serialize for tab in filter_tabs]), + 201, + ) + else: + raise ApiError('unable to return inserted filter tabs', 500) + + +@api.route('/filtertabs/', methods=['OPTIONS', 'GET']) +@cross_origin() +@permission(Scope.admin_alerts) +@jsonp +def filter_tab(filter_tab_id): + filter_tab = FilterTab.find_by_id(filter_tab_id) + + if filter_tab: + return jsonify(status='ok', total=1, filterTab=filter_tab.serialize) + else: + raise ApiError('not found', 404) + + +@api.route('/filtertabs', methods=['PUT']) +@cross_origin() +@permission(Scope.admin_alerts) +@jsonp +def update_filter_tab(): + # updates = [FilterTab.parse(update) for update in request.json] + updates = request.json + updated = FilterTab.update_all(updates) + + if len(updated) > 0: + return jsonify(status='ok', total=len(updated), updated=updated) + else: + raise ApiError('not found', 404) + + +@api.route('/filtertabs/index', methods=['PUT']) +@cross_origin() +@permission(Scope.admin_alerts) +@jsonp +def update_filter_tab_index(): + # updates = [FilterTab.parse(update) for update in request.json] + updates = request.json + updated = FilterTab.update_indexes(updates) + + if len(updated) > 0: + return jsonify(status='ok', total=len(updated), updated=updated) + else: + raise ApiError('not found', 404) + + +@api.route('/filtertabs', methods=['OPTIONS', 'GET']) +@cross_origin() +@permission(Scope.read_alerts) +@jsonp +def get_filter_tabs(): + filter_tabs = FilterTab.find_all() + filters = [(filter_tab.name, filter_tab.filter_args) for filter_tab in filter_tabs] + queries = [(name, qb.alerts.from_params(filter, customers=g.customers)) for name, filter in filters] + history_queries = [(name, qb.history.from_params(filter, customers=g.customers)) for name, filter in filters] + counts = {name:Alert.get_count(query) for name, query in queries} + history_counts = {name:Alert.get_history_count(query) for name, query in history_queries} + + if filter_tabs: + return jsonify( + status='ok', + filterTabs=[tab.serialize for tab in filter_tabs], + counts=counts, + historyCounts=history_counts, + total=len(filter_tabs), + ) + else: + return jsonify( + status='ok', + message='not found', + filterTabs=[], + counts={}, + total=0, + ) + + +@api.route('/filtertabs/count', methods=['OPTIONS', 'GET']) +@cross_origin() +@permission(Scope.read_alerts) +@jsonp +def get_filter_tabs_counts(): + filters = [(filter_tab.name, filter_tab.filter_args) for filter_tab in FilterTab.find_all()] + queries = [(name, qb.alerts.from_params(filter, customers=g.customers)) for name, filter in filters] + counts = {name:Alert.get_count(query) for name, query in queries} + + if filters: + return jsonify( + status='ok', + counts=counts, + total=len(filters), + ) + else: + return jsonify( + status='ok', + message='not found', + filterTabs=[], + total=0, + )