From 60a65b6cbb5f8797a91e3302f61835c0dabe9f9a Mon Sep 17 00:00:00 2001 From: Vladimir Filonov Date: Wed, 9 Oct 2024 12:15:12 +0400 Subject: [PATCH] feat: add filtering for incidents list page (#2064) Co-authored-by: Kirill Chernakov Co-authored-by: Tal --- .../incidents/incident-table-component.tsx | 1 - .../incident-table-filters-context.tsx | 110 +++++++++++ .../app/incidents/incident-table-filters.tsx | 85 ++++++++ keep-ui/app/incidents/incident.tsx | 85 +++++--- keep-ui/app/incidents/incidents-table.tsx | 6 +- keep-ui/app/incidents/layout.tsx | 6 + keep-ui/app/incidents/models.ts | 8 + keep-ui/utils/helpers.ts | 4 + keep-ui/utils/hooks/useIncidents.ts | 45 ++++- keep/api/core/db.py | 186 +++++++++++++++--- keep/api/core/db_utils.py | 19 ++ keep/api/models/alert.py | 38 +++- keep/api/routes/incidents.py | 56 +++++- tests/conftest.py | 2 +- tests/test_incidents.py | 120 ++++++++--- 15 files changed, 674 insertions(+), 97 deletions(-) create mode 100644 keep-ui/app/incidents/incident-table-filters-context.tsx create mode 100644 keep-ui/app/incidents/incident-table-filters.tsx create mode 100644 keep-ui/app/incidents/layout.tsx diff --git a/keep-ui/app/incidents/incident-table-component.tsx b/keep-ui/app/incidents/incident-table-component.tsx index 3bae86c36..69240dfcd 100644 --- a/keep-ui/app/incidents/incident-table-component.tsx +++ b/keep-ui/app/incidents/incident-table-component.tsx @@ -35,7 +35,6 @@ const SortableHeaderCell = ({ size="xs" color="neutral" onClick={(event) => { - console.log("clicked for sorting"); event.stopPropagation(); const toggleSorting = header.column.getToggleSortingHandler(); if (toggleSorting) toggleSorting(event); diff --git a/keep-ui/app/incidents/incident-table-filters-context.tsx b/keep-ui/app/incidents/incident-table-filters-context.tsx new file mode 100644 index 000000000..3211b908b --- /dev/null +++ b/keep-ui/app/incidents/incident-table-filters-context.tsx @@ -0,0 +1,110 @@ +import {Dispatch, SetStateAction, useCallback, useContext, useEffect} from 'react'; + +import { createContext, useState, FC, PropsWithChildren } from "react"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import {useIncidentsMeta} from "../../utils/hooks/useIncidents"; +import {IncidentsMetaDto} from "./models"; + +interface IIncidentFilterContext { + meta: IncidentsMetaDto | undefined; + + statuses: string[]; + severities: string[]; + assignees: string[]; + services: string[]; + sources: string[]; + + setStatuses: (value: string[]) => void; + setSeverities: (value: string[]) => void; + setAssignees: (value: string[]) => void; + setServices: (value: string[]) => void; + setSources: (value: string[]) => void; +} + +const IncidentFilterContext = createContext(null); + +export const IncidentFilterContextProvider: FC = ({ children }) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const {data: incidentsMeta, isLoading} = useIncidentsMeta(); + + const setFilterValue = (filterName: string) => { + return () => { + if (incidentsMeta === undefined) return []; + + const values = searchParams?.get(filterName); + const valuesArray = values?.split(',').filter( + value => incidentsMeta[filterName as keyof IncidentsMetaDto]?.includes(value) + ); + + return (valuesArray || []) as string[]; + } + } + + const [statuses, setStatuses] = useState(setFilterValue("statuses")); + const [severities, setSeverities] = useState(setFilterValue("severities")); + const [assignees, setAssignees] = useState(setFilterValue("assignees")); + const [services, setServices] = useState(setFilterValue("services")); + const [sources, setSources] = useState(setFilterValue("sources")); + + useEffect(() => { + if (!isLoading) { + setStatuses(setFilterValue("statuses")); + setSeverities(setFilterValue("severities")); + setAssignees(setFilterValue("assignees")); + setServices(setFilterValue("services")); + setSources(setFilterValue("sources")); + } + }, [isLoading]) + + const createQueryString = useCallback( + (name: string, value: string[]) => { + const params = new URLSearchParams(searchParams?.toString()) + if (value.length == 0) { + params.delete(name); + } else { + params.set(name, value.join(",")); + } + + + return params.toString(); + }, + [searchParams] + ) + + const filterSetter = (filterName: string, stateSetter: Dispatch>) => { + return (value: string[]) => { + router.push(pathname + '?' + createQueryString(filterName, value)); + stateSetter(value); + } + } + + const contextValue: IIncidentFilterContext = { + meta: incidentsMeta, + statuses, + severities, + assignees, + services, + sources, + + setStatuses: filterSetter("statuses", setStatuses), + setSeverities: filterSetter("severities", setSeverities), + setAssignees: filterSetter("assignees", setAssignees), + setServices: filterSetter("services", setServices), + setSources: filterSetter("sources", setSources), + } + + return {children} +} + +export const useIncidentFilterContext = (): IIncidentFilterContext => { + const filterContext = useContext(IncidentFilterContext); + + if (!filterContext) { + throw new ReferenceError('Usage of useIncidentFilterContext outside of IncidentFilterContext provider is forbidden'); + } + + return filterContext; +} \ No newline at end of file diff --git a/keep-ui/app/incidents/incident-table-filters.tsx b/keep-ui/app/incidents/incident-table-filters.tsx new file mode 100644 index 000000000..54d9622e4 --- /dev/null +++ b/keep-ui/app/incidents/incident-table-filters.tsx @@ -0,0 +1,85 @@ +import type { FC } from "react"; +import { MultiSelect, MultiSelectItem } from "@tremor/react"; +import { useIncidentFilterContext } from "./incident-table-filters-context"; +import { capitalize } from "@/utils/helpers"; + +export const IncidentTableFilters: FC = (props) => { + const { + meta, + statuses, + severities, + assignees, + services, + sources, + setStatuses, + setSeverities, + setAssignees, + setServices, + setSources, + } = useIncidentFilterContext(); + + return ( +
+ {/* TODO: use copy-and-paste multiselect component to be able to control the width */} + + {meta?.statuses.map((value) => ( + + {capitalize(value)} + + ))} + + + + {meta?.severities.map((value) => ( + + {capitalize(value)} + + ))} + + + + {meta?.assignees.map((value) => ( + + {capitalize(value)} + + ))} + + + + {meta?.services.map((value) => ( + + {capitalize(value)} + + ))} + + + + {meta?.sources.map((value) => ( + + {capitalize(value)} + + ))} + +
+ ); +}; diff --git a/keep-ui/app/incidents/incident.tsx b/keep-ui/app/incidents/incident.tsx index e5b9e51c5..4fc8efaa2 100644 --- a/keep-ui/app/incidents/incident.tsx +++ b/keep-ui/app/incidents/incident.tsx @@ -10,13 +10,23 @@ import { IncidentPlaceholder } from "./IncidentPlaceholder"; import Modal from "@/components/ui/Modal"; import { PlusCircleIcon } from "@heroicons/react/24/outline"; import PredictedIncidentsTable from "./predicted-incidents-table"; -import {SortingState} from "@tanstack/react-table"; +import { SortingState } from "@tanstack/react-table"; +import { IncidentTableFilters } from "./incident-table-filters"; +import { useIncidentFilterContext } from "./incident-table-filters-context"; interface Pagination { limit: number; offset: number; } +interface Filters { + status: string[]; + severity: string[]; + assignees: string[]; + sources: string[]; + affected_services: string[]; +} + export default function Incident() { const [incidentsPagination, setIncidentsPagination] = useState({ limit: 20, @@ -27,11 +37,28 @@ export default function Incident() { { id: "creation_time", desc: true }, ]); + const { statuses, severities, assignees, services, sources } = + useIncidentFilterContext(); + + const filters: Filters = { + status: statuses, + severity: severities, + assignees: assignees, + affected_services: services, + sources: sources, + }; + const { data: incidents, isLoading, mutate: mutateIncidents, - } = useIncidents(true, incidentsPagination.limit, incidentsPagination.offset, incidentsSorting[0]); + } = useIncidents( + true, + incidentsPagination.limit, + incidentsPagination.offset, + incidentsSorting[0], + filters + ); const { data: predictedIncidents, isLoading: isPredictedLoading, @@ -82,27 +109,29 @@ export default function Incident() { ) : null} - {isLoading ? ( - - ) : incidents && incidents.items.length > 0 ? ( -
-
-
- Incidents - Manage your incidents -
-
- -
+
+
+
+ Incidents + Manage your incidents
- + +
+ +
+
+ + + {isLoading ? ( + + ) : incidents && incidents.items.length > 0 ? ( - -
- ) : ( -
- + ) : ( - -
- )} + )} + +
( -
+
{row.original.services .filter((service) => service !== "null") .map((service) => ( - - {service} - + {service} ))}
), diff --git a/keep-ui/app/incidents/layout.tsx b/keep-ui/app/incidents/layout.tsx new file mode 100644 index 000000000..9ee0c7e45 --- /dev/null +++ b/keep-ui/app/incidents/layout.tsx @@ -0,0 +1,6 @@ +"use client"; +import {IncidentFilterContextProvider} from "./incident-table-filters-context"; + +export default function Layout({ children }: { children: any }) { + return {children} +} \ No newline at end of file diff --git a/keep-ui/app/incidents/models.ts b/keep-ui/app/incidents/models.ts index 8f9ff2960..60b4af29a 100644 --- a/keep-ui/app/incidents/models.ts +++ b/keep-ui/app/incidents/models.ts @@ -40,3 +40,11 @@ export interface PaginatedIncidentAlertsDto { count: number; items: AlertDto[]; } + +export interface IncidentsMetaDto { + statuses: string[]; + severities: string[]; + assignees: string[]; + services: string[]; + sources: string[]; +} diff --git a/keep-ui/utils/helpers.ts b/keep-ui/utils/helpers.ts index 2a871638a..a54434487 100644 --- a/keep-ui/utils/helpers.ts +++ b/keep-ui/utils/helpers.ts @@ -13,6 +13,10 @@ function isValidDate(d: Date) { return d instanceof Date && !isNaN(d.getTime()); } +export function capitalize(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + export function toDateObjectWithFallback(date: string | Date) { /** * Since we have a weak typing validation in the backend today (lastReceived is just a string), diff --git a/keep-ui/utils/hooks/useIncidents.ts b/keep-ui/utils/hooks/useIncidents.ts index 4eed3f21b..29c09fc67 100644 --- a/keep-ui/utils/hooks/useIncidents.ts +++ b/keep-ui/utils/hooks/useIncidents.ts @@ -1,7 +1,8 @@ import { IncidentDto, + IncidentsMetaDto, PaginatedIncidentAlertsDto, - PaginatedIncidentsDto, + PaginatedIncidentsDto } from "../../app/incidents/models"; import { PaginatedWorkflowExecutionDto } from "app/workflows/builder/types"; import { useSession } from "next-auth/react"; @@ -16,23 +17,47 @@ interface IncidentUpdatePayload { incident_id: string | null; } +interface Filters { + status: string[], + severity: string[], + assignees: string[] + sources: string[], + affected_services: string[], +} + export const useIncidents = ( confirmed: boolean = true, limit: number = 25, offset: number = 0, sorting: { id: string; desc: boolean } = { id: "creation_time", desc: false }, + filters: Filters | {} = {}, options: SWRConfiguration = { revalidateOnFocus: false, } ) => { const apiUrl = getApiURL(); const { data: session } = useSession(); + + const filtersParams = new URLSearchParams(); + + Object.entries(filters).forEach(([key, value]) => { + if (value.length == 0) { + filtersParams.delete(key as string); + } else { + value.forEach((s: string) => { + filtersParams.append(key, s) + }); + } + }); + + console.log(filters); + return useSWR( () => session ? `${apiUrl}/incidents?confirmed=${confirmed}&limit=${limit}&offset=${offset}&sorting=${ sorting.desc ? "-" : "" - }${sorting.id}` + }${sorting.id}&${filtersParams.toString()}` : null, (url) => fetcher(url, session?.accessToken), options @@ -128,3 +153,19 @@ export const usePollIncidents = (mutateIncidents: any) => { }; }, [bind, unbind, handleIncoming]); }; + + +export const useIncidentsMeta = ( + options: SWRConfiguration = { + revalidateOnFocus: false, + } +) => { + const apiUrl = getApiURL(); + const { data: session } = useSession(); + + return useSWR( + () => (session ? `${apiUrl}/incidents/meta` : null), + (url) => fetcher(url, session?.accessToken), + options + ); +}; \ No newline at end of file diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 901c6f7bd..11abb87e3 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -12,7 +12,6 @@ from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timedelta, timezone -from enum import Enum from typing import Any, Dict, List, Tuple, Union, Callable from uuid import uuid4 @@ -32,7 +31,7 @@ from keep.api.core.db_utils import create_db_engine, get_json_extract_field # This import is required to create the tables -from keep.api.models.alert import IncidentDtoIn, AlertStatus +from keep.api.models.alert import IncidentDtoIn, IncidentSorting, AlertStatus from keep.api.models.db.action import Action from keep.api.models.db.alert import * # pylint: disable=unused-wildcard-import from keep.api.models.db.dashboard import * # pylint: disable=unused-wildcard-import @@ -58,27 +57,13 @@ SQLAlchemyInstrumentor().instrument(enable_commenter=True, engine=engine) -class IncidentSorting(Enum): - creation_time = "creation_time" - start_time = "start_time" - last_seen_time = "last_seen_time" - severity = "severity" - status = "status" - alerts_count = "alerts_count" - - creation_time_desc = "-creation_time" - start_time_desc = "-start_time" - last_seen_time_desc = "-last_seen_time" - severity_desc = "-severity" - status_desc = "-status" - alerts_count_desc = "-alerts_count" - - def get_order_by(self): - if self.value.startswith("-"): - return desc(col(getattr(Incident, self.value[1:]))) - - return col(getattr(Incident, self.value)) - +ALLOWED_INCIDENT_FILTERS = [ + "status", + "severity", + "sources", + "affected_services", + "assignee" +] @contextmanager def existed_or_new_session(session: Optional[Session] = None) -> Session: @@ -2496,6 +2481,152 @@ def get_workflows_with_last_executions_v2( return result +def get_incidents_meta_for_tenant(tenant_id: str) -> dict: + with Session(engine) as session: + + if session.bind.dialect.name == "sqlite": + + sources_join = func.json_each(Incident.sources).table_valued("value") + affected_services_join = func.json_each(Incident.affected_services).table_valued("value") + + query = ( + select( + func.json_group_array(col(Incident.assignee).distinct()).label("assignees"), + func.json_group_array(sources_join.c.value.distinct()).label("sources"), + func.json_group_array(affected_services_join.c.value.distinct()).label("affected_services"), + ) + .select_from(Incident) + .outerjoin(sources_join, True) + .outerjoin(affected_services_join, True) + .filter( + Incident.tenant_id == tenant_id, + Incident.is_confirmed == True + ) + ) + results = session.exec(query).one_or_none() + + if not results: + return {} + + return { + "assignees": list(filter(bool, json.loads(results.assignees))), + "sources": list(filter(bool, json.loads(results.sources))), + "services": list(filter(bool, json.loads(results.affected_services))), + } + + elif session.bind.dialect.name == "mysql": + + sources_join = func.json_table(Incident.sources, Column('value', String(127))).table_valued("value") + affected_services_join = func.json_table(Incident.affected_services, Column('value', String(127))).table_valued("value") + + query = ( + select( + func.group_concat(col(Incident.assignee).distinct()).label("assignees"), + func.group_concat(sources_join.c.value.distinct()).label("sources"), + func.group_concat(affected_services_join.c.value.distinct()).label("affected_services"), + ) + .select_from(Incident) + .outerjoin(sources_join, True) + .outerjoin(affected_services_join, True) + .filter( + Incident.tenant_id == tenant_id, + Incident.is_confirmed == True + ) + ) + + results = session.exec(query).one_or_none() + + if not results: + return {} + + return { + "assignees": results.assignees.split(","), + "sources": results.sources.split(","), + "services": results.affected_services.split(","), + } + elif session.bind.dialect.name == "postgresql": + + sources_join = func.json_array_elements_text(Incident.sources).table_valued("value") + affected_services_join = func.json_array_elements_text(Incident.affected_services).table_valued("value") + + query = ( + select( + func.json_agg(col(Incident.assignee).distinct()).label("assignees"), + func.json_agg(sources_join.c.value.distinct()).label("sources"), + func.json_agg(affected_services_join.c.value.distinct()).label("affected_services"), + ) + .select_from(Incident) + .outerjoin(sources_join, True) + .outerjoin(affected_services_join, True) + .filter( + Incident.tenant_id == tenant_id, + Incident.is_confirmed == True + ) + ) + + results = session.exec(query).one_or_none() + if not results: + return {} + + return { + "assignees": list(filter(bool, results.assignees)), + "sources": list(filter(bool, results.sources)), + "services": list(filter(bool, results.affected_services)), + } + return {} + +def apply_incident_filters(session: Session, filters: dict, query): + for field_name, value in filters.items(): + if field_name in ALLOWED_INCIDENT_FILTERS: + if field_name in ["affected_services", "sources"]: + field = getattr(Incident, field_name) + + # Rare case with empty values + if isinstance(value, list) and not any(value): + continue + + query = filter_query(session, query, field, value) + + else: + field = getattr(Incident, field_name) + if isinstance(value, list): + query = query.filter( + col(field).in_(value) + ) + else: + query = query.filter( + col(field) == value + ) + return query + +def filter_query(session: Session, query, field, value): + if session.bind.dialect.name in ["mysql", "postgresql"]: + if isinstance(value, list): + if session.bind.dialect.name == "mysql": + query = query.filter( + func.json_overlaps(field, func.json_array(value)) + ) + else: + query = query.filter( + col(field).op('?|')(func.array(value)) + ) + + else: + query = query.filter( + func.json_contains(field, value) + ) + + elif session.bind.dialect.name == "sqlite": + json_each_alias = func.json_each(field).table_valued("value") + subquery = select(1).select_from(json_each_alias) + if isinstance(value, list): + subquery = subquery.where(json_each_alias.c.value.in_(value)) + else: + subquery = subquery.where(json_each_alias.c.value == value) + + query = query.filter(subquery.exists()) + return query + def get_last_incidents( tenant_id: str, limit: int = 25, @@ -2507,6 +2638,7 @@ def get_last_incidents( sorting: Optional[IncidentSorting] = IncidentSorting.creation_time, with_alerts: bool = False, is_predicted: bool = None, + filters: Optional[dict] = None, ) -> Tuple[list[Incident], int]: """ Get the last incidents and total amount of incidents. @@ -2522,7 +2654,8 @@ def get_last_incidents( is_confirmed (bool): filter incident candidates or real incidents sorting: Optional[IncidentSorting]: how to sort the data with_alerts (bool): Pre-load alerts or not - + is_predicted (bool): filter only incidents predicted by KeepAI + filters (dict): dict of filters Returns: List[Incident]: A list of Incident objects. """ @@ -2552,8 +2685,11 @@ def get_last_incidents( elif lower_timestamp: query = query.filter(Incident.last_seen_time >= lower_timestamp) + if filters: + query = apply_incident_filters(session, filters, query) + if sorting: - query = query.order_by(sorting.get_order_by()) + query = query.order_by(sorting.get_order_by(Incident)) total_count = query.count() diff --git a/keep/api/core/db_utils.py b/keep/api/core/db_utils.py index df6641151..1c4fd0e14 100644 --- a/keep/api/core/db_utils.py +++ b/keep/api/core/db_utils.py @@ -12,6 +12,9 @@ from dotenv import find_dotenv, load_dotenv from google.cloud.sql.connector import Connector from sqlalchemy import func +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.sql.ddl import CreateColumn +from sqlalchemy.sql.functions import GenericFunction from sqlmodel import Session, create_engine # This import is required to create the tables @@ -181,3 +184,19 @@ def get_aggreated_field(session: Session, column_name: str, alias: str): return func.group_concat(column_name).label(alias) else: return func.array_agg(column_name).label(alias) + + +class json_table(GenericFunction): + inherit_cache = True + + +@compiles(json_table, "mysql") +def _compile_json_table(element, compiler, **kw): + ddl_compiler = compiler.dialect.ddl_compiler(compiler.dialect, None) + return "JSON_TABLE({}, '$[*]' COLUMNS({} PATH '$'))".format( + compiler.process(element.clauses.clauses[0], **kw), + ",".join( + ddl_compiler.process(CreateColumn(clause), **kw) + for clause in element.clauses.clauses[1:] + ), + ) diff --git a/keep/api/models/alert.py b/keep/api/models/alert.py index 6426a8238..00ba8d1a3 100644 --- a/keep/api/models/alert.py +++ b/keep/api/models/alert.py @@ -14,8 +14,10 @@ Extra, PrivateAttr, root_validator, - validator, + validator ) +from sqlalchemy import desc +from sqlmodel import col logger = logging.getLogger(__name__) @@ -459,11 +461,11 @@ def from_db_incident(cls, db_incident): last_seen_time=db_incident.last_seen_time, end_time=db_incident.end_time, alerts_count=db_incident.alerts_count, - alert_sources=db_incident.sources, + alert_sources=db_incident.sources or [], severity=severity, status=db_incident.status, assignee=db_incident.assignee, - services=db_incident.affected_services, + services=db_incident.affected_services or [], rule_fingerprint=db_incident.rule_fingerprint, ) @@ -505,3 +507,33 @@ class DeduplicationRuleRequestDto(BaseModel): class IncidentStatusChangeDto(BaseModel): status: IncidentStatus comment: str | None + + +class IncidentSorting(Enum): + creation_time = "creation_time" + start_time = "start_time" + last_seen_time = "last_seen_time" + severity = "severity" + status = "status" + alerts_count = "alerts_count" + + creation_time_desc = "-creation_time" + start_time_desc = "-start_time" + last_seen_time_desc = "-last_seen_time" + severity_desc = "-severity" + status_desc = "-status" + alerts_count_desc = "-alerts_count" + + def get_order_by(self, model): + if self.value.startswith("-"): + return desc(col(getattr(model, self.value[1:]))) + + return col(getattr(model, self.value)) + + +class IncidentListFilterParamsDto(BaseModel): + statuses: List[IncidentStatus] = [s.value for s in IncidentStatus] + severities: List[IncidentSeverity] = [s.value for s in IncidentSeverity] + assignees: List[str] + services: List[str] + sources: List[str] diff --git a/keep/api/routes/incidents.py b/keep/api/routes/incidents.py index 8785d8d75..52f60002a 100644 --- a/keep/api/routes/incidents.py +++ b/keep/api/routes/incidents.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import List -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Query from pusher import Pusher from pydantic.types import UUID @@ -24,17 +24,23 @@ get_last_incidents, get_workflow_executions_for_incident_or_alert, remove_alerts_to_incident_by_incident_id, + change_incident_status_by_id, update_incident_from_dto_by_id, + get_incidents_meta_for_tenant, ) from keep.api.core.dependencies import get_pusher_client from keep.api.models.alert import ( AlertDto, - EnrichAlertRequestBody, IncidentDto, IncidentDtoIn, - IncidentStatus, IncidentStatusChangeDto, + IncidentStatus, + EnrichAlertRequestBody, + IncidentSorting, + IncidentSeverity, + IncidentListFilterParamsDto, ) + from keep.api.routes.alerts import _enrich_alert from keep.api.utils.enrichment_helpers import convert_db_alerts_to_dto_alerts from keep.api.utils.import_ee import mine_incidents_and_create_objects @@ -139,6 +145,21 @@ def create_incident_endpoint( return new_incident_dto +@router.get( + "/meta", + description="Get incidents' metadata for filtering", + response_model=IncidentListFilterParamsDto, +) +def get_incidents_meta( + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:alert"]) + ), +) -> IncidentListFilterParamsDto: + tenant_id = authenticated_entity.tenant_id + meta = get_incidents_meta_for_tenant(tenant_id=tenant_id) + return IncidentListFilterParamsDto(**meta) + + @router.get( "", description="Get last incidents", @@ -148,23 +169,48 @@ def get_all_incidents( limit: int = 25, offset: int = 0, sorting: IncidentSorting = IncidentSorting.creation_time, + status: List[IncidentStatus] = Query(None), + severity: List[IncidentSeverity] = Query(None), + assignees: List[str] = Query(None), + sources: List[str] = Query(None), + affected_services: List[str] = Query(None), authenticated_entity: AuthenticatedEntity = Depends( IdentityManagerFactory.get_auth_verifier(["read:alert"]) ), ) -> IncidentsPaginatedResultsDto: tenant_id = authenticated_entity.tenant_id + + filters = {} + if status: + filters["status"] = [s.value for s in status] + if severity: + filters["severity"] = [s.order for s in severity] + if assignees: + filters["assignee"] = assignees + if sources: + filters["sources"] = sources + if affected_services: + filters["affected_services"] = affected_services + + logger.info( "Fetching incidents from DB", extra={ "tenant_id": tenant_id, + "limit": limit, + "offset": offset, + "sorting": sorting, + "filters": filters, }, ) + incidents, total_count = get_last_incidents( tenant_id=tenant_id, is_confirmed=confirmed, limit=limit, offset=offset, sorting=sorting, + filters=filters, ) incidents_dto = [] @@ -175,6 +221,10 @@ def get_all_incidents( "Fetched incidents from DB", extra={ "tenant_id": tenant_id, + "limit": limit, + "offset": offset, + "sorting": sorting, + "filters": filters, }, ) diff --git a/tests/conftest.py b/tests/conftest.py index df7a5b175..e1e21649d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -577,7 +577,7 @@ def _create_alert(fingerprint, status, timestamp, details=None): tenant_id=SINGLE_TENANT_UUID, provider_id="test", provider_type=( - details["source"][0] if details and "source" in details else None + details["source"][0] if details and "source" in details and details["source"] else None ), fingerprint=fingerprint, api_key_name="test", diff --git a/tests/test_incidents.py b/tests/test_incidents.py index b2ca94fdf..7337d51da 100644 --- a/tests/test_incidents.py +++ b/tests/test_incidents.py @@ -27,6 +27,7 @@ from tests.fixtures.client import client, test_app # noqa + def test_get_alerts_data_for_incident(db_session, setup_stress_alerts_no_elastic): alerts = setup_stress_alerts_no_elastic(100) assert 100 == db_session.query(func.count(Alert.id)).scalar() @@ -128,24 +129,28 @@ def test_add_remove_alert_to_incidents(db_session, setup_stress_alerts_no_elasti def test_get_last_incidents(db_session, create_alert): - severity_cycle = cycle([IncidentSeverity.from_number(s).order for s in range(1, 6)]) + severity_cycle = cycle([s.order for s in IncidentSeverity]) + status_cycle = cycle([s.value for s in IncidentStatus]) + services_cycle = cycle(["keep", None]) for i in range(50): severity = next(severity_cycle) - incident = create_incident_from_dict( - SINGLE_TENANT_UUID, - { - "user_generated_name": f"test-{i}", - "user_summary": f"test-{i}", - "is_confirmed": True, - "severity": severity, - }, - ) + status = next(status_cycle) + incident = create_incident_from_dict(SINGLE_TENANT_UUID, { + "user_generated_name": f"test-{i}", + "user_summary": f"test-{i}", + "is_confirmed": True, + "severity": severity, + "status": status, + }) create_alert( f"alert-test-{i}", - AlertStatus.FIRING, + AlertStatus(status), datetime.utcnow(), - {"severity": AlertSeverity.from_number(severity).value}, + { + "severity": AlertSeverity.from_number(severity), + "service": next(services_cycle), + } ) alert = db_session.query(Alert).order_by(Alert.timestamp.desc()).first() @@ -202,24 +207,37 @@ def test_get_last_incidents(db_session, create_alert): [i.severity == IncidentSeverity.LOW.order for i in incidents_sorted_by_severity] ) - incidents_sorted_by_severity_desc, _ = get_last_incidents( - SINGLE_TENANT_UUID, - is_confirmed=True, - sorting=IncidentSorting.severity_desc, - limit=5, - ) - assert all( - [ - i.severity == IncidentSeverity.CRITICAL.order - for i in incidents_sorted_by_severity_desc - ] - ) + # Test filters + + filters_1 = {"severity": [1]} + incidents_with_filters_1, _ = get_last_incidents(SINGLE_TENANT_UUID, is_confirmed=True, filters=filters_1, limit=100) + assert len(incidents_with_filters_1) == 10 + assert all([i.severity == 1 for i in incidents_with_filters_1]) + + filters_2 = {"status": ["firing", "acknowledged"]} + incidents_with_filters_2, _ = get_last_incidents(SINGLE_TENANT_UUID, is_confirmed=True, filters=filters_2, limit=100) + assert len(incidents_with_filters_2) == 17 + 16 + assert all([i.status in ["firing", "acknowledged"] for i in incidents_with_filters_2]) + + filters_3 = {"sources": ["keep"]} + incidents_with_filters_3, _ = get_last_incidents(SINGLE_TENANT_UUID, is_confirmed=True, filters=filters_3, limit=100) + assert len(incidents_with_filters_3) == 50 + assert all(["keep" in i.sources for i in incidents_with_filters_3]) + filters_4 = {"sources": ["grafana"]} + incidents_with_filters_4, _ = get_last_incidents(SINGLE_TENANT_UUID, is_confirmed=True, filters=filters_4, limit=100) + assert len(incidents_with_filters_4) == 0 + filters_5 = {"affected_services": "keep"} + incidents_with_filters_5, _ = get_last_incidents(SINGLE_TENANT_UUID, is_confirmed=True, filters=filters_5, limit=100) + assert len(incidents_with_filters_5) == 25 + assert all(["keep" in i.affected_services for i in incidents_with_filters_5]) -@pytest.mark.parametrize("test_app", ["NO_AUTH"], indirect=True) -def test_incident_status_change( - db_session, client, test_app, setup_stress_alerts_no_elastic -): + + +@pytest.mark.parametrize( + "test_app", ["NO_AUTH"], indirect=True +) +def test_incident_status_change(db_session, client, test_app, setup_stress_alerts_no_elastic): alerts = setup_stress_alerts_no_elastic(100) incident = create_incident_from_dict( @@ -298,3 +316,49 @@ def test_incident_status_change( ) == 100 ) + + +@pytest.mark.parametrize( + "test_app", ["NO_AUTH"], indirect=True +) +def test_incident_metadata(db_session, client, test_app, setup_stress_alerts_no_elastic): + severity_cycle = cycle([s.order for s in IncidentSeverity]) + status_cycle = cycle([s.value for s in IncidentStatus]) + sources_cycle = cycle(["keep", "keep-test", "keep-test-2"]) + services_cycle = cycle(["keep", "keep-test", "keep-test-2"]) + + for i in range(50): + severity = next(severity_cycle) + status = next(status_cycle) + service = next(services_cycle) + source = next(sources_cycle) + create_incident_from_dict(SINGLE_TENANT_UUID, { + "user_generated_name": f"test-{i}", + "user_summary": f"test-{i}", + "is_confirmed": True, + "assignee": f"assignee-{i % 5}", + "severity": severity, + "status": status, + "sources": [source], + "affected_services": [service], + }) + + response = client.get( + "/incidents/meta/", + headers={"x-api-key": "some-key"}, + ) + + assert response.status_code == 200 + + data = response.json() + assert len(data) == 5 + assert "statuses" in data + assert data["statuses"] == [s.value for s in IncidentStatus] + assert "severities" in data + assert data["severities"] == [s.value for s in IncidentSeverity] + assert "assignees" in data + assert data["assignees"] == [f"assignee-{i}" for i in range(5)] + assert "services" in data + assert data["services"] == ["keep", "keep-test", "keep-test-2"] + assert "sources" in data + assert data["sources"] == ["keep", "keep-test", "keep-test-2"]