diff --git a/keep-ui/app/(keep)/incidents/[id]/incident-overview.tsx b/keep-ui/app/(keep)/incidents/[id]/incident-overview.tsx index 277a1824eb..98bcfc833d 100644 --- a/keep-ui/app/(keep)/incidents/[id]/incident-overview.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/incident-overview.tsx @@ -5,15 +5,18 @@ import { type IncidentDto, } from "@/entities/incidents/model"; import React, { useState } from "react"; -import { useIncident, useIncidentAlerts } from "@/utils/hooks/useIncidents"; +import { + useIncident, + useIncidentAlerts, + useIncidentsByIds, +} from "@/utils/hooks/useIncidents"; import { Disclosure } from "@headlessui/react"; import { IoChevronDown } from "react-icons/io5"; import remarkRehype from "remark-rehype"; import rehypeRaw from "rehype-raw"; import Markdown from "react-markdown"; -import { Badge, Callout, Icon, TextInput } from "@tremor/react"; +import { Badge, Callout } from "@tremor/react"; import { Button, DynamicImageProviderIcon, Link } from "@/components/ui"; -import Modal from "@/components/ui/Modal"; import { IncidentChangeStatusSelect } from "features/incidents/change-incident-status"; import { getIncidentName } from "@/entities/incidents/lib/utils"; import { DateTimeField, FieldHeader } from "@/shared/ui"; @@ -166,7 +169,7 @@ function Summary({ ); } -function MergedCallout({ +function ThisIncidentMergedToCallout({ merged_into_incident_id, className, }: { @@ -201,6 +204,48 @@ function MergedCallout({ ); } +function IncidentsMergedIntoThisOneCallout({ + merged_incidents, + className, +}: { + merged_incidents: string[]; + className?: string; +}) { + const incidents = useIncidentsByIds(merged_incidents); + + if (!incidents) { + return null; + } + + return ( + +

Those incidents merged into this one:

+ {incidents?.data?.map((incident: IncidentDto) => ( + ( + + )} + href={`/incidents/${incident?.id}`} + > + {getIncidentName(incident)} + + )) || null} + + } + color="purple" + className={className} + /> + ); +} + export function IncidentOverview({ incident: initialIncidentData }: Props) { const router = useRouter(); const { data: fetchedIncident, mutate } = useIncident( @@ -311,22 +356,18 @@ export function IncidentOverview({ incident: initialIncidentData }: Props) { alerts={alerts.items} incident={incident} /> - {/* @tb: not sure how we use this, but leaving it here for now - {incident.user_summary && incident.generated_summary ? ( - - ) : null} */} {incident.merged_into_incident_id && ( - )} + {incident.merged_incidents?.length > 0 && ( + + )}
diff --git a/keep-ui/entities/incidents/model/models.ts b/keep-ui/entities/incidents/model/models.ts index 6c886f1cd9..8fa675afe4 100644 --- a/keep-ui/entities/incidents/model/models.ts +++ b/keep-ui/entities/incidents/model/models.ts @@ -66,6 +66,7 @@ export interface IncidentDto { merged_into_incident_id: string; merged_by: string; merged_at: Date; + merged_incidents: string[]; fingerprint: string; enrichments: { [key: string]: any }; incident_type?: string; diff --git a/keep-ui/utils/hooks/useIncidents.ts b/keep-ui/utils/hooks/useIncidents.ts index 10142e9974..93c2d16bc6 100644 --- a/keep-ui/utils/hooks/useIncidents.ts +++ b/keep-ui/utils/hooks/useIncidents.ts @@ -135,6 +135,27 @@ export const useIncidents = ( }; }; +export const useIncidentsByIds = ( + incidentIds: string[], + options: SWRConfiguration = { + revalidateOnFocus: false, + } +) => { + const api = useApi(); + + return useSWR( + () => + api.isReady() && incidentIds.length + ? incidentIds.map((id) => `/incidents/${id}`) + : null, + async (urls) => { + const results = await Promise.all(urls.map((url) => api.get(url))); + return results; + }, + options + ); +}; + export const useIncidentAlerts = ( incidentId: string, limit: number = 20, diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 8595d22972..6a10e191c1 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -40,7 +40,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, subqueryload, foreign +from sqlalchemy.orm import joinedload, subqueryload from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.sql import exists, expression from sqlmodel import Session, SQLModel, col, or_, select, text @@ -3679,20 +3679,22 @@ def get_incident_by_id( if isinstance(incident_id, str): incident_id = __convert_to_uuid(incident_id, should_raise=True) with existed_or_new_session(session) as session: - query = session.query( - Incident, - AlertEnrichment, - ).outerjoin( - AlertEnrichment, - and_( - Incident.tenant_id == AlertEnrichment.tenant_id, - cast(col(Incident.id), String) == foreign(AlertEnrichment.alert_fingerprint), - ), - ).filter( - Incident.tenant_id == tenant_id, - Incident.id == incident_id, + query = ( + select(Incident, AlertEnrichment) + .join( + AlertEnrichment, + and_( + Incident.tenant_id == AlertEnrichment.tenant_id, + cast(col(Incident.id), String) == AlertEnrichment.alert_fingerprint, + ), + isouter=True, + ) + .where(Incident.tenant_id == tenant_id, Incident.id == incident_id) + .options(joinedload(Incident.merged_incidents)) ) - incident_with_enrichments = query.first() + + incident_with_enrichments = session.exec(query).first() + if incident_with_enrichments: incident, enrichments = incident_with_enrichments if with_alerts: diff --git a/keep/api/models/incident.py b/keep/api/models/incident.py index d2322fc5bb..5639be00a0 100644 --- a/keep/api/models/incident.py +++ b/keep/api/models/incident.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Extra, Field, PrivateAttr, root_validator from sqlmodel import col, desc +from sqlalchemy.orm.exc import DetachedInstanceError from keep.api.models.db.incident import Incident, IncidentSeverity, IncidentStatus from keep.api.models.db.rule import ResolveOn, Rule @@ -73,6 +74,7 @@ class IncidentDto(IncidentDtoIn): merged_into_incident_id: UUID | None merged_by: str | None merged_at: datetime.datetime | None + merged_incidents: list[UUID] | None = [] enrichments: dict | None = {} incident_type: str | None @@ -158,6 +160,14 @@ def from_db_incident(cls, db_incident: "Incident", rule: "Rule" = None): else db_incident.severity ) + # merged_incidents are joined only when needed + try: + merged_incidents = db_incident.merged_incidents or [] + if isinstance(merged_incidents, list): + merged_incidents = [incident.id for incident in merged_incidents] + except DetachedInstanceError: + merged_incidents = None + # some default value for resolve_on if not db_incident.resolve_on: db_incident.resolve_on = ResolveOn.ALL.value @@ -193,6 +203,7 @@ def from_db_incident(cls, db_incident: "Incident", rule: "Rule" = None): rule_id=rule.id if rule else None, rule_name=rule.name if rule else None, rule_is_deleted=rule.is_deleted if rule else None, + merged_incidents=merged_incidents, ) # This field is required for getting alerts when required