From 4bedf49ce492d29a4442fd936b6b82a5274013a4 Mon Sep 17 00:00:00 2001 From: Shahar Glazner Date: Tue, 16 Jul 2024 11:57:01 +0300 Subject: [PATCH] feat: alert sidepanel, audit trail, servicemap (#1378) --- .gitignore | 2 +- examples/workflows/autosupress.yml | 3 - keep-ui/app/alerts/alert-graph-viz.tsx | 173 +++++++ keep-ui/app/alerts/alert-menu.tsx | 421 +++++++++--------- keep-ui/app/alerts/alert-sidebar.tsx | 108 +++++ keep-ui/app/alerts/alert-table.tsx | 15 + keep-ui/app/alerts/alert-timeline.tsx | 140 ++++++ keep-ui/app/alerts/alerts-table-body.tsx | 18 +- keep-ui/package-lock.json | 208 +++++++++ keep-ui/package.json | 2 + keep-ui/tailwind.config.js | 8 +- keep-ui/utils/hooks/useAlerts.ts | 12 + keep-ui/utils/hooks/usePusher.ts | 23 + keep/api/api.py | 2 +- keep/api/bl/enrichments.py | 34 +- keep/api/config.py | 2 +- keep/api/core/db.py | 89 +++- keep/api/core/elastic.py | 14 +- keep/api/logging.py | 2 +- keep/api/models/db/alert.py | 52 ++- keep/api/models/db/migrations/env.py | 11 +- .../versions/2024-07-15-15-10_c37ec8f6db3e.py | 62 +++ keep/api/routes/alerts.py | 76 ++++ keep/api/tasks/process_event_task.py | 37 +- keep/providers/base/base_provider.py | 14 +- package-lock.json | 290 ++++++++++++ package.json | 5 + scripts/migrate_to_elastic.py | 9 + tests/conftest.py | 4 +- .../test_pushing_prometheus_alerts.py | 92 ++-- tests/test_search_alerts.py | 4 + 31 files changed, 1660 insertions(+), 272 deletions(-) create mode 100644 keep-ui/app/alerts/alert-graph-viz.tsx create mode 100644 keep-ui/app/alerts/alert-sidebar.tsx create mode 100644 keep-ui/app/alerts/alert-timeline.tsx create mode 100644 keep/api/models/db/migrations/versions/2024-07-15-15-10_c37ec8f6db3e.py create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index cf320c6d1..a4c1a8531 100644 --- a/.gitignore +++ b/.gitignore @@ -199,4 +199,4 @@ docs/node_modules/ scripts/automatic_extraction_rules.py playwright_dump_*.html -playwright_dump_*.png \ No newline at end of file +playwright_dump_*.png diff --git a/examples/workflows/autosupress.yml b/examples/workflows/autosupress.yml index f76be29c7..3e6e85cf4 100644 --- a/examples/workflows/autosupress.yml +++ b/examples/workflows/autosupress.yml @@ -4,9 +4,6 @@ workflow: description: demonstrates how to automatically suppress alerts triggers: - type: alert - filters: - - key: name - value: r"(somename)" actions: - name: dismiss-alert provider: diff --git a/keep-ui/app/alerts/alert-graph-viz.tsx b/keep-ui/app/alerts/alert-graph-viz.tsx new file mode 100644 index 000000000..df7b96c0b --- /dev/null +++ b/keep-ui/app/alerts/alert-graph-viz.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useCallback } from 'react'; +import { + ReactFlow, + Node, + Edge, + useNodesState, + useEdgesState, + addEdge, + NodeProps, + EdgeProps, + Connection, + MiniMap, + Controls, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +const connectionLineStyle = { stroke: '#ccc', strokeWidth: 3 }; +const snapGrid: [number, number] = [20, 20]; +const defaultViewport = { x: 0, y: 0, zoom: 0.5 }; + +const getHealthColor = (): string => { + return 'gray'; +}; + +interface ServiceNode extends Node { + data: { + label: string; + }; +} + +interface ServiceEdge extends Edge { + animated?: boolean; + } + +const generateMockData = (): { nodes: ServiceNode[]; edges: ServiceEdge[] } => { + const nodes: ServiceNode[] = []; + const edges: Edge[] = []; + const services = [ + 'Web App', 'DB', 'MongoDB', 'Snowflake', 'Cache', 'Auth Service', 'Payment Gateway', + 'Notification Service', 'Analytics Engine', 'Search Service', 'Third Party API 1', 'Third Party API 2' + ]; + + const layout = [1, 2, 3, 2, 4, 2]; + const rowHeight = 200; + const columnWidth = 250; + + let currentIndex = 0; + let yOffset = 0; + + layout.forEach((rowCount, rowIndex) => { + const xOffset = (6 - rowCount) * columnWidth / 2; + for (let i = 0; i < rowCount; i++) { + if (currentIndex < services.length) { + nodes.push({ + id: `${currentIndex + 1}`, + type: 'default', + data: { label: services[currentIndex] }, + position: { x: xOffset + i * columnWidth, y: yOffset }, + style: { + background: getHealthColor(), + color: '#fff', + borderRadius: '50%', + padding: 10, + width: 120, + height: 120, + textAlign: 'center', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '14px' + }, + }); + currentIndex++; + } + } + yOffset += rowHeight; + }); + + for (let i = 0; i < services.length - 1; i++) { + edges.push({ + id: `e${i + 1}-${i + 2}`, + source: `${i + 1}`, + target: `${i + 2}`, + animated: true, + style: { stroke: '#ccc', strokeWidth: 3 }, + }); + } + + return { nodes, edges }; +}; + +interface GraphVisualizationProps { + demoMode: boolean; +} + +const GraphVisualization: React.FC = ({ demoMode }) => { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + useEffect(() => { + const { nodes: mockNodes, edges: mockEdges } = generateMockData(); + setNodes(mockNodes); + setEdges(mockEdges); + }, []); + + const onConnect = useCallback( + (params: Connection) => + setEdges((eds) => + addEdge({ + ...params, + animated: true, + }, eds).map(edge => ({ + ...edge, + style: { stroke: '#ccc', strokeWidth: 3 } + })) + ), + [] + ); + + const onNodeClick = (event: React.MouseEvent, node: Node) => { + window.open('https://github.com/keephq/keep/discussions/1377', '_blank'); + }; + + return ( +
+ + n.style?.background as string} + nodeColor={(n: Node) => n.style?.background as string} + /> + + + {demoMode && ( +
+

Service Map

+

Connect your service map provider to use this feature.

+
+ )} +
+ ); +}; + +export default GraphVisualization; diff --git a/keep-ui/app/alerts/alert-menu.tsx b/keep-ui/app/alerts/alert-menu.tsx index e616412ca..22c41fbe9 100644 --- a/keep-ui/app/alerts/alert-menu.tsx +++ b/keep-ui/app/alerts/alert-menu.tsx @@ -1,5 +1,5 @@ import { Menu, Portal, Transition } from "@headlessui/react"; -import { Fragment, useState } from "react"; +import { Fragment, useEffect } from "react"; import { Icon } from "@tremor/react"; import { ChevronDoubleRightIcon, @@ -30,6 +30,7 @@ interface Props { setDismissModalAlert?: (alert: AlertDto[]) => void; setChangeStatusAlert?: (alert: AlertDto) => void; presetName: string; + isInSidebar?: boolean; } export default function AlertMenu({ @@ -38,8 +39,9 @@ export default function AlertMenu({ setIsMenuOpen, setRunWorkflowModalAlert, setDismissModalAlert, - setChangeStatusAlert, // Added prop + setChangeStatusAlert, presetName, + isInSidebar, }: Props) { const router = useRouter(); @@ -150,222 +152,213 @@ export default function AlertMenu({ setIsMenuOpen(""); }; - return ( + useEffect(() => { + const rowElement = document.getElementById(`alert-row-${fingerprint}`); + if (rowElement) { + if (isMenuOpen) { + rowElement.classList.add("menu-open"); + } else { + rowElement.classList.remove("menu-open"); + } + } + }, [isMenuOpen, fingerprint]); + + const menuItems = ( <> - - - - - {isMenuOpen && ( - - {/* when menu is opened, prevent scrolling with fixed div */} - - {provider?.methods && provider?.methods?.length > 0 && ( -
- {provider.methods.map((method) => { - const methodEnabled = isMethodEnabled(method); - return ( - - {({ active }) => ( - - )} - - ); - })} -
+
+ + + ); + + return ( + <> + {!isInSidebar ? ( + + + + + {isMenuOpen && ( + + + ) : ( + +
+ {menuItems} +
+
+ )} ); } diff --git a/keep-ui/app/alerts/alert-sidebar.tsx b/keep-ui/app/alerts/alert-sidebar.tsx new file mode 100644 index 000000000..cc822a438 --- /dev/null +++ b/keep-ui/app/alerts/alert-sidebar.tsx @@ -0,0 +1,108 @@ +import { Fragment } from "react"; +import { Dialog, Transition } from "@headlessui/react"; +import { AlertDto } from "./models"; +import { Button, Title, Card, Badge } from "@tremor/react"; +import { IoMdClose } from "react-icons/io"; +import AlertMenu from "./alert-menu"; +import GraphVisualization from "./alert-graph-viz"; +import AlertTimeline from "./alert-timeline"; +import { useAlerts } from "utils/hooks/useAlerts"; + +type AlertSidebarProps = { + isOpen: boolean; + toggle: VoidFunction; + alert: AlertDto | null; +}; + +const AlertSidebar = ({ isOpen, toggle, alert }: AlertSidebarProps) => { + const { useAlertAudit } = useAlerts(); + const { data: auditData, isLoading, mutate } = useAlertAudit(alert?.fingerprint || ""); + + const handleRefresh = async () => { + console.log("Refresh button clicked"); + await mutate(); + }; + + return ( + + + + + + ); +}; + +export default AlertSidebar; diff --git a/keep-ui/app/alerts/alert-table.tsx b/keep-ui/app/alerts/alert-table.tsx index c03396b86..887389166 100644 --- a/keep-ui/app/alerts/alert-table.tsx +++ b/keep-ui/app/alerts/alert-table.tsx @@ -31,6 +31,7 @@ import { evalWithContext } from "./alerts-rules-builder"; import { TitleAndFilters } from "./TitleAndFilters"; import { severityMapping } from "./models"; import AlertTabs from "./alert-tabs"; +import AlertSidebar from "./alert-sidebar"; interface PresetTab { name: string; @@ -76,6 +77,7 @@ export function AlertTable({ ) ); + const columnsIds = getColumnsIds(columns); const [columnOrder] = useLocalStorage( @@ -112,6 +114,8 @@ export function AlertTable({ ]); const [selectedTab, setSelectedTab] = useState(0); + const [selectedAlert, setSelectedAlert] = useState(null); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); const filteredAlerts = alerts.filter(tabs[selectedTab].filter); @@ -158,6 +162,11 @@ export function AlertTable({ // if showSkeleton and not loading, show empty state let showEmptyState = !isAsyncLoading && showSkeleton; + const handleRowClick = (alert: AlertDto) => { + setSelectedAlert(alert); + setIsSidebarOpen(true); + }; + return (
+ setIsSidebarOpen(false)} + alert={selectedAlert} + />
); } diff --git a/keep-ui/app/alerts/alert-timeline.tsx b/keep-ui/app/alerts/alert-timeline.tsx new file mode 100644 index 000000000..6b76faa64 --- /dev/null +++ b/keep-ui/app/alerts/alert-timeline.tsx @@ -0,0 +1,140 @@ +import React, { useState } 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"; + +const getInitials = (name: string) => + ((name.match(/(^\S\S?|\b\S)?/g) ?? []).join("").match(/(^\S|\S$)?/g) ?? []) + .join("") + .toUpperCase(); + +const formatTimestamp = (timestamp: Date | string) => { + const date = new Date(timestamp); + return date.toLocaleString(); +}; + +type AuditEvent = { + user_id: string; + action: string; + description: string; + timestamp: string; +}; + +type AlertTimelineProps = { + alert: AlertDto | null; + auditData: AuditEvent[]; + isLoading: boolean; + onRefresh: () => void; +}; + +const AlertTimeline: React.FC = ({ alert, auditData, isLoading, onRefresh }) => { + // Default audit event if no audit data is available + const defaultAuditEvent = alert + ? [ + { + user_id: "system", + action: "Alert is triggered", + description: "alert received from provider with status firing", + timestamp: alert.lastReceived, + }, + ] + : []; + + const auditContent = auditData?.length ? auditData : defaultAuditEvent; + const content = auditContent.map((entry, index) => ( +
+ {entry.user_id.toLowerCase() === "system" ? ( + Keep Logo + ) : ( + + + {getInitials(entry.user_id)} + + + )} +
+ + {entry.action.toLowerCase()} + + + {entry.description.toLowerCase()} + +
+
+ )); + + return ( +
+
+ Timeline +
+ {isLoading ? ( +
+

Loading...

+
+ ) : ( +
+ ({ + title: formatTimestamp(entry.timestamp), + }) + ) || [] + } + hideControls + disableToolbar + borderLessCards + slideShow={false} + mode="VERTICAL" + theme={{ + primary: "orange", + secondary: "rgb(255 247 237)", + titleColor: "orange", + titleColorActive: "orange", + }} + fontSizes={{ + title: ".75rem", + }} + cardWidth={400} + cardHeight="auto" + classNames={{ + card: "hidden", + cardMedia: "hidden", + cardSubTitle: "hidden", + cardText: "hidden", + cardTitle: "hidden", + title: "mb-3", + contentDetails: "w-full !m-0", + }} + > + {content} + +
+ )} +
+ ); +}; + +export default AlertTimeline; diff --git a/keep-ui/app/alerts/alerts-table-body.tsx b/keep-ui/app/alerts/alerts-table-body.tsx index 414f06147..a0f8cfbd1 100644 --- a/keep-ui/app/alerts/alerts-table-body.tsx +++ b/keep-ui/app/alerts/alerts-table-body.tsx @@ -11,6 +11,7 @@ interface Props { showSkeleton: boolean; showEmptyState: boolean; theme: { [key: string]: string }; + onRowClick: (alert: AlertDto) => void; } export function AlertsTableBody({ @@ -18,6 +19,7 @@ export function AlertsTableBody({ showSkeleton, showEmptyState, theme, + onRowClick, }: Props) { if (showEmptyState) { return ( @@ -49,7 +51,19 @@ export function AlertsTableBody({ + const handleRowClick = (e: React.MouseEvent, alert: AlertDto) => { + // Prevent row click when clicking on specified elements + if ((e.target as HTMLElement).closest("button, .menu, input, a, span, .prevent-row-click")) { + return; + } + const rowElement = (e.currentTarget as HTMLElement); + if (rowElement.classList.contains("menu-open")) { + return; + } + + onRowClick(alert); + }; return ( @@ -59,7 +73,9 @@ export function AlertsTableBody({ const rowBgColor = theme[severity] || "bg-white"; // Fallback to 'bg-white' if no theme color return ( - + handleRowClick(e, row.original)}> {row.getVisibleCells().map((cell) => ( =17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.35.tgz", + "integrity": "sha512-QaUkahvmMs2gY2ykxUfjs5CbkXzU5fQNtmoQQ6HmHoAr8n2D7UyLO/UEXlke2jxuCDuiwpXhrzn4DmffVJd2qA==", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -5202,6 +5262,11 @@ "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -5607,6 +5672,11 @@ "node": ">=4" } }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==" + }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -5683,6 +5753,18 @@ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -5800,6 +5882,14 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -5841,6 +5931,39 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5909,6 +6032,11 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/dayjs": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", + "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -7383,6 +7511,11 @@ "dtype": "^2.0.0" } }, + "node_modules/focus-visible": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/focus-visible/-/focus-visible-5.2.0.tgz", + "integrity": "sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==" + }, "node_modules/follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", @@ -11570,6 +11703,23 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-chrono": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/react-chrono/-/react-chrono-2.6.1.tgz", + "integrity": "sha512-9KcREgRaUd39rjNjFqP9OiDUWWpu8pBoPcfZGPQoXlpmBjrN10BMmb2StHNcyWrpIFvpG4Ok4Dh1pRv1cuTuWg==", + "dependencies": { + "classnames": "^2.5.1", + "dayjs": "^1.11.10", + "focus-visible": "^5.2.0", + "styled-components": "^6.1.8", + "use-debounce": "^10.0.0", + "xss": "^1.0.15" + }, + "peerDependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0" + } + }, "node_modules/react-code-blocks": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/react-code-blocks/-/react-code-blocks-0.1.6.tgz", @@ -14214,6 +14364,17 @@ "punycode": "^2.1.0" } }, + "node_modules/use-debounce": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.1.tgz", + "integrity": "sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", @@ -14516,6 +14677,26 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -14559,6 +14740,33 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zustand": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.4.tgz", + "integrity": "sha512-/BPMyLKJPtFEvVL0E9E9BTUM63MNyhPGlvxk1XjrfWTUlV+BR8jufjsovHzrtR6YNcBEcL7cMHovL1n9xHawEg==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/keep-ui/package.json b/keep-ui/package.json index 261ea4c2a..503d83d51 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -29,6 +29,7 @@ "@tanstack/react-table": "^8.11.0", "@tremor/react": "^3.15.1", "@types/react-select": "^5.0.1", + "@xyflow/react": "^12.0.1", "add": "^2.0.6", "ajv": "^6.12.6", "ansi-regex": "^5.0.1", @@ -283,6 +284,7 @@ "queue-microtask": "^1.2.3", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", + "react-chrono": "^2.6.1", "react-code-blocks": "^0.1.3", "react-datepicker": "^6.1.0", "react-dom": "^18.2.0", diff --git a/keep-ui/tailwind.config.js b/keep-ui/tailwind.config.js index 69cd3a2b6..b11c0c383 100644 --- a/keep-ui/tailwind.config.js +++ b/keep-ui/tailwind.config.js @@ -15,10 +15,10 @@ module.exports = { // light mode tremor: { brand: { - faint: "rgb(255 247 237)", // blue-50 - muted: "rgb(255 237 213)", // blue-200 - subtle: "rgb(251 146 60)", // blue-400 - DEFAULT: "rgb(249 115 22)", // blue-500 + faint: "rgb(255 247 237)", // orange-50 + muted: "rgb(255 237 213)", // orange-200 + subtle: "rgb(251 146 60)", // orange-400 + DEFAULT: "rgb(249 115 22)", // orange-500 emphasis: "#1d4ed8", // blue-700 inverted: "#ffffff", // white }, diff --git a/keep-ui/utils/hooks/useAlerts.ts b/keep-ui/utils/hooks/useAlerts.ts index a7bfffd48..0f5944a00 100644 --- a/keep-ui/utils/hooks/useAlerts.ts +++ b/keep-ui/utils/hooks/useAlerts.ts @@ -78,9 +78,21 @@ export const useAlerts = () => { }; }; + const useAlertAudit = ( + fingerprint: string, + options: SWRConfiguration = { revalidateOnFocus: false } + ) => { + return useSWR( + () => (session ? `${apiUrl}/alerts/${fingerprint}/audit` : null), + (url) => fetcher(url, session?.accessToken), + options + ); + }; + return { useAlertHistory, useAllAlerts, usePresetAlerts, + useAlertAudit }; }; diff --git a/keep-ui/utils/hooks/usePusher.ts b/keep-ui/utils/hooks/usePusher.ts index 456a3edbc..ba5c99ef5 100644 --- a/keep-ui/utils/hooks/usePusher.ts +++ b/keep-ui/utils/hooks/usePusher.ts @@ -54,8 +54,31 @@ export const usePusher = () => { const channelName = `private-${session.tenantId}`; const pusherChannel = pusher.subscribe(channelName); + let lastPollTime = 0; + + pusherChannel.bind("poll-alerts", (incoming: any) => { + const currentTime = Date.now(); + const timeSinceLastPoll = currentTime - lastPollTime; + next(null, (data) => { + if (timeSinceLastPoll < 3000) { + // If less than 3 seconds since last poll, return pollAlerts: 0 + if(data){ + return { + ...(data), + pollAlerts: 0, + }; + return { + pollAlerts: 0, + isAsyncLoading: false, + pusherChannel, + } + } + + } + lastPollTime = currentTime; + if (data) { return { ...data, diff --git a/keep/api/api.py b/keep/api/api.py index dcce9efe9..2aa37727d 100644 --- a/keep/api/api.py +++ b/keep/api/api.py @@ -45,7 +45,7 @@ from keep.workflowmanager.workflowmanager import WorkflowManager load_dotenv(find_dotenv()) -keep.api.logging.setup() +keep.api.logging.setup_logging() logger = logging.getLogger(__name__) HOST = os.environ.get("KEEP_HOST", "0.0.0.0") diff --git a/keep/api/bl/enrichments.py b/keep/api/bl/enrichments.py index e9e44043a..bf03d9cbb 100644 --- a/keep/api/bl/enrichments.py +++ b/keep/api/bl/enrichments.py @@ -11,6 +11,7 @@ from keep.api.core.db import get_enrichment, get_mapping_rule_by_id from keep.api.core.elastic import ElasticClient from keep.api.models.alert import AlertDto +from keep.api.models.db.alert import AlertActionType from keep.api.models.db.extraction import ExtractionRule from keep.api.models.db.mapping import MappingRule @@ -280,7 +281,14 @@ def _check_alert_matches_rule(self, alert: AlertDto, rule: MappingRule) -> bool: # SHAHAR: since when running this enrich_alert, the alert is not in elastic yet (its indexed after), # enrich alert will fail to update the alert in elastic. # hence should_exist = False - self.enrich_alert(alert.fingerprint, enrichments, should_exist=False) + self.enrich_alert( + alert.fingerprint, + enrichments, + action_type=AlertActionType.MAPPING_RULE_ENRICH, + action_callee="system", + action_description="Alert enriched with mapping rule", + should_exist=False, + ) self.logger.info( "Alert enriched", @@ -340,11 +348,16 @@ def enrich_alert( self, fingerprint: str, enrichments: dict, + action_type: AlertActionType, + action_callee: str, + action_description: str, should_exist=True, dispose_on_new_alert=False, ): """ should_exist = False only in mapping where the alert is not yet in elastic + action_type = AlertActionType - the action type of the enrichment + action_callee = the action callee of the enrichment Enrich the alert with extraction and mapping rules """ @@ -368,7 +381,16 @@ def enrich_alert( } enrichments.update(disposable_enrichments) - enrich_alert_db(self.tenant_id, fingerprint, enrichments, self.db_session) + enrich_alert_db( + self.tenant_id, + fingerprint, + enrichments, + action_callee=action_callee, + action_type=action_type, + action_description=action_description, + session=self.db_session, + ) + self.logger.debug( "alert enriched in db, enriching elastic", extra={"fingerprint": fingerprint}, @@ -407,12 +429,18 @@ def dispose_enrichments(self, fingerprint: str): elif f"disposable_{key}" not in enrichments.enrichments: new_enrichments[key] = val # Only update the alert if there are disposable enrichments to dispose + disposed_keys = set(enrichments.enrichments.keys()) - set( + new_enrichments.keys() + ) if disposed: enrich_alert_db( self.tenant_id, fingerprint, new_enrichments, - self.db_session, + session=self.db_session, + action_callee="system", + action_type=AlertActionType.DISPOSE_ENRICHED_ALERT, + action_description=f"Disposing enrichments from alert - {disposed_keys}", force=True, ) self.elastic_client.enrich_alert(fingerprint, new_enrichments) diff --git a/keep/api/config.py b/keep/api/config.py index 95835f20c..914f38da1 100644 --- a/keep/api/config.py +++ b/keep/api/config.py @@ -9,7 +9,7 @@ PORT = int(os.environ.get("PORT", 8080)) -keep.api.logging.setup() +keep.api.logging.setup_logging() logger = logging.getLogger(__name__) diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 3142a5711..6260c723c 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -630,7 +630,26 @@ def get_last_workflow_executions(tenant_id: str, limit=20): return execution_with_logs -def _enrich_alert(session, tenant_id, fingerprint, enrichments, force=False): +def _enrich_alert( + session, + tenant_id, + fingerprint, + enrichments, + action_type: AlertActionType, + action_callee: str, + action_description: str, + force=False, +): + """ + Enrich an alert with the provided enrichments. + + Args: + session (Session): The database session. + tenant_id (str): The tenant ID to filter the alert enrichments by. + fingerprint (str): The alert fingerprint to filter the alert enrichments by. + enrichments (dict): The enrichments to add to the alert. + force (bool): Whether to force the enrichment to be updated. This is used to dispose enrichments if necessary. + """ enrichment = get_enrichment_with_session(session, tenant_id, fingerprint) if enrichment: # if force - override exisitng enrichments. being used to dispose enrichments if necessary @@ -646,6 +665,15 @@ def _enrich_alert(session, tenant_id, fingerprint, enrichments, force=False): .values(enrichments=new_enrichment_data) ) session.execute(stmt) + # add audit event + audit = AlertAudit( + tenant_id=tenant_id, + fingerprint=fingerprint, + user_id=action_callee, + action=action_type.value, + description=action_description, + ) + session.add(audit) session.commit() # Refresh the instance to get updated data from the database session.refresh(enrichment) @@ -657,18 +685,52 @@ def _enrich_alert(session, tenant_id, fingerprint, enrichments, force=False): enrichments=enrichments, ) session.add(alert_enrichment) + # add audit event + audit = AlertAudit( + tenant_id=tenant_id, + fingerprint=fingerprint, + user_id=action_callee, + action=action_type.value, + description=action_description, + ) + session.add(audit) session.commit() return alert_enrichment -def enrich_alert(tenant_id, fingerprint, enrichments, session=None, force=False): +def enrich_alert( + tenant_id, + fingerprint, + enrichments, + action_type: AlertActionType, + action_callee: str, + action_description: str, + session=None, + force=False, +): # else, the enrichment doesn't exist, create it if not session: with Session(engine) as session: return _enrich_alert( - session, tenant_id, fingerprint, enrichments, force=force + session, + tenant_id, + fingerprint, + enrichments, + action_type, + action_callee, + action_description, + force=force, ) - return _enrich_alert(session, tenant_id, fingerprint, enrichments, force=force) + return _enrich_alert( + session, + tenant_id, + fingerprint, + enrichments, + action_type, + action_callee, + action_description, + force=force, + ) def get_enrichment(tenant_id, fingerprint): @@ -1175,7 +1237,10 @@ def assign_alert_to_group( enrich_alert( tenant_id, fingerprint, - {"group_expired": True}, + enrichments={"group_expired": True}, + action_type=AlertActionType.GENERIC_ENRICH, # TODO: is this a live code? + action_callee="system", + action_description="Enriched group with group_expired flag", ) logger.info(f"Enriched group {group.id} with group_expired flag") # change the group status to resolve so it won't spam the UI @@ -1691,3 +1756,17 @@ def update_preset_options(tenant_id: str, preset_id: str, options: dict) -> Pres session.commit() session.refresh(preset) return preset + + +def get_alert_audit( + tenant_id: str, fingerprint: str, limit: int = 50 +) -> List[AlertAudit]: + 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() + return audit diff --git a/keep/api/core/elastic.py b/keep/api/core/elastic.py index cdf807b78..c62b9ff68 100644 --- a/keep/api/core/elastic.py +++ b/keep/api/core/elastic.py @@ -107,10 +107,20 @@ def run_query(self, query: str, limit: int = 1000): self.logger.warning("Index does not exist yet.") return [] else: - self.logger.error(f"Failed to run query in Elastic: {e}") + self.logger.exception( + f"Failed to run query in Elastic: {e}", + extra={ + "tenant_id": self.tenant_id, + }, + ) raise Exception(f"Failed to run query in Elastic: {e}") except Exception as e: - self.logger.error(f"Failed to run query in Elastic: {e}") + self.logger.exception( + f"Failed to run query in Elastic: {e}", + extra={ + "tenant_id": self.tenant_id, + }, + ) raise Exception(f"Failed to run query in Elastic: {e}") def search_alerts(self, query: str, limit: int) -> list[AlertDto]: diff --git a/keep/api/logging.py b/keep/api/logging.py index 2d488b3cf..995c4d713 100644 --- a/keep/api/logging.py +++ b/keep/api/logging.py @@ -224,7 +224,7 @@ def _log( ) -def setup(): +def setup_logging(): logging.config.dictConfig(CONFIG) uvicorn_error_logger = logging.getLogger("uvicorn.error") uvicorn_error_logger.__class__ = CustomizedUvicornLogger diff --git a/keep/api/models/db/alert.py b/keep/api/models/db/alert.py index c9481e03c..8c149918b 100644 --- a/keep/api/models/db/alert.py +++ b/keep/api/models/db/alert.py @@ -1,3 +1,4 @@ +import enum import hashlib import logging from datetime import datetime @@ -9,7 +10,7 @@ from sqlalchemy.dialects.mysql import DATETIME as MySQL_DATETIME from sqlalchemy.engine.url import make_url from sqlalchemy_utils import UUIDType -from sqlmodel import JSON, Column, DateTime, Field, Relationship, SQLModel +from sqlmodel import JSON, Column, DateTime, Field, Index, Relationship, SQLModel from keep.api.consts import RUNNING_IN_CLOUD_RUN from keep.api.core.config import config @@ -157,3 +158,52 @@ class AlertRaw(SQLModel, table=True): class Config: arbitrary_types_allowed = True + + +class AlertAudit(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + fingerprint: str + tenant_id: str = Field(foreign_key="tenant.id", nullable=False) + # when + timestamp: datetime = Field(default_factory=datetime.utcnow, nullable=False) + # who + user_id: str = Field(nullable=False) + # what + action: str = Field(nullable=False) + description: str + + __table_args__ = ( + Index("ix_alert_audit_tenant_id", "tenant_id"), + Index("ix_alert_audit_fingerprint", "fingerprint"), + Index("ix_alert_audit_tenant_id_fingerprint", "tenant_id", "fingerprint"), + Index("ix_alert_audit_timestamp", "timestamp"), + ) + + +class AlertActionType(enum.Enum): + # the alert was triggered + TIGGERED = "alert was triggered" + # someone acknowledged the alert + ACKNOWLEDGE = "alert acknowledged" + # the alert was resolved + AUTOMATIC_RESOLVE = "alert automatically resolved" + # the alert was resolved manually + MANUAL_RESOLVE = "alert manually resolved" + MANUAL_STATUS_CHANGE = "alert status manually changed" + # the alert was escalated + WORKFLOW_ENRICH = "alert enriched by workflow" + MAPPING_RULE_ENRICH = "alert enriched by mapping rule" + # the alert was deduplicated + DEDUPLICATED = "alert was deduplicated" + # a ticket was created + TICKET_ASSIGNED = "alert was assigned with ticket" + # a ticket was updated + TICKET_UPDATED = "alert ticket was updated" + # disposing enriched alert + DISPOSE_ENRICHED_ALERT = "alert enrichments disposed" + # delete alert + DELETE_ALERT = "alert deleted" + # generic enrichment + GENERIC_ENRICH = "alert enriched" + # commented + COMMENT = "a comment was added to the alert" diff --git a/keep/api/models/db/migrations/env.py b/keep/api/models/db/migrations/env.py index 8e3c251e5..f655e0a9c 100644 --- a/keep/api/models/db/migrations/env.py +++ b/keep/api/models/db/migrations/env.py @@ -5,20 +5,19 @@ from sqlalchemy.future import Connection from sqlmodel import SQLModel +import keep.api.logging from keep.api.core.db_utils import create_db_engine - -from keep.api.models.db.alert import * from keep.api.models.db.action import * +from keep.api.models.db.alert import * from keep.api.models.db.dashboard import * from keep.api.models.db.extraction import * from keep.api.models.db.mapping import * from keep.api.models.db.preset import * from keep.api.models.db.provider import * -from keep.api.models.db.tenant import * from keep.api.models.db.rule import * +from keep.api.models.db.tenant import * from keep.api.models.db.user import * from keep.api.models.db.workflow import * -from keep.api.models.db.dashboard import * target_metadata = SQLModel.metadata @@ -30,6 +29,8 @@ # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: + # backup the current config + logging_config = config.get_section("loggers") fileConfig(config.config_file_name) @@ -87,3 +88,5 @@ async def run_migrations_online() -> None: task = run_migrations_online() loop.run_until_complete(task) +# SHAHAR: set back the logs to the default after alembic is done +keep.api.logging.setup_logging() diff --git a/keep/api/models/db/migrations/versions/2024-07-15-15-10_c37ec8f6db3e.py b/keep/api/models/db/migrations/versions/2024-07-15-15-10_c37ec8f6db3e.py new file mode 100644 index 000000000..58025824c --- /dev/null +++ b/keep/api/models/db/migrations/versions/2024-07-15-15-10_c37ec8f6db3e.py @@ -0,0 +1,62 @@ +"""Adding alertaudit table + +Revision ID: c37ec8f6db3e +Revises: 54c1252b2c8a +Create Date: 2024-07-15 15:10:51.175030 + +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c37ec8f6db3e" +down_revision = "54c1252b2c8a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "alertaudit", + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("fingerprint", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.Column("user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("action", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenant.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_alert_audit_fingerprint", "alertaudit", ["fingerprint"], unique=False + ) + op.create_index( + "ix_alert_audit_tenant_id", "alertaudit", ["tenant_id"], unique=False + ) + op.create_index( + "ix_alert_audit_tenant_id_fingerprint", + "alertaudit", + ["tenant_id", "fingerprint"], + unique=False, + ) + op.create_index( + "ix_alert_audit_timestamp", "alertaudit", ["timestamp"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_alert_audit_timestamp", table_name="alertaudit") + op.drop_index("ix_alert_audit_tenant_id_fingerprint", table_name="alertaudit") + op.drop_index("ix_alert_audit_tenant_id", table_name="alertaudit") + op.drop_index("ix_alert_audit_fingerprint", table_name="alertaudit") + op.drop_table("alertaudit") + # ### end Alembic commands ### diff --git a/keep/api/routes/alerts.py b/keep/api/routes/alerts.py index 44bae7eeb..46fca8ebd 100644 --- a/keep/api/routes/alerts.py +++ b/keep/api/routes/alerts.py @@ -23,6 +23,7 @@ from keep.api.arq_worker import get_pool from keep.api.bl.enrichments import EnrichmentsBl from keep.api.core.config import config +from keep.api.core.db import get_alert_audit as get_alert_audit_db from keep.api.core.db import get_alerts_by_fingerprint, get_enrichment, get_last_alerts from keep.api.core.dependencies import ( AuthenticatedEntity, @@ -31,6 +32,7 @@ ) from keep.api.core.elastic import ElasticClient from keep.api.models.alert import AlertDto, DeleteRequestBody, EnrichAlertRequestBody +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 from keep.api.utils.email_utils import EmailTemplates, send_email @@ -172,6 +174,9 @@ def delete_alert( "deletedAt": deleted_last_received, "assignees": assignees_last_receievd, }, + action_type=AlertActionType.DELETE_ALERT, + action_description=f"Alert deleted by {user_email}", + action_callee=user_email, ) logger.info( @@ -218,6 +223,9 @@ def assign_alert( enrichment_bl.enrich_alert( fingerprint=fingerprint, enrichments={"assignees": assignees_last_receievd}, + action_type=AlertActionType.ACKNOWLEDGE, + action_description=f"Alert assigned to {user_email}", + action_callee=user_email, ) try: @@ -419,9 +427,29 @@ def enrich_alert( try: enrichement_bl = EnrichmentsBl(tenant_id) + # Shahar: TODO, change to the specific action type, good enough for now + if "status" in enrich_data.enrichments: + action_type = ( + AlertActionType.MANUAL_RESOLVE + if enrich_data.enrichments["status"] == "resolved" + else AlertActionType.MANUAL_STATUS_CHANGE + ) + action_description = f"Alert status was changed to {enrich_data.enrichments['status']} by {authenticated_entity.email}" + elif "note" in enrich_data.enrichments: + action_type = AlertActionType.COMMENT + action_description = f"Comment added by {authenticated_entity.email} - {enrich_data.enrichments['note']}" + elif "ticket_url" in enrich_data.enrichments: + action_type = AlertActionType.TICKET_ASSIGNED + action_description = f"Ticket assigned by {authenticated_entity.email} - {enrich_data.enrichments['ticket_url']}" + else: + action_type = AlertActionType.GENERIC_ENRICH + action_description = f"Alert enriched by {authenticated_entity.email} - {enrich_data.enrichments}" enrichement_bl.enrich_alert( fingerprint=enrich_data.fingerprint, enrichments=enrich_data.enrichments, + action_type=action_type, + action_callee=authenticated_entity.email, + action_description=action_description, dispose_on_new_alert=dispose_on_new_alert, ) # get the alert with the new enrichment @@ -507,3 +535,51 @@ async def search_alerts( except Exception as e: logger.exception("Failed to search alerts", extra={"error": str(e)}) raise HTTPException(status_code=500, detail="Failed to search alerts") + + +@router.get( + "/{fingerprint}/audit", + description="Get alert enrichment", +) +def get_alert_audit( + fingerprint: str, + authenticated_entity: AuthenticatedEntity = Depends(AuthVerifier(["read:alert"])), +): + tenant_id = authenticated_entity.tenant_id + logger.info( + "Fetching alert audit", + extra={ + "fingerprint": fingerprint, + "tenant_id": tenant_id, + }, + ) + alert_audit = get_alert_audit_db(tenant_id, fingerprint) + 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()) + + return grouped_events diff --git a/keep/api/tasks/process_event_task.py b/keep/api/tasks/process_event_task.py index 2523ccd1c..1de60adb7 100644 --- a/keep/api/tasks/process_event_task.py +++ b/keep/api/tasks/process_event_task.py @@ -18,7 +18,7 @@ from keep.api.core.dependencies import get_pusher_client from keep.api.core.elastic import ElasticClient from keep.api.models.alert import AlertDto, AlertStatus -from keep.api.models.db.alert import Alert, AlertRaw +from keep.api.models.db.alert import Alert, AlertActionType, AlertAudit, AlertRaw from keep.api.models.db.preset import PresetDto from keep.providers.providers_factory import ProvidersFactory from keep.rulesengine.rulesengine import RulesEngine @@ -58,6 +58,7 @@ def __save_to_db( session: Session, raw_events: list[dict], formatted_events: list[AlertDto], + deduplicated_events: list[AlertDto], provider_id: str | None = None, ): try: @@ -70,6 +71,17 @@ def __save_to_db( raw_alert=raw_event, ) session.add(alert) + # add audit to the deduplicated events + for event in deduplicated_events: + audit = AlertAudit( + tenant_id=tenant_id, + fingerprint=event.fingerprint, + status=event.status, + action=AlertActionType.DEDUPLICATED.value, + user_id="system", + description="Alert was deduplicated", + ) + session.add(audit) enriched_formatted_events = [] for formatted_event in formatted_events: formatted_event.pushed = True @@ -114,6 +126,18 @@ def __save_to_db( alert_hash=formatted_event.alert_hash, ) session.add(alert) + audit = AlertAudit( + tenant_id=tenant_id, + fingerprint=formatted_event.fingerprint, + action=( + AlertActionType.AUTOMATIC_RESOLVE.value + if formatted_event.status == AlertStatus.RESOLVED.value + else AlertActionType.TIGGERED.value + ), + user_id="system", + description=f"Alert recieved from provider with status {formatted_event.status}", + ) + session.add(audit) session.flush() session.refresh(alert) formatted_event.event_id = str(alert.id) @@ -198,13 +222,22 @@ def __handle_formatted_events( event.isDuplicate = event_deduplicated # filter out the deduplicated events + deduplicated_events = list( + filter(lambda event: event.isDuplicate, formatted_events) + ) formatted_events = list( filter(lambda event: not event.isDuplicate, formatted_events) ) # save to db enriched_formatted_events = __save_to_db( - tenant_id, provider_type, session, raw_events, formatted_events, provider_id + tenant_id, + provider_type, + session, + raw_events, + formatted_events, + deduplicated_events, + provider_id, ) # after the alert enriched and mapped, lets send it to the elasticsearch diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index d846d9518..5d0de6661 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -21,6 +21,7 @@ from keep.api.bl.enrichments import EnrichmentsBl from keep.api.core.db import get_enrichments from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus +from keep.api.models.db.alert import AlertActionType from keep.api.utils.enrichment_helpers import parse_and_enrich_deleted_and_assignees from keep.contextmanager.contextmanager import ContextManager from keep.providers.models.provider_config import ProviderConfig, ProviderScope @@ -186,7 +187,18 @@ def _enrich_alert(self, enrichments, results): self.logger.info("Enriching alert", extra={"fingerprint": fingerprint}) try: enrichments_bl = EnrichmentsBl(self.context_manager.tenant_id) - enrichments_bl.enrich_alert(fingerprint, _enrichments) + enrichment_string = "" + for key, value in _enrichments.items(): + enrichment_string += f"{key}={value}, " + # remove the last comma + enrichment_string = enrichment_string[:-2] + enrichments_bl.enrich_alert( + fingerprint, + _enrichments, + action_type=AlertActionType.WORKFLOW_ENRICH, # shahar: todo: should be specific, good enough for now + action_callee="system", + action_description=f"Workflow enriched the alert with {enrichment_string}", + ) except Exception as e: self.logger.error( diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..df76ae174 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,290 @@ +{ + "name": "keep", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "react-chrono": "^2.6.1" + } + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==" + }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/dayjs": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", + "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" + }, + "node_modules/focus-visible": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/focus-visible/-/focus-visible-5.2.0.tgz", + "integrity": "sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chrono": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/react-chrono/-/react-chrono-2.6.1.tgz", + "integrity": "sha512-9KcREgRaUd39rjNjFqP9OiDUWWpu8pBoPcfZGPQoXlpmBjrN10BMmb2StHNcyWrpIFvpG4Ok4Dh1pRv1cuTuWg==", + "dependencies": { + "classnames": "^2.5.1", + "dayjs": "^1.11.10", + "focus-visible": "^5.2.0", + "styled-components": "^6.1.8", + "use-debounce": "^10.0.0", + "xss": "^1.0.15" + }, + "peerDependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-components": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.11.tgz", + "integrity": "sha512-Ui0jXPzbp1phYij90h12ksljKGqF8ncGx+pjrNPsSPhbUUjWT2tD1FwGo2LF6USCnbrsIhNngDfodhxbegfEOA==", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.38", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/use-debounce": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.1.tgz", + "integrity": "sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..db0a6644a --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "react-chrono": "^2.6.1" + } +} diff --git a/scripts/migrate_to_elastic.py b/scripts/migrate_to_elastic.py index 40df3f700..e8832f3ca 100644 --- a/scripts/migrate_to_elastic.py +++ b/scripts/migrate_to_elastic.py @@ -5,10 +5,12 @@ from dateutil.parser import ParserError from dotenv import load_dotenv +from keep.api.consts import STATIC_PRESETS from keep.api.core.db import get_alerts_with_filters from keep.api.core.elastic import ElasticClient from keep.api.models.alert import AlertDto from keep.api.utils.enrichment_helpers import convert_db_alerts_to_dto_alerts +from keep.searchengine.searchengine import SearchEngine load_dotenv() TENANT_ID = os.environ.get("MIGRATION_TENANT_ID") @@ -82,7 +84,14 @@ def change_keys_recursively(data): if __name__ == "__main__": # dismissedUntil + group last_updated_time + split to 500 + elastic_client = ElasticClient(TENANT_ID) + + preset = STATIC_PRESETS["feed"] + search_engine = SearchEngine(tenant_id=TENANT_ID) + search_engine.search_alerts(preset.query) + # get the number of alerts + noisy alerts for each preset + alerts = get_alerts_with_filters(TENANT_ID, time_delta=365) # year ago print(f"Found {len(alerts)} alerts") alerts_dto = convert_db_alerts_to_dto_alerts(alerts) diff --git a/tests/conftest.py b/tests/conftest.py index 0ba7e4b3f..99d56297f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -310,9 +310,11 @@ def elastic_client(request): def browser(): from playwright.sync_api import sync_playwright + # SHAHAR: you can remove locally, but keep in github actions # headless = os.getenv("PLAYWRIGHT_HEADLESS", "true") == "true" + headless = True with sync_playwright() as p: - browser = p.chromium.launch(headless=True) + browser = p.chromium.launch(headless=headless) context = browser.new_context() page = context.new_page() page.set_default_timeout(5000) diff --git a/tests/e2e_tests/test_pushing_prometheus_alerts.py b/tests/e2e_tests/test_pushing_prometheus_alerts.py index 88df31781..1812d2c08 100644 --- a/tests/e2e_tests/test_pushing_prometheus_alerts.py +++ b/tests/e2e_tests/test_pushing_prometheus_alerts.py @@ -1,80 +1,118 @@ -import re, os, sys, time -import requests - +import os +import re +import sys +import time from datetime import datetime + +import requests from playwright.sync_api import expect # Dear developer, thank you for checking E2E tests! -# For instructions, please check test_end_to_end.py. +# For instructions, please check test_end_to_end.py. os.environ["PLAYWRIGHT_HEADLESS"] = "false" + def test_pulling_prometheus_alerts_to_provider(browser): - try: + try: provider_name = "playwright_test_" + datetime.now().strftime("%Y%m%d%H%M%S") # Wait for prometheus to wake up and evaluate alert rule as "firing" alerts = None - while alerts is None or \ - len(alerts["data"]["alerts"]) == 0 or \ - alerts["data"]["alerts"][0]['state'] != "firing": + while ( + alerts is None + or len(alerts["data"]["alerts"]) == 0 + or alerts["data"]["alerts"][0]["state"] != "firing" + ): print("Waiting for prometheus to fire an alert...") time.sleep(1) alerts = requests.get("http://localhost:9090/api/v1/alerts").json() print(alerts) - - # Create prometheus provider + + # Create prometheus provider browser.goto("http://localhost:3000/providers") browser.get_by_placeholder("Filter providers...").click() browser.get_by_placeholder("Filter providers...").fill("prometheus") browser.get_by_placeholder("Filter providers...").press("Enter") browser.get_by_text("Available Providers").hover() - browser.locator("div").filter(has_text=re.compile(r"^prometheus dataalertConnect$")).nth(1).hover() - + browser.locator("div").filter( + has_text=re.compile(r"^prometheus dataalertConnect$") + ).nth(1).hover() + browser.get_by_role("button", name="Connect").click() browser.get_by_placeholder("Enter provider name").click() browser.get_by_placeholder("Enter provider name").fill(provider_name) browser.get_by_placeholder("Enter url").click() + """ if os.getenv("GITHUB_ACTIONS") == "true": browser.get_by_placeholder("Enter url").fill("http://prometheus-server-for-test-target:9090/") else: browser.get_by_placeholder("Enter url").fill("http://localhost:9090/") + """ + browser.get_by_placeholder("Enter url").fill( + "http://prometheus-server-for-test-target:9090/" + ) browser.mouse.wheel(1000, 10000) # Scroll down. browser.get_by_role("button", name="Connect").click() - # Validate provider is created - expect(browser.locator("div").filter(has_text=re.compile(re.escape(provider_name))).first).to_be_visible() + expect( + browser.locator("div") + .filter(has_text=re.compile(re.escape(provider_name))) + .first + ).to_be_visible() browser.reload() - - # Check if alerts were pulled - for i in range(0, 5): + + max_attemps = 5 + + for attempt in range(max_attemps): + print(f"Attempt {attempt + 1} to load alerts...") browser.get_by_role("link", name="Feed").click() - browser.wait_for_timeout(5000) # Wait for alerts to be loaded - browser.reload() + try: + # Wait for an element that indicates alerts have loaded + browser.wait_for_selector("text=AlwaysFiringAlert", timeout=5000) + print("Alerts loaded successfully.") + except Exception: + if attempt < max_attemps - 1: + print("Alerts not loaded yet. Retrying...") + browser.reload() + else: + print("Failed to load alerts after maximum attempts.") + raise Exception("Failed to load alerts after maximum attempts.") + browser.reload() # Make sure we pulled multiple instances of the alert browser.get_by_text("AlwaysFiringAlert").click() - - # Delete provider + # Close the side panel by touching outside of it. + browser.mouse.click(0, 0) + + # Delete provider browser.get_by_role("link", name="Providers").click() - browser.locator("div").filter(has_text=re.compile(re.escape(provider_name))).first.hover() + browser.locator("div").filter( + has_text=re.compile(re.escape(provider_name)) + ).first.hover() browser.locator(".tile-basis").first.click() browser.once("dialog", lambda dialog: dialog.accept()) browser.get_by_role("button", name="Delete").click() # Assert provider was deleted - expect(browser.locator("div").filter(has_text=re.compile(re.escape(provider_name))).first).not_to_be_visible() + expect( + browser.locator("div") + .filter(has_text=re.compile(re.escape(provider_name))) + .first + ).not_to_be_visible() except Exception: # Current file + test name for unique html and png dump. - current_test_name = \ - "playwright_dump_" + \ - os.path.basename(__file__)[:-3] + \ - "_" + sys._getframe().f_code.co_name + current_test_name = ( + "playwright_dump_" + + os.path.basename(__file__)[:-3] + + "_" + + sys._getframe().f_code.co_name + ) browser.screenshot(path=current_test_name + ".png") with open(current_test_name + ".html", "w") as f: diff --git a/tests/test_search_alerts.py b/tests/test_search_alerts.py index 203227f95..29bff10fe 100644 --- a/tests/test_search_alerts.py +++ b/tests/test_search_alerts.py @@ -5,6 +5,7 @@ from keep.api.bl.enrichments import EnrichmentsBl from keep.api.core.dependencies import SINGLE_TENANT_UUID +from keep.api.models.db.alert import AlertActionType from keep.api.models.db.preset import PresetSearchQuery as SearchQuery from keep.searchengine.searchengine import SearchEngine @@ -146,6 +147,9 @@ def test_search_sanity_4(db_session, setup_alerts): enrichment_bl.enrich_alert( fingerprint="test-1", enrichments={"dismissed": True}, + action_callee="test", + action_description="test", + action_type=AlertActionType.GENERIC_ENRICH, ) search_query = SearchQuery( sql_query={