From 294f920df066466e167e8bd830b00142752ed56e Mon Sep 17 00:00:00 2001 From: Tal Date: Sun, 20 Oct 2024 18:01:38 +0300 Subject: [PATCH 1/4] fix: don't reset last stargazer (#2252) --- keep/providers/github_provider/github_provider.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/keep/providers/github_provider/github_provider.py b/keep/providers/github_provider/github_provider.py index 90b4eb68b..c4d524a8d 100644 --- a/keep/providers/github_provider/github_provider.py +++ b/keep/providers/github_provider/github_provider.py @@ -123,9 +123,9 @@ def _query( # Save last stargazer name so we can use it next iteration last_stargazer = ( - stargazers_with_dates[-1].user.login - if len(stargazers_with_dates) > 1 - else "" + new_stargazers[-1]["username"] + if len(new_stargazers) >= 1 + else last_stargazer ) return { From e1e7eff1f21fb4d03457a4dfb2c400601d616983 Mon Sep 17 00:00:00 2001 From: Tal Date: Sun, 20 Oct 2024 18:17:35 +0300 Subject: [PATCH 2/4] fix: a couple of issues with extraction (#2250) --- ... => developer-onboarding-notification.yml} | 41 ++--- keep-ui/app/alerts/alerts-rules-builder.tsx | 156 ++++++++++-------- .../create-or-update-extraction-rule.tsx | 7 +- keep/api/bl/enrichments_bl.py | 2 - 4 files changed, 108 insertions(+), 98 deletions(-) rename .github/workflows/{developer_onboarding_notification.yml => developer-onboarding-notification.yml} (69%) diff --git a/.github/workflows/developer_onboarding_notification.yml b/.github/workflows/developer-onboarding-notification.yml similarity index 69% rename from .github/workflows/developer_onboarding_notification.yml rename to .github/workflows/developer-onboarding-notification.yml index 0ed44fca4..8a35fb308 100644 --- a/.github/workflows/developer_onboarding_notification.yml +++ b/.github/workflows/developer-onboarding-notification.yml @@ -52,7 +52,6 @@ jobs: try { const prCount = parseInt(process.env.PR_COUNT); const author = process.env.AUTHOR; - const mention = 'talboren'; const prNumber = context.payload.pull_request.number; const repo = process.env.REPO; @@ -68,49 +67,41 @@ jobs: return `${emoji} **Fantastic work @${author}!** Your very first PR to ${repo} has been merged! 🎉🥳\n\n` + `You've just taken your first step into open-source, and we couldn't be happier to have you onboard. 🙌\n` + `If you're feeling adventurous, why not dive into another issue and keep contributing? The community would love to see more from you! 🚀\n\n` + - `For any support, feel free to reach out to the developer onboarding lead: @${mention}. Happy coding! 👩‍💻👨‍💻`; + `For any support, feel free to reach out on the community: https://slack.keephq.dev. Happy coding! 👩‍💻👨‍💻`; case 2: return `${emoji} **Well done @${author}!** Two PRs merged already! 🎉🥳\n\n` + `With your second PR, you're on a roll, and your contributions are already making a difference. 🌟\n` + - `Looking forward to seeing even more contributions from you. The developer onboarding lead: @${mention} is here if you need any help! Keep up the great work! 🚀`; + `Looking forward to seeing even more contributions from you. See you in Slack https://slack.keephq.dev 🚀`; case 3: return `${emoji} **You're on fire, @${author}!** Three PRs merged and counting! 🔥🎉\n\n` + `Your consistent contributions are truly impressive. You're becoming a valued member of our community! 💖\n` + `Have you considered taking on some more challenging issues? We'd love to see what you can do! 💪\n\n` + - `Remember, @${mention} is always here to support you. Keep blazing that trail! 🚀`; + `Remember, the team is always here to support you. Keep blazing that trail! 🚀`; case 5: return `${emoji} **High five, @${author}!** You've hit the incredible milestone of 5 merged PRs! 🖐️✨\n\n` + `Your dedication to ${repo} is outstanding. You're not just contributing code; you're shaping the future of this project! 🌠\n` + `We'd love to hear your thoughts on the project. Any ideas for new features or improvements? 🤔\n\n` + - `@${mention} and the whole team applaud your efforts. You're a superstar! 🌟`; + `The whole team applaud your efforts. You're a superstar! 🌟`; case 10: return `${emoji} **Double digits, @${author}!** 10 merged PRs is a massive achievement! 🏆🎊\n\n` + `Your impact on ${repo} is undeniable. You've become a pillar of our community! 🏛️\n` + `We'd be thrilled to have you take on a mentorship role for newer contributors. Interested? 🧑‍🏫\n\n` + - `@${mention} and everyone here are in awe of your contributions. You're an open source hero! 🦸‍♀️🦸‍♂️`; + `Everyone here are in awe of your contributions. You're an open source hero! 🦸‍♀️🦸‍♂️`; default: - if (count > 10) { - return `${emoji} **Incredible, @${author}!** You've merged your ${count}th PR! 🎯🎊\n\n` + - `Your ongoing commitment to ${repo} is truly remarkable. You're a driving force in our community! 🚀\n` + - `Your contributions are helping to shape the future of this project. What exciting features or improvements do you envision next? 🔮\n\n` + - `@${mention} and the entire team are grateful for your dedication. You're an inspiration to us all! 💫`; - } else { - return `${emoji} **Great job, @${author}!** You've merged your ${count}th PR! 🎊\n\n` + - `Your contributions to ${repo} are making a real difference. Keep up the fantastic work! 💪\n` + - `Remember, every PR counts and helps improve the project. What will you tackle next? 🤔\n\n` + - `@${mention} is here if you need any guidance. Onward and upward! 🚀`; - } + return ""; } } const message = getMessage(prCount); - - await github.rest.issues.createComment({ - owner: process.env.OWNER, - repo: process.env.REPO, - issue_number: prNumber, - body: message - }); + + if (message) { + await github.rest.issues.createComment({ + owner: process.env.OWNER, + repo: process.env.REPO, + issue_number: prNumber, + body: message + }); + } } catch (error) { core.setFailed(`Error creating comment: ${error.message}`); - } \ No newline at end of file + } diff --git a/keep-ui/app/alerts/alerts-rules-builder.tsx b/keep-ui/app/alerts/alerts-rules-builder.tsx index 950bdb007..bc40b9cde 100644 --- a/keep-ui/app/alerts/alerts-rules-builder.tsx +++ b/keep-ui/app/alerts/alerts-rules-builder.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Modal from "components/ui/Modal"; import { Button, Textarea, Badge } from "@tremor/react"; import QueryBuilder, { @@ -12,7 +12,12 @@ import QueryBuilder, { } from "react-querybuilder"; import "react-querybuilder/dist/query-builder.scss"; import { Table } from "@tanstack/react-table"; -import { AlertDto, Preset, severityMapping, reverseSeverityMapping } from "./models"; +import { + AlertDto, + Preset, + severityMapping, + reverseSeverityMapping, +} from "./models"; import { XMarkIcon, TrashIcon } from "@heroicons/react/24/outline"; import { FiSave } from "react-icons/fi"; import { TbDatabaseImport } from "react-icons/tb"; @@ -21,6 +26,7 @@ import Select, { components, MenuListProps } from "react-select"; import { IoSearchOutline } from "react-icons/io5"; import { FiExternalLink } from "react-icons/fi"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { toast } from "react-toastify"; const staticOptions = [ { value: 'severity > "info"', label: 'severity > "info"' }, @@ -127,68 +133,68 @@ const getAllMatches = (pattern: RegExp, string: string) => // make sure string is a String, and make sure pattern has the /g flag String(string).match(new RegExp(pattern, "g")); - const sanitizeCELIntoJS = (celExpression: string): string => { - // First, replace "contains" with "includes" - let jsExpression = celExpression.replace(/contains/g, "includes"); - - // Replace severity comparisons with mapped values - jsExpression = jsExpression.replace( - /severity\s*([<>]=?|==)\s*(\d+|"[^"]*")/g, - (match, operator, value) => { - let severityKey; - - if (/^\d+$/.test(value)) { - // If the value is a number - severityKey = severityMapping[Number(value)]; - } else { - // If the value is a string - severityKey = value.replace(/"/g, '').toLowerCase(); // Remove quotes from the string value and convert to lowercase - } - - const severityValue = reverseSeverityMapping[severityKey]; - - if (severityValue === undefined) { - return match; // If no mapping found, return the original match - } - - // For equality, directly replace with the severity level - if (operator === "==") { - return `severity == "${severityKey}"`; - } - - // For greater than or less than, include multiple levels based on the mapping - const levels = Object.entries(reverseSeverityMapping); - let replacement = ""; - if (operator === ">") { - const filteredLevels = levels - .filter(([, level]) => level > severityValue) - .map(([key]) => `severity == "${key}"`); - replacement = filteredLevels.join(" || "); - } else if (operator === "<") { - const filteredLevels = levels - .filter(([, level]) => level < severityValue) - .map(([key]) => `severity == "${key}"`); - replacement = filteredLevels.join(" || "); - } - - return `(${replacement})`; +const sanitizeCELIntoJS = (celExpression: string): string => { + // First, replace "contains" with "includes" + let jsExpression = celExpression.replace(/contains/g, "includes"); + + // Replace severity comparisons with mapped values + jsExpression = jsExpression.replace( + /severity\s*([<>]=?|==)\s*(\d+|"[^"]*")/g, + (match, operator, value) => { + let severityKey; + + if (/^\d+$/.test(value)) { + // If the value is a number + severityKey = severityMapping[Number(value)]; + } else { + // If the value is a string + severityKey = value.replace(/"/g, "").toLowerCase(); // Remove quotes from the string value and convert to lowercase } - ); - // Convert 'in' syntax to '.includes()' - jsExpression = jsExpression.replace( - /(\w+)\s+in\s+\[([^\]]+)\]/g, - (match, variable, list) => { - // Split the list by commas, trim spaces, and wrap items in quotes if not already done - const items = list - .split(",") - .map((item: string) => item.trim().replace(/^([^"]*)$/, '"$1"')); - return `[${items.join(", ")}].includes(${variable})`; + const severityValue = reverseSeverityMapping[severityKey]; + + if (severityValue === undefined) { + return match; // If no mapping found, return the original match } - ); - return jsExpression; - }; + // For equality, directly replace with the severity level + if (operator === "==") { + return `severity == "${severityKey}"`; + } + + // For greater than or less than, include multiple levels based on the mapping + const levels = Object.entries(reverseSeverityMapping); + let replacement = ""; + if (operator === ">") { + const filteredLevels = levels + .filter(([, level]) => level > severityValue) + .map(([key]) => `severity == "${key}"`); + replacement = filteredLevels.join(" || "); + } else if (operator === "<") { + const filteredLevels = levels + .filter(([, level]) => level < severityValue) + .map(([key]) => `severity == "${key}"`); + replacement = filteredLevels.join(" || "); + } + + return `(${replacement})`; + } + ); + + // Convert 'in' syntax to '.includes()' + jsExpression = jsExpression.replace( + /(\w+)\s+in\s+\[([^\]]+)\]/g, + (match, variable, list) => { + // Split the list by commas, trim spaces, and wrap items in quotes if not already done + const items = list + .split(",") + .map((item: string) => item.trim().replace(/^([^"]*)$/, '"$1"')); + return `[${items.join(", ")}].includes(${variable})`; + } + ); + + return jsExpression; +}; // this pattern is far from robust const variablePattern = /[a-zA-Z$_][0-9a-zA-Z$_]*/; @@ -278,6 +284,8 @@ type AlertsRulesBuilderProps = { customFields?: Field[]; showSave?: boolean; minimal?: boolean; + showToast?: boolean; + shouldSetQueryParam?: boolean; }; const SQL_QUERY_PLACEHOLDER = `SELECT * @@ -296,6 +304,8 @@ export const AlertsRulesBuilder = ({ showSqlImport = true, showSave = true, minimal = false, + showToast = false, + shouldSetQueryParam = true, }: AlertsRulesBuilderProps) => { const router = useRouter(); const pathname = usePathname(); @@ -336,6 +346,12 @@ export const AlertsRulesBuilder = ({ const [showSuggestions, setShowSuggestions] = useState(false); + const handleClearInput = useCallback(() => { + setCELRules(""); + table?.resetGlobalFilter(); + setIsValidCEL(true); + }, [table]); + const toggleSuggestions = () => { setShowSuggestions(!showSuggestions); }; @@ -385,6 +401,14 @@ export const AlertsRulesBuilder = ({ }; }, []); + useEffect(() => { + if (defaultQuery === "") { + handleClearInput(); + } else { + setCELRules(defaultQuery); + } + }, [defaultQuery, handleClearInput]); + useEffect(() => { // Use the constructCELRules function to set the initial value of celRules const initialCELRules = constructCELRules(selectedPreset); @@ -422,12 +446,6 @@ export const AlertsRulesBuilder = ({ adjustTextAreaHeight(); }, [celRules]); - const handleClearInput = () => { - setCELRules(""); - table?.resetGlobalFilter(); - setIsValidCEL(true); - }; - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); // Prevents the default action of Enter key in a form @@ -443,9 +461,11 @@ export const AlertsRulesBuilder = ({ // close the menu setShowSuggestions(false); if (isValidCEL) { - setQueryParam("cel", celRules); + if (shouldSetQueryParam) setQueryParam("cel", celRules); onApplyFilter(); updateOutputCEL?.(celRules); + if (showToast) + toast.success("Condition applied", { position: "top-right" }); } } }; @@ -629,7 +649,7 @@ export const AlertsRulesBuilder = ({ options={staticOptions} onChange={handleSelectChange} menuIsOpen={true} - components={minimal? undefined: customComponents} + components={minimal ? undefined : customComponents} onBlur={() => setShowSuggestions(false)} styles={customStyles} /> diff --git a/keep-ui/app/extraction/create-or-update-extraction-rule.tsx b/keep-ui/app/extraction/create-or-update-extraction-rule.tsx index 599bccc4c..43b959322 100644 --- a/keep-ui/app/extraction/create-or-update-extraction-rule.tsx +++ b/keep-ui/app/extraction/create-or-update-extraction-rule.tsx @@ -129,15 +129,14 @@ export default function CreateOrUpdateExtractionRule({ if (response.ok) { exitEditMode(); mutate(); - toast.success("Mapping updated successfully"); + toast.success("Extraction updated successfully"); } else { toast.error( - "Failed to update mapping, please contact us if this issue persists." + "Failed to update extraction, please contact us if this issue persists." ); } }; - // If the mapping is successfully updated or the user cancels the update we exit the editMode and set the editRule in the mapping.tsx to null. const exitEditMode = async () => { editCallback(null); clearForm(); @@ -280,6 +279,8 @@ export default function CreateOrUpdateExtractionRule({ updateOutputCEL={setCondition} showSave={false} showSqlImport={false} + showToast={true} + shouldSetQueryParam={false} /> diff --git a/keep/api/bl/enrichments_bl.py b/keep/api/bl/enrichments_bl.py index 733c07b86..858579f33 100644 --- a/keep/api/bl/enrichments_bl.py +++ b/keep/api/bl/enrichments_bl.py @@ -145,8 +145,6 @@ def run_extraction_rules( "fingerprint": fingerprint, }, ) - # Stop after the first match - break else: self.logger.info( "Regex did not match, skipping extraction", From 5a93f1034ad48bcc9ab5246282f2baf7de6ceff9 Mon Sep 17 00:00:00 2001 From: Vikash Prem Sharma <106796672+vikashsprem@users.noreply.github.com> Date: Mon, 21 Oct 2024 13:48:29 +0530 Subject: [PATCH 3/4] feat: Added dashboard for Alert Quality (#1977) Signed-off-by: Rajesh Jonnalagadda <38752904+rajeshj11@users.noreply.github.com> Co-authored-by: Matvey Kukuy Co-authored-by: Tal Co-authored-by: Rajesh Jonnalagadda Co-authored-by: Rajesh Jonnalagadda <38752904+rajeshj11@users.noreply.github.com> --- keep-ui/app/alerts/alert-quality-table.tsx | 361 ++++++++++++++++++ keep-ui/app/dashboard/GridItem.tsx | 107 +++++- keep-ui/app/dashboard/GridItemContainer.tsx | 5 +- keep-ui/app/dashboard/GridLayout.tsx | 85 +++-- keep-ui/app/dashboard/MenuButton.tsx | 22 +- keep-ui/app/dashboard/WidgetModal.tsx | 116 +++++- keep-ui/app/dashboard/[id]/dashboard.tsx | 26 +- keep-ui/app/dashboard/types.tsx | 15 +- keep-ui/components/filters/GenericFilters.tsx | 210 ++++++---- .../navbar/CustomPresetAlertLinks.tsx | 2 +- keep-ui/components/navbar/Search.tsx | 8 +- keep-ui/components/table/GenericTable.tsx | 10 +- keep-ui/utils/hooks/useAlertQuality.ts | 40 ++ keep/api/core/db.py | 61 ++- keep/api/routes/alerts.py | 32 +- keep/api/utils/time_stamp_helpers.py | 20 + 16 files changed, 975 insertions(+), 145 deletions(-) create mode 100644 keep-ui/app/alerts/alert-quality-table.tsx create mode 100644 keep-ui/utils/hooks/useAlertQuality.ts create mode 100644 keep/api/utils/time_stamp_helpers.py diff --git a/keep-ui/app/alerts/alert-quality-table.tsx b/keep-ui/app/alerts/alert-quality-table.tsx new file mode 100644 index 000000000..bdde64bc8 --- /dev/null +++ b/keep-ui/app/alerts/alert-quality-table.tsx @@ -0,0 +1,361 @@ +"use client"; // Add this line at the top to make this a Client Component + +import React, { + useState, + useEffect, + Dispatch, + SetStateAction, + useMemo, +} from "react"; +import { GenericTable } from "@/components/table/GenericTable"; +import { useAlertQualityMetrics } from "utils/hooks/useAlertQuality"; +import { useProviders } from "utils/hooks/useProviders"; +import { Provider, ProvidersResponse } from "app/providers/providers"; +import { TabGroup, TabList, Tab } from "@tremor/react"; +import { GenericFilters } from "@/components/filters/GenericFilters"; +import { useSearchParams } from "next/navigation"; +import { AlertKnownKeys } from "./models"; +import { createColumnHelper, DisplayColumnDef } from "@tanstack/react-table"; + +const tabs = [ + { name: "All", value: "all" }, + { name: "Installed", value: "installed" }, + { name: "Linked", value: "linked" }, +]; + +const ALERT_QUALITY_FILTERS = [ + { + type: "date", + key: "time_stamp", + value: "", + name: "Last received", + }, +]; + +export const FilterTabs = ({ + tabs, + setTab, + tab, +}: { + tabs: { name: string; value: string }[]; + setTab: Dispatch>; + tab: number; +}) => { + return ( +
+ { + setTab(index); + }} + > + + {tabs.map((tabItem) => ( + {tabItem.name} + ))} + + +
+ ); +}; + +interface AlertMetricQuality { + alertsReceived: number; + alertsCorrelatedToIncidentsPercentage: number; + alertsWithSeverityPercentage: number; + [key: string]: number; +} + +type FinalAlertQuality = Provider & + AlertMetricQuality & { provider_display_name: string }; +interface Pagination { + limit: number; + offset: number; +} + +const QualityTable = ({ + providersMeta, + alertsQualityMetrics, + isDashBoard, + setFields, + fieldsValue, +}: { + providersMeta: ProvidersResponse | undefined; + alertsQualityMetrics: Record> | undefined; + isDashBoard?: boolean; + setFields: (fields: string | string[] | Record) => void; + fieldsValue: string | string[] | Record; +}) => { + const [pagination, setPagination] = useState({ + limit: 10, + offset: 0, + }); + const customFieldFilter = { + type: "select", + key: "fields", + value: isDashBoard ? fieldsValue : "", + name: "Field", + options: AlertKnownKeys.map((key) => ({ value: key, label: key })), + // only_one: true, + searchParamsNotNeed: isDashBoard, + can_select: 3, + setFilter: setFields, + }; + const searchParams = useSearchParams(); + const entries = searchParams ? Array.from(searchParams.entries()) : []; + const columnHelper = createColumnHelper(); + + const params = entries.reduce( + (acc, [key, value]) => { + if (key in acc) { + if (Array.isArray(acc[key])) { + acc[key] = [...acc[key], value]; + return acc; + } else { + acc[key] = [acc[key] as string, value]; + } + return acc; + } + acc[key] = value; + return acc; + }, + {} as Record + ); + function toArray(value: string | string[]) { + if (!value) { + return []; + } + + if (!Array.isArray(value) && value) { + return [value]; + } + + return value; + } + const fields = toArray( + params?.["fields"] || (fieldsValue as string | string[]) || [] + ) as string[]; + const [tab, setTab] = useState(0); + + const handlePaginationChange = (newLimit: number, newOffset: number) => { + setPagination({ limit: newLimit, offset: newOffset }); + }; + + useEffect(() => { + handlePaginationChange(10, 0); + }, [tab, searchParams?.toString()]); + + // Construct columns based on the fields selected + const columns = useMemo(() => { + const baseColumns = [ + columnHelper.display({ + id: "provider_display_name", + header: "Provider Name", + cell: ({ row }) => { + const displayName = row.original.provider_display_name; + return ( +
+
{displayName}
+
id: {row.original.id}
+
type: {row.original.type}
+
+ ); + }, + }), + columnHelper.accessor("alertsReceived", { + id: "alertsReceived", + header: "Alerts Received", + }), + columnHelper.display({ + id: "alertsCorrelatedToIncidentsPercentage", + header: "% of Alerts Correlated to Incidents", + cell: ({ row }) => { + return `${row.original.alertsCorrelatedToIncidentsPercentage.toFixed(2)}%`; + }, + }), + ] as DisplayColumnDef[]; + + // Add dynamic columns based on the fields + const dynamicColumns = fields.map((field: string) => + columnHelper.accessor( + `alertsWith${field.charAt(0).toUpperCase() + field.slice(1)}Percentage`, + { + id: `alertsWith${ + field.charAt(0).toUpperCase() + field.slice(1) + }Percentage`, + header: `% of Alerts Having ${ + field.charAt(0).toUpperCase() + field.slice(1) + }`, + cell: (info: any) => `${info.getValue().toFixed(2)}%`, + } + ) + ) as DisplayColumnDef[]; + + return [ + ...baseColumns, + ...dynamicColumns, + ] as DisplayColumnDef[]; + }, [fields]); + + // Process data and include dynamic fields + const finalData = useMemo(() => { + let providers: Provider[] | null = null; + + if (!providersMeta || !alertsQualityMetrics) { + return null; + } + + switch (tab) { + case 0: + providers = [ + ...providersMeta?.installed_providers, + ...providersMeta?.linked_providers, + ]; + break; + case 1: + providers = providersMeta?.installed_providers || []; + break; + case 2: + providers = providersMeta?.linked_providers || []; + break; + default: + providers = [ + ...providersMeta?.installed_providers, + ...providersMeta?.linked_providers, + ]; + break; + } + + if (!providers) { + return null; + } + + function getProviderDisplayName(provider: Provider) { + return ( + (provider?.details?.name + ? `${provider.details.name} (${provider.display_name})` + : provider.display_name) || provider.type + ); + } + + const innerData: FinalAlertQuality[] = providers.map((provider) => { + const providerId = provider.id; + const providerType = provider.type; + const key = `${providerId}_${providerType}`; + const alertQuality = alertsQualityMetrics[key]; + const totalAlertsReceived = alertQuality?.total_alerts ?? 0; + const correlated_alerts = alertQuality?.correlated_alerts ?? 0; + const correltedPert = + totalAlertsReceived && correlated_alerts + ? (correlated_alerts / totalAlertsReceived) * 100 + : 0; + const severityPert = totalAlertsReceived + ? ((alertQuality?.severity_count ?? 0) / totalAlertsReceived) * 100 + : 0; + + // Calculate percentages for dynamic fields + const dynamicFieldPercentages = fields.reduce( + (acc, field: string) => { + acc[ + `alertsWith${ + field.charAt(0).toUpperCase() + field.slice(1) + }Percentage` + ] = totalAlertsReceived + ? ((alertQuality?.[`${field}_count`] ?? 0) / totalAlertsReceived) * + 100 + : 0; + return acc; + }, + {} as Record + ); + + return { + ...provider, + alertsReceived: totalAlertsReceived, + alertsCorrelatedToIncidentsPercentage: correltedPert, + alertsWithSeverityPercentage: severityPert, + ...dynamicFieldPercentages, // Add dynamic field percentages here + provider_display_name: getProviderDisplayName(provider), + } as FinalAlertQuality; + }); + + return innerData; + }, [tab, providersMeta, alertsQualityMetrics, fields]); + + return ( +
+
+ {!isDashBoard && ( +

+ Alert Quality Dashboard +

+ )} +
+
+ +
+ +
+
+ {finalData && ( + + data={finalData} + columns={columns} + rowCount={finalData?.length} + offset={pagination.offset} + limit={pagination.limit} + onPaginationChange={handlePaginationChange} + dataFetchedAtOneGO={true} + onRowClick={(row) => { + console.log("Row clicked:", row); + }} + /> + )} +
+ ); +}; + +const AlertQuality = ({ + isDashBoard, + filters, + setFilters, +}: { + isDashBoard?: boolean; + filters: { + [x: string]: string | string[]; + }; + setFilters: any; +}) => { + const fieldsValue = filters?.fields || ""; + const { data: providersMeta } = useProviders(); + const { data: alertsQualityMetrics, error } = useAlertQualityMetrics( + isDashBoard ? (fieldsValue as string | string[]) : "" + ); + + return ( + { + setFilters((filters: any) => { + return { + ...filters, + fields: field, + }; + }); + }} + fieldsValue={fieldsValue} + /> + ); +}; + +export default AlertQuality; diff --git a/keep-ui/app/dashboard/GridItem.tsx b/keep-ui/app/dashboard/GridItem.tsx index 7488ab12b..a1ef8a5ea 100644 --- a/keep-ui/app/dashboard/GridItem.tsx +++ b/keep-ui/app/dashboard/GridItem.tsx @@ -1,19 +1,62 @@ -import React from "react"; +import React, { useState } from "react"; import { Card } from "@tremor/react"; import MenuButton from "./MenuButton"; import { WidgetData } from "./types"; +import AlertQuality from "@/app/alerts/alert-quality-table"; +import { useSearchParams } from "next/navigation"; interface GridItemProps { item: WidgetData; - onEdit: (id: string) => void; + onEdit: (id: string, updateData?: WidgetData) => void; onDelete: (id: string) => void; + onSave: (updateItem: WidgetData) => void; } -const GridItem: React.FC = ({ item, onEdit, onDelete }) => { +function GenericMetrics({ + item, + filters, + setFilters, +}: { + item: WidgetData; + filters: any; + setFilters: any; +}) { + switch (item?.genericMetrics?.key) { + case "alert_quality": + return ( + + ); + + default: + return null; + } +} + +const GridItem: React.FC = ({ + item, + onEdit, + onDelete, + onSave, +}) => { + const searchParams = useSearchParams(); + const [filters, setFilters] = useState({ + ...(item?.genericMetrics?.meta?.defaultFilters || {}), + }); + let timeStampParams = searchParams?.get("time_stamp") ?? "{}"; + let timeStamp: { start?: string; end?: string } = {}; + try { + timeStamp = JSON.parse(timeStampParams as string); + } catch (e) { + timeStamp = {}; + } const getColor = () => { - let color = '#000000'; + let color = "#000000"; for (let i = item.thresholds.length - 1; i >= 0; i--) { - if (item.preset.alerts_count >= item.thresholds[i].value) { + if (item.preset && item.preset.alerts_count >= item.thresholds[i].value) { color = item.thresholds[i].color; break; } @@ -21,17 +64,59 @@ const GridItem: React.FC = ({ item, onEdit, onDelete }) => { return color; }; + function getUpdateItem() { + let newUpdateItem = item.genericMetrics; + if (newUpdateItem && newUpdateItem.meta) { + newUpdateItem.meta = { + ...newUpdateItem.meta, + defaultFilters: filters || {}, + }; + return { ...item }; + } + return item; + } + const handleEdit = () => { + onEdit(item.i, getUpdateItem()); + }; + return (
-
- {item.name} - onEdit(item.i)} onDelete={() => onDelete(item.i)} /> +
+ {/* For table view we need intract with table filter and pagination.so we aare dragging the widget here */} + + {item.name} + + onDelete(item.i)} + onSave={() => { + onSave(getUpdateItem()); + }} + />
-
-
- {item.preset.alerts_count} + {item.preset && ( + //We can remove drag and drop style and make it same as table view. if we want to maintain consistency. +
+
+ {item.preset.alerts_count} +
+ )} +
+
diff --git a/keep-ui/app/dashboard/GridItemContainer.tsx b/keep-ui/app/dashboard/GridItemContainer.tsx index c6b1384c2..356d8a621 100644 --- a/keep-ui/app/dashboard/GridItemContainer.tsx +++ b/keep-ui/app/dashboard/GridItemContainer.tsx @@ -6,11 +6,12 @@ interface GridItemContainerProps { item: WidgetData; onEdit: (id: string) => void; onDelete: (id: string) => void; + onSave: (updateItem: WidgetData) => void; } -const GridItemContainer: React.FC = ({ item, onEdit, onDelete }) => { +const GridItemContainer: React.FC = ({ item, onEdit, onDelete, onSave }) => { return ( - onEdit(item.i)} onDelete={() => onDelete(item.i)}/> + onEdit(item.i)} onDelete={() => onDelete(item.i)} onSave={onSave}/> ); }; diff --git a/keep-ui/app/dashboard/GridLayout.tsx b/keep-ui/app/dashboard/GridLayout.tsx index 89ed7e4db..647fc3f3b 100644 --- a/keep-ui/app/dashboard/GridLayout.tsx +++ b/keep-ui/app/dashboard/GridLayout.tsx @@ -14,43 +14,66 @@ interface GridLayoutProps { onEdit: (id: string) => void; onDelete: (id: string) => void; presets: Preset[]; + onSave: (updateItem: WidgetData) => void; } -const GridLayout: React.FC = ({ layout, onLayoutChange, data, onEdit, onDelete, presets }) => { +const GridLayout: React.FC = ({ + layout, + onLayoutChange, + data, + onEdit, + onDelete, + onSave, + presets, +}) => { const layouts = { lg: layout }; return ( - { - const updatedLayout = currentLayout.map(item => ({ - ...item, - static: item.static ?? false // Ensure static is a boolean - })); - onLayoutChange(updatedLayout as LayoutItem[]); - }} - breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} - cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} - rowHeight={30} - containerPadding={[0, 0]} - margin={[10, 10]} - useCSSTransforms={true} - isDraggable={true} - isResizable={true} - compactType={null} - draggableHandle=".grid-item__widget" - > - {data.map((item) => { + <> + { + const updatedLayout = currentLayout.map((item) => ({ + ...item, + static: item.static ?? false, // Ensure static is a boolean + })); + onLayoutChange(updatedLayout as LayoutItem[]); + }} + breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} + cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} + rowHeight={30} + containerPadding={[0, 0]} + margin={[10, 10]} + useCSSTransforms={true} + isDraggable={true} + isResizable={true} + compactType={null} + draggableHandle=".grid-item__widget" + > + {data.map((item) => { //Fixing the static hardcode db value. - const preset = presets?.find(p => p?.id === item?.preset?.id); - item.preset = { ...item.preset,alerts_count: preset?.alerts_count ?? 0}; - return ( -
- -
- )})} -
+ if (item.preset) { + const preset = presets?.find((p) => p?.id === item?.preset?.id); + item.preset = { + ...item.preset, + alerts_count: preset?.alerts_count ?? 0, + }; + + } + return ( +
+ +
+ ); })} +
+ ); }; diff --git a/keep-ui/app/dashboard/MenuButton.tsx b/keep-ui/app/dashboard/MenuButton.tsx index fb08bf699..b27fb2df3 100644 --- a/keep-ui/app/dashboard/MenuButton.tsx +++ b/keep-ui/app/dashboard/MenuButton.tsx @@ -3,13 +3,15 @@ import { Menu, Transition } from "@headlessui/react"; import { Icon } from "@tremor/react"; import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; import { Bars3Icon } from "@heroicons/react/20/solid"; +import { FiSave } from "react-icons/fi"; interface MenuButtonProps { onEdit: () => void; onDelete: () => void; + onSave?: () => void; } -const MenuButton: React.FC = ({ onEdit, onDelete }) => { +const MenuButton: React.FC = ({ onEdit, onDelete, onSave }) => { const stopPropagation = (e: React.MouseEvent) => { e.stopPropagation(); }; @@ -70,6 +72,24 @@ const MenuButton: React.FC = ({ onEdit, onDelete }) => { )} + {onSave && ( + + {({ active }) => ( + + )} + + )}
diff --git a/keep-ui/app/dashboard/WidgetModal.tsx b/keep-ui/app/dashboard/WidgetModal.tsx index d269c0b7d..a264c869a 100644 --- a/keep-ui/app/dashboard/WidgetModal.tsx +++ b/keep-ui/app/dashboard/WidgetModal.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, ChangeEvent, FormEvent } from "react"; import Modal from "@/components/ui/Modal"; import { Button, Subtitle, TextInput, Select, SelectItem, Icon } from "@tremor/react"; import { Trashcan } from "components/icons"; -import { Threshold, WidgetData } from "./types"; +import { Threshold, WidgetData, GenericsMertics } from "./types"; import { Preset } from "app/alerts/models"; import { useForm, Controller, get } from "react-hook-form"; @@ -10,17 +10,36 @@ interface WidgetForm { widgetName: string; selectedPreset: string; thresholds: Threshold[]; + selectedWidgetType: string; + selectedGenericMetrics: string; } interface WidgetModalProps { isOpen: boolean; onClose: () => void; - onAddWidget: (preset: Preset, thresholds: Threshold[], name: string) => void; + onAddWidget: ( + preset: Preset | null, + thresholds: Threshold[], + name: string, + widgetType?: string, + genericMetrics?: GenericsMertics | null, + ) => void; onEditWidget: (updatedWidget: WidgetData) => void; presets: Preset[]; editingItem?: WidgetData | null; } +const GENERIC_METRICS = [ + { + key: "alert_quality", + label: "Alert Quality", + widgetType: "table", + meta: { + defaultFilters: {"fields":"severity"}, + }, + }, +] as GenericsMertics[]; + const WidgetModal: React.FC = ({ isOpen, onClose, onAddWidget, onEditWidget, presets, editingItem }) => { const [thresholds, setThresholds] = useState([ { value: 0, color: '#22c55e' }, // Green @@ -32,19 +51,26 @@ const WidgetModal: React.FC = ({ isOpen, onClose, onAddWidget, widgetName: '', selectedPreset: '', thresholds: thresholds, + selectedWidgetType: '', + selectedGenericMetrics: '' } }); + const [currentWidgetType, setCurrentWidgetType] = useState(''); + useEffect(() => { if (editingItem) { - setValue('widgetName', editingItem.name); - setValue('selectedPreset', editingItem.preset.id); + setValue("widgetName", editingItem.name); + setValue("selectedPreset", editingItem?.preset?.id ?? ""); + setValue("selectedWidgetType", editingItem?.widgetType ?? ""); + setValue("selectedGenericMetrics", editingItem?.genericMetrics?.key ?? ""); setThresholds(editingItem.thresholds); } else { reset({ widgetName: '', selectedPreset: '', thresholds: thresholds, + selectedWidgetType: "", }); } }, [editingItem, setValue, reset]); @@ -75,24 +101,43 @@ const WidgetModal: React.FC = ({ isOpen, onClose, onAddWidget, setThresholds(thresholds.filter((_, i) => i !== index)); }; + const deepClone = (obj: GenericsMertics|undefined) => { + if(!obj){ + return null; + } + try{ + return JSON.parse(JSON.stringify(obj)) as GenericsMertics; + }catch(e){ + return null; + } + }; + const onSubmit = (data: WidgetForm) => { - const preset = presets.find(p => p.id === data.selectedPreset); - if (preset) { + const preset = presets.find(p => p.id === data.selectedPreset) ?? null; + if (preset || data.selectedGenericMetrics) { const formattedThresholds = thresholds.map(t => ({ ...t, value: parseInt(t.value.toString(), 10) || 0 })); if (editingItem) { - const updatedWidget: WidgetData = { + let updatedWidget: WidgetData = { ...editingItem, name: data.widgetName, + widgetType: data.selectedWidgetType || "preset", //backwards compatibility preset, thresholds: formattedThresholds, + genericMetrics: editingItem.genericMetrics || null, }; onEditWidget(updatedWidget); } else { - onAddWidget(preset, formattedThresholds, data.widgetName); + onAddWidget( + preset, + formattedThresholds, + data.widgetName, + data.selectedWidgetType, + deepClone(GENERIC_METRICS.find((g) => g.key === data.selectedGenericMetrics)) + ); // cleanup form setThresholds([ { value: 0, color: '#22c55e' }, // Green @@ -102,6 +147,8 @@ const WidgetModal: React.FC = ({ isOpen, onClose, onAddWidget, widgetName: '', selectedPreset: '', thresholds: thresholds, + selectedGenericMetrics: '', + selectedWidgetType: '', }); } onClose(); @@ -128,6 +175,34 @@ const WidgetModal: React.FC = ({ isOpen, onClose, onAddWidget, />
+ Widget Type + { + setCurrentWidgetType(field.value); + return + }} + /> +
+ {currentWidgetType === 'preset' ? ( + <> +
Preset = ({ isOpen, onClose, onAddWidget, ))}
+ + ): currentWidgetType === 'generic_metrics' && <> +
+ Generic Metrics + ( + + )} + /> +
+ } diff --git a/keep-ui/app/dashboard/[id]/dashboard.tsx b/keep-ui/app/dashboard/[id]/dashboard.tsx index f9d4009de..627b9773e 100644 --- a/keep-ui/app/dashboard/[id]/dashboard.tsx +++ b/keep-ui/app/dashboard/[id]/dashboard.tsx @@ -5,7 +5,7 @@ import GridLayout from '../GridLayout'; import { usePresets } from "utils/hooks/usePresets"; import WidgetModal from '../WidgetModal'; import { Button, Card, TextInput, Subtitle, Icon } from '@tremor/react'; -import { LayoutItem, WidgetData, Threshold } from '../types'; +import { LayoutItem, WidgetData, Threshold, GenericsMertics } from '../types'; import { Preset } from 'app/alerts/models'; import { FiSave, FiEdit2 } from 'react-icons/fi'; import { useSession } from 'next-auth/react'; @@ -54,16 +54,16 @@ const DashboardPage = () => { }; const closeModal = () => setIsModalOpen(false); - const handleAddWidget = (preset: Preset, thresholds: Threshold[], name: string) => { + const handleAddWidget = (preset: Preset|null, thresholds: Threshold[], name: string, widgetType?: string, genericMetrics?: GenericsMertics|null) => { const uniqueId = `w-${Date.now()}`; const newItem: LayoutItem = { i: uniqueId, x: (layout.length % 12) * 2, y: Math.floor(layout.length / 12) * 2, - w: 3, - h: 3, - minW: 2, - minH: 2, + w: genericMetrics ? 12 : 3, + h: genericMetrics ? 20 : 3, + minW: genericMetrics ? 10 : 2, + minH: genericMetrics ? 15 : 2, static: false }; const newWidget: WidgetData = { @@ -71,15 +71,22 @@ const DashboardPage = () => { thresholds, preset, name, + widgetType: widgetType || 'preset', + genericMetrics: genericMetrics || null, }; setLayout((prevLayout) => [...prevLayout, newItem]); setWidgetData((prevData) => [...prevData, newWidget]); }; - const handleEditWidget = (id: string) => { - const itemToEdit = widgetData.find(d => d.i === id) || null; - setEditingItem(itemToEdit); + const handleEditWidget = (id: string, update?: WidgetData) => { + let itemToEdit = widgetData.find(d => d.i === id) || null; + if(itemToEdit && update){ + setEditingItem({...itemToEdit, ...update}); + }else { + setEditingItem(itemToEdit); + } setIsModalOpen(true); + }; const handleSaveEdit = (updatedItem: WidgetData) => { @@ -202,6 +209,7 @@ const DashboardPage = () => { data={widgetData} onEdit={handleEditWidget} onDelete={handleDeleteWidget} + onSave={handleSaveEdit} presets={allPresets} />
diff --git a/keep-ui/app/dashboard/types.tsx b/keep-ui/app/dashboard/types.tsx index f21c53e14..05ca0cd95 100644 --- a/keep-ui/app/dashboard/types.tsx +++ b/keep-ui/app/dashboard/types.tsx @@ -10,10 +10,23 @@ export interface LayoutItem { static: boolean; } + export interface GenericsMertics { + key: string; + label: string; + widgetType: "table" | "chart"; + meta: { + defaultFilters: { + [key: string]: string|string[]; + }, + } + } + export interface WidgetData extends LayoutItem { thresholds: Threshold[]; - preset: Preset; + preset: Preset | null; name: string; + widgetType?:string; + genericMetrics?: GenericsMertics| null; } export interface Threshold { diff --git a/keep-ui/components/filters/GenericFilters.tsx b/keep-ui/components/filters/GenericFilters.tsx index 7d30e9f6c..71dd59acd 100644 --- a/keep-ui/components/filters/GenericFilters.tsx +++ b/keep-ui/components/filters/GenericFilters.tsx @@ -1,8 +1,6 @@ import GenericPopover from "@/components/popover/GenericPopover"; -import { Textarea, Badge, Button, Tab, TabGroup, TabList } from "@tremor/react"; -import moment from "moment"; import { usePathname, useSearchParams, useRouter } from "next/navigation"; -import { useRef, useState, useEffect, ChangeEvent } from "react"; +import { useRef, useState, useEffect, ChangeEvent, useMemo } from "react"; import { GoPlusCircle } from "react-icons/go"; import { DateRangePicker, DateRangePickerValue, Title } from "@tremor/react"; import { MdOutlineDateRange } from "react-icons/md"; @@ -17,6 +15,10 @@ type Filter = { options?: { value: string; label: string }[]; name: string; icon?: IconType; + only_one?: boolean; + searchParamsNotNeed?: boolean; + setFilter?: (value: string | string[] | Record) => void; + can_select?: number; }; interface FiltersProps { @@ -27,6 +29,9 @@ interface PopoverContentProps { filterRef: React.MutableRefObject; filterKey: string; type: string; + only_one?: boolean; + can_select?: number; + onApply?: () => void; } function toArray(value: string | string[]) { @@ -41,49 +46,82 @@ function toArray(value: string | string[]) { // TODO: Testing is needed function CustomSelect({ filter, - setLocalFilter, + only_one, + handleSelect, + can_select, }: { filter: Filter | null; - setLocalFilter: (value: string | string[]) => void; + handleSelect: (value: string | string[]) => void; + only_one?: boolean; + can_select?: number; }) { const filterKey = filter?.key || ""; const [selectedOptions, setSelectedOptions] = useState>( new Set() ); + const [localFilter, setLocalFilter] = useState(null); + useEffect(() => { if (filter) { setSelectedOptions(new Set(toArray(filter.value as string | string[]))); + setLocalFilter({ ...filter }); } - }, [filter]); + }, [filter, filter?.value]); const handleCheckboxChange = (option: string, checked: boolean) => { setSelectedOptions((prev) => { - const updatedOptions = new Set(prev); - if (checked) { + let updatedOptions = new Set(prev); + if (only_one) { + updatedOptions.clear(); + } + if ( + checked && + (!can_select || (can_select && updatedOptions.size < can_select)) + ) { updatedOptions.add(option); } else { updatedOptions.delete(option); } - if (filter) { - setLocalFilter(Array.from(updatedOptions)); - // setFilter((prev) => ({ ...prev, ...filter })); - } + let newValues = Array.from(updatedOptions); + setLocalFilter((prev) => { + if (prev) { + return { + ...prev, + value: newValues, + }; + } + return prev; + }); + handleSelect(newValues); return updatedOptions; }); }; - if (!filter) { + if (!localFilter) { return null; } + const name = `${filterKey?.charAt(0)?.toUpperCase() + filterKey?.slice(1)}`; + return ( - <> +
- Select {filterKey?.charAt(0)?.toUpperCase() + filterKey?.slice(1)} + Select {`${can_select ? `${can_select} ${name}` : name}`} + {can_select && ( + = can_select + ? "text-red-500" + : "text-green-600" + }`} + > + ({selectedOptions.size}/{can_select}) + + )}
    - {filter.options?.map((option) => ( + {localFilter.options?.map((option) => (
- +
); } @@ -137,7 +175,6 @@ function CustomDate({ const endDate = end || start; const endOfDayDate = endDate ? endOfDay(endDate) : end; - setDateRange({ from: start ?? undefined, to: endOfDayDate ?? undefined }); handleDate(start, endOfDayDate); }; @@ -168,39 +205,27 @@ const PopoverContent: React.FC = ({ filterRef, filterKey, type, + only_one, + can_select, + onApply, }) => { // Initialize local state for selected options + const filter = filterRef.current?.find((f) => f.key === filterKey); + if (!filter) { + return null; + } - const filter = filterRef.current?.find((filter) => filter.key === filterKey); - - const [localFilter, setLocalFilter] = useState(null); - - useEffect(() => { - if (filter) { - setLocalFilter({ ...filter }); - } - }, []); - - useEffect(() => { - if (localFilter && filter) { - filter.value = localFilter.value; - } - }, [localFilter?.value]); - - const handleLocalFilter = (value: string | string[]) => { - if (filter) { - filter.value = value; + const handleSelect = (value: string | string[]) => { + if (filterRef.current) { + const updatedFilters = filterRef.current.map((f) => + f.key === filterKey ? { ...f, value: value } : f + ); + filterRef.current = updatedFilters; } - setLocalFilter((prev) => { - if (prev) { - return { ...prev, value }; - } - return null; - }); }; const handleDate = (start?: Date, end?: Date) => { - let newValue = "" + let newValue = ""; if (!start && !end) { newValue = ""; } else { @@ -209,25 +234,28 @@ const PopoverContent: React.FC = ({ end: end || start, }); } - if (filter) { - filter.value = newValue; + if (filterRef.current) { + const updatedFilters = filterRef.current.map((f) => + f.key === filterKey ? { ...f, value: newValue } : f + ); + filterRef.current = updatedFilters; + onApply?.(); } - setLocalFilter((prev) => { - if (prev) { - return { ...prev, value: newValue }; - } - return null; - }); }; // Return the appropriate content based on the selected type switch (type) { case "select": return ( - + ); case "date": - return ; + return ; default: return null; } @@ -239,7 +267,7 @@ export const GenericFilters: React.FC = ({ filters }) => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const searchParamString = searchParams?.toString(); + const searchParamString = searchParams?.toString() || ""; const [apply, setApply] = useState(false); useEffect(() => { @@ -250,6 +278,13 @@ export const GenericFilters: React.FC = ({ filters }) => { const keys = filterRef.current.map((filter) => filter.key); keys.forEach((key) => newParams.delete(key)); for (const { key, value } of filterRef.current) { + const filter = filterRef.current.find( + (filter) => filter.key === key && filter.searchParamsNotNeed + ); + if (filter) { + newParams.delete(key); + continue; + } if (Array.isArray(value)) { for (const item of value) { newParams.append(key, item); @@ -262,8 +297,21 @@ export const GenericFilters: React.FC = ({ filters }) => { } } } - - router.push(`${pathname}?${newParams.toString()}`); + for (const { key, value } of filterRef.current) { + const filter = filterRef.current.find( + (filter) => filter.key === key && filter.searchParamsNotNeed + ); + if (filter && filter.type == 'select') { + let newValue = Array.isArray(value) && value.length == 0 ? "" : toArray(value as string | string[]); + if (filter.setFilter) { + filter.setFilter(newValue || ""); + } + continue; + } + } + if ((newParams?.toString() || "") !== searchParamString) { + router.push(`${pathname}?${newParams.toString()}`); + } setApply(false); // Reset apply state } }, [apply]); @@ -277,7 +325,7 @@ export const GenericFilters: React.FC = ({ filters }) => { if (Array.isArray(acc[key])) { acc[key] = [...acc[key], value]; return acc; - }else { + } else { acc[key] = [acc[key] as string, value]; } return acc; @@ -289,7 +337,12 @@ export const GenericFilters: React.FC = ({ filters }) => { // Update filterRef.current with the new params filterRef.current = filters.map((filter) => ({ ...filter, - value: params[filter.key] || "", + value: params[filter.key] || filter?.value || "", + })); + } else { + filterRef.current = filters.map((filter) => ({ + ...filter, + value: filter.value || "", })); } }, [searchParamString, filters]); @@ -312,23 +365,36 @@ export const GenericFilters: React.FC = ({ filters }) => { return (
{filters && - filters?.map(({ key, type, name, icon }) => { + filters?.map(({ key, type, name, icon, only_one, can_select }) => { //only type==select and date need popover i guess other text and textarea can be handled different. for now handling select and date icon = icon ?? type === "date" ? MdOutlineDateRange : GoPlusCircle; return (
- - } - onApply={() => setApply(true)} - /> + {type !== "date" ? ( + + } + onApply={() => setApply(true)} + /> + ) : ( + setApply(true)} + /> + )}
); })} diff --git a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx index a36cced85..460d65fe7 100644 --- a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx +++ b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx @@ -189,7 +189,7 @@ export const CustomPresetAlertLinks = ({ oldOrder.filter((p) => p.id !== presetId) ); - router.push("/alerts/feed"); + router.push("/alerts/feed"); // Redirect to feed } } }; diff --git a/keep-ui/components/navbar/Search.tsx b/keep-ui/components/navbar/Search.tsx index 9c8c8d778..63bfd6ad9 100644 --- a/keep-ui/components/navbar/Search.tsx +++ b/keep-ui/components/navbar/Search.tsx @@ -19,7 +19,7 @@ import { } from "@heroicons/react/24/outline"; import { VscDebugDisconnect } from "react-icons/vsc"; import { LuWorkflow } from "react-icons/lu"; -import { AiOutlineAlert } from "react-icons/ai"; +import { AiOutlineAlert, AiOutlineGroup } from "react-icons/ai"; import { MdOutlineEngineering, MdOutlineSearchOff } from "react-icons/md"; import KeepPng from "../../keep.png"; @@ -36,6 +36,12 @@ const NAVIGATION_OPTIONS = [ shortcut: ["g"], navigate: "/alerts/feed", }, + { + icon: AiOutlineGroup, + label: "Go to alert quality", + shortcut: ["q"], + navigate: "/alerts/quality", + }, { icon: MdOutlineEngineering, label: "Go to alert groups", diff --git a/keep-ui/components/table/GenericTable.tsx b/keep-ui/components/table/GenericTable.tsx index 0c26077fe..cc14683d2 100644 --- a/keep-ui/components/table/GenericTable.tsx +++ b/keep-ui/components/table/GenericTable.tsx @@ -26,6 +26,7 @@ interface GenericTableProps { limit: number; onPaginationChange: ( limit: number, offset: number ) => void; onRowClick?: (row: T) => void; + dataFetchedAtOneGO?: boolean } export function GenericTable({ @@ -36,6 +37,7 @@ export function GenericTable({ limit, onPaginationChange, onRowClick, + dataFetchedAtOneGO, }: GenericTableProps) { const [expanded, setExpanded] = useState({}); const [pagination, setPagination] = useState({ @@ -60,9 +62,11 @@ export function GenericTable({ } }, [pagination]); + const finalData = (dataFetchedAtOneGO ? data.slice(pagination.pageSize * pagination.pageIndex, pagination.pageSize * (pagination.pageIndex + 1)) : data) as T[] + const table = useReactTable({ columns, - data, + data: finalData, state: { expanded, pagination }, getCoreRowModel: getCoreRowModel(), manualPagination: true, @@ -76,7 +80,7 @@ export function GenericTable({ return (
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -115,7 +119,7 @@ export function GenericTable({
-
+
{pagination&& { + const { data: session } = useSession(); + const apiUrl = getApiURL(); + const searchParams = useSearchParams(); + ``; + let filters = useMemo(() => { + let params = searchParams?.toString(); + if (fields) { + fields = Array.isArray(fields) ? fields : [fields]; + let fieldParams = new URLSearchParams(""); + fields.forEach((field) => { + fieldParams.append("fields", field); + }); + params = params + ? `${params}&${fieldParams.toString()}` + : fieldParams.toString(); + } + return params; + }, [fields?.toString(), searchParams?.toString()]); + // TODO: Proper type needs to be defined. + return useSWRImmutable>>( + () => + session + ? `${apiUrl}/alerts/quality/metrics${filters ? `?${filters}` : ""}` + : null, + (url) => fetcher(url, session?.accessToken), + options + ); +}; diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 10a00c445..fdb6b30bb 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -19,7 +19,7 @@ import validators from dotenv import find_dotenv, load_dotenv from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor -from sqlalchemy import and_, case, desc, literal, null, union, update +from sqlalchemy import and_, case, desc, literal, null, union, update, func, case from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert @@ -3600,7 +3600,6 @@ def get_workflow_executions_for_incident_or_alert( results = session.execute(final_query).all() return results, total_count - def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Session] = None) -> bool: if incident.alerts_count == 0: @@ -3688,4 +3687,60 @@ def is_edge_incident_alert_resolved(incident: Incident, direction: Callable, ses return ( enriched_status == AlertStatus.RESOLVED.value or (enriched_status is None and status == AlertStatus.RESOLVED.value) - ) \ No newline at end of file + ) +def get_alerts_metrics_by_provider( + tenant_id: str, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + fields: Optional[List[str]] = [] +) -> Dict[str, Dict[str, Any]]: + + dynamic_field_sums = [ + func.sum( + case( + [ + ( + func.json_extract(Alert.event, f'$.{field}').isnot(None) & + (func.json_extract(Alert.event, f'$.{field}') != False), + 1 + ) + ], + else_=0 + ) + ).label(f"{field}_count") + for field in fields + ] + + with Session(engine) as session: + query = ( + session.query( + Alert.provider_type, + Alert.provider_id, + func.count(Alert.id).label("total_alerts"), + func.sum(case([(AlertToIncident.alert_id.isnot(None), 1)], else_=0)).label("correlated_alerts"), + *dynamic_field_sums + ) + .outerjoin(AlertToIncident, Alert.id == AlertToIncident.alert_id) + .filter( + Alert.tenant_id == tenant_id, + ) + ) + + # Add timestamp filter only if both start_date and end_date are provided + if start_date and end_date: + query = query.filter( + Alert.timestamp >= start_date, + Alert.timestamp <= end_date + ) + + results = query.group_by(Alert.provider_id, Alert.provider_type).all() + + return { + f"{row.provider_id}_{row.provider_type}": { + "total_alerts": row.total_alerts, + "correlated_alerts": row.correlated_alerts, + "provider_type": row.provider_type, + **{f"{field}_count": getattr(row, f"{field}_count") for field in fields} # Add field-specific counts + } + for row in results + } diff --git a/keep/api/routes/alerts.py b/keep/api/routes/alerts.py index ec02e8018..c14c092f9 100644 --- a/keep/api/routes/alerts.py +++ b/keep/api/routes/alerts.py @@ -4,7 +4,7 @@ import json import logging import os -from typing import Optional +from typing import Optional, List import celpy from arq import ArqRedis @@ -25,7 +25,7 @@ from keep.api.consts import KEEP_ARQ_QUEUE_BASIC 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.db import get_alerts_by_fingerprint, get_enrichment, get_last_alerts, get_alerts_metrics_by_provider from keep.api.core.dependencies import extract_generic_body, get_pusher_client from keep.api.core.elastic import ElasticClient from keep.api.models.alert import ( @@ -44,6 +44,8 @@ from keep.identitymanager.identitymanagerfactory import IdentityManagerFactory from keep.providers.providers_factory import ProvidersFactory from keep.searchengine.searchengine import SearchEngine +from keep.api.utils.time_stamp_helpers import get_time_stamp_filter +from keep.api.models.time_stamp import TimeStampFilter router = APIRouter() logger = logging.getLogger(__name__) @@ -756,3 +758,29 @@ def get_alert_audit( grouped_events = AlertAuditDto.from_orm_list(alert_audit) return grouped_events + + +@router.get("/quality/metrics", description="Get alert quality") +def get_alert_quality( + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["read:alert"]) + ), + time_stamp: TimeStampFilter = Depends(get_time_stamp_filter), + fields: Optional[List[str]] = Query([]), +): + logger.info( + "Fetching alert quality metrics per provider", + extra={ + "tenant_id": authenticated_entity.tenant_id, + "fields": fields + }, + + ) + start_date = time_stamp.lower_timestamp if time_stamp else None + end_date = time_stamp.upper_timestamp if time_stamp else None + db_alerts_quality = get_alerts_metrics_by_provider( + tenant_id=authenticated_entity.tenant_id, start_date=start_date, end_date=end_date, + fields=fields + ) + + return db_alerts_quality diff --git a/keep/api/utils/time_stamp_helpers.py b/keep/api/utils/time_stamp_helpers.py new file mode 100644 index 000000000..8e39324e8 --- /dev/null +++ b/keep/api/utils/time_stamp_helpers.py @@ -0,0 +1,20 @@ +from keep.api.models.time_stamp import TimeStampFilter +from fastapi import ( + HTTPException, + Query +) +from typing import Optional +import json + +def get_time_stamp_filter( + time_stamp: Optional[str] = Query(None) +) -> TimeStampFilter: + if time_stamp: + try: + # Parse the JSON string + time_stamp_dict = json.loads(time_stamp) + # Return the TimeStampFilter object, Pydantic will map 'from' -> lower_timestamp and 'to' -> upper_timestamp + return TimeStampFilter(**time_stamp_dict) + except (json.JSONDecodeError, TypeError): + raise HTTPException(status_code=400, detail="Invalid time_stamp format") + return TimeStampFilter() \ No newline at end of file From 7bf3c836c50104906e2f3d79ecd52e1c960b7ba1 Mon Sep 17 00:00:00 2001 From: Tal Date: Mon, 21 Oct 2024 16:20:21 +0300 Subject: [PATCH 4/4] feat(incident): activity tab (#2185) Signed-off-by: Tal Signed-off-by: Tal Co-authored-by: Shahar Glazner Co-authored-by: Kirill Chernakov --- keep-ui/app/alerts/alert-timeline.tsx | 6 +- .../app/incidents/[id]/incident-activity.css | 53 ++++ .../app/incidents/[id]/incident-activity.tsx | 263 ++++++++++++++++++ keep-ui/app/incidents/[id]/incident-info.tsx | 176 ++++++++---- keep-ui/app/incidents/[id]/incident.tsx | 19 +- keep-ui/app/settings/auth/users-table.tsx | 23 +- keep-ui/app/topology/model/useTopology.ts | 23 +- .../topology/model/useTopologyApplications.ts | 14 +- keep-ui/components/navbar/UserAvatar.tsx | 29 ++ keep-ui/components/navbar/UserInfo.tsx | 22 +- keep-ui/utils/hooks/useAlerts.ts | 9 +- keep-ui/utils/hooks/useDashboards.ts | 5 +- keep-ui/utils/hooks/useIncidents.ts | 41 ++- keep-ui/utils/hooks/useWorkflowExecutions.ts | 6 +- keep/api/core/db.py | 200 +++++++------ keep/api/models/db/alert.py | 12 +- keep/api/routes/incidents.py | 61 +++- 17 files changed, 741 insertions(+), 221 deletions(-) create mode 100644 keep-ui/app/incidents/[id]/incident-activity.css create mode 100644 keep-ui/app/incidents/[id]/incident-activity.tsx create mode 100644 keep-ui/components/navbar/UserAvatar.tsx diff --git a/keep-ui/app/alerts/alert-timeline.tsx b/keep-ui/app/alerts/alert-timeline.tsx index db584d624..02fe484e0 100644 --- a/keep-ui/app/alerts/alert-timeline.tsx +++ b/keep-ui/app/alerts/alert-timeline.tsx @@ -5,11 +5,7 @@ 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) ?? []) - .join("") - .toUpperCase(); +import { getInitials } from "@/components/navbar/UserAvatar"; const formatTimestamp = (timestamp: Date | string) => { const date = new Date(timestamp); diff --git a/keep-ui/app/incidents/[id]/incident-activity.css b/keep-ui/app/incidents/[id]/incident-activity.css new file mode 100644 index 000000000..5b4fc518d --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident-activity.css @@ -0,0 +1,53 @@ +.using-icon { + width: unset !important; + height: unset !important; + background: none !important; +} + +.rc-card { + filter: unset !important; +} + +.active { + color: unset !important; + background: unset !important; + border: unset !important; +} + +:focus { + outline: unset !important; +} + +li[class^="VerticalItemWrapper-"] { + margin: unset !important; +} + +[class^="TimelineTitleWrapper-"] { + display: none !important; +} + +[class^="TimelinePointWrapper-"] { + width: 5% !important; +} + +[class^="TimelineVerticalWrapper-"] + li + [class^="TimelinePointWrapper-"]::before { + background: lightgray !important; + width: 0.5px; +} + +[class^="TimelineVerticalWrapper-"] li [class^="TimelinePointWrapper-"]::after { + background: lightgray !important; + width: 0.5px; +} + +[class^="TimelineVerticalWrapper-"] + li:nth-of-type(1) + [class^="TimelinePointWrapper-"]::before { + display: none; +} + +.vertical-item-row { + justify-content: unset !important; +} diff --git a/keep-ui/app/incidents/[id]/incident-activity.tsx b/keep-ui/app/incidents/[id]/incident-activity.tsx new file mode 100644 index 000000000..2ab11e50f --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident-activity.tsx @@ -0,0 +1,263 @@ +import { AlertDto } from "@/app/alerts/models"; +import { IncidentDto } from "../models"; +import { Chrono } from "react-chrono"; +import { useUsers } from "@/utils/hooks/useUsers"; +import Image from "next/image"; +import UserAvatar from "@/components/navbar/UserAvatar"; +import "./incident-activity.css"; +import AlertSeverity from "@/app/alerts/alert-severity"; +import TimeAgo from "react-timeago"; +import { Button, TextInput } from "@tremor/react"; +import { + useIncidentAlerts, + usePollIncidentComments, +} from "@/utils/hooks/useIncidents"; +import { AuditEvent, useAlerts } from "@/utils/hooks/useAlerts"; +import Loading from "@/app/loading"; +import { useCallback, useState, useEffect } from "react"; +import { getApiURL } from "@/utils/apiUrl"; +import { useSession } from "next-auth/react"; +import { KeyedMutator } from "swr"; +import { toast } from "react-toastify"; + +interface IncidentActivity { + id: string; + type: "comment" | "alert" | "newcomment"; + text?: string; + timestamp: string; + initiator?: string | AlertDto; +} + +export function IncidentActivityChronoItem({ activity }: { activity: any }) { + const title = + typeof activity.initiator === "string" + ? activity.initiator + : activity.initiator?.name; + const subTitle = + typeof activity.initiator === "string" + ? " Added a comment. " + : (activity.initiator?.status === "firing" ? " triggered" : " resolved") + + ". "; + return ( +
+ {activity.type === "alert" && ( + + )} + {title} + + {subTitle} + + {activity.text && ( +
+ {activity.text} +
+ )} +
+ ); +} + + +export function IncidentActivityChronoItemComment({ + incident, + mutator, +}: { + incident: IncidentDto; + mutator: KeyedMutator; +}) { + const [comment, setComment] = useState(""); + const apiUrl = getApiURL(); + const { data: session } = useSession(); + + const onSubmit = useCallback(async () => { + const response = await fetch(`${apiUrl}/incidents/${incident.id}/comment`, { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: incident.status, + comment: comment, + }), + }); + if (response.ok) { + toast.success("Comment added!", { position: "top-right" }); + setComment(""); + mutator(); + } else { + toast.error("Failed to add comment", { position: "top-right" }); + } + }, [ + apiUrl, + incident.id, + incident.status, + comment, + session?.accessToken, + mutator, + ]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key === "Enter" && + (event.metaKey || event.ctrlKey) && + comment + ) { + onSubmit(); + } + }, + [onSubmit, comment] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [comment, handleKeyDown]); + + return ( +
+ + +
+ ); +} + +export default function IncidentActivity({ + incident, +}: { + incident: IncidentDto; +}) { + const { data: session } = useSession(); + const { useMultipleFingerprintsAlertAudit, useAlertAudit } = useAlerts(); + const { data: alerts, isLoading: alertsLoading } = useIncidentAlerts( + incident.id + ); + const { data: auditEvents, isLoading: auditEventsLoading } = + useMultipleFingerprintsAlertAudit(alerts?.items.map((m) => m.fingerprint)); + const { + data: incidentEvents, + isLoading: incidentEventsLoading, + mutate: mutateIncidentActivity, + } = useAlertAudit(incident.id); + + const { data: users, isLoading: usersLoading } = useUsers(); + usePollIncidentComments(incident.id); + + if ( + usersLoading || + incidentEventsLoading || + auditEventsLoading || + alertsLoading + ) + return ; + + const newCommentActivity = { + id: "newcomment", + type: "newcomment", + timestamp: new Date().toISOString(), + initiator: session?.user.email, + }; + + const auditActivities = + auditEvents + ?.concat(incidentEvents || []) + .sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ) + .map((auditEvent) => { + const _type = + auditEvent.action === "A comment was added to the incident" // @tb: I wish this was INCIDENT_COMMENT and not the text.. + ? "comment" + : "alert"; + return { + id: auditEvent.id, + type: _type, + initiator: + _type === "comment" + ? auditEvent.user_id + : alerts?.items.find( + (a) => a.fingerprint === auditEvent.fingerprint + ), + text: _type === "comment" ? auditEvent.description : "", + timestamp: auditEvent.timestamp, + } as IncidentActivity; + }) || []; + + const activities = [newCommentActivity, ...auditActivities]; + + const chronoContent = activities?.map((activity, index) => + activity.type === "newcomment" ? ( + + ) : ( + + ) + ); + const chronoIcons = activities?.map((activity, index) => { + if (activity.type === "comment" || activity.type === "newcomment") { + const user = users?.find((user) => user.email === activity.initiator); + return ( + + ); + } else { + const source = (activity.initiator as AlertDto).source[0]; + const imagePath = `/icons/${source}-icon.png`; + return ( + {source} + ); + } + }); + + return ( + ({ + id: activity.id, + title: activity.timestamp, + }))} + hideControls + disableToolbar + borderLessCards={true} + slideShow={false} + mode="VERTICAL" + cardWidth={600} + cardHeight={100} + allowDynamicUpdate={true} + disableAutoScrollOnClick={true} + > + {chronoContent} +
{chronoIcons}
+
+ ); +} diff --git a/keep-ui/app/incidents/[id]/incident-info.tsx b/keep-ui/app/incidents/[id]/incident-info.tsx index b8b1314de..87c9c35fe 100644 --- a/keep-ui/app/incidents/[id]/incident-info.tsx +++ b/keep-ui/app/incidents/[id]/incident-info.tsx @@ -1,10 +1,13 @@ -import {Badge, Button, Icon, Title} from "@tremor/react"; +import { Badge, Button, Icon, 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, MdPlayArrow} from "react-icons/md"; -import { useIncident, useIncidentFutureIncidents } from "@/utils/hooks/useIncidents"; +import { MdBlock, MdDone, MdModeEdit, MdPlayArrow } from "react-icons/md"; +import { + useIncident, + useIncidentFutureIncidents, +} from "@/utils/hooks/useIncidents"; import { deleteIncident, @@ -19,7 +22,7 @@ import classNames from "classnames"; import { IoChevronDown } from "react-icons/io5"; import IncidentChangeStatusModal from "@/app/incidents/incident-change-status-modal"; import ChangeSameIncidentInThePast from "@/app/incidents/incident-change-same-in-the-past"; -import {STATUS_ICONS} from "@/app/incidents/statuses"; +import { STATUS_ICONS } from "@/app/incidents/statuses"; import remarkRehype from "remark-rehype"; import rehypeRaw from "rehype-raw"; import Markdown from "react-markdown"; @@ -29,11 +32,13 @@ interface Props { incident: IncidentDto; } -function FollowingIncident({incidentId}: {incidentId: string}) { +function FollowingIncident({ incidentId }: { incidentId: string }) { const { data: incident } = useIncident(incidentId); return ( ); } @@ -49,13 +54,11 @@ function Summary({ collapsable?: boolean; className?: string; }) { - - const formatedSummary = + const formatedSummary = ( + {summary} + ); if (collapsable) { return ( @@ -125,7 +128,10 @@ export default function IncidentInformation({ incident }: Props) { setChangeStatusIncident(incident); }; - const handleChangeSameIncidentInThePast = (e: React.MouseEvent, incident: IncidentDto) => { + const handleChangeSameIncidentInThePast = ( + e: React.MouseEvent, + incident: IncidentDto + ) => { e.preventDefault(); e.stopPropagation(); setChangeSameIncidentInThePast(incident); @@ -133,8 +139,12 @@ export default function IncidentInformation({ incident }: Props) { const formatString = "dd, MMM yyyy - HH:mm.ss 'UTC'"; const summary = incident.user_summary || incident.generated_summary; - const { data: same_incident_in_the_past } = useIncident(incident.same_incident_in_the_past_id); - const { data: same_incidents_in_the_future } = useIncidentFutureIncidents(incident.id); + const { data: same_incident_in_the_past } = useIncident( + incident.same_incident_in_the_past_id + ); + const { data: same_incidents_in_the_future } = useIncidentFutureIncidents( + incident.id + ); const severity = incident.severity; let severityColor; @@ -150,19 +160,18 @@ export default function IncidentInformation({ incident }: Props) { {incident.is_confirmed ? "⚔️ " : "Possible "}Incident
- {incident.severity} + + {incident.severity} + {incident.user_generated_name || incident.ai_generated_name} @@ -235,7 +246,10 @@ export default function IncidentInformation({ incident }: Props) {

Status

-
handleChangeStatus(e, incident)} className="capitalize flex-grow-0 inline-flex items-center cursor-pointer"> +
handleChangeStatus(e, incident)} + className="capitalize flex-grow-0 inline-flex items-center cursor-pointer" + > {STATUS_ICONS[incident.status]} {incident.status}
@@ -252,37 +266,75 @@ export default function IncidentInformation({ incident }: Props) {

Assignee

- {incident.assignee ?

{incident.assignee}

:

No assignee yet

} + {incident.assignee ? ( +

{incident.assignee}

+ ) : ( +

No assignee yet

+ )}
-
- -
- -
+
+ - {same_incidents_in_the_future && same_incidents_in_the_future.items.length > 0 && ( -
-

Following Incidents

-
    - {same_incidents_in_the_future.items.map((item) => ( +
    +
+ {same_incidents_in_the_future && + same_incidents_in_the_future.items.length > 0 && ( +
+

Following Incidents

+
    + {same_incidents_in_the_future.items.map((item) => (
  • - ))} -
-
- )} + ))} + +
+ )}
{!!incident.start_time && (
-

Started at

+

Started at

{format(new Date(incident.start_time), formatString)}

@@ -316,18 +368,20 @@ export default function IncidentInformation({ incident }: Props) { /> - {changeSameIncidentInThePast ? setChangeSameIncidentInThePast(null)} - title="Link to the same incident in the past" - className="w-[600px]" - > - setChangeSameIncidentInThePast(null)} - /> - : null} + {changeSameIncidentInThePast ? ( + setChangeSameIncidentInThePast(null)} + title="Link to the same incident in the past" + className="w-[600px]" + > + setChangeSameIncidentInThePast(null)} + /> + + ) : null} ; if (error) return Incident does not exist.; @@ -56,6 +55,15 @@ export default function IncidentView({ incidentId }: Props) { color="orange" className="sticky xl:-top-10 -top-4 bg-white z-10" > + + Activity + + New + + Alerts Timeline Topology @@ -71,13 +79,16 @@ export default function IncidentView({ incidentId }: Props) { + + + - + @@ -41,13 +41,16 @@ export function UsersTable({ {/** Image */} - {authType === AuthenticationType.AUTH0 || authType === AuthenticationType.KEYCLOAK + {authType === AuthenticationType.AUTH0 || + authType === AuthenticationType.KEYCLOAK ? "Email" : "Username"} Name Role - {groupsAllowed && Groups} + {groupsAllowed && ( + Groups + )} Last Login @@ -84,9 +87,7 @@ export function UsersTable({
{user.email}
- {user.ldap && ( - LDAP - )} + {user.ldap && LDAP}
@@ -119,7 +120,11 @@ export function UsersTable({ )} - {user.last_login ? new Date(user.last_login).toLocaleString() : "Never"} + + {user.last_login + ? new Date(user.last_login).toLocaleString() + : "Never"} + {!isDisabled && user.email !== currentUserEmail && !user.ldap && ( diff --git a/keep-ui/app/topology/model/useTopology.ts b/keep-ui/app/topology/model/useTopology.ts index e31bad4b0..ee57a78ae 100644 --- a/keep-ui/app/topology/model/useTopology.ts +++ b/keep-ui/app/topology/model/useTopology.ts @@ -1,6 +1,6 @@ import { TopologyService } from "@/app/topology/model/models"; import { useSession } from "next-auth/react"; -import useSWR from "swr"; +import useSWR, { SWRConfiguration } from "swr"; import { getApiURL } from "@/utils/apiUrl"; import { fetcher } from "@/utils/fetcher"; import { useEffect } from "react"; @@ -14,15 +14,23 @@ type UseTopologyOptions = { services?: string[]; environment?: string; initialData?: TopologyService[]; + options?: SWRConfiguration; }; // TODO: ensure that hook is memoized so could be used multiple times in the tree without rerenders -export const useTopology = ({ - providerIds, - services, - environment, - initialData: fallbackData, -}: UseTopologyOptions = {}) => { +export const useTopology = ( + { + providerIds, + services, + environment, + initialData: fallbackData, + options, + }: UseTopologyOptions = { + options: { + revalidateOnFocus: false, + }, + } +) => { const { data: session } = useSession(); const pollTopology = useTopologyPollingContext(); @@ -35,6 +43,7 @@ export const useTopology = ({ (url: string) => fetcher(url, session!.accessToken), { fallbackData, + ...options, } ); diff --git a/keep-ui/app/topology/model/useTopologyApplications.ts b/keep-ui/app/topology/model/useTopologyApplications.ts index 524a4b79c..93da4e5f8 100644 --- a/keep-ui/app/topology/model/useTopologyApplications.ts +++ b/keep-ui/app/topology/model/useTopologyApplications.ts @@ -1,6 +1,6 @@ import { TopologyApplication } from "./models"; import { getApiURL } from "@/utils/apiUrl"; -import useSWR from "swr"; +import useSWR, { SWRConfiguration } from "swr"; import { fetcher } from "@/utils/fetcher"; import { useSession } from "next-auth/react"; import { useCallback, useMemo } from "react"; @@ -9,11 +9,16 @@ import { useRevalidateMultiple } from "@/utils/state"; type UseTopologyApplicationsOptions = { initialData?: TopologyApplication[]; + options?: SWRConfiguration; }; -export function useTopologyApplications({ - initialData, -}: UseTopologyApplicationsOptions = {}) { +export function useTopologyApplications( + { initialData, options }: UseTopologyApplicationsOptions = { + options: { + revalidateOnFocus: false, + }, + } +) { const apiUrl = getApiURL(); const { data: session } = useSession(); const revalidateMultiple = useRevalidateMultiple(); @@ -24,6 +29,7 @@ export function useTopologyApplications({ (url: string) => fetcher(url, session!.accessToken), { fallbackData: initialData, + ...options, } ); diff --git a/keep-ui/components/navbar/UserAvatar.tsx b/keep-ui/components/navbar/UserAvatar.tsx new file mode 100644 index 000000000..d90300363 --- /dev/null +++ b/keep-ui/components/navbar/UserAvatar.tsx @@ -0,0 +1,29 @@ +import Image from "next/image"; + +interface Props { + image: string | null | undefined; + name: string; +} + +export const getInitials = (name: string) => + ((name.match(/(^\S\S?|\b\S)?/g) ?? []).join("").match(/(^\S|\S$)?/g) ?? []) + .join("") + .toUpperCase(); + +export default function UserAvatar({ image, name }: Props) { + return image ? ( + user avatar + ) : ( + + + {getInitials(name)} + + + ); +} diff --git a/keep-ui/components/navbar/UserInfo.tsx b/keep-ui/components/navbar/UserInfo.tsx index 0af88c1f2..e848579a3 100644 --- a/keep-ui/components/navbar/UserInfo.tsx +++ b/keep-ui/components/navbar/UserInfo.tsx @@ -14,11 +14,7 @@ import { VscDebugDisconnect } from "react-icons/vsc"; import DarkModeToggle from "app/dark-mode-toggle"; import { useFloating } from "@floating-ui/react"; import { Icon, Subtitle } from "@tremor/react"; - -export const getInitials = (name: string) => - ((name.match(/(^\S\S?|\b\S)?/g) ?? []).join("").match(/(^\S|\S$)?/g) ?? []) - .join("") - .toUpperCase(); +import UserAvatar from "./UserAvatar"; type UserDropdownProps = { session: Session; @@ -38,21 +34,7 @@ const UserDropdown = ({ session }: UserDropdownProps) => { - {image ? ( - user avatar - ) : ( - - - {getInitials(name ?? email)} - - - )}{" "} + {" "} {name ?? email} diff --git a/keep-ui/utils/hooks/useAlerts.ts b/keep-ui/utils/hooks/useAlerts.ts index fb2e86dbd..8b0686b62 100644 --- a/keep-ui/utils/hooks/useAlerts.ts +++ b/keep-ui/utils/hooks/useAlerts.ts @@ -90,7 +90,10 @@ export const useAlerts = () => { const useMultipleFingerprintsAlertAudit = ( fingerprints: string[] | undefined, - options: SWRConfiguration = { revalidateOnFocus: true } + options: SWRConfiguration = { + revalidateOnFocus: true, + revalidateOnMount: false, + } ) => { return useSWR( () => @@ -112,7 +115,9 @@ export const useAlerts = () => { const useAlertAudit = ( fingerprint: string, - options: SWRConfiguration = { revalidateOnFocus: false } + options: SWRConfiguration = { + revalidateOnFocus: false, + } ) => { return useSWR( () => diff --git a/keep-ui/utils/hooks/useDashboards.ts b/keep-ui/utils/hooks/useDashboards.ts index a796afad4..de61bd967 100644 --- a/keep-ui/utils/hooks/useDashboards.ts +++ b/keep-ui/utils/hooks/useDashboards.ts @@ -15,7 +15,10 @@ export const useDashboards = () => { const { data, error, mutate } = useSWR( session ? `${apiUrl}/dashboard` : null, - (url: string) => fetcher(url, session!.accessToken) + (url: string) => fetcher(url, session!.accessToken), + { + revalidateOnFocus: false, + } ); return { diff --git a/keep-ui/utils/hooks/useIncidents.ts b/keep-ui/utils/hooks/useIncidents.ts index c9ba9d4f6..9e94c6386 100644 --- a/keep-ui/utils/hooks/useIncidents.ts +++ b/keep-ui/utils/hooks/useIncidents.ts @@ -2,7 +2,7 @@ import { IncidentDto, IncidentsMetaDto, PaginatedIncidentAlertsDto, - PaginatedIncidentsDto + PaginatedIncidentsDto, } from "../../app/incidents/models"; import { PaginatedWorkflowExecutionDto } from "app/workflows/builder/types"; import { useSession } from "next-auth/react"; @@ -11,17 +11,18 @@ import { getApiURL } from "utils/apiUrl"; import { fetcher } from "utils/fetcher"; import { useWebsocket } from "./usePusher"; import { useCallback, useEffect } from "react"; +import { useAlerts } from "./useAlerts"; interface IncidentUpdatePayload { incident_id: string | null; } interface Filters { - status: string[], - severity: string[], - assignees: string[] - sources: string[], - affected_services: string[], + status: string[]; + severity: string[]; + assignees: string[]; + sources: string[]; + affected_services: string[]; } export const useIncidents = ( @@ -32,7 +33,7 @@ export const useIncidents = ( filters: Filters | {} = {}, options: SWRConfiguration = { revalidateOnFocus: false, - }, + } ) => { const apiUrl = getApiURL(); const { data: session } = useSession(); @@ -44,7 +45,7 @@ export const useIncidents = ( filtersParams.delete(key as string); } else { value.forEach((s: string) => { - filtersParams.append(key, s) + filtersParams.append(key, s); }); } }); @@ -91,7 +92,8 @@ export const useIncidentFutureIncidents = ( const { data: session } = useSession(); return useSWR( - () => (session ? `${apiUrl}/incidents/${incidentId}/future_incidents` : null), + () => + session ? `${apiUrl}/incidents/${incidentId}/future_incidents` : null, (url) => fetcher(url, session?.accessToken), options ); @@ -133,6 +135,24 @@ export const useIncidentWorkflowExecutions = ( ); }; +export const usePollIncidentComments = (incidentId: string) => { + const { bind, unbind } = useWebsocket(); + const { useAlertAudit } = useAlerts(); + const { mutate: mutateIncidentActivity } = useAlertAudit(incidentId); + const handleIncoming = useCallback( + (data: IncidentUpdatePayload) => { + mutateIncidentActivity(); + }, + [mutateIncidentActivity] + ); + useEffect(() => { + bind("incident-comment", handleIncoming); + return () => { + unbind("incident-comment", handleIncoming); + }; + }, [bind, unbind, handleIncoming]); +}; + export const usePollIncidentAlerts = (incidentId: string) => { const { bind, unbind } = useWebsocket(); const { mutate } = useIncidentAlerts(incidentId); @@ -167,7 +187,6 @@ export const usePollIncidents = (mutateIncidents: any) => { }, [bind, unbind, handleIncoming]); }; - export const useIncidentsMeta = ( options: SWRConfiguration = { revalidateOnFocus: false, @@ -181,4 +200,4 @@ export const useIncidentsMeta = ( (url) => fetcher(url, session?.accessToken), options ); -}; \ No newline at end of file +}; diff --git a/keep-ui/utils/hooks/useWorkflowExecutions.ts b/keep-ui/utils/hooks/useWorkflowExecutions.ts index 19647af95..2414ce3c6 100644 --- a/keep-ui/utils/hooks/useWorkflowExecutions.ts +++ b/keep-ui/utils/hooks/useWorkflowExecutions.ts @@ -9,7 +9,11 @@ import useSWR, { SWRConfiguration } from "swr"; import { getApiURL } from "utils/apiUrl"; import { fetcher } from "utils/fetcher"; -export const useWorkflowExecutions = (options?: SWRConfiguration) => { +export const useWorkflowExecutions = ( + options: SWRConfiguration = { + revalidateOnFocus: false, + } +) => { const apiUrl = getApiURL(); const { data: session } = useSession(); diff --git a/keep/api/core/db.py b/keep/api/core/db.py index fdb6b30bb..0d1c0e3c5 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -12,7 +12,7 @@ from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Tuple, Union, Callable +from typing import Any, Callable, Dict, List, Tuple, Union from uuid import uuid4 import numpy as np @@ -25,13 +25,13 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.orm import joinedload, selectinload, subqueryload -from sqlalchemy.sql import expression, exists +from sqlalchemy.sql import exists, expression from sqlmodel import Session, col, or_, select, text from keep.api.core.db_utils import create_db_engine, get_json_extract_field # This import is required to create the tables -from keep.api.models.alert import IncidentDtoIn, IncidentSorting, AlertStatus +from keep.api.models.alert import AlertStatus, IncidentDtoIn, IncidentSorting from keep.api.models.db.action import Action from keep.api.models.db.alert import * # pylint: disable=unused-wildcard-import from keep.api.models.db.dashboard import * # pylint: disable=unused-wildcard-import @@ -62,9 +62,10 @@ "severity", "sources", "affected_services", - "assignee" + "assignee", ] + @contextmanager def existed_or_new_session(session: Optional[Session] = None) -> Session: if session: @@ -810,6 +811,27 @@ def get_last_workflow_executions(tenant_id: str, limit=20): return execution_with_logs +def add_audit( + tenant_id: str, + fingerprint: str, + user_id: str, + action: AlertActionType, + description: str, +) -> AlertAudit: + with Session(engine) as session: + audit = AlertAudit( + tenant_id=tenant_id, + fingerprint=fingerprint, + user_id=user_id, + action=action.value, + description=description, + ) + session.add(audit) + session.commit() + session.refresh(audit) + return audit + + def _enrich_alert( session, tenant_id, @@ -1504,7 +1526,7 @@ def create_rule( grouping_criteria=None, group_description=None, require_approve=False, - resolve_on=ResolveOn.NEVER.value + resolve_on=ResolveOn.NEVER.value, ): grouping_criteria = grouping_criteria or [] with Session(engine) as session: @@ -2373,13 +2395,12 @@ def update_preset_options(tenant_id: str, preset_id: str, options: dict) -> Pres def assign_alert_to_incident( - alert_id: UUID | str, - incident: Incident, - tenant_id: str, - session: Optional[Session]=None): - return add_alerts_to_incident( - tenant_id, incident, [alert_id], session=session - ) + alert_id: UUID | str, + incident: Incident, + tenant_id: str, + session: Optional[Session] = None, +): + return add_alerts_to_incident(tenant_id, incident, [alert_id], session=session) def is_alert_assigned_to_incident( @@ -2506,21 +2527,26 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: if session.bind.dialect.name == "sqlite": sources_join = func.json_each(Incident.sources).table_valued("value") - affected_services_join = func.json_each(Incident.affected_services).table_valued("value") + affected_services_join = func.json_each( + Incident.affected_services + ).table_valued("value") query = ( select( - func.json_group_array(col(Incident.assignee).distinct()).label("assignees"), - func.json_group_array(sources_join.c.value.distinct()).label("sources"), - func.json_group_array(affected_services_join.c.value.distinct()).label("affected_services"), + func.json_group_array(col(Incident.assignee).distinct()).label( + "assignees" + ), + func.json_group_array(sources_join.c.value.distinct()).label( + "sources" + ), + func.json_group_array( + affected_services_join.c.value.distinct() + ).label("affected_services"), ) .select_from(Incident) .outerjoin(sources_join, True) .outerjoin(affected_services_join, True) - .filter( - Incident.tenant_id == tenant_id, - Incident.is_confirmed == True - ) + .filter(Incident.tenant_id == tenant_id, Incident.is_confirmed == True) ) results = session.exec(query).one_or_none() @@ -2535,22 +2561,27 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: elif session.bind.dialect.name == "mysql": - sources_join = func.json_table(Incident.sources, Column('value', String(127))).table_valued("value") - affected_services_join = func.json_table(Incident.affected_services, Column('value', String(127))).table_valued("value") + sources_join = func.json_table( + Incident.sources, Column("value", String(127)) + ).table_valued("value") + affected_services_join = func.json_table( + Incident.affected_services, Column("value", String(127)) + ).table_valued("value") query = ( select( - func.group_concat(col(Incident.assignee).distinct()).label("assignees"), + func.group_concat(col(Incident.assignee).distinct()).label( + "assignees" + ), func.group_concat(sources_join.c.value.distinct()).label("sources"), - func.group_concat(affected_services_join.c.value.distinct()).label("affected_services"), + func.group_concat(affected_services_join.c.value.distinct()).label( + "affected_services" + ), ) .select_from(Incident) .outerjoin(sources_join, True) .outerjoin(affected_services_join, True) - .filter( - Incident.tenant_id == tenant_id, - Incident.is_confirmed == True - ) + .filter(Incident.tenant_id == tenant_id, Incident.is_confirmed == True) ) results = session.exec(query).one_or_none() @@ -2561,26 +2592,33 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: return { "assignees": results.assignees.split(",") if results.assignees else [], "sources": results.sources.split(",") if results.sources else [], - "services": results.affected_services.split(",") if results.affected_services else [], + "services": ( + results.affected_services.split(",") + if results.affected_services + else [] + ), } elif session.bind.dialect.name == "postgresql": - sources_join = func.json_array_elements_text(Incident.sources).table_valued("value") - affected_services_join = func.json_array_elements_text(Incident.affected_services).table_valued("value") + sources_join = func.json_array_elements_text(Incident.sources).table_valued( + "value" + ) + affected_services_join = func.json_array_elements_text( + Incident.affected_services + ).table_valued("value") query = ( select( func.json_agg(col(Incident.assignee).distinct()).label("assignees"), func.json_agg(sources_join.c.value.distinct()).label("sources"), - func.json_agg(affected_services_join.c.value.distinct()).label("affected_services"), + func.json_agg(affected_services_join.c.value.distinct()).label( + "affected_services" + ), ) .select_from(Incident) .outerjoin(sources_join, True) .outerjoin(affected_services_join, True) - .filter( - Incident.tenant_id == tenant_id, - Incident.is_confirmed == True - ) + .filter(Incident.tenant_id == tenant_id, Incident.is_confirmed == True) ) results = session.exec(query).one_or_none() @@ -2594,6 +2632,7 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: } return {} + def apply_incident_filters(session: Session, filters: dict, query): for field_name, value in filters.items(): if field_name in ALLOWED_INCIDENT_FILTERS: @@ -2609,31 +2648,22 @@ def apply_incident_filters(session: Session, filters: dict, query): else: field = getattr(Incident, field_name) if isinstance(value, list): - query = query.filter( - col(field).in_(value) - ) + query = query.filter(col(field).in_(value)) else: - query = query.filter( - col(field) == value - ) + query = query.filter(col(field) == value) return query + def filter_query(session: Session, query, field, value): if session.bind.dialect.name in ["mysql", "postgresql"]: if isinstance(value, list): if session.bind.dialect.name == "mysql": - query = query.filter( - func.json_overlaps(field, func.json_array(value)) - ) + query = query.filter(func.json_overlaps(field, func.json_array(value))) else: - query = query.filter( - col(field).op('?|')(func.array(value)) - ) + query = query.filter(col(field).op("?|")(func.array(value))) else: - query = query.filter( - func.json_contains(field, value) - ) + query = query.filter(func.json_contains(field, value)) elif session.bind.dialect.name == "sqlite": json_each_alias = func.json_each(field).table_valued("value") @@ -2646,6 +2676,7 @@ def filter_query(session: Session, query, field, value): query = query.filter(subquery.exists()) return query + def get_last_incidents( tenant_id: str, limit: int = 25, @@ -2778,7 +2809,9 @@ def update_incident_from_dto_by_id( incident.user_generated_name = updated_incident_dto.user_generated_name incident.assignee = updated_incident_dto.assignee - incident.same_incident_in_the_past_id = updated_incident_dto.same_incident_in_the_past_id + incident.same_incident_in_the_past_id = ( + updated_incident_dto.same_incident_in_the_past_id + ) if generated_by_ai: incident.generated_summary = updated_incident_dto.user_summary @@ -2868,7 +2901,6 @@ def get_incident_alerts_and_links_by_incident_id( return query.all(), total_count - def get_incident_alerts_by_incident_id(*args, **kwargs) -> tuple[List[Alert], int]: """ Unpacking (List[(Alert, AlertToIncident)], int) to (List[Alert], int). @@ -2884,12 +2916,10 @@ def get_future_incidents_by_incident_id( offset: Optional[int] = None, ) -> tuple[List[Incident], int]: with Session(engine) as session: - query = ( - session.query( - Incident, - ).filter(Incident.same_incident_in_the_past_id == incident_id) - ) - + query = session.query( + Incident, + ).filter(Incident.same_incident_in_the_past_id == incident_id) + if limit: query = query.limit(limit) if offset: @@ -3025,7 +3055,9 @@ def add_alerts_to_incident( if not new_alert_ids: return incident - alerts_data_for_incident = get_alerts_data_for_incident(new_alert_ids, session) + alerts_data_for_incident = get_alerts_data_for_incident( + new_alert_ids, session + ) incident.sources = list( set(incident.sources if incident.sources else []) | set(alerts_data_for_incident["sources"]) @@ -3115,7 +3147,7 @@ def get_last_alerts_for_incidents( def remove_alerts_to_incident_by_incident_id( - tenant_id: str, incident_id: str | UUID, alert_ids: List[UUID] + tenant_id: str, incident_id: str | UUID, alert_ids: List[UUID] ) -> Optional[int]: with Session(engine) as session: incident = session.exec( @@ -3600,14 +3632,18 @@ def get_workflow_executions_for_incident_or_alert( results = session.execute(final_query).all() return results, total_count -def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Session] = None) -> bool: +def is_all_incident_alerts_resolved( + incident: Incident, session: Optional[Session] = None +) -> bool: if incident.alerts_count == 0: return False with existed_or_new_session(session) as session: - enriched_status_field = get_json_extract_field(session, AlertEnrichment.enrichments, "status") + enriched_status_field = get_json_extract_field( + session, AlertEnrichment.enrichments, "status" + ) status_field = get_json_extract_field(session, Alert.event, "status") subquery = ( @@ -3616,7 +3652,9 @@ def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Sessio status_field.label("status"), ) .select_from(Alert) - .outerjoin(AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint) + .outerjoin( + AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint + ) .join(AlertToIncident, AlertToIncident.alert_id == Alert.id) .where( AlertToIncident.deleted_at == NULL_FOR_DELETED_AT, @@ -3638,8 +3676,8 @@ def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Sessio subquery.c.enriched_status != AlertStatus.RESOLVED.value, and_( subquery.c.enriched_status.is_(None), - subquery.c.status != AlertStatus.RESOLVED.value - ) + subquery.c.status != AlertStatus.RESOLVED.value, + ), ) ) ) @@ -3648,46 +3686,52 @@ def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Sessio return not not_resolved_exists -def is_last_incident_alert_resolved(incident: Incident, session: Optional[Session] = None) -> bool: +def is_last_incident_alert_resolved( + incident: Incident, session: Optional[Session] = None +) -> bool: return is_edge_incident_alert_resolved(incident, func.max, session) -def is_first_incident_alert_resolved(incident: Incident, session: Optional[Session] = None) -> bool: +def is_first_incident_alert_resolved( + incident: Incident, session: Optional[Session] = None +) -> bool: return is_edge_incident_alert_resolved(incident, func.min, session) -def is_edge_incident_alert_resolved(incident: Incident, direction: Callable, session: Optional[Session] = None) -> bool: +def is_edge_incident_alert_resolved( + incident: Incident, direction: Callable, session: Optional[Session] = None +) -> bool: if incident.alerts_count == 0: return False with existed_or_new_session(session) as session: - enriched_status_field = get_json_extract_field(session, AlertEnrichment.enrichments, "status") + enriched_status_field = get_json_extract_field( + session, AlertEnrichment.enrichments, "status" + ) status_field = get_json_extract_field(session, Alert.event, "status") finerprint, enriched_status, status = session.exec( - select( - Alert.fingerprint, - enriched_status_field, - status_field - ) + select(Alert.fingerprint, enriched_status_field, status_field) .select_from(Alert) - .outerjoin(AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint) + .outerjoin( + AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint + ) .join(AlertToIncident, AlertToIncident.alert_id == Alert.id) .where( - AlertToIncident.deleted_at == NULL_FOR_DELETED_AT, AlertToIncident.incident_id == incident.id ) .group_by(Alert.fingerprint) .having(func.max(Alert.timestamp)) .order_by(direction(Alert.timestamp)) ).first() - + return ( enriched_status == AlertStatus.RESOLVED.value or (enriched_status is None and status == AlertStatus.RESOLVED.value) ) + def get_alerts_metrics_by_provider( tenant_id: str, start_date: Optional[datetime] = None, diff --git a/keep/api/models/db/alert.py b/keep/api/models/db/alert.py index 171c6f612..b46690734 100644 --- a/keep/api/models/db/alert.py +++ b/keep/api/models/db/alert.py @@ -1,8 +1,7 @@ import enum import logging -from typing import Optional from datetime import datetime -from typing import List +from typing import List, Optional from uuid import UUID, uuid4 from sqlalchemy import ForeignKey, UniqueConstraint @@ -143,14 +142,14 @@ class Incident(SQLModel, table=True): ), ) - same_incident_in_the_past: Optional['Incident'] = Relationship( + same_incident_in_the_past: Optional["Incident"] = Relationship( back_populates="same_incidents_in_the_future", sa_relationship_kwargs=dict( - remote_side='Incident.id', - ) + remote_side="Incident.id", + ), ) - same_incidents_in_the_future: list['Incident'] = Relationship( + same_incidents_in_the_future: list["Incident"] = Relationship( back_populates="same_incident_in_the_past", ) @@ -388,3 +387,4 @@ class AlertActionType(enum.Enum): COMMENT = "a comment was added to the alert" UNCOMMENT = "a comment was removed from the alert" MAINTENANCE = "Alert is in maintenance window" + INCIDENT_COMMENT = "A comment was added to the incident" diff --git a/keep/api/routes/incidents.py b/keep/api/routes/incidents.py index 9160d9e62..bf2921bd8 100644 --- a/keep/api/routes/incidents.py +++ b/keep/api/routes/incidents.py @@ -6,13 +6,15 @@ from datetime import datetime from typing import List -from fastapi import APIRouter, Depends, HTTPException, Query, Response, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from pusher import Pusher from pydantic.types import UUID from keep.api.arq_pool import get_pool from keep.api.core.db import ( add_alerts_to_incident_by_incident_id, + add_audit, + change_incident_status_by_id, confirm_predicted_incident_by_id, create_incident_from_dto, delete_incident_by_id, @@ -21,27 +23,26 @@ get_incident_alerts_and_links_by_incident_id, get_incident_by_id, get_incident_unique_fingerprint_count, + get_incidents_meta_for_tenant, get_last_incidents, get_workflow_executions_for_incident_or_alert, remove_alerts_to_incident_by_incident_id, - change_incident_status_by_id, update_incident_from_dto_by_id, - get_incidents_meta_for_tenant, ) from keep.api.core.dependencies import get_pusher_client from keep.api.core.elastic import ElasticClient from keep.api.models.alert import ( AlertDto, + EnrichAlertRequestBody, IncidentDto, IncidentDtoIn, - IncidentStatusChangeDto, - IncidentStatus, - EnrichAlertRequestBody, - IncidentSorting, - IncidentSeverity, IncidentListFilterParamsDto, + IncidentSeverity, + IncidentSorting, + IncidentStatus, + IncidentStatusChangeDto, ) - +from keep.api.models.db.alert import AlertActionType, AlertAudit from keep.api.routes.alerts import _enrich_alert from keep.api.utils.enrichment_helpers import convert_db_alerts_to_dto_alerts from keep.api.utils.import_ee import mine_incidents_and_create_objects @@ -193,7 +194,6 @@ def get_all_incidents( if affected_services: filters["affected_services"] = affected_services - logger.info( "Fetching incidents from DB", extra={ @@ -437,7 +437,9 @@ def get_future_incidents_for_an_incident( offset=offset, incident_id=incident_id, ) - future_incidents = [IncidentDto.from_db_incident(incident) for incident in db_incidents] + future_incidents = [ + IncidentDto.from_db_incident(incident) for incident in db_incidents + ] logger.info( "Fetched future incidents from DB", extra={ @@ -524,7 +526,9 @@ async def add_alerts_to_incident( limit=len(alert_ids) + incident.alerts_count, ) - enriched_alerts_dto = convert_db_alerts_to_dto_alerts(db_alerts, with_incidents=True) + enriched_alerts_dto = convert_db_alerts_to_dto_alerts( + db_alerts, with_incidents=True + ) logger.info( "Fetched alerts from DB", extra={ @@ -726,3 +730,36 @@ def change_incident_status( new_incident_dto = IncidentDto.from_db_incident(incident) return new_incident_dto + + +@router.post("/{incident_id}/comment", description="Add incident audit activity") +def add_comment( + incident_id: UUID, + change: IncidentStatusChangeDto, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["write:incident"]) + ), + pusher_client: Pusher = Depends(get_pusher_client), +) -> AlertAudit: + extra = { + "tenant_id": authenticated_entity.tenant_id, + "commenter": authenticated_entity.email, + "comment": change.comment, + "incident_id": str(incident_id), + } + logger.info("Adding comment to incident", extra=extra) + comment = add_audit( + authenticated_entity.tenant_id, + str(incident_id), + authenticated_entity.email, + AlertActionType.INCIDENT_COMMENT, + change.comment, + ) + + if pusher_client: + pusher_client.trigger( + f"private-{authenticated_entity.tenant_id}", "incident-comment", {} + ) + + logger.info("Added comment to incident", extra=extra) + return comment