From b14ea913f30df4ef943de68535bf4a1956f2ba94 Mon Sep 17 00:00:00 2001 From: Shahar Glazner Date: Wed, 13 Nov 2024 10:45:45 +0200 Subject: [PATCH 1/2] feat(ui): improve facets a little bit (#2458) --- .../app/alerts/alert-table-alert-facets.tsx | 269 +++++++++++ .../app/alerts/alert-table-facet-dynamic.tsx | 98 ++++ .../app/alerts/alert-table-facet-types.tsx | 43 ++ .../app/alerts/alert-table-facet-utils.tsx | 110 +++++ .../app/alerts/alert-table-facet-value.tsx | 133 ++++++ keep-ui/app/alerts/alert-table-facet.tsx | 421 +----------------- keep-ui/app/alerts/alert-table.tsx | 64 ++- 7 files changed, 726 insertions(+), 412 deletions(-) create mode 100644 keep-ui/app/alerts/alert-table-alert-facets.tsx create mode 100644 keep-ui/app/alerts/alert-table-facet-dynamic.tsx create mode 100644 keep-ui/app/alerts/alert-table-facet-types.tsx create mode 100644 keep-ui/app/alerts/alert-table-facet-utils.tsx create mode 100644 keep-ui/app/alerts/alert-table-facet-value.tsx diff --git a/keep-ui/app/alerts/alert-table-alert-facets.tsx b/keep-ui/app/alerts/alert-table-alert-facets.tsx new file mode 100644 index 000000000..b6e75287c --- /dev/null +++ b/keep-ui/app/alerts/alert-table-alert-facets.tsx @@ -0,0 +1,269 @@ +import React from "react"; +import { + AlertFacetsProps, + FacetValue, + FacetFilters, +} from "./alert-table-facet-types"; +import { Facet } from "./alert-table-facet"; +import { + getFilteredAlertsForFacet, + getSeverityOrder, +} from "./alert-table-facet-utils"; +import { useLocalStorage } from "utils/hooks/useLocalStorage"; +import { AlertDto } from "./models"; +import { + DynamicFacet, + DynamicFacetWrapper, + AddFacetModal, +} from "./alert-table-facet-dynamic"; +import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; + +export const AlertFacets: React.FC = ({ + alerts, + facetFilters, + setFacetFilters, + dynamicFacets, + setDynamicFacets, + onDelete, + className, + table, +}) => { + const presetName = window.location.pathname.split("/").pop() || "default"; + + const [isModalOpen, setIsModalOpen] = useLocalStorage( + `addFacetModalOpen-${presetName}`, + false + ); + + const handleSelect = ( + facetKey: string, + value: string, + exclusive: boolean, + isAllOnly: boolean + ) => { + const newFilters = { ...facetFilters }; + + if (isAllOnly) { + if (exclusive) { + newFilters[facetKey] = [value]; + } else { + delete newFilters[facetKey]; + } + } else { + if (exclusive) { + newFilters[facetKey] = [value]; + } else { + const currentValues = newFilters[facetKey] || []; + if (currentValues.includes(value)) { + newFilters[facetKey] = currentValues.filter((v) => v !== value); + if (newFilters[facetKey].length === 0) { + delete newFilters[facetKey]; + } + } else { + newFilters[facetKey] = [...currentValues, value]; + } + } + } + + setFacetFilters(newFilters); + }; + + const getFacetValues = (key: keyof AlertDto | string): FacetValue[] => { + const filteredAlerts = getFilteredAlertsForFacet(alerts, facetFilters, key); + const valueMap = new Map(); + let nullCount = 0; + + filteredAlerts.forEach((alert) => { + let value; + + // Handle nested keys like "labels.host" + if (typeof key === "string" && key.includes(".")) { + const [parentKey, childKey] = key.split("."); + const parentValue = alert[parentKey as keyof AlertDto]; + + if ( + typeof parentValue === "object" && + parentValue !== null && + !Array.isArray(parentValue) && + !(parentValue instanceof Date) + ) { + value = (parentValue as Record)[childKey]; + } else { + value = undefined; + } + } else { + value = alert[key as keyof AlertDto]; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + nullCount++; + } else { + value.forEach((v) => { + valueMap.set(v, (valueMap.get(v) || 0) + 1); + }); + } + } else if (value !== undefined && value !== null) { + const strValue = String(value); + valueMap.set(strValue, (valueMap.get(strValue) || 0) + 1); + } else { + nullCount++; + } + }); + + let values = Array.from(valueMap.entries()).map(([label, count]) => ({ + label, + count, + isSelected: + facetFilters[key]?.includes(label) || !facetFilters[key]?.length, + })); + + if (["assignee", "incident"].includes(key as string) && nullCount > 0) { + values.push({ + label: "n/a", + count: nullCount, + isSelected: + facetFilters[key]?.includes("n/a") || !facetFilters[key]?.length, + }); + } + + if (key === "severity") { + values.sort((a, b) => { + if (a.label === "n/a") return 1; + if (b.label === "n/a") return -1; + const orderDiff = getSeverityOrder(a.label) - getSeverityOrder(b.label); + if (orderDiff !== 0) return orderDiff; + return b.count - a.count; + }); + } else { + values.sort((a, b) => { + if (a.label === "n/a") return 1; + if (b.label === "n/a") return -1; + return b.count - a.count; + }); + } + + return values; + }; + + const staticFacets = [ + "severity", + "status", + "source", + "assignee", + "dismissed", + "incident", + ]; + + const handleAddFacet = (column: string) => { + setDynamicFacets([ + ...dynamicFacets, + { + key: column, + name: column.charAt(0).toUpperCase() + column.slice(1), + }, + ]); + }; + + const handleDeleteFacet = (facetKey: string) => { + setDynamicFacets(dynamicFacets.filter((df) => df.key !== facetKey)); + const newFilters = { ...facetFilters }; + delete newFilters[facetKey]; + setFacetFilters(newFilters); + }; + + return ( +
+
+ {/* Facet button */} + + + handleSelect("severity", value, exclusive, isAllOnly) + } + facetKey="severity" + facetFilters={facetFilters} + /> + + handleSelect("status", value, exclusive, isAllOnly) + } + facetKey="status" + facetFilters={facetFilters} + /> + + handleSelect("source", value, exclusive, isAllOnly) + } + facetKey="source" + facetFilters={facetFilters} + /> + + handleSelect("assignee", value, exclusive, isAllOnly) + } + facetKey="assignee" + facetFilters={facetFilters} + /> + + handleSelect("dismissed", value, exclusive, isAllOnly) + } + facetKey="dismissed" + facetFilters={facetFilters} + /> + + handleSelect("incident", value, exclusive, isAllOnly) + } + facetFilters={facetFilters} + /> + {/* Dynamic facets */} + {dynamicFacets.map((facet) => ( + + handleSelect(facet.key, value, exclusive, isAllOnly) + } + facetKey={facet.key} + facetFilters={facetFilters} + onDelete={() => handleDeleteFacet(facet.key)} + /> + ))} + + {/* Facet Modal */} + setIsModalOpen(false)} + table={table} + onAddFacet={handleAddFacet} + existingFacets={[ + ...staticFacets, + ...dynamicFacets.map((df) => df.key), + ]} + /> +
+
+ ); +}; diff --git a/keep-ui/app/alerts/alert-table-facet-dynamic.tsx b/keep-ui/app/alerts/alert-table-facet-dynamic.tsx new file mode 100644 index 000000000..2d36dc75c --- /dev/null +++ b/keep-ui/app/alerts/alert-table-facet-dynamic.tsx @@ -0,0 +1,98 @@ +import React, { useState } from "react"; +import { TextInput } from "@tremor/react"; +import { TrashIcon } from "@heroicons/react/24/outline"; +import { FacetProps, FacetFilters } from "./alert-table-facet-types"; +import { AlertDto } from "./models"; +import { Facet } from "./alert-table-facet"; +import Modal from "@/components/ui/Modal"; +import { Table } from "@tanstack/table-core"; +import { FiSearch } from "react-icons/fi"; + +export interface DynamicFacet { + key: string; + name: string; +} + +interface AddFacetModalProps { + isOpen: boolean; + onClose: () => void; + table: Table; + onAddFacet: (column: string) => void; + existingFacets: string[]; +} + +export const AddFacetModal: React.FC = ({ + isOpen, + onClose, + table, + onAddFacet, + existingFacets, +}) => { + const [searchTerm, setSearchTerm] = useState(""); + + const availableColumns = table + .getAllColumns() + .filter( + (col) => + // Filter out pinned columns and existing facets + !col.getIsPinned() && + !existingFacets.includes(col.id) && + // Filter by search term + col.id.toLowerCase().includes(searchTerm.toLowerCase()) + ) + .map((col) => col.id); + + return ( + +
+ setSearchTerm(e.target.value)} + className="mb-4" + /> +
+ {availableColumns.map((column) => ( + + ))} +
+
+
+ ); +}; + +export interface DynamicFacetProps extends FacetProps { + onDelete: () => void; +} + +export const DynamicFacetWrapper: React.FC = ({ + onDelete, + ...facetProps +}) => { + return ( +
+ + +
+ ); +}; diff --git a/keep-ui/app/alerts/alert-table-facet-types.tsx b/keep-ui/app/alerts/alert-table-facet-types.tsx new file mode 100644 index 000000000..346c8ac1e --- /dev/null +++ b/keep-ui/app/alerts/alert-table-facet-types.tsx @@ -0,0 +1,43 @@ +import { AlertDto, Severity } from "./models"; +import { Table } from "@tanstack/table-core"; +import { DynamicFacet } from "./alert-table-facet-dynamic"; + +export interface FacetValue { + label: string; + count: number; + isSelected: boolean; +} + +export interface FacetFilters { + [key: string]: string[]; +} + +export interface FacetValueProps { + label: string; + count: number; + isSelected: boolean; + onSelect: (value: string, exclusive: boolean, isAllOnly: boolean) => void; + facetKey: string; + showIcon?: boolean; + facetFilters: FacetFilters; +} + +export interface FacetProps { + name: string; + values: FacetValue[]; + onSelect: (value: string, exclusive: boolean, isAllOnly: boolean) => void; + facetKey: string; + facetFilters: FacetFilters; + showIcon?: boolean; +} + +export interface AlertFacetsProps { + alerts: AlertDto[]; + facetFilters: FacetFilters; + setFacetFilters: (filters: FacetFilters) => void; + dynamicFacets: DynamicFacet[]; + setDynamicFacets: (facets: DynamicFacet[]) => void; + onDelete: (facetKey: string) => void; + className?: string; + table: Table; +} diff --git a/keep-ui/app/alerts/alert-table-facet-utils.tsx b/keep-ui/app/alerts/alert-table-facet-utils.tsx new file mode 100644 index 000000000..680043162 --- /dev/null +++ b/keep-ui/app/alerts/alert-table-facet-utils.tsx @@ -0,0 +1,110 @@ +import { FacetFilters } from "./alert-table-facet-types"; +import { AlertDto, Severity } from "./models"; +import { + ChevronDownIcon, + ChevronRightIcon, + UserCircleIcon, + BellIcon, + ExclamationCircleIcon, + CheckCircleIcon, + CircleStackIcon, + BellSlashIcon, + FireIcon, +} from "@heroicons/react/24/outline"; + +export const getFilteredAlertsForFacet = ( + alerts: AlertDto[], + facetFilters: FacetFilters, + excludeFacet: string +): AlertDto[] => { + return alerts.filter((alert) => { + return Object.entries(facetFilters).every(([facetKey, includedValues]) => { + if (facetKey === excludeFacet || includedValues.length === 0) { + return true; + } + + let value; + if (facetKey.includes(".")) { + // Handle nested keys like "labels.job" + const [parentKey, childKey] = facetKey.split("."); + const parentValue = alert[parentKey as keyof AlertDto]; + + if ( + typeof parentValue === "object" && + parentValue !== null && + !Array.isArray(parentValue) && + !(parentValue instanceof Date) + ) { + value = (parentValue as Record)[childKey]; + } + } else { + value = alert[facetKey as keyof AlertDto]; + } + + if (facetKey === "source") { + const sources = value as string[]; + if (includedValues.includes("n/a")) { + return !sources || sources.length === 0; + } + return ( + Array.isArray(sources) && + sources.some((source) => includedValues.includes(source)) + ); + } + + if (includedValues.includes("n/a")) { + return value === null || value === undefined || value === ""; + } + + if (value === null || value === undefined || value === "") { + return false; + } + + return includedValues.includes(String(value)); + }); + }); +}; + +export const getStatusIcon = (status: string) => { + switch (status.toLowerCase()) { + case "firing": + return ExclamationCircleIcon; + case "resolved": + return CheckCircleIcon; + case "acknowledged": + return CircleStackIcon; + default: + return CircleStackIcon; + } +}; + +export const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "firing": + return "red"; + case "resolved": + return "green"; + case "acknowledged": + return "blue"; + default: + return "gray"; + } +}; + +export const getSeverityOrder = (severity: string): number => { + switch (severity) { + case "low": + return 1; + case "info": + return 2; + case "warning": + return 3; + case "error": + case "high": + return 4; + case "critical": + return 5; + default: + return 6; + } +}; diff --git a/keep-ui/app/alerts/alert-table-facet-value.tsx b/keep-ui/app/alerts/alert-table-facet-value.tsx new file mode 100644 index 000000000..b46291a99 --- /dev/null +++ b/keep-ui/app/alerts/alert-table-facet-value.tsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import { Icon } from "@tremor/react"; +import Image from "next/image"; +import { Text } from "@tremor/react"; +import { FacetValueProps } from "./alert-table-facet-types"; +import { getStatusIcon, getStatusColor } from "./alert-table-facet-utils"; +import { + UserCircleIcon, + BellIcon, + BellSlashIcon, + FireIcon, +} from "@heroicons/react/24/outline"; +import AlertSeverity from "./alert-severity"; +import { Severity } from "./models"; + +export const FacetValue: React.FC = ({ + label, + count, + isSelected, + onSelect, + facetKey, + showIcon = false, + facetFilters, +}) => { + const [isHovered, setIsHovered] = useState(false); + + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(label, false, false); + }; + + const isExclusivelySelected = () => { + const currentFilter = facetFilters[facetKey] || []; + return currentFilter.length === 1 && currentFilter[0] === label; + }; + + const handleActionClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isExclusivelySelected()) { + onSelect("", false, true); + } else { + onSelect(label, true, true); + } + }; + + const currentFilter = facetFilters[facetKey] || []; + const isValueSelected = + !currentFilter?.length || currentFilter.includes(label); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {}} + style={{ accentColor: "#eb6221" }} + className="h-4 w-4 rounded border-gray-300 cursor-pointer" + /> +
+ + {showIcon && ( +
+ {facetKey === "source" && ( + {label} + )} + {facetKey === "severity" && ( + + )} + {facetKey === "assignee" && ( + + )} + {facetKey === "status" && ( + + )} + {facetKey === "dismissed" && ( + + )} + {facetKey === "incident" && ( + + )} +
+ )} + +
+ {label} +
+ +
+ {isHovered ? ( + + ) : ( + count > 0 && {count} + )} +
+
+ ); +}; diff --git a/keep-ui/app/alerts/alert-table-facet.tsx b/keep-ui/app/alerts/alert-table-facet.tsx index fc02eedfb..84c801afb 100644 --- a/keep-ui/app/alerts/alert-table-facet.tsx +++ b/keep-ui/app/alerts/alert-table-facet.tsx @@ -1,261 +1,32 @@ import React, { useState } from "react"; -import { Icon, Title, Text } from "@tremor/react"; -import Image from "next/image"; -import { - ChevronDownIcon, - ChevronRightIcon, - MagnifyingGlassIcon, - CircleStackIcon, - CheckCircleIcon, - XCircleIcon, - BellIcon, - ExclamationCircleIcon, - UserCircleIcon, - BellSlashIcon, - FireIcon, -} from "@heroicons/react/24/outline"; -import { AlertDto, Severity } from "./models"; -import AlertSeverity from "./alert-severity"; +import { Icon, Title } from "@tremor/react"; +import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; +import { FacetProps } from "./alert-table-facet-types"; +import { FacetValue } from "./alert-table-facet-value"; +import { useLocalStorage } from "utils/hooks/useLocalStorage"; -interface FacetValue { - label: string; - count: number; - isSelected: boolean; -} - -export interface FacetFilters { - [key: string]: string[]; -} - -const getFilteredAlertsForFacet = ( - alerts: AlertDto[], - facetFilters: FacetFilters, - excludeFacet: string -): AlertDto[] => { - return alerts.filter((alert) => { - return Object.entries(facetFilters).every(([facetKey, includedValues]) => { - // Skip the current facet when filtering - if (facetKey === excludeFacet || includedValues.length === 0) { - return true; - } - - const value = alert[facetKey as keyof AlertDto]; - - if (facetKey === "source") { - const sources = value as string[]; - if (includedValues.includes("n/a")) { - return !sources || sources.length === 0; - } - return ( - Array.isArray(sources) && - sources.some((source) => includedValues.includes(source)) - ); - } - - if (includedValues.includes("n/a")) { - return value === null || value === undefined || value === ""; - } - - if (value === null || value === undefined || value === "") { - return false; - } - - return includedValues.includes(String(value)); - }); - }); -}; - -interface FacetValueProps { - label: string; - count: number; - isSelected: boolean; - onSelect: (value: string, exclusive: boolean, isAllOnly: boolean) => void; - facetKey: string; - showIcon?: boolean; - isOnlySelected?: boolean; - facetFilters: FacetFilters; -} - -const getStatusIcon = (status: string) => { - switch (status.toLowerCase()) { - case "firing": - return ExclamationCircleIcon; - case "resolved": - return CheckCircleIcon; - case "acknowledged": - return CircleStackIcon; - default: - return CircleStackIcon; - } -}; - -const getStatusColor = (status: string) => { - switch (status.toLowerCase()) { - case "firing": - return "red"; - case "resolved": - return "green"; - case "acknowledged": - return "blue"; - default: - return "gray"; - } -}; - -const getSeverityOrder = (severity: string): number => { - switch (severity) { - case "low": - return 1; - case "info": - return 2; - case "warning": - return 3; - case "error": - case "high": - return 4; - case "critical": - return 5; - default: - return 6; // Unknown severities go last - } -}; - -const FacetValue: React.FC = ({ - label, - count, - isSelected, +export const Facet: React.FC = ({ + name, + values, onSelect, facetKey, - showIcon = false, facetFilters, + showIcon = true, }) => { - const [isHovered, setIsHovered] = useState(false); - - const handleCheckboxClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onSelect(label, false, false); - }; - - const isExclusivelySelected = () => { - const currentFilter = facetFilters[facetKey] || []; - return currentFilter.length === 1 && currentFilter[0] === label; - }; - - const handleActionClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (isExclusivelySelected()) { - onSelect("", false, true); - } else { - onSelect(label, true, true); - } - }; - - const currentFilter = facetFilters[facetKey] || []; - const isValueSelected = - !currentFilter?.length || currentFilter.includes(label); - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
- {}} - style={{ accentColor: "#eb6221" }} - className="h-4 w-4 rounded border-gray-300 cursor-pointer" - /> -
- - {showIcon && ( -
- {facetKey === "source" && ( - {label} - )} - {facetKey === "severity" && ( - - )} - {facetKey === "assignee" && ( - - )} - {facetKey === "status" && ( - - )} - {facetKey === "dismissed" && ( - - )} - {facetKey === "incident" && ( - - )} -
- )} - -
- {label} -
+ // Get preset name from URL + const presetName = window.location.pathname.split("/").pop() || "default"; -
- {isHovered ? ( - - ) : ( - count > 0 && {count} - )} -
-
+ // Store open/close state in localStorage with a unique key per preset and facet + const [isOpen, setIsOpen] = useLocalStorage( + `facet-${presetName}-${facetKey}-open`, + true ); -}; - -interface FacetProps { - name: string; - values: FacetValue[]; - onSelect: (value: string, exclusive: boolean, isAllOnly: boolean) => void; - facetKey: string; - facetFilters: FacetFilters; -} -const Facet: React.FC = ({ - name, - values, - onSelect, - facetKey, - facetFilters, -}) => { - const [isOpen, setIsOpen] = useState(true); - const [filter, setFilter] = useState(""); + // Store filter value in localStorage per preset and facet + const [filter, setFilter] = useLocalStorage( + `facet-${presetName}-${facetKey}-filter`, + "" + ); const filteredValues = values.filter((v) => v.label.toLowerCase().includes(filter.toLowerCase()) @@ -300,7 +71,7 @@ const Facet: React.FC = ({ isSelected={facetFilters[facetKey]?.includes(value.label)} onSelect={onSelect} facetKey={facetKey} - showIcon={true} + showIcon={showIcon} facetFilters={facetFilters} /> )) @@ -315,153 +86,3 @@ const Facet: React.FC = ({ ); }; - -interface AlertFacetsProps { - alerts: AlertDto[]; - facetFilters: FacetFilters; - onSelect: ( - facetKey: string, - value: string, - exclusive: boolean, - isAllOnly: boolean - ) => void; - className?: string; -} -const AlertFacets: React.FC = ({ - alerts, - facetFilters, - onSelect, - className, -}) => { - const getFacetValues = (key: keyof AlertDto): FacetValue[] => { - // Get alerts filtered by all other facets except the current one - const filteredAlerts = getFilteredAlertsForFacet( - alerts, - facetFilters, - key as string - ); - - const valueMap = new Map(); - let nullCount = 0; - - filteredAlerts.forEach((alert) => { - let value = alert[key]; - - if (Array.isArray(value)) { - if (value.length === 0) { - nullCount++; - } else { - value.forEach((v) => { - valueMap.set(v, (valueMap.get(v) || 0) + 1); - }); - } - } else if (value !== undefined && value !== null) { - const strValue = String(value); - valueMap.set(strValue, (valueMap.get(strValue) || 0) + 1); - } else { - nullCount++; - } - }); - - let values = Array.from(valueMap.entries()).map(([label, count]) => ({ - label, - count, - isSelected: - facetFilters[key]?.includes(label) || !facetFilters[key]?.length, - })); - - if (shouldShowNAValue(key) && nullCount > 0) { - values.push({ - label: "n/a", - count: nullCount, - isSelected: - facetFilters[key]?.includes("n/a") || !facetFilters[key]?.length, - }); - } - - if (key === "severity") { - values.sort((a, b) => { - if (a.label === "n/a") return 1; - if (b.label === "n/a") return -1; - const orderDiff = getSeverityOrder(a.label) - getSeverityOrder(b.label); - if (orderDiff !== 0) return orderDiff; - return b.count - a.count; - }); - } else { - values.sort((a, b) => { - if (a.label === "n/a") return 1; - if (b.label === "n/a") return -1; - return b.count - a.count; - }); - } - - return values; - }; - - const shouldShowNAValue = (key: keyof AlertDto): boolean => { - return ["assignee", "incident"].includes(key as string); - }; - - return ( -
-
- - onSelect("severity", value, exclusive, isAllOnly) - } - facetFilters={facetFilters} - /> - - onSelect("status", value, exclusive, isAllOnly) - } - facetFilters={facetFilters} - /> - - onSelect("source", value, exclusive, isAllOnly) - } - facetFilters={facetFilters} - /> - - onSelect("assignee", value, exclusive, isAllOnly) - } - facetFilters={facetFilters} - /> - - onSelect("dismissed", value, exclusive, isAllOnly) - } - facetFilters={facetFilters} - /> - - onSelect("incident", value, exclusive, isAllOnly) - } - facetFilters={facetFilters} - /> -
-
- ); -}; - -export default AlertFacets; diff --git a/keep-ui/app/alerts/alert-table.tsx b/keep-ui/app/alerts/alert-table.tsx index 8bcf13f3a..6c3436dd1 100644 --- a/keep-ui/app/alerts/alert-table.tsx +++ b/keep-ui/app/alerts/alert-table.tsx @@ -32,8 +32,9 @@ import { TitleAndFilters } from "./TitleAndFilters"; import { severityMapping } from "./models"; import AlertTabs from "./alert-tabs"; import AlertSidebar from "./alert-sidebar"; -import AlertFacets from "./alert-table-facet"; -import { FacetFilters } from "./alert-table-facet"; +import { AlertFacets } from "./alert-table-alert-facets"; +import { FacetFilters } from "./alert-table-facet-types"; +import { DynamicFacet } from "./alert-table-facet-dynamic"; interface PresetTab { name: string; @@ -81,14 +82,33 @@ export function AlertTable({ ) ); - const [facetFilters, setFacetFilters] = useState({ - severity: [], - status: [], - source: [], - assignee: [], - dismissed: [], - incident: [], - }); + const [facetFilters, setFacetFilters] = useLocalStorage( + `alertFacetFilters-${presetName}`, + { + severity: [], + status: [], + source: [], + assignee: [], + dismissed: [], + incident: [], + } + ); + + const [dynamicFacets, setDynamicFacets] = useLocalStorage( + `dynamicFacets-${presetName}`, + [] + ); + + const handleFacetDelete = (facetKey: string) => { + setDynamicFacets((prevFacets) => + prevFacets.filter((df) => df.key !== facetKey) + ); + setFacetFilters((prevFilters) => { + const newFilters = { ...prevFilters }; + delete newFilters[facetKey]; + return newFilters; + }); + }; const columnsIds = getColumnsIds(columns); @@ -142,7 +162,23 @@ export function AlertTable({ return true; } - const value = alert[facetKey as keyof AlertDto]; + let value; + if (facetKey.includes(".")) { + // Handle nested keys like "labels.job" + const [parentKey, childKey] = facetKey.split("."); + const parentValue = alert[parentKey as keyof AlertDto]; + + if ( + typeof parentValue === "object" && + parentValue !== null && + !Array.isArray(parentValue) && + !(parentValue instanceof Date) + ) { + value = (parentValue as Record)[childKey]; + } + } else { + value = alert[facetKey as keyof AlertDto]; + } // Handle source array separately if (facetKey === "source") { @@ -303,7 +339,11 @@ export function AlertTable({ className="sticky top-0" alerts={alerts} facetFilters={facetFilters} - onSelect={handleFacetSelect} + setFacetFilters={setFacetFilters} + dynamicFacets={dynamicFacets} + setDynamicFacets={setDynamicFacets} + onDelete={handleFacetDelete} + table={table} /> {/* Using p-4 -m-4 to set overflow-hidden without affecting shadow */} From befe1110148a64941147eb6a1fb91c365a31e8a8 Mon Sep 17 00:00:00 2001 From: Tal Date: Wed, 13 Nov 2024 14:09:48 +0200 Subject: [PATCH 2/2] fix(provider): pagerduty with routing key to not validate scopes (#2468) --- keep-ui/app/workflows/builder/builder.tsx | 2 +- keep/api/tasks/process_event_task.py | 7 ++- keep/providers/base/base_provider.py | 16 +++---- .../pagerduty_provider/pagerduty_provider.py | 44 +++++++++++++++++-- pyproject.toml | 2 +- 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/keep-ui/app/workflows/builder/builder.tsx b/keep-ui/app/workflows/builder/builder.tsx index e2400c65c..d217e5386 100644 --- a/keep-ui/app/workflows/builder/builder.tsx +++ b/keep-ui/app/workflows/builder/builder.tsx @@ -322,7 +322,7 @@ function Builder({ icon={CheckCircleIcon} color="teal" > - Alert can be generated successfully + Workflow can be generated successfully ); }; diff --git a/keep/api/tasks/process_event_task.py b/keep/api/tasks/process_event_task.py index 4f7ee3f1f..53a064dcc 100644 --- a/keep/api/tasks/process_event_task.py +++ b/keep/api/tasks/process_event_task.py @@ -496,7 +496,7 @@ def process_event( api_key_name: str | None, trace_id: str | None, # so we can track the job from the request to the digest event: ( - AlertDto | list[AlertDto] | IncidentDto | list[IncidentDto] | dict + AlertDto | list[AlertDto] | IncidentDto | list[IncidentDto] | dict | None ), # the event to process, either plain (generic) or from a specific provider notify_client: bool = True, timestamp_forced: datetime.datetime | None = None, @@ -547,6 +547,11 @@ def process_event( "This is a subscription notification message from AWS - skipping processing" ) return + elif event is None: + logger.info( + "Provider returned None (failed silently), skipping processing" + ) + return # In case when provider_type is not set if isinstance(event, dict): diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index ebb92685c..493c29001 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -24,12 +24,7 @@ get_enrichments, is_linked_provider, ) -from keep.api.models.alert import ( - AlertDto, - AlertSeverity, - AlertStatus, - IncidentDto, -) +from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus, IncidentDto from keep.api.models.db.alert import AlertActionType from keep.api.models.db.topology import TopologyServiceInDto from keep.api.utils.enrichment_helpers import parse_and_enrich_deleted_and_assignees @@ -324,7 +319,7 @@ def format_alert( tenant_id: str | None, provider_type: str | None, provider_id: str | None, - ) -> AlertDto | list[AlertDto]: + ) -> AlertDto | list[AlertDto] | None: logger = logging.getLogger(__name__) provider_instance: BaseProvider | None = None @@ -355,6 +350,11 @@ def format_alert( ) logger.debug("Formatting alert") formatted_alert = cls._format_alert(event, provider_instance) + if formatted_alert is None: + logger.debug( + "Provider returned None, which means it decided not to format the alert" + ) + return None logger.debug("Alert formatted") # after the provider calculated the default fingerprint # check if there is a custom deduplication rule and apply @@ -785,4 +785,4 @@ def setup_incident_webhook( Raises: NotImplementedError: _description_ """ - raise NotImplementedError("setup_webhook() method not implemented") \ No newline at end of file + raise NotImplementedError("setup_webhook() method not implemented") diff --git a/keep/providers/pagerduty_provider/pagerduty_provider.py b/keep/providers/pagerduty_provider/pagerduty_provider.py index f31f0c75a..fd8318278 100644 --- a/keep/providers/pagerduty_provider/pagerduty_provider.py +++ b/keep/providers/pagerduty_provider/pagerduty_provider.py @@ -281,6 +281,16 @@ def validate_scopes(self): headers = self.__get_headers() scopes = {} for scope in self.PROVIDER_SCOPES: + + # If the provider is installed using a routing key, we skip scopes validation for now. + if self.authentication_config.routing_key: + if scope.name == "incidents_read": + # This is because incidents_read is mandatory and will not let the provider install otherwise + scopes[scope.name] = True + else: + scopes[scope.name] = "Skipped due to routing key" + continue + try: # Todo: how to check validity for write scopes? if scope.name.startswith("incidents"): @@ -351,11 +361,19 @@ def _send_alert(self, title: str, body: str, dedup: str | None = None): url = "https://events.pagerduty.com/v2/enqueue" - result = requests.post(url, json=self._build_alert(title, body, dedup)) + payload = self._build_alert(title, body, dedup) + result = requests.post(url, json=payload) + result.raise_for_status() - self.logger.debug("Alert status: %s", result.status_code) - self.logger.debug("Alert response: %s", result.text) - return result.text + self.logger.info( + "Sent alert to PagerDuty", + extra={ + "status_code": result.status_code, + "response_text": result.text, + "routing_key": self.authentication_config.routing_key, + }, + ) + return result.json() def _trigger_incident( self, @@ -403,6 +421,11 @@ def setup_incident_webhook( setup_alerts: bool = True, ): self.logger.info("Setting up Pagerduty webhook") + + if self.authentication_config.routing_key: + self.logger.info("Skipping webhook setup due to routing key") + return + headers = self.__get_headers() request = requests.get(self.SUBSCRIPTION_API_URL, headers=headers) if not request.ok: @@ -534,6 +557,11 @@ def _format_alert( def _format_alert_old(event: dict) -> AlertDto: actual_event = event.get("event", {}) data = actual_event.get("data", {}) + + event_type = data.get("type", "incident") + if event_type != "incident": + return None + url = data.pop("self", data.pop("html_url", None)) # format status and severity to Keep format status = PagerdutyProvider.ALERT_STATUS_MAP.get(data.pop("status", "firing")) @@ -648,6 +676,10 @@ def __get_all_services(self, business_services: bool = False): return all_services def pull_topology(self) -> list[TopologyServiceInDto]: + # Skipping topology pulling when we're installed with routing_key + if self.authentication_config.routing_key: + return [] + all_services = self.__get_all_services() all_business_services = self.__get_all_services(business_services=True) service_metadata = {} @@ -701,6 +733,10 @@ def pull_topology(self) -> list[TopologyServiceInDto]: return list(service_topology.values()) def _get_incidents(self) -> list[IncidentDto]: + # Skipping incidents pulling when we're installed with routing_key + if self.authentication_config.routing_key: + return [] + raw_incidents = self.__get_all_incidents_or_alerts() incidents = [] for incident in raw_incidents: diff --git a/pyproject.toml b/pyproject.toml index 31159eeab..390c1f11e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.28.7" +version = "0.28.8" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] readme = "README.md"