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