diff --git a/keep-ui/app/alerts/alert-severity.tsx b/keep-ui/app/alerts/alert-severity.tsx index a7bdd634d..cafa2fddb 100644 --- a/keep-ui/app/alerts/alert-severity.tsx +++ b/keep-ui/app/alerts/alert-severity.tsx @@ -10,9 +10,10 @@ import { interface Props { severity: Severity | undefined; + marginLeft?: boolean; } -export default function AlertSeverity({ severity }: Props) { +export default function AlertSeverity({ severity, marginLeft = true }: Props) { let icon: any; let color: any; let severityText: string; @@ -56,7 +57,7 @@ export default function AlertSeverity({ severity }: Props) { icon={icon} tooltip={severityText} size="sm" - className="ml-2.5" + className={marginLeft ? "ml-2.5" : ""} /> ); } diff --git a/keep-ui/app/alerts/alert-sidebar.tsx b/keep-ui/app/alerts/alert-sidebar.tsx index 2f9195a20..abef81bcb 100644 --- a/keep-ui/app/alerts/alert-sidebar.tsx +++ b/keep-ui/app/alerts/alert-sidebar.tsx @@ -102,7 +102,7 @@ const AlertSidebar = ({ isOpen, toggle, alert }: AlertSidebarProps) => { diff --git a/keep-ui/app/alerts/alert-table-utils.tsx b/keep-ui/app/alerts/alert-table-utils.tsx index 2dc2147d9..01a26fb09 100644 --- a/keep-ui/app/alerts/alert-table-utils.tsx +++ b/keep-ui/app/alerts/alert-table-utils.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { ColumnDef, FilterFn, - Row, RowSelectionState, VisibilityState, createColumnHelper, @@ -18,7 +17,7 @@ import AlertAssignee from "./alert-assignee"; import AlertExtraPayload from "./alert-extra-payload"; import AlertMenu from "./alert-menu"; import { isSameDay, isValid, isWithinInterval, startOfDay } from "date-fns"; -import { Severity, severityMapping } from "./models"; +import { severityMapping } from "./models"; import { MdOutlineNotificationsActive, MdOutlineNotificationsOff } from "react-icons/md"; export const DEFAULT_COLS = [ diff --git a/keep-ui/app/alerts/alert-timeline.tsx b/keep-ui/app/alerts/alert-timeline.tsx index 72d1e0f2f..db584d624 100644 --- a/keep-ui/app/alerts/alert-timeline.tsx +++ b/keep-ui/app/alerts/alert-timeline.tsx @@ -1,9 +1,10 @@ -import React, { useState } from "react"; +import React from "react"; import { Subtitle, Button } from "@tremor/react"; import { Chrono } from "react-chrono"; import Image from "next/image"; import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { AlertDto } from "./models"; +import { AuditEvent } from "utils/hooks/useAlerts"; const getInitials = (name: string) => ((name.match(/(^\S\S?|\b\S)?/g) ?? []).join("").match(/(^\S|\S$)?/g) ?? []) @@ -15,13 +16,6 @@ const formatTimestamp = (timestamp: Date | string) => { return date.toLocaleString(); }; -type AuditEvent = { - user_id: string; - action: string; - description: string; - timestamp: string; -}; - type AlertTimelineProps = { alert: AlertDto | null; auditData: AuditEvent[]; @@ -29,7 +23,12 @@ type AlertTimelineProps = { onRefresh: () => void; }; -const AlertTimeline: React.FC = ({ alert, auditData, isLoading, onRefresh }) => { +const AlertTimeline: React.FC = ({ + alert, + auditData, + isLoading, + onRefresh, +}) => { // Default audit event if no audit data is available const defaultAuditEvent = alert ? [ @@ -97,11 +96,9 @@ const AlertTimeline: React.FC = ({ alert, auditData, isLoadi
({ - title: formatTimestamp(entry.timestamp), - }) - ) || [] + auditContent.map((entry) => ({ + title: formatTimestamp(entry.timestamp), + })) || [] } hideControls disableToolbar diff --git a/keep-ui/app/incidents/[id]/incident-alerts.tsx b/keep-ui/app/incidents/[id]/incident-alerts.tsx index 8e18ae9f3..e4946ac37 100644 --- a/keep-ui/app/incidents/[id]/incident-alerts.tsx +++ b/keep-ui/app/incidents/[id]/incident-alerts.tsx @@ -27,8 +27,8 @@ import AlertName from "app/alerts/alert-name"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import IncidentAlertMenu from "./incident-alert-menu"; import IncidentPagination from "../incident-pagination"; -import React, {Dispatch, SetStateAction, useEffect, useState} from "react"; -import {IncidentDto} from "../models"; +import React, { useEffect, useState } from "react"; +import { IncidentDto } from "../models"; interface Props { incident: IncidentDto; @@ -39,7 +39,6 @@ interface Pagination { offset: number; } - const columnHelper = createColumnHelper(); export default function IncidentAlerts({ incident }: Props) { @@ -48,11 +47,15 @@ export default function IncidentAlerts({ incident }: Props) { offset: 0, }); - const { data: alerts, isLoading } = useIncidentAlerts(incident.id, alertsPagination.limit, alertsPagination.offset); + const { data: alerts, isLoading } = useIncidentAlerts( + incident.id, + alertsPagination.limit, + alertsPagination.offset + ); const [pagination, setTablePagination] = useState({ - pageIndex: alerts? Math.ceil(alerts.offset / alerts.limit) : 0, - pageSize: alerts? alerts.limit : 20, + pageIndex: alerts ? Math.ceil(alerts.offset / alerts.limit) : 0, + pageSize: alerts ? alerts.limit : 20, }); useEffect(() => { @@ -60,16 +63,16 @@ export default function IncidentAlerts({ incident }: Props) { setAlertsPagination({ limit: pagination.pageSize, offset: 0, - }) + }); } const currentOffset = pagination.pageSize * pagination.pageIndex; if (alerts && alerts.offset != currentOffset) { setAlertsPagination({ limit: pagination.pageSize, offset: currentOffset, - }) + }); } - }, [pagination]) + }, [pagination]); usePollIncidentAlerts(incident.id); const columns = [ @@ -116,7 +119,7 @@ export default function IncidentAlerts({ incident }: Props) { (context.getValue() ?? []).map((source, index) => ( {source} ( - incident.is_confirmed && + cell: (context) => + incident.is_confirmed && ( - ), + ), }), ]; @@ -164,9 +167,9 @@ export default function IncidentAlerts({ incident }: Props) { {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => { + {headerGroup.headers.map((header, index) => { return ( - + {flexRender( header.column.columnDef.header, header.getContext() @@ -179,10 +182,13 @@ export default function IncidentAlerts({ incident }: Props) { {alerts && alerts?.items?.length > 0 && ( - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - + {table.getRowModel().rows.map((row, index) => ( + + {row.getVisibleCells().map((cell, index) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} @@ -196,10 +202,10 @@ export default function IncidentAlerts({ incident }: Props) { {Array(pagination.pageSize) .fill("") - .map((index) => ( - - {columns.map((c) => ( - + .map((index, rowIndex) => ( + + {columns.map((c, cellIndex) => ( + ))} @@ -211,7 +217,7 @@ export default function IncidentAlerts({ incident }: Props) {
- +
); diff --git a/keep-ui/app/incidents/[id]/incident-info.tsx b/keep-ui/app/incidents/[id]/incident-info.tsx index 8bf6cb9c3..842f73b0d 100644 --- a/keep-ui/app/incidents/[id]/incident-info.tsx +++ b/keep-ui/app/incidents/[id]/incident-info.tsx @@ -1,14 +1,17 @@ - -import {Button, Title} from "@tremor/react"; +import { Button, Title } from "@tremor/react"; import { IncidentDto } from "../models"; import CreateOrUpdateIncident from "../create-or-update-incident"; import Modal from "@/components/ui/Modal"; -import React, {useState} from "react"; -import {MdBlock, MdDone, MdModeEdit} from "react-icons/md"; -import {useIncident} from "../../../utils/hooks/useIncidents"; -import {deleteIncident, handleConfirmPredictedIncident} from "../incident-candidate-actions"; -import {useSession} from "next-auth/react"; -import {useRouter} from "next/navigation"; +import React, { useState } from "react"; +import { MdBlock, MdDone, MdModeEdit } from "react-icons/md"; +import { useIncident } from "../../../utils/hooks/useIncidents"; +import { + deleteIncident, + handleConfirmPredictedIncident, +} from "../incident-candidate-actions"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { format } from "date-fns"; // import { RiSparkling2Line } from "react-icons/ri"; interface Props { @@ -34,12 +37,16 @@ export default function IncidentInformation({ incident }: Props) { mutate(); }; + const formatString = "dd, MMM yyyy - HH:mm.ss 'UTC'"; + return (
- {incident.is_confirmed ? "⚔️ " : "Possible "}Incident Information - {incident.is_confirmed && + + {incident.is_confirmed ? "⚔️ " : "Possible "}Incident Information + + {incident.is_confirmed && ( + > + Confirm +
- } + )} +
+
+ {incident.user_generated_name || incident.ai_generated_name}
-
{incident.user_generated_name || incident.ai_generated_name}

Summary: {incident.user_summary || incident.generated_summary}

- {!!incident.start_time &&

Started at: {new Date(incident.start_time + "Z").toLocaleString()}

} - {!!incident.last_seen_time &&

Last seen at: {new Date(incident.last_seen_time + "Z").toLocaleString()}

} - {!!incident.rule_fingerprint &&

Group by value: {incident.rule_fingerprint}

} - + {!!incident.start_time && ( +

+ Started at: {format(new Date(incident.start_time), formatString)} +

+ )} + {!!incident.last_seen_time && ( +

+ Last seen at:{" "} + {format(new Date(incident.last_seen_time), formatString)} +

+ )} + {!!incident.rule_fingerprint && ( +

Group by value: {incident.rule_fingerprint}

+ )}
void; + isSelected: boolean; +} + +const AlertEventInfo: React.FC<{ event: AuditEvent; alert: AlertDto }> = ({ + event, + alert, +}) => { + return ( +
+

+ {alert.name} ({alert.fingerprint}) +

+

{alert.description}

+
+

Date:

+

+ {format(parseISO(event.timestamp), "dd, MMM yyyy - HH:mm.ss 'UTC'")} +

+ +

Action:

+

{event.action}

+ +

Description:

+

{event.description}

+ +

Severity:

+
+ +

{alert.severity}

+
+ +

Source:

+
+ {alert.source.map((source, index) => ( + {source} + ))} +

{alert.source.join(",")}

+
+ +

Status:

+

{alert.status}

+
+
+ ); +}; + +const EventDot: React.FC = ({ + event, + alertStart, + alertEnd, + color, + onClick, + isSelected, +}) => { + const eventTime = parseISO(event.timestamp); + let position = + ((eventTime.getTime() - alertStart.getTime()) / + (alertEnd.getTime() - alertStart.getTime())) * + 100; + if (position == 0) position = 5; + if (position == 100) position = 90; + + return ( +
onClick(event)} + > +
+
+ ); +}; + +interface AlertBarProps { + alert: AlertDto; + auditEvents: AuditEvent[]; + startTime: Date; + endTime: Date; + timeScale: "minutes" | "hours" | "days"; + onEventClick: (event: AuditEvent | null) => void; + selectedEventId: string | null; + isFirstRow: boolean; + isLastRow: boolean; +} + +const AlertBar: React.FC = ({ + alert, + auditEvents, + startTime, + endTime, + timeScale, + onEventClick, + selectedEventId, + isFirstRow, + isLastRow, +}) => { + const alertEvents = auditEvents.filter( + (event) => event.fingerprint === alert.fingerprint + ); + const alertStart = new Date( + Math.min(...alertEvents.map((e) => parseISO(e.timestamp).getTime())) + ); + const alertEnd = new Date( + Math.max(...alertEvents.map((e) => parseISO(e.timestamp).getTime())) + ); + + const startPosition = + ((alertStart.getTime() - startTime.getTime()) / + (endTime.getTime() - startTime.getTime())) * + 100; + let width = + ((alertEnd.getTime() - alertStart.getTime()) / + (endTime.getTime() - startTime.getTime())) * + 100; + + // Ensure the width is at least 0.5% to make it visible + width = Math.max(width, 0.5); + + const handleEventClick = (event: AuditEvent) => { + onEventClick(selectedEventId === event.id ? null : event); + }; + + return ( +
+
+ {Array.from({ length: 24 }).map((_, index) => ( +
+ ))} +
+
+
+
+ + + {alert.name} + +
+ {alertEvents.map((event, index) => ( + + ))} +
+
+
+ ); +}; + +export default function IncidentTimeline({ + incident, +}: { + incident: IncidentDto; +}) { + const { data: alerts, isLoading: alertsLoading } = useIncidentAlerts( + incident.id + ); + const { useMultipleFingerprintsAlertAudit } = useAlerts(); + const { + data: auditEvents, + isLoading: auditEventsLoading, + mutate, + } = useMultipleFingerprintsAlertAudit( + alerts?.items.map((m) => m.fingerprint) + ); + + const [selectedEvent, setSelectedEvent] = useState(null); + + useEffect(() => { + mutate(); + }, [alerts, mutate]); + + const timelineData = useMemo(() => { + if (auditEvents && alerts) { + const allTimestamps = auditEvents.map((event) => + parseISO(event.timestamp).getTime() + ); + + const startTime = new Date(Math.min(...allTimestamps)); + const endTime = new Date(Math.max(...allTimestamps)); + + // Add padding to start and end times + const paddedStartTime = new Date(startTime.getTime() - 1000 * 60 * 10); // 10 minutes before + const paddedEndTime = new Date(endTime.getTime() + 1000 * 60 * 10); // 10 minutes after + + const totalDuration = paddedEndTime.getTime() - paddedStartTime.getTime(); + const pixelsPerMillisecond = 5000 / totalDuration; // Assuming 5000px minimum width + + let timeScale: "minutes" | "hours" | "days"; + let intervalDuration: number; + let formatString: string; + + if (totalDuration > 3 * 24 * 60 * 60 * 1000) { + timeScale = "days"; + intervalDuration = 24 * 60 * 60 * 1000; + formatString = "MMM dd"; + } else if (totalDuration > 24 * 60 * 60 * 1000) { + timeScale = "hours"; + intervalDuration = 60 * 60 * 1000; + formatString = "HH:mm"; + } else { + timeScale = "minutes"; + intervalDuration = 5 * 60 * 1000; // 5-minute intervals + formatString = "HH:mm:ss"; + } + + const intervals: Date[] = []; + let currentTime = paddedStartTime; + while (currentTime <= paddedEndTime) { + intervals.push(new Date(currentTime)); + currentTime = new Date(currentTime.getTime() + intervalDuration); + } + + return { + startTime: paddedStartTime, + endTime: paddedEndTime, + intervals, + formatString, + timeScale, + pixelsPerMillisecond, + }; + } + return {}; + }, [auditEvents, alerts]); + + if (auditEventsLoading || !auditEvents || alertsLoading) return <>No Data; + + const { + startTime, + endTime, + intervals, + formatString, + timeScale, + pixelsPerMillisecond, + } = timelineData; + + if ( + !intervals || + !startTime || + !endTime || + !timeScale || + !pixelsPerMillisecond + ) + return <>No Data; + + const totalWidth = Math.max( + 5000, + (endTime.getTime() - startTime.getTime()) * pixelsPerMillisecond + ); + + return ( +
+
+
+ {/* Time labels */} +
+ {intervals.map((time, index) => ( +
+ {format(time, formatString)} +
+ ))} + {/* Add an extra label for the first time, positioned at the start */} +
+ {format(intervals[0], formatString)} +
+
+ + {/* Alert bars */} +
+ {alerts?.items + .sort((a, b) => { + const aStart = Math.min( + ...auditEvents + .filter((e) => e.fingerprint === a.fingerprint) + .map((e) => parseISO(e.timestamp).getTime()) + ); + const bStart = Math.min( + ...auditEvents + .filter((e) => e.fingerprint === b.fingerprint) + .map((e) => parseISO(e.timestamp).getTime()) + ); + return aStart - bStart; + }) + .map((alert, index, array) => ( + + ))} +
+
+
+
+ {/* Event details box */} + {selectedEvent && ( +
+ a.fingerprint === selectedEvent.fingerprint + )! + } + /> +
+ )} +
+ ); +} diff --git a/keep-ui/app/incidents/[id]/incident.tsx b/keep-ui/app/incidents/[id]/incident.tsx index b552b51dc..416bead04 100644 --- a/keep-ui/app/incidents/[id]/incident.tsx +++ b/keep-ui/app/incidents/[id]/incident.tsx @@ -16,7 +16,9 @@ import { import IncidentAlerts from "./incident-alerts"; import { ArrowUturnLeftIcon } from "@heroicons/react/24/outline"; import { useRouter } from "next/navigation"; -import {useState} from "react"; +import IncidentTimeline from "./incident-timeline"; +import { CiBellOn, CiViewTimeline } from "react-icons/ci"; +import { IoIosGitNetwork } from "react-icons/io"; interface Props { incidentId: string; @@ -47,24 +49,29 @@ export default function IncidentView({ incidentId }: Props) { onClick={() => router.back()} />
- -
-
-
+ +
+
+
-
+
- Alerts - Timeline - Topology + Alerts + Timeline + Topology - Coming Soon... + + + Coming Soon... diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json index 4459999ce..ebe5dba09 100644 --- a/keep-ui/package-lock.json +++ b/keep-ui/package-lock.json @@ -372,6 +372,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.12", + "@types/d3-time-format": "^4.0.3", "@types/js-cookie": "^3.0.3", "@types/js-yaml": "^4.0.5", "@types/json-logic-js": "^2.0.7", @@ -4022,6 +4023,12 @@ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", diff --git a/keep-ui/package.json b/keep-ui/package.json index 206ea0c2e..e1c722b95 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -373,6 +373,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.12", + "@types/d3-time-format": "^4.0.3", "@types/js-cookie": "^3.0.3", "@types/js-yaml": "^4.0.5", "@types/json-logic-js": "^2.0.7", diff --git a/keep-ui/tailwind.config.js b/keep-ui/tailwind.config.js index aae009bf3..1646a014b 100644 --- a/keep-ui/tailwind.config.js +++ b/keep-ui/tailwind.config.js @@ -8,6 +8,10 @@ module.exports = { darkMode: "class", theme: { extend: { + gridTemplateColumns: { + 20: "repeat(20, minmax(0, 1fr))", + 24: "repeat(24, minmax(0, 1fr))", + }, minHeight: { "screen-minus-200": "calc(100vh - 200px)", }, @@ -129,5 +133,8 @@ module.exports = { /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, }, ], - plugins: [require("@headlessui/tailwindcss"), require('@tailwindcss/typography')], + plugins: [ + require("@headlessui/tailwindcss"), + require("@tailwindcss/typography"), + ], }; diff --git a/keep-ui/utils/hooks/useAlerts.ts b/keep-ui/utils/hooks/useAlerts.ts index 53b343b93..23d73139c 100644 --- a/keep-ui/utils/hooks/useAlerts.ts +++ b/keep-ui/utils/hooks/useAlerts.ts @@ -6,6 +6,15 @@ import { getApiURL } from "utils/apiUrl"; import { fetcher } from "utils/fetcher"; import { toDateObjectWithFallback } from "utils/helpers"; +export type AuditEvent = { + id: string; + user_id: string; + action: string; + description: string; + timestamp: string; + fingerprint: string; +}; + export const useAlerts = () => { const apiUrl = getApiURL(); const { data: session } = useSession(); @@ -33,7 +42,8 @@ export const useAlerts = () => { options: SWRConfiguration = { revalidateOnFocus: false } ) => { return useSWR( - () => (session && presetName ? `${apiUrl}/preset/${presetName}/alerts` : null), + () => + session && presetName ? `${apiUrl}/preset/${presetName}/alerts` : null, (url) => fetcher(url, session?.accessToken), options ); @@ -78,12 +88,32 @@ export const useAlerts = () => { }; }; + const useMultipleFingerprintsAlertAudit = ( + fingerprints: string[] | undefined, + options: SWRConfiguration = { revalidateOnFocus: true } + ) => { + return useSWR( + () => (session && fingerprints ? `${apiUrl}/alerts/audit` : null), + (url) => + fetcher(url, session?.accessToken, { + method: "POST", + body: JSON.stringify(fingerprints), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.accessToken}`, + }, + }), + options + ); + }; + const useAlertAudit = ( fingerprint: string, options: SWRConfiguration = { revalidateOnFocus: false } ) => { - return useSWR( - () => (session && fingerprint ? `${apiUrl}/alerts/${fingerprint}/audit` : null), + return useSWR( + () => + session && fingerprint ? `${apiUrl}/alerts/${fingerprint}/audit` : null, (url) => fetcher(url, session?.accessToken), options ); @@ -93,6 +123,7 @@ export const useAlerts = () => { useAlertHistory, useAllAlerts, usePresetAlerts, - useAlertAudit + useAlertAudit, + useMultipleFingerprintsAlertAudit, }; }; diff --git a/keep-ui/utils/hooks/useTopology.ts b/keep-ui/utils/hooks/useTopology.ts index ba959cd54..a3180815c 100644 --- a/keep-ui/utils/hooks/useTopology.ts +++ b/keep-ui/utils/hooks/useTopology.ts @@ -4,7 +4,7 @@ import useSWR from "swr"; import { getApiURL } from "utils/apiUrl"; import { fetcher } from "utils/fetcher"; import { useWebsocket } from "./usePusher"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "react-toastify"; const isNullOrUndefined = (value: any) => value === null || value === undefined; @@ -20,7 +20,7 @@ export const useTopology = ( environment?: string ) => { const { data: session } = useSession(); - useTopologyPolling(); + const { data: pollTopology } = useTopologyPolling(); const apiUrl = getApiURL(); const url = !session @@ -36,6 +36,12 @@ export const useTopology = ( (url: string) => fetcher(url, session!.accessToken) ); + useEffect(() => { + if (pollTopology) { + mutate(); + } + }, [pollTopology, mutate]); + return { topologyData: data, error, @@ -46,12 +52,14 @@ export const useTopology = ( export const useTopologyPolling = () => { const { bind, unbind } = useWebsocket(); + const [pollTopology, setPollTopology] = useState(0); const handleIncoming = useCallback((data: TopologyUpdate) => { toast.success( `Topology pulled from ${data.providerId} (${data.providerType})`, { position: "top-right" } ); + setPollTopology(Math.floor(Math.random() * 10000)); }, []); useEffect(() => { @@ -60,4 +68,6 @@ export const useTopologyPolling = () => { unbind("topology-update", handleIncoming); }; }, [bind, unbind, handleIncoming]); + + return { data: pollTopology }; }; diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 2adf87a74..fbc21d200 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -2094,16 +2094,38 @@ def get_incidents(tenant_id) -> List[Incident]: def get_alert_audit( - tenant_id: str, fingerprint: str, limit: int = 50 + tenant_id: str, fingerprint: str | list[str], limit: int = 50 ) -> List[AlertAudit]: + """ + Get the alert audit for the given fingerprint(s). + + Args: + tenant_id (str): the tenant_id to filter the alert audit by + fingerprint (str | list[str]): the fingerprint(s) to filter the alert audit by + limit (int, optional): the maximum number of alert audits to return. Defaults to 50. + + Returns: + List[AlertAudit]: the alert audit for the given fingerprint(s) + """ with Session(engine) as session: - audit = session.exec( - select(AlertAudit) - .where(AlertAudit.tenant_id == tenant_id) - .where(AlertAudit.fingerprint == fingerprint) - .order_by(desc(AlertAudit.timestamp)) - .limit(limit) - ).all() + if isinstance(fingerprint, list): + query = ( + select(AlertAudit) + .where(AlertAudit.tenant_id == tenant_id) + .where(AlertAudit.fingerprint.in_(fingerprint)) + .order_by(desc(AlertAudit.timestamp), AlertAudit.fingerprint) + ) + if limit: + query = query.limit(limit) + audit = session.exec(query).all() + else: + audit = session.exec( + select(AlertAudit) + .where(AlertAudit.tenant_id == tenant_id) + .where(AlertAudit.fingerprint == fingerprint) + .order_by(desc(AlertAudit.timestamp)) + .limit(limit) + ).all() return audit diff --git a/keep/api/models/alert_audit.py b/keep/api/models/alert_audit.py new file mode 100644 index 000000000..f03a5af67 --- /dev/null +++ b/keep/api/models/alert_audit.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from pydantic import BaseModel + +from keep.api.models.db.alert import AlertActionType, AlertAudit + + +class AlertAuditDto(BaseModel): + id: str + timestamp: datetime + fingerprint: str + action: AlertActionType + user_id: str + description: str + + @classmethod + def from_orm(cls, alert_audit: AlertAudit) -> "AlertAuditDto": + return cls( + id=str(alert_audit.id), + timestamp=alert_audit.timestamp, + fingerprint=alert_audit.fingerprint, + action=alert_audit.action, + user_id=alert_audit.user_id, + description=alert_audit.description, + ) + + @classmethod + def from_orm_list(cls, alert_audits: list[AlertAudit]) -> list["AlertAuditDto"]: + grouped_events = [] + previous_event = None + count = 1 + + for event in alert_audits: + # Check if the current event is similar to the previous event + if previous_event and ( + event.user_id == previous_event.user_id + and event.action == previous_event.action + and event.description == previous_event.description + ): + # Increment the count if the events are similar + count += 1 + else: + # If the events are not similar, append the previous event to the grouped events + if previous_event: + if count > 1: + previous_event.description += f" x{count}" + grouped_events.append(AlertAuditDto.from_orm(previous_event)) + # Update the previous event to the current event and reset the count + previous_event = event + count = 1 + + # Add the last event to the grouped events + if previous_event: + if count > 1: + previous_event.description += f" x{count}" + grouped_events.append(AlertAuditDto.from_orm(previous_event)) + return grouped_events diff --git a/keep/api/routes/alerts.py b/keep/api/routes/alerts.py index 5935d63a4..210d5adea 100644 --- a/keep/api/routes/alerts.py +++ b/keep/api/routes/alerts.py @@ -34,6 +34,7 @@ EnrichAlertRequestBody, UnEnrichAlertRequestBody, ) +from keep.api.models.alert_audit import AlertAuditDto from keep.api.models.db.alert import AlertActionType from keep.api.models.search_alert import SearchAlertsRequest from keep.api.tasks.process_event_task import process_event @@ -686,16 +687,49 @@ async def search_alerts( raise HTTPException(status_code=500, detail="Failed to search alerts") +@router.post( + "/audit", + description="Get alert timeline audit trail for multiple fingerprints", +) +def get_multiple_fingerprint_alert_audit( + fingerprints: list[str], + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:alert"]) + ), +) -> list[AlertAuditDto]: + tenant_id = authenticated_entity.tenant_id + logger.info( + "Fetching alert audit", + extra={"fingerprints": fingerprints, "tenant_id": tenant_id}, + ) + alert_audit = get_alert_audit_db(tenant_id, fingerprints) + + if not alert_audit: + raise HTTPException(status_code=404, detail="Alert not found") + grouped_events = [] + + # Group the results by fingerprint for "deduplication" (2x, 3x, etc.) thingy.. + grouped_audit = {} + for audit in alert_audit: + if audit.fingerprint not in grouped_audit: + grouped_audit[audit.fingerprint] = [] + grouped_audit[audit.fingerprint].append(audit) + + for values in grouped_audit.values(): + grouped_events.extend(AlertAuditDto.from_orm_list(values)) + return grouped_events + + @router.get( "/{fingerprint}/audit", - description="Get alert enrichment", + description="Get alert timeline audit trail", ) def get_alert_audit( fingerprint: str, authenticated_entity: AuthenticatedEntity = Depends( IdentityManagerFactory.get_auth_verifier(["read:alert"]) ), -): +) -> list[AlertAuditDto]: tenant_id = authenticated_entity.tenant_id logger.info( "Fetching alert audit", @@ -708,29 +742,5 @@ def get_alert_audit( if not alert_audit: raise HTTPException(status_code=404, detail="Alert not found") - grouped_events = [] - previous_event = None - count = 1 - - for event in alert_audit: - if previous_event and ( - event.user_id == previous_event.user_id - and event.action == previous_event.action - and event.description == previous_event.description - ): - count += 1 - else: - if previous_event: - if count > 1: - previous_event.description += f" x{count}" - grouped_events.append(previous_event.dict()) - previous_event = event - count = 1 - - # Add the last event - if previous_event: - if count > 1: - previous_event.description += f" x{count}" - grouped_events.append(previous_event.dict()) - + grouped_events = AlertAuditDto.from_orm_list(alert_audit) return grouped_events diff --git a/keep/api/utils/enrichment_helpers.py b/keep/api/utils/enrichment_helpers.py index 7085626c0..bd2baa38c 100644 --- a/keep/api/utils/enrichment_helpers.py +++ b/keep/api/utils/enrichment_helpers.py @@ -108,9 +108,7 @@ def convert_db_alerts_to_dto_alerts(alerts: list[Alert]) -> list[AlertDto]: ) continue - # include the db event id if it's not present - if alert_dto.event_id is None: - alert_dto.event_id = str(alert.id) + alert_dto.event_id = str(alert.id) # enrich provider id when it's possible if alert_dto.providerId is None: