Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add soft-delete for incidents through the status #2922

Merged
merged 3 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion keep-ui/app/(keep)/incidents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions keep-ui/entities/incidents/model/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion keep-ui/entities/incidents/ui/statuses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -49,6 +49,14 @@ export const STATUS_ICONS = {
className="w-4 h-4 mr-2"
/>
),
[Status.Deleted]: (
<Icon
icon={IoIosTrash}
tooltip={capitalize(Status.Deleted)}
color="gray"
className="w-4 h-4 mr-2"
/>
),
};

export function StatusIcon({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<div className="flex items-center">
Expand All @@ -57,7 +57,7 @@ export function IncidentChangeStatusSelect({
</div>
),
})),
[]
[value]
);

const handleChange = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,16 +44,19 @@ export const IncidentFilterContextProvider: FC<PropsWithChildren> = ({
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[];
};
Expand All @@ -61,7 +65,7 @@ export const IncidentFilterContextProvider: FC<PropsWithChildren> = ({
);

const [statuses, setStatuses] = useState<string[]>(
setFilterValue("statuses")
setFilterValue("statuses", incidentsMeta?.statuses.filter((status) => DefaultIncidentFilteredStatuses.includes(status)))
);
const [severities, setSeverities] = useState<string[]>(
setFilterValue("severities")
Expand All @@ -76,7 +80,7 @@ export const IncidentFilterContextProvider: FC<PropsWithChildren> = ({

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"));
Expand Down
4 changes: 3 additions & 1 deletion keep/api/bl/incidents_bl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
38 changes: 14 additions & 24 deletions keep/api/core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
talboren marked this conversation as resolved.
Show resolved Hide resolved
.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]:
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions keep/api/models/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
62 changes: 30 additions & 32 deletions tests/test_incidents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
VladimirFilonov marked this conversation as resolved.
Show resolved Hide resolved

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):
Expand All @@ -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
Expand Down Expand Up @@ -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]
)
Expand All @@ -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"]}
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -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
Expand Down
Loading