diff --git a/keep-ui/app/(keep)/incidents/page.tsx b/keep-ui/app/(keep)/incidents/page.tsx index 67ca38e1b..de8a52c88 100644 --- a/keep-ui/app/(keep)/incidents/page.tsx +++ b/keep-ui/app/(keep)/incidents/page.tsx @@ -2,13 +2,14 @@ import { IncidentList } from "@/features/incident-list"; import { getIncidents, GetIncidentsParams } from "@/entities/incidents/api"; import { PaginatedIncidentsDto } from "@/entities/incidents/model"; import { createServerApiClient } from "@/shared/api/server"; +import {DefaultIncidentFilters} from "@/entities/incidents/model/models"; const defaultIncidentsParams: GetIncidentsParams = { confirmed: true, limit: 20, offset: 0, sorting: { id: "creation_time", desc: true }, - filters: {}, + filters: DefaultIncidentFilters, }; export default async function Page() { diff --git a/keep-ui/entities/incidents/model/models.ts b/keep-ui/entities/incidents/model/models.ts index ffd3b201e..1f8f3a5a2 100644 --- a/keep-ui/entities/incidents/model/models.ts +++ b/keep-ui/entities/incidents/model/models.ts @@ -6,6 +6,12 @@ export enum Status { Resolved = "resolved", Acknowledged = "acknowledged", Merged = "merged", + Deleted = "deleted", +} + +export const DefaultIncidentFilteredStatuses: string[] = [Status.Firing, Status.Acknowledged, Status.Merged]; +export const DefaultIncidentFilters: object = { + "status": DefaultIncidentFilteredStatuses, } export interface IncidentDto { diff --git a/keep-ui/entities/incidents/ui/statuses.tsx b/keep-ui/entities/incidents/ui/statuses.tsx index 9e049d36d..8f2dbe2c9 100644 --- a/keep-ui/entities/incidents/ui/statuses.tsx +++ b/keep-ui/entities/incidents/ui/statuses.tsx @@ -5,7 +5,7 @@ import { ExclamationCircleIcon, PauseIcon, } from "@heroicons/react/24/outline"; -import { IoIosGitPullRequest } from "react-icons/io"; +import {IoIosGitPullRequest, IoIosTrash} from "react-icons/io"; import React from "react"; import { capitalize } from "@/utils/helpers"; @@ -49,6 +49,14 @@ export const STATUS_ICONS = { className="w-4 h-4 mr-2" /> ), + [Status.Deleted]: ( + + ), }; export function StatusIcon({ diff --git a/keep-ui/features/change-incident-status/ui/incident-change-status-select.tsx b/keep-ui/features/change-incident-status/ui/incident-change-status-select.tsx index 8b1c574a2..41d7febd0 100644 --- a/keep-ui/features/change-incident-status/ui/incident-change-status-select.tsx +++ b/keep-ui/features/change-incident-status/ui/incident-change-status-select.tsx @@ -48,7 +48,7 @@ export function IncidentChangeStatusSelect({ const { changeStatus } = useIncidentActions(); const statusOptions = useMemo( () => - Object.values(Status).map((status) => ({ + Object.values(Status).filter((status) => status != Status.Deleted || value == Status.Deleted).map((status) => ({ value: status, label: (
@@ -57,7 +57,7 @@ export function IncidentChangeStatusSelect({
), })), - [] + [value] ); const handleChange = useCallback( diff --git a/keep-ui/features/incident-list/ui/incident-table-filters-context.tsx b/keep-ui/features/incident-list/ui/incident-table-filters-context.tsx index 8fd6549e4..51767f62b 100644 --- a/keep-ui/features/incident-list/ui/incident-table-filters-context.tsx +++ b/keep-ui/features/incident-list/ui/incident-table-filters-context.tsx @@ -10,6 +10,7 @@ import { createContext, useState, FC, PropsWithChildren } from "react"; import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useIncidentsMeta } from "../../../utils/hooks/useIncidents"; import type { IncidentsMetaDto } from "@/entities/incidents/model"; +import { DefaultIncidentFilteredStatuses } from "@/entities/incidents/model/models"; interface IIncidentFilterContext { meta: IncidentsMetaDto | undefined; @@ -43,16 +44,19 @@ export const IncidentFilterContextProvider: FC = ({ const { data: incidentsMeta, isLoading } = useIncidentsMeta(); const setFilterValue = useCallback( - (filterName: string) => { + (filterName: string, defaultValues: string[] | undefined = undefined) => { return () => { if (incidentsMeta === undefined) return []; const values = searchParams?.get(filterName); - const valuesArray = values - ?.split(",") - .filter((value) => - incidentsMeta[filterName as keyof IncidentsMetaDto]?.includes(value) - ); + let valuesArray = values?.split(",") + if (!valuesArray) { + valuesArray = defaultValues; + } + + valuesArray = valuesArray?.filter((value) => + incidentsMeta[filterName as keyof IncidentsMetaDto]?.includes(value) + ); return (valuesArray || []) as string[]; }; @@ -61,7 +65,7 @@ export const IncidentFilterContextProvider: FC = ({ ); const [statuses, setStatuses] = useState( - setFilterValue("statuses") + setFilterValue("statuses", incidentsMeta?.statuses.filter((status) => DefaultIncidentFilteredStatuses.includes(status))) ); const [severities, setSeverities] = useState( setFilterValue("severities") @@ -76,7 +80,7 @@ export const IncidentFilterContextProvider: FC = ({ useEffect(() => { if (!isLoading) { - setStatuses(setFilterValue("statuses")); + setStatuses(setFilterValue("statuses", incidentsMeta?.statuses.filter((status) => DefaultIncidentFilteredStatuses.includes(status)))); setSeverities(setFilterValue("severities")); setAssignees(setFilterValue("assignees")); setServices(setFilterValue("services")); diff --git a/keep/api/bl/incidents_bl.py b/keep/api/bl/incidents_bl.py index 7feb07bb6..c5525e7cd 100644 --- a/keep/api/bl/incidents_bl.py +++ b/keep/api/bl/incidents_bl.py @@ -43,9 +43,11 @@ class IncidentBl: def __init__( - self, tenant_id: str, session: Session, pusher_client: Optional[Pusher] = None + self, tenant_id: str, session: Session, pusher_client: Optional[Pusher] = None, + user: str = None ): self.tenant_id = tenant_id + self.user = user self.session = session self.pusher_client = pusher_client self.logger = logging.getLogger(__name__) diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 158972d6a..859076b56 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -37,7 +37,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.exc import IntegrityError, OperationalError -from sqlalchemy.orm import joinedload, selectinload, subqueryload +from sqlalchemy.orm import joinedload, subqueryload from sqlalchemy.sql import exists, expression from sqlmodel import Session, SQLModel, col, or_, select, text @@ -1786,6 +1786,7 @@ def get_incident_for_grouping_rule( .where(Incident.rule_id == rule.id) .where(Incident.rule_fingerprint == rule_fingerprint) .where(Incident.status != IncidentStatus.RESOLVED.value) + .where(Incident.status != IncidentStatus.DELETED.value) .order_by(Incident.creation_time.desc()) ).first() @@ -2921,25 +2922,16 @@ def is_alert_assigned_to_incident( with Session(engine) as session: assigned = session.exec( select(LastAlertToIncident) + .join(Incident, LastAlertToIncident.incident_id == Incident.id) .where(LastAlertToIncident.fingerprint == fingerprint) .where(LastAlertToIncident.incident_id == incident_id) .where(LastAlertToIncident.tenant_id == tenant_id) .where(LastAlertToIncident.deleted_at == NULL_FOR_DELETED_AT) + .where(Incident.status != IncidentStatus.DELETED.value) ).first() return assigned is not None -def get_incidents(tenant_id) -> List[Incident]: - with Session(engine) as session: - incidents = session.exec( - select(Incident) - .options(selectinload(Incident.alerts)) - .where(Incident.tenant_id == tenant_id) - .order_by(desc(Incident.creation_time)) - ).all() - return incidents - - def get_alert_audit( tenant_id: str, fingerprint: str | list[str], limit: int = 50 ) -> List[AlertAudit]: @@ -3493,27 +3485,25 @@ def delete_incident_by_id( if isinstance(incident_id, str): incident_id = __convert_to_uuid(incident_id) with Session(engine) as session: - incident = ( - session.query(Incident) + incident = session.exec( + select(Incident) .filter( Incident.tenant_id == tenant_id, Incident.id == incident_id, ) - .first() - ) - - # Delete all associations with alerts: + ).first() - ( - session.query(LastAlertToIncident) + session.execute( + update(Incident) .where( - LastAlertToIncident.tenant_id == tenant_id, - LastAlertToIncident.incident_id == incident.id, + Incident.tenant_id == tenant_id, + Incident.id == incident.id, ) - .delete() + .values({ + "status": IncidentStatus.DELETED.value + }) ) - session.delete(incident) session.commit() return True diff --git a/keep/api/models/alert.py b/keep/api/models/alert.py index e4d2dac26..71d120070 100644 --- a/keep/api/models/alert.py +++ b/keep/api/models/alert.py @@ -113,6 +113,8 @@ class IncidentStatus(Enum): ACKNOWLEDGED = "acknowledged" # Incident was merged with another incident MERGED = "merged" + # Incident was removed + DELETED = "deleted" class IncidentSeverity(SeverityBaseInterface): diff --git a/tests/test_incidents.py b/tests/test_incidents.py index da35ce3c1..21f80d92b 100644 --- a/tests/test_incidents.py +++ b/tests/test_incidents.py @@ -227,7 +227,7 @@ 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([s.order for s in IncidentSeverity]) - status_cycle = cycle([s.value for s in IncidentStatus]) + status_cycle = cycle([s.value for s in IncidentStatus if s not in [IncidentStatus.MERGED, IncidentStatus.DELETED]]) services_cycle = cycle(["keep", None]) for i in range(60): @@ -244,33 +244,31 @@ def test_get_last_incidents(db_session, create_alert): "status": status, }, ) - # Merged incidents don't have alerts - if status != IncidentStatus.MERGED.value: - create_alert( - f"alert-test-{i}", - AlertStatus(status), - datetime.utcnow(), - { - "severity": AlertSeverity.from_number(severity), - "service": service, - }, - ) - alert = db_session.query(Alert).order_by(Alert.timestamp.desc()).first() + create_alert( + f"alert-test-{i}", + AlertStatus(status), + datetime.utcnow(), + { + "severity": AlertSeverity.from_number(severity), + "service": service, + }, + ) + alert = db_session.query(Alert).order_by(Alert.timestamp.desc()).first() - create_alert( - f"alert-test-2-{i}", - AlertStatus(status), - datetime.utcnow(), - { - "severity": AlertSeverity.from_number(severity), - "service": service, - }, - ) - alert2 = db_session.query(Alert).order_by(Alert.timestamp.desc()).first() + create_alert( + f"alert-test-2-{i}", + AlertStatus(status), + datetime.utcnow(), + { + "severity": AlertSeverity.from_number(severity), + "service": service, + }, + ) + alert2 = db_session.query(Alert).order_by(Alert.timestamp.desc()).first() - add_alerts_to_incident_by_incident_id( - SINGLE_TENANT_UUID, incident.id, [alert.fingerprint, alert2.fingerprint] - ) + add_alerts_to_incident_by_incident_id( + SINGLE_TENANT_UUID, incident.id, [alert.fingerprint, alert2.fingerprint] + ) incidents_default, incidents_default_count = get_last_incidents(SINGLE_TENANT_UUID) assert len(incidents_default) == 0 @@ -333,8 +331,8 @@ def test_get_last_incidents(db_session, create_alert): SINGLE_TENANT_UUID, is_confirmed=True, filters=filters_2, limit=100 ) assert ( - len(incidents_with_filters_2) == 15 + 15 - ) # 15 confirmed, 15 acknowledged because 60 incidents with cycled status + len(incidents_with_filters_2) == 20 + 20 + ) # 20 confirmed, 20 acknowledged because 60 incidents with cycled status assert all( [i.status in ["firing", "acknowledged"] for i in incidents_with_filters_2] ) @@ -343,7 +341,7 @@ def test_get_last_incidents(db_session, create_alert): incidents_with_filters_3, _ = get_last_incidents( SINGLE_TENANT_UUID, is_confirmed=True, filters=filters_3, limit=100 ) - assert len(incidents_with_filters_3) == 45 # 60 minus 15 merged with no alerts + assert len(incidents_with_filters_3) == 60 assert all(["keep" in i.sources for i in incidents_with_filters_3]) filters_4 = {"sources": ["grafana"]} @@ -458,7 +456,7 @@ 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]) + status_cycle = cycle([s.value for s in IncidentStatus if s != IncidentStatus.DELETED.value]) sources_cycle = cycle(["keep", "keep-test", "keep-test-2"]) services_cycle = cycle(["keep", "keep-test", "keep-test-2"]) @@ -1177,12 +1175,12 @@ def test_incident_bl_delete_incident(db_session): incident_dto = incident_bl.create_incident(incident_dto_in) - incidents_count = db_session.query(Incident).count() + incidents_count = db_session.query(Incident).filter(Incident.status != IncidentStatus.DELETED.value).count() assert incidents_count == 1 incident_bl.delete_incident(incident_dto.id) - incidents_count = db_session.query(Incident).count() + incidents_count = db_session.query(Incident).filter(Incident.status != IncidentStatus.DELETED.value).count() assert incidents_count == 0 # Check pusher