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/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/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/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/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/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-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