diff --git a/keep-ui/app/(keep)/alerts/alert-actions.tsx b/keep-ui/app/(keep)/alerts/alert-actions.tsx index bb0a0d6db..ccbba1398 100644 --- a/keep-ui/app/(keep)/alerts/alert-actions.tsx +++ b/keep-ui/app/(keep)/alerts/alert-actions.tsx @@ -3,13 +3,14 @@ import { Button } from "@tremor/react"; import { AlertDto } from "./models"; import { PlusIcon, RocketIcon } from "@radix-ui/react-icons"; import { toast } from "react-toastify"; -import { usePresets } from "utils/hooks/usePresets"; import { useRouter } from "next/navigation"; import { SilencedDoorbellNotification } from "@/components/icons"; import AlertAssociateIncidentModal from "./alert-associate-incident-modal"; import CreateIncidentWithAIModal from "./alert-create-incident-ai-modal"; import { useApi } from "@/shared/lib/hooks/useApi"; +import { useRevalidateMultiple } from "@/shared/lib/state-utils"; + interface Props { selectedRowIds: string[]; alerts: AlertDto[]; @@ -30,11 +31,9 @@ export default function AlertActions({ isIncidentSelectorOpen, }: Props) { const router = useRouter(); - const { useAllPresets } = usePresets(); const api = useApi(); - const { mutate: presetsMutator } = useAllPresets({ - revalidateOnFocus: false, - }); + const revalidateMultiple = useRevalidateMultiple(); + const presetsMutator = () => revalidateMultiple(["/preset"]); const [isCreateIncidentWithAIOpen, setIsCreateIncidentWithAIOpen] = useState(false); diff --git a/keep-ui/app/(keep)/alerts/alert-change-status-modal.tsx b/keep-ui/app/(keep)/alerts/alert-change-status-modal.tsx index 561d7204a..7218af97e 100644 --- a/keep-ui/app/(keep)/alerts/alert-change-status-modal.tsx +++ b/keep-ui/app/(keep)/alerts/alert-change-status-modal.tsx @@ -16,11 +16,12 @@ import { XCircleIcon, QuestionMarkCircleIcon, } from "@heroicons/react/24/outline"; -import { usePresets } from "utils/hooks/usePresets"; import { useAlerts } from "utils/hooks/useAlerts"; import { useApi } from "@/shared/lib/hooks/useApi"; import { showErrorToast } from "@/shared/ui/utils/showErrorToast"; +import { useRevalidateMultiple } from "@/shared/lib/state-utils"; + const statusIcons = { [Status.Firing]: , [Status.Resolved]: , @@ -76,8 +77,8 @@ export default function AlertChangeStatusModal({ }: Props) { const api = useApi(); const [selectedStatus, setSelectedStatus] = useState(null); - const { useAllPresets } = usePresets(); - const { mutate: presetsMutator } = useAllPresets(); + const revalidateMultiple = useRevalidateMultiple(); + const presetsMutator = () => revalidateMultiple(["/preset"]); const { useAllAlerts } = useAlerts(); const { mutate: alertsMutator } = useAllAlerts(presetName, { revalidateOnMount: false, diff --git a/keep-ui/app/(keep)/alerts/alert-dismiss-modal.tsx b/keep-ui/app/(keep)/alerts/alert-dismiss-modal.tsx index af30eb28e..3b161f628 100644 --- a/keep-ui/app/(keep)/alerts/alert-dismiss-modal.tsx +++ b/keep-ui/app/(keep)/alerts/alert-dismiss-modal.tsx @@ -16,7 +16,6 @@ import "react-datepicker/dist/react-datepicker.css"; import "react-quill/dist/quill.snow.css"; import { AlertDto } from "./models"; import { set, isSameDay, isAfter } from "date-fns"; -import { usePresets } from "utils/hooks/usePresets"; import { useAlerts } from "utils/hooks/useAlerts"; import { toast } from "react-toastify"; const ReactQuill = @@ -25,6 +24,8 @@ import "./alert-dismiss-modal.css"; import { useApi } from "@/shared/lib/hooks/useApi"; import { showErrorToast } from "@/shared/ui/utils/showErrorToast"; +import { useRevalidateMultiple } from "@/shared/lib/state-utils"; + interface Props { preset: string; alert: AlertDto[] | null | undefined; @@ -41,8 +42,8 @@ export default function AlertDismissModal({ const [selectedDateTime, setSelectedDateTime] = useState(null); const [showError, setShowError] = useState(false); - const { useAllPresets } = usePresets(); - const { mutate: presetsMutator } = useAllPresets(); + const revalidateMultiple = useRevalidateMultiple(); + const presetsMutator = () => revalidateMultiple(["/preset"]); const { usePresetAlerts } = useAlerts(); const { mutate: alertsMutator } = usePresetAlerts(presetName, { revalidateOnMount: false, diff --git a/keep-ui/app/(keep)/alerts/alert-preset-manager.tsx b/keep-ui/app/(keep)/alerts/alert-preset-manager.tsx new file mode 100644 index 000000000..f07ecf2dc --- /dev/null +++ b/keep-ui/app/(keep)/alerts/alert-preset-manager.tsx @@ -0,0 +1,96 @@ +import React, { useMemo, useState } from "react"; +import { AlertDto } from "./models"; +import Modal from "@/components/ui/Modal"; +import { useRouter } from "next/navigation"; +import { Table } from "@tanstack/react-table"; +import { AlertsRulesBuilder } from "./alerts-rules-builder"; +import { CreateOrUpdatePresetForm } from "@/features/create-or-update-preset"; +import { STATIC_PRESETS_NAMES } from "@/entities/presets/model/constants"; +import { Preset } from "@/entities/presets/model/types"; +import { usePresets } from "@/entities/presets/model/usePresets"; +import { CopilotKit } from "@copilotkit/react-core"; + +interface Props { + presetName: string; + // TODO: pass specific functions not the whole table? + table: Table; +} + +export function AlertPresetManager({ presetName, table }: Props) { + const { dynamicPresets } = usePresets({ + revalidateOnFocus: false, + }); + // TODO: make a hook for this? store in the context? + const selectedPreset = useMemo(() => { + return dynamicPresets?.find( + (p) => + p.name.toLowerCase() === decodeURIComponent(presetName).toLowerCase() + ) as Preset | undefined; + }, [dynamicPresets, presetName]); + const [presetCEL, setPresetCEL] = useState(""); + + // modal + const [isModalOpen, setIsModalOpen] = useState(false); + const router = useRouter(); + + const onCreateOrUpdatePreset = (preset: Preset) => { + setIsModalOpen(false); + const encodedPresetName = encodeURIComponent(preset.name.toLowerCase()); + router.push(`/alerts/${encodedPresetName}`); + }; + + const handleModalClose = () => { + setIsModalOpen(false); + }; + + const isDynamic = + selectedPreset && !STATIC_PRESETS_NAMES.includes(selectedPreset.name); + + // Static presets are not editable + const idToUpdate = isDynamic ? selectedPreset.id : null; + + const presetData = isDynamic + ? { + CEL: presetCEL, + name: selectedPreset.name, + isPrivate: selectedPreset.is_private, + isNoisy: selectedPreset.is_noisy, + tags: selectedPreset.tags, + } + : { + CEL: presetCEL, + name: undefined, + isPrivate: undefined, + isNoisy: undefined, + tags: undefined, + }; + + return ( + <> +
+ +
+ + + + + + + ); +} diff --git a/keep-ui/app/(keep)/alerts/alert-presets.tsx b/keep-ui/app/(keep)/alerts/alert-presets.tsx deleted file mode 100644 index d9aa4ce7e..000000000 --- a/keep-ui/app/(keep)/alerts/alert-presets.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { AlertDto, Preset } from "./models"; -import Modal from "@/components/ui/Modal"; -import { - Button, - Badge, - Card, - Subtitle, - TextInput, - Switch, - Text, -} from "@tremor/react"; -import { useConfig } from "utils/hooks/useConfig"; -import { toast } from "react-toastify"; -import { usePresets } from "utils/hooks/usePresets"; -import { useTags } from "utils/hooks/useTags"; -import { useRouter } from "next/navigation"; -import { Table } from "@tanstack/react-table"; -import { AlertsRulesBuilder } from "./alerts-rules-builder"; -import { formatQuery, parseCEL, RuleGroupType } from "react-querybuilder"; -import { useApi } from "@/shared/lib/hooks/useApi"; -import CreatableMultiSelect from "@/components/ui/CreatableMultiSelect"; -import { MultiValue } from "react-select"; -import { - useCopilotAction, - useCopilotContext, - useCopilotReadable, - CopilotTask, -} from "@copilotkit/react-core"; -import { TbSparkles } from "react-icons/tb"; -import { useSearchAlerts } from "utils/hooks/useSearchAlerts"; -import { Tooltip } from "@/shared/ui/Tooltip"; -import { InformationCircleIcon } from "@heroicons/react/24/outline"; -import { showErrorToast } from "@/shared/ui/utils/showErrorToast"; - -interface TagOption { - id?: number; - name: string; -} - -interface Props { - presetNameFromApi: string; - isLoading: boolean; - table: Table; - presetPrivate?: boolean; - presetNoisy?: boolean; -} - -interface AlertsFoundBadgeProps { - alertsFound: AlertDto[] | undefined; // Updated to use AlertDto type - isLoading: boolean; - isDebouncing: boolean; - vertical?: boolean; -} - -export const AlertsFoundBadge: React.FC = ({ - alertsFound, - isLoading, - isDebouncing, - vertical = false, -}) => { - // Show loading state when searching or debouncing - if (isLoading || isDebouncing) { - return ( - -
-
- - ... - - Searching... -
-
-
- ); - } - - // Don't show anything if there's no data - if (!alertsFound) { - return null; - } - - return ( - -
-
- - {alertsFound.length} - - - {alertsFound.length === 1 ? "Alert" : "Alerts"} found - -
-
- - These are the alerts that would match your preset - -
- ); -}; - -interface PresetControlsProps { - isPrivate: boolean; - setIsPrivate: (value: boolean) => void; - isNoisy: boolean; - setIsNoisy: (value: boolean) => void; -} - -export const PresetControls: React.FC = ({ - isPrivate, - setIsPrivate, - isNoisy, - setIsNoisy, -}) => { - return ( -
-
-
- setIsPrivate(!isPrivate)} - color="orange" - /> - - Private presets are only visible to you} - className="z-50" - > - - -
- -
- setIsNoisy(!isNoisy)} - color="orange" - /> - - Noisy presets will trigger sound for every matching event - } - className="z-50" - > - - -
-
-
- ); -}; - -export default function AlertPresets({ - presetNameFromApi, - isLoading, - table, - presetPrivate = false, - presetNoisy = false, -}: Props) { - const api = useApi(); - const { useAllPresets } = usePresets(); - const { mutate: presetsMutator, data: savedPresets = [] } = useAllPresets({ - revalidateOnFocus: false, - }); - const { data: tags = [], mutate: mutateTags } = useTags(); - const router = useRouter(); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [presetName, setPresetName] = useState( - presetNameFromApi === "feed" || presetNameFromApi === "deleted" - ? "" - : presetNameFromApi - ); - const [isPrivate, setIsPrivate] = useState(presetPrivate); - const [isNoisy, setIsNoisy] = useState(presetNoisy); - const [presetCEL, setPresetCEL] = useState(""); - - // Create - const defaultQuery: RuleGroupType = parseCEL(presetCEL) as RuleGroupType; - - // Parse CEL to RuleGroupType or use default empty rule group - const parsedQuery = presetCEL - ? (parseCEL(presetCEL) as RuleGroupType) - : defaultQuery; - - // Add useSearchAlerts hook with proper typing - const { data: alertsFound, isLoading: isSearching } = useSearchAlerts({ - query: parsedQuery, - timeframe: 0, - }); - - const [generatingName, setGeneratingName] = useState(false); - const [selectedTags, setSelectedTags] = useState([]); - const [newTags, setNewTags] = useState([]); // New tags created during the session - const { data: configData } = useConfig(); - - const isAIEnabled = configData?.OPEN_AI_API_KEY_SET; - const context = useCopilotContext(); - - useCopilotReadable({ - description: "The CEL query for the alert preset", - value: presetCEL, - }); - - useCopilotAction({ - name: "setGeneratedName", - description: "Set the generated preset name", - parameters: [ - { name: "name", type: "string", description: "The generated name" }, - ], - handler: async ({ name }) => { - setPresetName(name); - }, - }); - - const generatePresetName = async () => { - setGeneratingName(true); - const task = new CopilotTask({ - instructions: - "Generate a short, descriptive name for an alert preset based on the provided CEL query. The name should be concise but meaningful, reflecting the key conditions in the query.", - }); - await task.run(context); - setGeneratingName(false); - }; - - const selectedPreset = savedPresets.find( - (savedPreset) => - savedPreset.name.toLowerCase() === - decodeURIComponent(presetNameFromApi).toLowerCase() - ) as Preset | undefined; - - useEffect(() => { - if (selectedPreset) { - setSelectedTags( - selectedPreset.tags.map((tag) => ({ id: tag.id, name: tag.name })) - ); - } - }, [selectedPreset]); - - async function deletePreset(presetId: string) { - if ( - confirm( - `You are about to delete preset ${presetNameFromApi}, are you sure?` - ) - ) { - try { - const response = await api.delete(`/preset/${presetId}`); - toast(`Preset ${presetNameFromApi} deleted!`, { - position: "top-left", - type: "success", - }); - presetsMutator(); - router.push("/alerts/feed"); - } catch (error) { - toast(`Error deleting preset ${presetNameFromApi}`, { - position: "top-left", - type: "error", - }); - } - } - } - - async function addOrUpdatePreset() { - if (!presetName) return; - - let sqlQuery; - try { - sqlQuery = formatQuery(parseCEL(presetCEL), { - format: "parameterized_named", - parseNumbers: true, - }); - } catch (error) { - showErrorToast(error, "Failed to parse the CEL query"); - return; - } - const body = { - name: presetName, - options: [ - { - label: "CEL", - value: presetCEL, - }, - { - label: "SQL", - value: sqlQuery, - }, - ], - is_private: isPrivate, - is_noisy: isNoisy, - tags: selectedTags.map((tag) => ({ - id: tag.id, - name: tag.name, - })), - }; - - try { - const response = selectedPreset?.id - ? await api.put(`/preset/${selectedPreset?.id}`, body) - : await api.post(`/preset`, body); - setIsModalOpen(false); - await presetsMutator(); - await mutateTags(); - router.replace(`/alerts/${encodeURIComponent(presetName.toLowerCase())}`); - toast( - selectedPreset - ? `Preset ${presetName} updated!` - : `Preset ${presetName} created!`, - { - position: "top-left", - type: "success", - } - ); - } catch (error) { - showErrorToast(error, "Failed to update preset"); - } - } - - const handleCreateTag = (inputValue: string) => { - const newTag = { name: inputValue }; - setNewTags((prevTags) => [...prevTags, inputValue]); - setSelectedTags((prevTags) => [...prevTags, newTag]); - }; - - const handleChange = ( - newValue: MultiValue<{ value: string; label: string }> - ) => { - setSelectedTags( - newValue.map((tag) => ({ - id: tags.find((t) => t.name === tag.value)?.id, - name: tag.value, - })) - ); - }; - - // Handle modal close - const handleModalClose = () => { - setIsModalOpen(false); - setPresetName(""); - setPresetCEL(""); - }; - - return ( - <> - -
-
-

{presetName ? "Update preset" : "Enter new preset name"}

-
- -
- Preset Name -
-
- setPresetName(e.target.value)} - className="w-full" - /> - {isAIEnabled && ( - - )} -
-
- -
- Tags - ({ - value: tag.name, - label: tag.name, - }))} - onChange={handleChange} - onCreateOption={handleCreateTag} - options={tags.map((tag) => ({ - value: tag.name, - label: tag.name, - }))} - placeholder="Select or create tags" - /> - - {/* Add alerts count card before the save buttons */} - {presetCEL && ( -
- -
- )} - -
- - -
-
-
-
- -
- - ); -} diff --git a/keep-ui/app/(keep)/alerts/alert-push-alert-to-server-modal.tsx b/keep-ui/app/(keep)/alerts/alert-push-alert-to-server-modal.tsx index f16ff6d73..d5aedf09e 100644 --- a/keep-ui/app/(keep)/alerts/alert-push-alert-to-server-modal.tsx +++ b/keep-ui/app/(keep)/alerts/alert-push-alert-to-server-modal.tsx @@ -10,11 +10,12 @@ import Modal from "@/components/ui/Modal"; import { useProviders } from "utils/hooks/useProviders"; import ImageWithFallback from "@/components/ImageWithFallback"; import { useAlerts } from "utils/hooks/useAlerts"; -import { usePresets } from "utils/hooks/usePresets"; import Select from "@/components/ui/Select"; import { useApi } from "@/shared/lib/hooks/useApi"; import { KeepApiError } from "@/shared/api"; +import { useRevalidateMultiple } from "@/shared/lib/state-utils"; + interface PushAlertToServerModalProps { handleClose: () => void; presetName: string; @@ -31,11 +32,8 @@ const PushAlertToServerModal = ({ presetName, }: PushAlertToServerModalProps) => { const [alertSources, setAlertSources] = useState([]); - const { useAllPresets } = usePresets(); - const { mutate: presetsMutator } = useAllPresets({ - revalidateIfStale: false, - revalidateOnFocus: false, - }); + const revalidateMultiple = useRevalidateMultiple(); + const presetsMutator = () => revalidateMultiple(["/preset"]); const { usePresetAlerts } = useAlerts(); const { mutate: mutateAlerts } = usePresetAlerts(presetName); @@ -80,6 +78,11 @@ const PushAlertToServerModal = ({ const onSubmit: SubmitHandler = async (data) => { try { + // if type is string, parse it to JSON + if (typeof data.alertJson === "string") { + data.alertJson = JSON.parse(data.alertJson); + } + const response = await api.post( `/alerts/event/${data.source.type}`, data.alertJson diff --git a/keep-ui/app/(keep)/alerts/alert-table-tab-panel.tsx b/keep-ui/app/(keep)/alerts/alert-table-tab-panel.tsx index e4f5310b5..372ca62b8 100644 --- a/keep-ui/app/(keep)/alerts/alert-table-tab-panel.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table-tab-panel.tsx @@ -1,6 +1,7 @@ import { AlertTable } from "./alert-table"; import { useAlertTableCols } from "./alert-table-utils"; -import { AlertDto, AlertKnownKeys, Preset, getTabsFromPreset } from "./models"; +import { AlertDto, AlertKnownKeys, getTabsFromPreset } from "./models"; +import { Preset } from "@/entities/presets/model/types"; interface Props { alerts: AlertDto[]; @@ -83,8 +84,6 @@ export default function AlertTableTabPanel({ setDismissedModalAlert={setDismissModalAlert} isAsyncLoading={isAsyncLoading} presetName={preset.name} - presetPrivate={preset.is_private} - presetNoisy={preset.is_noisy} presetStatic={preset.name === "feed"} presetId={preset.id} presetTabs={presetTabs} diff --git a/keep-ui/app/(keep)/alerts/alert-table.tsx b/keep-ui/app/(keep)/alerts/alert-table.tsx index 4c98140da..a15e58a6d 100644 --- a/keep-ui/app/(keep)/alerts/alert-table.tsx +++ b/keep-ui/app/(keep)/alerts/alert-table.tsx @@ -14,7 +14,6 @@ import { SortingState, getSortedRowModel, } from "@tanstack/react-table"; -import { CopilotKit } from "@copilotkit/react-core"; import AlertPagination from "./alert-pagination"; import AlertsTableHeaders from "./alert-table-headers"; import { useLocalStorage } from "utils/hooks/useLocalStorage"; @@ -25,7 +24,7 @@ import { DEFAULT_COLS, } from "./alert-table-utils"; import AlertActions from "./alert-actions"; -import AlertPresets from "./alert-presets"; +import { AlertPresetManager } from "./alert-preset-manager"; import { evalWithContext } from "./alerts-rules-builder"; import { TitleAndFilters } from "./TitleAndFilters"; import { severityMapping } from "./models"; @@ -33,6 +32,7 @@ import AlertTabs from "./alert-tabs"; import AlertSidebar from "./alert-sidebar"; import { AlertFacets } from "./alert-table-alert-facets"; import { DynamicFacet, FacetFilters } from "./alert-table-facet-types"; +import { useConfig } from "@/utils/hooks/useConfig"; interface PresetTab { name: string; @@ -44,8 +44,6 @@ interface Props { columns: ColumnDef[]; isAsyncLoading?: boolean; presetName: string; - presetPrivate?: boolean; - presetNoisy?: boolean; presetStatic?: boolean; presetId?: string; presetTabs?: PresetTab[]; @@ -63,8 +61,6 @@ export function AlertTable({ columns, isAsyncLoading = false, presetName, - presetPrivate = false, - presetNoisy = false, presetStatic = false, presetId = "", presetTabs = [], @@ -76,6 +72,8 @@ export function AlertTable({ setChangeStatusAlert, }: Props) { const a11yContainerRef = useRef(null); + const { data: configData } = useConfig(); + const noisyAlertsEnabled = configData?.NOISY_ALERTS_ENABLED; const [theme, setTheme] = useLocalStorage( "alert-table-theme", @@ -137,9 +135,9 @@ export function AlertTable({ setTheme(newTheme); }; - const [sorting, setSorting] = useState([ - { id: "noise", desc: true }, - ]); + const [sorting, setSorting] = useState( + noisyAlertsEnabled ? [{ id: "noise", desc: true }] : [] + ); const [tabs, setTabs] = useState([ { name: "All", filter: (alert: AlertDto) => true }, @@ -218,6 +216,10 @@ export function AlertTable({ }); }); + const leftPinnedColumns = noisyAlertsEnabled + ? ["severity", "checkbox", "noise"] + : ["severity", "checkbox"]; + const table = useReactTable({ data: filteredAlerts, columns: columns, @@ -226,7 +228,7 @@ export function AlertTable({ columnOrder: columnOrder, columnSizing: columnSizing, columnPinning: { - left: ["severity", "checkbox", "noise"], + left: leftPinnedColumns, right: ["alertMenu"], }, sorting: sorting, @@ -296,15 +298,7 @@ export function AlertTable({ isIncidentSelectorOpen={isIncidentSelectorOpen} /> ) : ( - - - + )} diff --git a/keep-ui/app/(keep)/alerts/alerts-rules-builder.tsx b/keep-ui/app/(keep)/alerts/alerts-rules-builder.tsx index c6d6a2967..3ddf2c0ee 100644 --- a/keep-ui/app/(keep)/alerts/alerts-rules-builder.tsx +++ b/keep-ui/app/(keep)/alerts/alerts-rules-builder.tsx @@ -12,12 +12,7 @@ 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, severityMapping, reverseSeverityMapping } from "./models"; import { XMarkIcon, TrashIcon } from "@heroicons/react/24/outline"; import { TbDatabaseImport } from "react-icons/tb"; import Select, { components, MenuListProps } from "react-select"; @@ -29,6 +24,9 @@ import { toast } from "react-toastify"; import { CornerDownLeft } from "lucide-react"; import { Link } from "@/components/ui"; import { DocumentTextIcon } from "@heroicons/react/24/outline"; +import { STATIC_PRESETS_NAMES } from "@/entities/presets/model/constants"; +import { Preset } from "@/entities/presets/model/types"; +import { usePresetActions } from "@/entities/presets/model/usePresetActions"; const staticOptions = [ { value: 'severity > "info"', label: 'severity > "info"' }, @@ -279,7 +277,6 @@ type AlertsRulesBuilderProps = { selectedPreset?: Preset; defaultQuery: string | undefined; setIsModalOpen?: React.Dispatch>; - deletePreset?: (presetId: string) => Promise; setPresetCEL?: React.Dispatch>; updateOutputCEL?: React.Dispatch>; showSqlImport?: boolean; @@ -299,7 +296,6 @@ export const AlertsRulesBuilder = ({ selectedPreset, defaultQuery = "", setIsModalOpen, - deletePreset, setPresetCEL, updateOutputCEL, customFields, @@ -313,6 +309,8 @@ export const AlertsRulesBuilder = ({ const pathname = usePathname(); const searchParams = useSearchParams(); + const { deletePreset } = usePresetActions(); + const [isGUIOpen, setIsGUIOpen] = useState(false); const [isImportSQLOpen, setImportSQLOpen] = useState(false); const [sqlQuery, setSQLQuery] = useState(""); @@ -321,6 +319,11 @@ export const AlertsRulesBuilder = ({ ); const parsedCELRulesToQuery = parseCEL(celRules); + const isDynamic = + selectedPreset && !STATIC_PRESETS_NAMES.includes(selectedPreset.name); + + const action = isDynamic ? "update" : "create"; + const setQueryParam = (key: string, value: string) => { const current = new URLSearchParams( Array.from(searchParams ? searchParams.entries() : []) @@ -505,8 +508,8 @@ export const AlertsRulesBuilder = ({ operators: getOperators(id), })) : customFields - ? customFields - : []; + ? customFields + : []; const onImportSQL = () => { setImportSQLOpen(true); @@ -648,24 +651,30 @@ export const AlertsRulesBuilder = ({ size="sm" disabled={!celRules.length} onClick={() => validateAndOpenSaveModal(celRules)} - tooltip="Save current filter as a preset" + tooltip={ + action === "update" + ? "Edit preset" + : "Save current filter as a preset" + } > - Save + {action === "update" ? "Edit" : "Save"} )} - {selectedPreset && - selectedPreset.name && - selectedPreset?.name !== "deleted" && - selectedPreset?.name !== "feed" && - selectedPreset?.name !== "dismissed" && - deletePreset && ( - - )} + {isDynamic && ( + + )} {/* Import SQL */} diff --git a/keep-ui/app/(keep)/alerts/alerts.tsx b/keep-ui/app/(keep)/alerts/alerts.tsx index e63c7fab7..b40347549 100644 --- a/keep-ui/app/(keep)/alerts/alerts.tsx +++ b/keep-ui/app/(keep)/alerts/alerts.tsx @@ -1,9 +1,8 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Preset } from "./models"; import { useAlerts } from "utils/hooks/useAlerts"; -import { usePresets } from "utils/hooks/usePresets"; +import { usePresets } from "@/entities/presets/model/usePresets"; import AlertTableTabPanel from "./alert-table-tab-panel"; import { AlertHistory } from "./alert-history"; import AlertAssignTicketModal from "./alert-assign-ticket-modal"; @@ -16,10 +15,12 @@ import AlertDismissModal from "./alert-dismiss-modal"; import { ViewAlertModal } from "./ViewAlertModal"; import { useRouter, useSearchParams } from "next/navigation"; import AlertChangeStatusModal from "./alert-change-status-modal"; -import { useAlertPolling } from "utils/hooks/usePusher"; import NotFound from "@/app/(keep)/not-found"; import { useApi } from "@/shared/lib/hooks/useApi"; import EnrichAlertSidePanel from "@/app/(keep)/alerts/EnrichAlertSidePanel"; +import Loading from "../loading"; +import { Preset } from "@/entities/presets/model/types"; +import { useAlertPolling } from "@/utils/hooks/useAlertPolling"; const defaultPresets: Preset[] = [ { @@ -32,36 +33,6 @@ const defaultPresets: Preset[] = [ should_do_noise_now: false, tags: [], }, - { - id: "dismissed", - name: "dismissed", - options: [], - is_private: false, - is_noisy: false, - alerts_count: 0, - should_do_noise_now: false, - tags: [], - }, - { - id: "groups", - name: "groups", - options: [], - is_private: false, - is_noisy: false, - alerts_count: 0, - should_do_noise_now: false, - tags: [], - }, - { - id: "without-incident", - name: "without-incident", - options: [], - is_private: false, - is_noisy: false, - alerts_count: 0, - should_do_noise_now: false, - tags: [], - }, ]; type AlertsProps = { @@ -69,6 +40,7 @@ type AlertsProps = { }; export default function Alerts({ presetName }: AlertsProps) { + const api = useApi(); const { usePresetAlerts } = useAlerts(); const { data: providersData = { installed_providers: [] } } = useProviders(); const router = useRouter(); @@ -76,9 +48,9 @@ export default function Alerts({ presetName }: AlertsProps) { const ticketingProviders = useMemo( () => providersData.installed_providers.filter((provider) => - provider.tags.includes("ticketing"), + provider.tags.includes("ticketing") ), - [providersData.installed_providers], + [providersData.installed_providers] ); const searchParams = useSearchParams(); @@ -95,15 +67,15 @@ export default function Alerts({ presetName }: AlertsProps) { const [viewEnrichAlertModal, setEnrichAlertModal] = useState(); const [isEnrichSidebarOpen, setIsEnrichSidebarOpen] = useState(false); - const { useAllPresets } = usePresets(); - - const { data: savedPresets = [] } = useAllPresets({ - revalidateOnFocus: false, - }); + const { dynamicPresets: savedPresets = [], isLoading: _isPresetsLoading } = + usePresets({ + revalidateOnFocus: false, + }); + const isPresetsLoading = _isPresetsLoading || !api.isReady(); const presets = [...defaultPresets, ...savedPresets] as const; const selectedPreset = presets.find( - (preset) => preset.name.toLowerCase() === decodeURIComponent(presetName), + (preset) => preset.name.toLowerCase() === decodeURIComponent(presetName) ); const { data: pollAlerts } = useAlertPolling(); @@ -114,13 +86,11 @@ export default function Alerts({ presetName }: AlertsProps) { error: alertsError, } = usePresetAlerts(selectedPreset ? selectedPreset.name : ""); - const api = useApi(); const isLoading = isAsyncLoading || !api.isReady(); useEffect(() => { const fingerprint = searchParams?.get("alertPayloadFingerprint"); const enrich = searchParams?.get("enrich"); - console.log(enrich, fingerprint); if (fingerprint && enrich) { const alert = alerts?.find((alert) => alert.fingerprint === fingerprint); setEnrichAlertModal(alert); @@ -141,6 +111,16 @@ export default function Alerts({ presetName }: AlertsProps) { } }, [mutateAlerts, pollAlerts]); + // if we don't have presets data yet, just show loading + if (!selectedPreset && isPresetsLoading) { + return ; + } + + // if we have an error, throw it, error.tsx will catch it + if (alertsError) { + throw alertsError; + } + if (!selectedPreset) { return ; } @@ -160,9 +140,18 @@ export default function Alerts({ presetName }: AlertsProps) { mutateAlerts={mutateAlerts} /> - {selectedPreset && ( - - )} + + setDismissModalAlert(null)} + /> + setChangeStatusAlert(null)} + /> + setTicketModalAlert(null)} ticketingProviders={ticketingProviders} @@ -172,21 +161,10 @@ export default function Alerts({ presetName }: AlertsProps) { handleClose={() => setNoteModalAlert(null)} alert={noteModalAlert ?? null} /> - {selectedPreset && } setRunWorkflowModalAlert(null)} /> - setDismissModalAlert(null)} - /> - setChangeStatusAlert(null)} - /> router.replace(`/alerts/${presetName}`)} diff --git a/keep-ui/app/(keep)/alerts/models.tsx b/keep-ui/app/(keep)/alerts/models.tsx index a7bbfb5d5..0077f4629 100644 --- a/keep-ui/app/(keep)/alerts/models.tsx +++ b/keep-ui/app/(keep)/alerts/models.tsx @@ -1,3 +1,5 @@ +import { Preset } from "@/entities/presets/model/types"; + export enum Severity { Critical = "critical", High = "high", @@ -66,28 +68,6 @@ export interface AlertDto { is_created_by_ai?: boolean; } -interface Option { - readonly label: string; - readonly value: string; -} - -export interface Tag { - id: number; - name: string; -} - -export interface Preset { - id: string; - name: string; - options: Option[]; - is_private: boolean; - is_noisy: boolean; - should_do_noise_now: boolean; - alerts_count: number; - created_by?: string; - tags: Tag[]; -} - export function getTabsFromPreset(preset: Preset): any[] { const tabsOption = preset.options.find( (option) => option.label.toLowerCase() === "tabs" diff --git a/keep-ui/app/(keep)/dashboard/GridLayout.tsx b/keep-ui/app/(keep)/dashboard/GridLayout.tsx index 20f4e7d56..bcee98a4f 100644 --- a/keep-ui/app/(keep)/dashboard/GridLayout.tsx +++ b/keep-ui/app/(keep)/dashboard/GridLayout.tsx @@ -3,8 +3,8 @@ import { Responsive, WidthProvider, Layout } from "react-grid-layout"; import GridItemContainer from "./GridItemContainer"; import { LayoutItem, WidgetData } from "./types"; import "react-grid-layout/css/styles.css"; -import { Preset } from "@/app/(keep)/alerts/models"; import { MetricsWidget } from "@/utils/hooks/useDashboardMetricWidgets"; +import { Preset } from "@/entities/presets/model/types"; const ResponsiveGridLayout = WidthProvider(Responsive); diff --git a/keep-ui/app/(keep)/dashboard/WidgetModal.tsx b/keep-ui/app/(keep)/dashboard/WidgetModal.tsx index 81ed554ec..d82fa3a78 100644 --- a/keep-ui/app/(keep)/dashboard/WidgetModal.tsx +++ b/keep-ui/app/(keep)/dashboard/WidgetModal.tsx @@ -10,9 +10,9 @@ import { } from "@tremor/react"; import { Trashcan } from "components/icons"; import { GenericsMetrics, Threshold, WidgetData, WidgetType } from "./types"; -import { Preset } from "@/app/(keep)/alerts/models"; import { Controller, get, useForm, useWatch } from "react-hook-form"; import { MetricsWidget } from "@/utils/hooks/useDashboardMetricWidgets"; +import { Preset } from "@/entities/presets/model/types"; interface WidgetForm { widgetName: string; diff --git a/keep-ui/app/(keep)/dashboard/[id]/dashboard.tsx b/keep-ui/app/(keep)/dashboard/[id]/dashboard.tsx index 6569184d1..0febd5439 100644 --- a/keep-ui/app/(keep)/dashboard/[id]/dashboard.tsx +++ b/keep-ui/app/(keep)/dashboard/[id]/dashboard.tsx @@ -11,7 +11,6 @@ import { WidgetData, WidgetType, } from "../types"; -import { Preset } from "@/app/(keep)/alerts/models"; import { FiEdit2, FiSave } from "react-icons/fi"; import { useDashboards } from "utils/hooks/useDashboards"; import { toast } from "react-toastify"; @@ -24,6 +23,7 @@ import { import { useApi } from "@/shared/lib/hooks/useApi"; import { showErrorToast } from "@/shared/ui/utils/showErrorToast"; import "../styles.css"; +import { Preset } from "@/entities/presets/model/types"; const DASHBOARD_FILTERS = [ { diff --git a/keep-ui/app/(keep)/dashboard/types.tsx b/keep-ui/app/(keep)/dashboard/types.tsx index 2de15c968..42f9851df 100644 --- a/keep-ui/app/(keep)/dashboard/types.tsx +++ b/keep-ui/app/(keep)/dashboard/types.tsx @@ -1,5 +1,5 @@ -import { Preset } from "@/app/(keep)/alerts/models"; import { MetricsWidget } from "@/utils/hooks/useDashboardMetricWidgets"; +import { Preset } from "@/entities/presets/model/types"; export interface LayoutItem { i: string; diff --git a/keep-ui/app/(keep)/rules/CorrelationSidebar/CorrelationSidebarBody.tsx b/keep-ui/app/(keep)/rules/CorrelationSidebar/CorrelationSidebarBody.tsx index 4699d58a0..4ad2e1021 100644 --- a/keep-ui/app/(keep)/rules/CorrelationSidebar/CorrelationSidebarBody.tsx +++ b/keep-ui/app/(keep)/rules/CorrelationSidebar/CorrelationSidebarBody.tsx @@ -121,10 +121,7 @@ export const CorrelationSidebarBody = ({ )} -
+
diff --git a/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx b/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx index b1848366f..1e24c6160 100644 --- a/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx +++ b/keep-ui/app/(keep)/settings/auth/permissions-tab.tsx @@ -4,7 +4,7 @@ import { usePermissions } from "utils/hooks/usePermissions"; import { useUsers } from "@/entities/users/model/useUsers"; import { useGroups } from "utils/hooks/useGroups"; import { useRoles } from "utils/hooks/useRoles"; -import { usePresets } from "utils/hooks/usePresets"; +import { usePresets } from "@/entities/presets/model/usePresets"; import { useIncidents } from "utils/hooks/useIncidents"; import Loading from "@/app/(keep)/loading"; import { PermissionsTable } from "./permissions-table"; @@ -38,8 +38,7 @@ export default function PermissionsTab({ isDisabled = false }: Props) { const { data: users } = useUsers(); const { data: groups } = useGroups(); const { data: roles } = useRoles(); - const { useAllPresets } = usePresets(); - const { data: presets } = useAllPresets(); + const { dynamicPresets: presets } = usePresets(); const { data: incidents } = useIncidents(); const [loading, setLoading] = useState(true); diff --git a/keep-ui/app/(keep)/topology/model/useTopologyApplications.ts b/keep-ui/app/(keep)/topology/model/useTopologyApplications.ts index 2bf46f2d3..7da395d93 100644 --- a/keep-ui/app/(keep)/topology/model/useTopologyApplications.ts +++ b/keep-ui/app/(keep)/topology/model/useTopologyApplications.ts @@ -2,10 +2,10 @@ import { TopologyApplication } from "./models"; import useSWR, { SWRConfiguration } from "swr"; import { useCallback, useMemo } from "react"; import { useTopology } from "./useTopology"; -import { useRevalidateMultiple } from "@/utils/state"; import { TOPOLOGY_URL } from "./useTopology"; import { KeepApiError } from "@/shared/api"; import { useApi } from "@/shared/lib/hooks/useApi"; +import { useRevalidateMultiple } from "@/shared/lib/state-utils"; type UseTopologyApplicationsOptions = { initialData?: TopologyApplication[]; diff --git a/keep-ui/components/navbar/AlertsLinks.tsx b/keep-ui/components/navbar/AlertsLinks.tsx index 01ed1e52f..8a856d42f 100644 --- a/keep-ui/components/navbar/AlertsLinks.tsx +++ b/keep-ui/components/navbar/AlertsLinks.tsx @@ -13,7 +13,7 @@ import CreatableMultiSelect from "@/components/ui/CreatableMultiSelect"; import { useLocalStorage } from "utils/hooks/useLocalStorage"; import { ActionMeta, MultiValue } from "react-select"; import { useTags } from "utils/hooks/useTags"; -import { usePresets } from "utils/hooks/usePresets"; +import { usePresets } from "@/entities/presets/model/usePresets"; import { useMounted } from "@/shared/lib/hooks/useMounted"; import clsx from "clsx"; @@ -34,9 +34,7 @@ export const AlertsLinks = ({ session }: AlertsLinksProps) => { const { data: tags = [] } = useTags(); - // Get latest static presets (merged local and server presets) - const { useLatestStaticPresets } = usePresets(); - const { data: staticPresets = [] } = useLatestStaticPresets({ + const { staticPresets, error: staticPresetsError } = usePresets({ revalidateIfStale: true, revalidateOnFocus: true, }); @@ -61,11 +59,11 @@ export const AlertsLinks = ({ session }: AlertsLinksProps) => { // Determine if we should show the feed link const shouldShowFeed = (() => { // For the initial render on the server, always show feed - if (!isMounted) { + if (!isMounted || (!staticPresets && !staticPresetsError)) { return true; } - return staticPresets.some((preset) => preset.name === "feed"); + return staticPresets?.some((preset) => preset.name === "feed"); })(); // Get the current alerts count only if we should show feed @@ -127,12 +125,7 @@ export const AlertsLinks = ({ session }: AlertsLinksProps) => { )} - {session && isMounted && ( - - )} + )} diff --git a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx index b2e9510f4..7ff6edde5 100644 --- a/keep-ui/components/navbar/CustomPresetAlertLinks.tsx +++ b/keep-ui/components/navbar/CustomPresetAlertLinks.tsx @@ -1,7 +1,5 @@ -import { CSSProperties, useEffect, useState } from "react"; -import { Session } from "next-auth"; -import { toast } from "react-toastify"; -import { usePresets } from "utils/hooks/usePresets"; +import { CSSProperties } from "react"; +import { usePresets } from "@/entities/presets/model/usePresets"; import { AiOutlineSwap } from "react-icons/ai"; import { usePathname, useRouter } from "next/navigation"; import { Subtitle } from "@tremor/react"; @@ -17,7 +15,6 @@ import { } from "@dnd-kit/core"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Preset } from "@/app/(keep)/alerts/models"; import { AiOutlineSound } from "react-icons/ai"; // Using dynamic import to avoid hydration issues with react-player import dynamic from "next/dynamic"; @@ -25,15 +22,21 @@ const ReactPlayer = dynamic(() => import("react-player"), { ssr: false }); // import css import "./CustomPresetAlertLink.css"; import clsx from "clsx"; -import { useApi } from "@/shared/lib/hooks/useApi"; +import { Preset } from "@/entities/presets/model/types"; +import { usePresetActions } from "@/entities/presets/model/usePresetActions"; +import { usePresetPolling } from "@/entities/presets/model/usePresetPolling"; -type PresetAlertProps = { +type AlertPresetLinkProps = { preset: Preset; pathname: string | null; deletePreset: (id: string, name: string) => void; }; -const PresetAlert = ({ preset, pathname, deletePreset }: PresetAlertProps) => { +const AlertPresetLink = ({ + preset, + pathname, + deletePreset, +}: AlertPresetLinkProps) => { const href = `/alerts/${preset.name.toLowerCase()}`; const isActive = decodeURIComponent(pathname?.toLowerCase() || "") === href; @@ -90,72 +93,35 @@ const PresetAlert = ({ preset, pathname, deletePreset }: PresetAlertProps) => { ); }; type CustomPresetAlertLinksProps = { - session: Session; selectedTags: string[]; }; export const CustomPresetAlertLinks = ({ - session, selectedTags, }: CustomPresetAlertLinksProps) => { - const api = useApi(); + const { deletePreset } = usePresetActions(); - const { useAllPresets, presetsOrderFromLS, setPresetsOrderFromLS } = - usePresets(); - const { data: presets = [], mutate: presetsMutator } = useAllPresets({ + const { dynamicPresets: presets, setLocalDynamicPresets } = usePresets({ revalidateIfStale: false, revalidateOnFocus: false, }); + usePresetPolling(); + const pathname = usePathname(); const router = useRouter(); - const [presetsOrder, setPresetsOrder] = useState([]); // Check for noisy presets and control sound playback - const anyNoisyNow = presets.some((preset) => preset.should_do_noise_now); - - const checkValidPreset = (preset: Preset) => { - if (!preset.is_private) { - return true; - } - return preset && preset.created_by == session?.user?.email; - }; - - useEffect(() => { - const filteredLS = presetsOrderFromLS.filter( - (preset) => - ![ - "feed", - "deleted", - "dismissed", - "without-incident", - "groups", - ].includes(preset.name) - ); + const anyNoisyNow = presets?.some((preset) => preset.should_do_noise_now); - // Combine live presets and local storage order - const combinedOrder = presets.reduce( - (acc, preset: Preset) => { - if (!acc.find((p) => p.id === preset.id)) { - acc.push(preset); - } - return acc.filter((preset) => checkValidPreset(preset)); - }, - [...filteredLS] - ); - - // Only update state if there's an actual change to prevent infinite loops - if (JSON.stringify(presetsOrder) !== JSON.stringify(combinedOrder)) { - setPresetsOrder(combinedOrder); - } - }, [presets, presetsOrderFromLS]); // Filter presets based on tags, or return all if no tags are selected const filteredOrderedPresets = selectedTags.length === 0 - ? presetsOrder - : presetsOrder.filter((preset) => + ? presets + : presets.filter((preset) => preset.tags.some((tag) => selectedTags.includes(tag.name)) ); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -171,35 +137,10 @@ export const CustomPresetAlertLinks = ({ }) ); - const deletePreset = async (presetId: string, presetName: string) => { - const isDeleteConfirmed = confirm( - `You are about to delete preset ${presetName}. Are you sure?` - ); - - if (isDeleteConfirmed) { - try { - await api.delete(`/preset/${presetId}`); - - toast(`Preset ${presetName} deleted!`, { - position: "top-left", - type: "success", - }); - - await presetsMutator(); - - // remove preset from saved order - setPresetsOrderFromLS((oldOrder) => - oldOrder.filter((p) => p.id !== presetId) - ); - - router.push("/alerts/feed"); // Redirect to feed - } catch (error) { - toast(`Error deleting preset ${presetName}: ${error}`, { - position: "top-left", - type: "error", - }); - } - } + const deletePresetAndRedirect = (presetId: string, presetName: string) => { + deletePreset(presetId, presetName).then(() => { + router.push("/alerts/feed"); + }); }; const onDragEnd = (event: DragEndEvent) => { @@ -209,22 +150,20 @@ export const CustomPresetAlertLinks = ({ return; } - const fromIndex = presetsOrder.findIndex( + const fromIndex = presets.findIndex( ({ id }) => id === active.id.toString() ); - const toIndex = presetsOrder.findIndex( - ({ id }) => id === over.id.toString() - ); + const toIndex = presets.findIndex(({ id }) => id === over.id.toString()); if (toIndex === -1) { return; } - const reorderedCols = [...presetsOrder]; + const reorderedCols = [...presets]; const reorderedItem = reorderedCols.splice(fromIndex, 1); reorderedCols.splice(toIndex, 0, reorderedItem[0]); - setPresetsOrderFromLS(reorderedCols); + setLocalDynamicPresets(reorderedCols); }; return ( @@ -234,13 +173,13 @@ export const CustomPresetAlertLinks = ({ collisionDetection={rectIntersection} onDragEnd={onDragEnd} > - + {filteredOrderedPresets.map((preset) => ( - ))} diff --git a/keep-ui/components/navbar/UserInfo.tsx b/keep-ui/components/navbar/UserInfo.tsx index d4b2d16f8..32747c2ad 100644 --- a/keep-ui/components/navbar/UserInfo.tsx +++ b/keep-ui/components/navbar/UserInfo.tsx @@ -18,6 +18,7 @@ import { useSignOut } from "@/shared/lib/hooks/useSignOut"; import { FaSlack } from "react-icons/fa"; import { ThemeControl } from "@/shared/ui/theme/ThemeControl"; import { HiOutlineDocumentText } from "react-icons/hi2"; +import { useMounted } from "@/shared/lib/hooks/useMounted"; const ONBOARDING_FLOW_ID = "flow_FHDz1hit"; @@ -90,11 +91,12 @@ export const UserInfo = ({ session }: UserInfoProps) => { const { flow } = Frigade.useFlow(ONBOARDING_FLOW_ID); const [isOnboardingOpen, setIsOnboardingOpen] = useState(false); + const isMounted = useMounted(); return ( <>
    - {!config?.FRIGADE_DISABLED && flow?.isCompleted === false && ( + {isMounted && !config?.FRIGADE_DISABLED && flow?.isCompleted === false && (
  • ( + LOCAL_PRESETS_KEY, + [] + ); + const revalidateMultiple = useRevalidateMultiple(); + const mutatePresetsList = useCallback( + () => revalidateMultiple(["/preset", "/preset?"]), + [revalidateMultiple] + ); + const mutateTags = useCallback( + () => revalidateMultiple(["/tags"]), + [revalidateMultiple] + ); + + const createPreset = useCallback( + async (data: PresetCreateUpdateDto) => { + try { + const body = createPresetBody(data); + const response = await api.post(`/preset`, body); + mutatePresetsList(); + mutateTags(); + showSuccessToast(`Preset ${data.name} created!`); + return response; + } catch (error) { + showErrorToast(error, "Failed to create preset"); + } + }, + [api, mutatePresetsList, mutateTags] + ); + + const updatePreset = useCallback( + async (presetId: string, data: PresetCreateUpdateDto) => { + try { + const body = createPresetBody(data); + const response = await api.put(`/preset/${presetId}`, body); + mutatePresetsList(); + mutateTags(); + showSuccessToast(`Preset ${data.name} updated!`); + return response; + } catch (error) { + showErrorToast(error, "Failed to update preset"); + } + }, + [api, mutatePresetsList, mutateTags] + ); + + const deletePreset = useCallback( + async (presetId: string, presetName: string) => { + const isDeleteConfirmed = confirm( + `You are about to delete preset ${presetName}. Are you sure?` + ); + if (!isDeleteConfirmed) { + return; + } + try { + const response = await api.delete(`/preset/${presetId}`); + showSuccessToast(`Preset ${presetName} deleted!`); + mutatePresetsList(); + setLocalDynamicPresets((oldOrder) => + oldOrder.filter((p) => p.id !== presetId) + ); + } catch (error) { + showErrorToast(error, `Error deleting preset ${presetName}`); + } + }, + [api, mutatePresetsList, setLocalDynamicPresets] + ); + + return { + createPreset, + updatePreset, + deletePreset, + }; +} diff --git a/keep-ui/entities/presets/model/usePresetPolling.ts b/keep-ui/entities/presets/model/usePresetPolling.ts new file mode 100644 index 000000000..1969a927f --- /dev/null +++ b/keep-ui/entities/presets/model/usePresetPolling.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useWebsocket } from "@/utils/hooks/usePusher"; +import { useRevalidateMultiple } from "@/shared/lib/state-utils"; + +const PRESET_POLLING_INTERVAL = 5 * 1000; // Once per 5 seconds + +export function usePresetPolling() { + const { bind, unbind } = useWebsocket(); + const revalidateMultiple = useRevalidateMultiple(); + const lastPollTimeRef = useRef(0); + + const handleIncoming = useCallback( + (presetNamesToUpdate: string[]) => { + const currentTime = Date.now(); + const timeSinceLastPoll = currentTime - lastPollTimeRef.current; + + if (timeSinceLastPoll < PRESET_POLLING_INTERVAL) { + console.log("usePresetPolling: Ignoring poll due to short interval"); + return; + } + + console.log("usePresetPolling: Revalidating preset data"); + lastPollTimeRef.current = currentTime; + revalidateMultiple(["/preset", "/preset?"], { + isExact: true, + }); + }, + [revalidateMultiple] + ); + + useEffect(() => { + console.log( + "usePresetPolling: Setting up event listener for 'poll-presets'" + ); + bind("poll-presets", handleIncoming); + return () => { + console.log( + "usePresetPolling: Cleaning up event listener for 'poll-presets'" + ); + unbind("poll-presets", handleIncoming); + }; + }, [bind, unbind, handleIncoming]); +} diff --git a/keep-ui/entities/presets/model/usePresets.ts b/keep-ui/entities/presets/model/usePresets.ts new file mode 100644 index 000000000..6c93ad5d8 --- /dev/null +++ b/keep-ui/entities/presets/model/usePresets.ts @@ -0,0 +1,142 @@ +import useSWR, { SWRConfiguration } from "swr"; +import { useLocalStorage } from "@/utils/hooks/useLocalStorage"; +import { useApi } from "@/shared/lib/hooks/useApi"; +import { useMemo, useCallback } from "react"; +import isEqual from "lodash/isEqual"; +import { Session } from "next-auth"; +import { + LOCAL_PRESETS_KEY, + LOCAL_STATIC_PRESETS_KEY, + STATIC_PRESETS_NAMES, +} from "@/entities/presets/model/constants"; +import { Preset } from "@/entities/presets/model/types"; +import { useHydratedSession } from "@/shared/lib/hooks/useHydratedSession"; + +type UsePresetsOptions = { + filters?: string; +} & SWRConfiguration; + +const checkPresetAccess = (preset: Preset, session: Session) => { + if (!preset.is_private) { + return true; + } + return preset && preset.created_by == session?.user?.email; +}; + +const combineOrder = (serverPresets: Preset[], localPresets: Preset[]) => { + // If the preset is in local, update it with the server data + // If the preset is not in local, add it + // If the preset is in local and not in server, remove it + const addedPresetsMap = new Map(); + const orderedPresets = localPresets + .map((preset) => { + const presetFromData = serverPresets.find((p) => p.id === preset.id); + addedPresetsMap.set(preset.id, !!presetFromData); + return presetFromData ? presetFromData : null; + }) + .filter((preset) => preset !== null) as Preset[]; + const serverPresetsNotInLocal = serverPresets.filter( + (preset) => !addedPresetsMap.get(preset.id) + ); + return [...orderedPresets, ...serverPresetsNotInLocal]; +}; + +export const usePresets = ({ filters, ...options }: UsePresetsOptions = {}) => { + const api = useApi(); + + const { data: session } = useHydratedSession(); + const [localDynamicPresets, setLocalDynamicPresets] = useLocalStorage< + Preset[] + >(LOCAL_PRESETS_KEY, []); + const [localStaticPresets, setLocalStaticPresets] = useLocalStorage( + LOCAL_STATIC_PRESETS_KEY, + [] + ); + + const updateLocalPresets = useCallback( + (presets: Preset[]) => { + if (!session) { + return; + } + // TODO: if the new preset coming from the server is not in the local storage, add it to the local storage + // Keep the order from the local storage, update the data from the server + const newDynamicPresets = combineOrder( + presets + .filter((preset) => !STATIC_PRESETS_NAMES.includes(preset.name)) + .filter((preset) => checkPresetAccess(preset, session)), + localDynamicPresets + ); + // Only update if the array actually changed + if (!isEqual(newDynamicPresets, localDynamicPresets)) { + setLocalDynamicPresets(newDynamicPresets); + } + const newStaticPresets = combineOrder( + presets + .filter((preset) => STATIC_PRESETS_NAMES.includes(preset.name)) + .filter((preset) => checkPresetAccess(preset, session)), + localStaticPresets + ); + if (!isEqual(newStaticPresets, localStaticPresets)) { + setLocalStaticPresets(newStaticPresets); + } + }, + [ + localDynamicPresets, + localStaticPresets, + session, + setLocalDynamicPresets, + setLocalStaticPresets, + ] + ); + + const { + data: allPresets, + isLoading, + error, + isValidating, + mutate, + } = useSWR( + api.isReady() ? `/preset${filters ? `?${filters}` : ""}` : null, + (url) => api.get(url), + { + onSuccess: updateLocalPresets, + ...options, + } + ); + + const dynamicPresets = useMemo(() => { + if (error) { + return []; + } + if (!allPresets || !session) { + return localDynamicPresets; + } + const dynamicPresets = allPresets + .filter((preset) => !STATIC_PRESETS_NAMES.includes(preset.name)) + .filter((preset) => checkPresetAccess(preset, session)); + return combineOrder(dynamicPresets, localDynamicPresets); + }, [allPresets, error, localDynamicPresets, session]); + + const staticPresets = useMemo(() => { + if (error) { + return []; + } + if (!allPresets) { + return localStaticPresets; + } + const staticPresets = allPresets.filter((preset) => + STATIC_PRESETS_NAMES.includes(preset.name) + ); + return combineOrder(staticPresets, localStaticPresets); + }, [allPresets, error, localStaticPresets]); + + return { + dynamicPresets, + staticPresets, + isLoading, + error, + isValidating, + mutate, + setLocalDynamicPresets, + }; +}; diff --git a/keep-ui/features/create-or-update-preset/index.ts b/keep-ui/features/create-or-update-preset/index.ts new file mode 100644 index 000000000..2dc6445c2 --- /dev/null +++ b/keep-ui/features/create-or-update-preset/index.ts @@ -0,0 +1 @@ +export { CreateOrUpdatePresetForm } from "./ui/create-or-update-preset-form"; diff --git a/keep-ui/features/create-or-update-preset/ui/alerts-count-badge.tsx b/keep-ui/features/create-or-update-preset/ui/alerts-count-badge.tsx new file mode 100644 index 000000000..9741a731c --- /dev/null +++ b/keep-ui/features/create-or-update-preset/ui/alerts-count-badge.tsx @@ -0,0 +1,80 @@ +// TODO: move models to entities/alerts +import { useSearchAlerts } from "@/utils/hooks/useSearchAlerts"; +import { Badge, Card, Text } from "@tremor/react"; +import { parseCEL, RuleGroupType } from "react-querybuilder"; + +interface AlertsCountBadgeProps { + presetCEL: string; + isDebouncing: boolean; + vertical?: boolean; +} + +export const AlertsCountBadge: React.FC = ({ + presetCEL, + isDebouncing, + vertical = false, +}) => { + console.log("AlertsCountBadge::presetCEL", presetCEL); + // Create + const defaultQuery: RuleGroupType = parseCEL(presetCEL) as RuleGroupType; + + // Parse CEL to RuleGroupType or use default empty rule group + const parsedQuery = presetCEL + ? (parseCEL(presetCEL) as RuleGroupType) + : defaultQuery; + + // Add useSearchAlerts hook with proper typing + const { data: alertsFound, isLoading: isSearching } = useSearchAlerts({ + query: parsedQuery, + timeframe: 0, + }); + + console.log("AlertsCountBadge::swr", alertsFound); + + // Show loading state when searching or debouncing + if (isSearching || isDebouncing) { + return ( + +
    +
    + + ... + + Searching... +
    +
    +
    + ); + } + + // Don't show anything if there's no data + if (!alertsFound) { + return null; + } + + return ( + +
    +
    + + {alertsFound.length} + + + {alertsFound.length === 1 ? "Alert" : "Alerts"} found + +
    +
    + + These are the alerts that would match your preset + +
    + ); +}; diff --git a/keep-ui/features/create-or-update-preset/ui/create-or-update-preset-form.tsx b/keep-ui/features/create-or-update-preset/ui/create-or-update-preset-form.tsx new file mode 100644 index 000000000..232df8d32 --- /dev/null +++ b/keep-ui/features/create-or-update-preset/ui/create-or-update-preset-form.tsx @@ -0,0 +1,234 @@ +import { Button } from "@/components/ui"; +import { useConfig } from "@/utils/hooks/useConfig"; +import { + useCopilotAction, + useCopilotContext, + useCopilotReadable, +} from "@copilotkit/react-core"; +import { CopilotTask } from "@copilotkit/react-core"; +import { Subtitle, TextInput } from "@tremor/react"; +import { useCallback, useState } from "react"; +import { PresetControls } from "./preset-controls"; +import CreatableMultiSelect from "@/components/ui/CreatableMultiSelect"; +import { AlertsCountBadge } from "./alerts-count-badge"; +import { TbSparkles } from "react-icons/tb"; +import { MultiValue } from "react-select"; +import { useTags } from "@/utils/hooks/useTags"; +import { Preset } from "@/entities/presets/model/types"; +import { usePresetActions } from "@/entities/presets/model/usePresetActions"; + +interface TagOption { + id?: string; + name: string; +} + +type CreateOrUpdatePresetFormProps = { + presetId: string | null; + presetData: { + CEL: string; + name: string | undefined; + isPrivate: boolean | undefined; + isNoisy: boolean | undefined; + tags: TagOption[] | undefined; + }; + onCreateOrUpdate?: (preset: Preset) => void; + onCancel?: () => void; +}; + +export function CreateOrUpdatePresetForm({ + presetId, + presetData, + onCreateOrUpdate, + onCancel, +}: CreateOrUpdatePresetFormProps) { + const [presetName, setPresetName] = useState(presetData.name ?? ""); + const [isPrivate, setIsPrivate] = useState(presetData.isPrivate ?? false); + const [isNoisy, setIsNoisy] = useState(presetData.isNoisy ?? false); + + const [generatingName, setGeneratingName] = useState(false); + const [selectedTags, setSelectedTags] = useState( + presetData.tags ?? [] + ); + + const clearForm = () => { + setPresetName(""); + setIsPrivate(false); + setIsNoisy(false); + setSelectedTags([]); + }; + + const handleCancel = () => { + clearForm(); + onCancel?.(); + }; + + const { data: tags = [] } = useTags(); + + const handleCreateTag = (inputValue: string) => { + const newTag = { name: inputValue }; + setSelectedTags((prevTags) => [...prevTags, newTag]); + }; + + const handleChange = ( + newValue: MultiValue<{ value: string; label: string }> + ) => { + setSelectedTags( + newValue.map((tag) => ({ + id: tags.find((t) => t.name === tag.value)?.id, + name: tag.value, + })) + ); + }; + + const { data: configData } = useConfig(); + const isAIEnabled = configData?.OPEN_AI_API_KEY_SET; + const context = useCopilotContext(); + + useCopilotReadable({ + description: "The CEL query for the alert preset", + value: presetData.CEL, + }); + + useCopilotAction({ + name: "setGeneratedName", + description: "Set the generated preset name", + parameters: [ + { name: "name", type: "string", description: "The generated name" }, + ], + handler: async ({ name }) => { + setPresetName(name); + }, + }); + + const generatePresetName = useCallback(async () => { + setGeneratingName(true); + const task = new CopilotTask({ + instructions: + "Generate a short, descriptive name for an alert preset based on the provided CEL query. The name should be concise but meaningful, reflecting the key conditions in the query.", + }); + await task.run(context); + setGeneratingName(false); + }, [context]); + + const { createPreset, updatePreset } = usePresetActions(); + const addOrUpdatePreset = async (e: React.FormEvent) => { + e.preventDefault(); + if (presetId) { + const updatedPreset = await updatePreset(presetId, { + ...presetData, + name: presetName, + isPrivate, + isNoisy, + tags: selectedTags.map((tag) => ({ + id: tag.id, + name: tag.name, + })), + }); + onCreateOrUpdate?.(updatedPreset); + } else { + const newPreset = await createPreset({ + ...presetData, + name: presetName, + isPrivate, + isNoisy, + tags: selectedTags.map((tag) => ({ + id: tag.id, + name: tag.name, + })), + }); + onCreateOrUpdate?.(newPreset); + } + }; + + return ( + +
    +

    {presetName ? "Update preset" : "Enter new preset name"}

    +
    + +
    + Preset Name +
    +
    + setPresetName(e.target.value)} + className="w-full" + /> + {isAIEnabled && ( + + )} +
    +
    + +
    + Tags + ({ + value: tag.name, + label: tag.name, + }))} + onChange={handleChange} + onCreateOption={handleCreateTag} + options={tags.map((tag) => ({ + value: tag.name, + label: tag.name, + }))} + placeholder="Select or create tags" + /> + + {/* Add alerts count card before the save buttons */} + {presetData.CEL && ( +
    + +
    + )} + +
    + + +
    + + ); +} diff --git a/keep-ui/features/create-or-update-preset/ui/preset-controls.tsx b/keep-ui/features/create-or-update-preset/ui/preset-controls.tsx new file mode 100644 index 000000000..518f5f8f3 --- /dev/null +++ b/keep-ui/features/create-or-update-preset/ui/preset-controls.tsx @@ -0,0 +1,61 @@ +import { Tooltip } from "@/shared/ui"; +import { InformationCircleIcon } from "@heroicons/react/24/outline"; +import { Switch, Text } from "@tremor/react"; + +interface PresetControlsProps { + isPrivate: boolean; + setIsPrivate: (value: boolean) => void; + isNoisy: boolean; + setIsNoisy: (value: boolean) => void; +} + +export const PresetControls: React.FC = ({ + isPrivate, + setIsPrivate, + isNoisy, + setIsNoisy, +}) => { + return ( +
    +
    +
    + setIsPrivate(!isPrivate)} + color="orange" + /> + + Private presets are only visible to you} + className="z-50" + > + + +
    + +
    + setIsNoisy(!isNoisy)} + color="orange" + /> + + Noisy presets will trigger sound for every matching event + } + className="z-50" + > + + +
    +
    +
    + ); +}; diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json index 88b3516a5..cc408c717 100644 --- a/keep-ui/package-lock.json +++ b/keep-ui/package-lock.json @@ -51,6 +51,7 @@ "eslint-visitor-keys": "^3.4.1", "https-proxy-agent": "^7.0.5", "js-yaml": "^4.1.0", + "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", "lucide-react": "^0.460.0", "next": "^14.2.13", diff --git a/keep-ui/package.json b/keep-ui/package.json index 7e2e17d73..f397841e4 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -52,6 +52,7 @@ "eslint-visitor-keys": "^3.4.1", "https-proxy-agent": "^7.0.5", "js-yaml": "^4.1.0", + "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", "lucide-react": "^0.460.0", "next": "^14.2.13", diff --git a/keep-ui/shared/lib/state-utils.ts b/keep-ui/shared/lib/state-utils.ts new file mode 100644 index 000000000..5f1b07d02 --- /dev/null +++ b/keep-ui/shared/lib/state-utils.ts @@ -0,0 +1,11 @@ +import { useSWRConfig } from "swr"; + +export const useRevalidateMultiple = () => { + const { mutate } = useSWRConfig(); + return (keys: string[], options: { isExact: boolean } = { isExact: false }) => + mutate( + (key) => + typeof key === "string" && + keys.some((k) => (options.isExact ? k === key : key.startsWith(k))) + ); +}; diff --git a/keep-ui/shared/ui/utils/showSuccessToast.tsx b/keep-ui/shared/ui/utils/showSuccessToast.tsx new file mode 100644 index 000000000..f2ec95ecf --- /dev/null +++ b/keep-ui/shared/ui/utils/showSuccessToast.tsx @@ -0,0 +1,5 @@ +import { toast, ToastOptions } from "react-toastify"; + +export function showSuccessToast(message: string, options?: ToastOptions) { + toast.success(message, options); +} diff --git a/keep-ui/utils/hooks/useAlertPolling.ts b/keep-ui/utils/hooks/useAlertPolling.ts new file mode 100644 index 000000000..cddf5627d --- /dev/null +++ b/keep-ui/utils/hooks/useAlertPolling.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useWebsocket } from "@/utils/hooks/usePusher"; + +const ALERT_POLLING_INTERVAL = 1000 * 10; // Once per 10 seconds. + +export const useAlertPolling = () => { + const { bind, unbind } = useWebsocket(); + const [pollAlerts, setPollAlerts] = useState(0); + const lastPollTimeRef = useRef(0); + + console.log("useAlertPolling: Initializing"); + + const handleIncoming = useCallback((incoming: any) => { + console.log("useAlertPolling: Received incoming data:", incoming); + const currentTime = Date.now(); + const timeSinceLastPoll = currentTime - lastPollTimeRef.current; + + console.log( + `useAlertPolling: Time since last poll: ${timeSinceLastPoll}ms` + ); + + const newPollValue = Math.floor(Math.random() * 10000); + + if (timeSinceLastPoll < ALERT_POLLING_INTERVAL) { + console.log("useAlertPolling: Ignoring poll due to short interval"); + setPollAlerts(0); + } else { + console.log("useAlertPolling: Updating poll alerts"); + lastPollTimeRef.current = currentTime; + console.log(`useAlertPolling: New poll value: ${newPollValue}`); + setPollAlerts(newPollValue); + } + }, []); + + useEffect(() => { + console.log("useAlertPolling: Setting up event listener for 'poll-alerts'"); + bind("poll-alerts", handleIncoming); + return () => { + console.log( + "useAlertPolling: Cleaning up event listener for 'poll-alerts'" + ); + unbind("poll-alerts", handleIncoming); + }; + }, [bind, unbind, handleIncoming]); + + console.log("useAlertPolling: Current poll alerts value:", pollAlerts); + return { data: pollAlerts }; +}; diff --git a/keep-ui/utils/hooks/useDashboardPresets.ts b/keep-ui/utils/hooks/useDashboardPresets.ts index a8cff38a0..e388797ec 100644 --- a/keep-ui/utils/hooks/useDashboardPresets.ts +++ b/keep-ui/utils/hooks/useDashboardPresets.ts @@ -1,56 +1,14 @@ -import { useHydratedSession as useSession } from "@/shared/lib/hooks/useHydratedSession"; -import { usePresets } from "./usePresets"; -import { Preset } from "@/app/(keep)/alerts/models"; -import { useCallback, useMemo } from "react"; +import { usePresets } from "@/entities/presets/model/usePresets"; import { useSearchParams } from "next/navigation"; export const useDashboardPreset = () => { - const { data: session } = useSession(); + const searchParams = useSearchParams(); - const { - useAllPresets, - useStaticPresets, - presetsOrderFromLS, - staticPresetsOrderFromLS, - } = usePresets("dashboard", true); - const { data: presets = [] } = useAllPresets({ + const { dynamicPresets, staticPresets } = usePresets({ + filters: searchParams?.toString(), revalidateIfStale: false, revalidateOnFocus: false, }); - const { data: fetchedPresets = [] } = useStaticPresets({ - revalidateIfStale: false, - }); - const searchParams = useSearchParams(); - - const checkValidPreset = useCallback( - (preset: Preset) => { - if (!preset.is_private) { - return true; - } - return preset && preset.created_by == session?.user?.email; - }, - [session] - ); - - let allPreset = useMemo(() => { - /*If any filters are applied on the dashboard, we will fetch live data; otherwise, - we will use data from localStorage to sync values between the navbar and the dashboard.*/ - let combinedPresets = searchParams?.toString() - ? [...presets, ...fetchedPresets] - : [...presetsOrderFromLS, ...staticPresetsOrderFromLS]; - //private preset checks - combinedPresets = combinedPresets.filter((preset) => - checkValidPreset(preset) - ); - return combinedPresets; - }, [ - searchParams, - presets, - fetchedPresets, - presetsOrderFromLS, - staticPresetsOrderFromLS, - checkValidPreset, - ]); - return allPreset; + return [...staticPresets, ...dynamicPresets]; }; diff --git a/keep-ui/utils/hooks/usePresets.ts b/keep-ui/utils/hooks/usePresets.ts deleted file mode 100644 index 5a6eb39f0..000000000 --- a/keep-ui/utils/hooks/usePresets.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { Preset } from "@/app/(keep)/alerts/models"; -import useSWR, { SWRConfiguration } from "swr"; -import { useLocalStorage } from "utils/hooks/useLocalStorage"; -import { useConfig } from "./useConfig"; -import useSWRSubscription from "swr/subscription"; -import { useWebsocket } from "./usePusher"; -import { useSearchParams } from "next/navigation"; -import { useApi } from "@/shared/lib/hooks/useApi"; - -interface EnhancedPreset extends Preset { - lastLocalUpdate?: number; - lastServerFetch?: number; -} - -const STATIC_PRESETS = ["feed"]; - -export const usePresets = (type?: string, useFilters?: boolean) => { - const api = useApi(); - const { data: configData } = useConfig(); - //ideally, we can use pathname. but hardcoding it for now. - const isDashBoard = type === "dashboard"; - const [presetsOrderFromLS, setPresetsOrderFromLS] = useLocalStorage( - "presets-order", - [] - ); - const searchParams = useSearchParams(); - - const newPresetsRef = useRef(null); - - const [staticPresetsOrderFromLS, setStaticPresetsOrderFromLS] = - useLocalStorage(`static-presets-order`, []); - // used to sync the presets with the server - const [isLocalStorageReady, setIsLocalStorageReady] = useState(false); - const presetsOrderRef = useRef(presetsOrderFromLS); - const staticPresetsOrderRef = useRef(staticPresetsOrderFromLS); - const { bind, unbind } = useWebsocket(); - - useEffect(() => { - presetsOrderRef.current = presetsOrderFromLS; - staticPresetsOrderRef.current = staticPresetsOrderFromLS; - }, [presetsOrderFromLS, staticPresetsOrderFromLS]); - - const updateLocalPresets = (newPresets: Preset[]) => { - const now = new Date(); - const enhancedNewPresets = newPresets.map((preset) => ({ - ...preset, - lastLocalUpdate: now.getTime(), - })); - - if (newPresetsRef) { - newPresetsRef.current = enhancedNewPresets; - } - const updatePresets = ( - currentPresets: EnhancedPreset[], - newPresets: EnhancedPreset[] - ) => { - const newPresetMap = new Map(newPresets.map((p) => [p.id, p])); - let updatedPresets = new Map(currentPresets.map((p) => [p.id, p])); - - newPresetMap.forEach((newPreset, newPresetId) => { - const currentPreset = updatedPresets.get(newPresetId); - if (currentPreset) { - // Update existing preset with new alerts count - updatedPresets.set(newPresetId, { - ...currentPreset, - alerts_count: currentPreset.alerts_count + newPreset.alerts_count, - created_by: newPreset.created_by, - is_private: newPreset.is_private, - lastLocalUpdate: newPreset.lastLocalUpdate, - lastServerFetch: newPreset.lastServerFetch, - }); - } else { - // If the preset is not in the current presets, add it - updatedPresets.set(newPresetId, { - ...newPreset, - alerts_count: newPreset.alerts_count, - lastServerFetch: newPreset.lastServerFetch, - lastLocalUpdate: newPreset.lastLocalUpdate, - }); - } - }); - return Array.from(updatedPresets.values()); - }; - setPresetsOrderFromLS((current) => - updatePresets( - presetsOrderRef.current, - enhancedNewPresets.filter((p) => !STATIC_PRESETS.includes(p.name)) - ) - ); - - setStaticPresetsOrderFromLS((current) => - updatePresets( - staticPresetsOrderRef.current, - enhancedNewPresets.filter((p) => STATIC_PRESETS.includes(p.name)) - ) - ); - }; - - useSWRSubscription( - () => - configData?.PUSHER_DISABLED === false && - api.isReady() && - isLocalStorageReady - ? "presets" - : null, - (_, { next }) => { - const newPresets = (newPresets: Preset[]) => { - updateLocalPresets(newPresets); - next(null, { - presets: newPresets, - isAsyncLoading: false, - lastSubscribedDate: new Date(), - }); - }; - - bind("async-presets", newPresets); - - return () => { - console.log("Unbinding from presets channel"); - unbind("async-presets", newPresets); - }; - }, - { revalidateOnFocus: false } - ); - - const useFetchAllPresets = (options?: SWRConfiguration) => { - const filters = searchParams?.toString(); - return useSWR( - () => - api.isReady() - ? `/preset${ - useFilters && filters && isDashBoard ? `?${filters}` : "" - }` - : null, - async (url) => { - const data = await api.get(url); - const now = new Date(); - // Enhance the fetched presets with timestamp of last server fetch - return data.map((preset: Preset) => ({ - ...preset, - lastServerFetch: now.getTime(), - })); - }, - { - ...options, - onSuccess: (data) => { - if (!data) { - return; - } - const dynamicPresets = data.filter( - (p) => !STATIC_PRESETS.includes(p.name) - ); - const staticPresets = data.filter((p) => - STATIC_PRESETS.includes(p.name) - ); - - //if it is dashboard we don't need to merge with local storage. - //if we need to merge. we need maintain multiple local storage for each dahboard view which make it very complex to maintain.(if we have more dashboards) - if (isDashBoard) { - return; - } - mergePresetsWithLocalStorage( - dynamicPresets, - presetsOrderFromLS, - setPresetsOrderFromLS - ); - mergePresetsWithLocalStorage( - staticPresets, - staticPresetsOrderFromLS, - setStaticPresetsOrderFromLS - ); - }, - } - ); - }; - - const mergePresetsWithLocalStorage = ( - serverPresets: Preset[], - localPresets: Preset[], - setter: (presets: Preset[]) => void - ) => { - // This map quickly checks presence by ID - const serverPresetIds = new Set(serverPresets.map((sp) => sp.id)); - - // Filter localPresets to remove those not present in serverPresets - const updatedLocalPresets = localPresets - .filter((lp) => serverPresetIds.has(lp.id)) - .map((lp) => { - // Find the server version of this local preset - const serverPreset = serverPresets.find((sp) => sp.id === lp.id); - // If found, merge, otherwise just return local (though filtered above) - return serverPreset ? { ...lp, ...serverPreset } : lp; - }); - - // Filter serverPresets to find those not in local storage, to add new presets from server - const newServerPresets = serverPresets.filter( - (sp) => !localPresets.some((lp) => lp.id === sp.id) - ); - - // Combine the updated local presets with any new server presets - const combinedPresets = updatedLocalPresets.concat(newServerPresets); - - // Update state with combined list - setter(combinedPresets); - setIsLocalStorageReady(true); - }; - - const useAllPresets = (options?: SWRConfiguration) => { - const { - data: presets, - error, - isValidating, - mutate, - } = useFetchAllPresets(options); - const filteredPresets = presets?.filter( - (preset) => !STATIC_PRESETS.includes(preset.name) - ); - return { - data: filteredPresets, - error, - isValidating, - mutate, - }; - }; - - const useStaticPresets = (options?: SWRConfiguration) => { - const { - data: presets, - error, - isValidating, - mutate, - } = useFetchAllPresets(options); - const staticPresets = presets?.filter((preset) => - STATIC_PRESETS.includes(preset.name) - ); - return { - data: staticPresets, - error, - isValidating, - mutate, - }; - }; - - // For each static preset, we check if the local preset is more recent than the server preset. - // It could happen because we update the local preset when we receive an "async-presets" event. - const useLatestStaticPresets = (options?: SWRConfiguration) => { - const { data: presets, ...rest } = useStaticPresets(options); - - // Compare timestamps - const getLatestPreset = (serverPreset: EnhancedPreset) => { - const localPreset = staticPresetsOrderFromLS.find( - (lp) => lp.id === serverPreset.id - ) as EnhancedPreset; - - if (!localPreset?.lastLocalUpdate || !serverPreset.lastServerFetch) { - return serverPreset; - } - - return localPreset.lastLocalUpdate > serverPreset.lastServerFetch - ? localPreset - : serverPreset; - }; - - // If no server presets, use local - if (!presets) { - return { - data: staticPresetsOrderFromLS, - ...rest, - }; - } - - // Compare and merge presets - const mergedPresets = presets.map(getLatestPreset); - - return { - data: mergedPresets, - ...rest, - }; - }; - - return { - useAllPresets, - useStaticPresets, - useLatestStaticPresets, - presetsOrderFromLS, - setPresetsOrderFromLS, - staticPresetsOrderFromLS, - newPresetsRef, - }; -}; diff --git a/keep-ui/utils/hooks/usePusher.ts b/keep-ui/utils/hooks/usePusher.ts index 7c6eb523a..c32ca9e1c 100644 --- a/keep-ui/utils/hooks/usePusher.ts +++ b/keep-ui/utils/hooks/usePusher.ts @@ -1,11 +1,9 @@ import Pusher, { Options as PusherOptions } from "pusher-js"; -import { useConfig } from "./useConfig"; +import { useApiUrl, useConfig } from "./useConfig"; import { useHydratedSession as useSession } from "@/shared/lib/hooks/useHydratedSession"; -import { useApiUrl } from "./useConfig"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; let PUSHER: Pusher | null = null; -const POLLING_INTERVAL = 1000 * 10; // Once per 10 seconds. export const useWebsocket = () => { const apiUrl = useApiUrl(); @@ -161,47 +159,3 @@ export const useWebsocket = () => { channel, }; }; - -export const useAlertPolling = () => { - const { bind, unbind } = useWebsocket(); - const [pollAlerts, setPollAlerts] = useState(0); - const lastPollTimeRef = useRef(0); - - console.log("useAlertPolling: Initializing"); - - const handleIncoming = useCallback((incoming: any) => { - console.log("useAlertPolling: Received incoming data:", incoming); - const currentTime = Date.now(); - const timeSinceLastPoll = currentTime - lastPollTimeRef.current; - - console.log( - `useAlertPolling: Time since last poll: ${timeSinceLastPoll}ms` - ); - - const newPollValue = Math.floor(Math.random() * 10000); - - if (timeSinceLastPoll < POLLING_INTERVAL) { - console.log("useAlertPolling: Ignoring poll due to short interval"); - setPollAlerts(0); - } else { - console.log("useAlertPolling: Updating poll alerts"); - lastPollTimeRef.current = currentTime; - console.log(`useAlertPolling: New poll value: ${newPollValue}`); - setPollAlerts(newPollValue); - } - }, []); - - useEffect(() => { - console.log("useAlertPolling: Setting up event listener for 'poll-alerts'"); - bind("poll-alerts", handleIncoming); - return () => { - console.log( - "useAlertPolling: Cleaning up event listener for 'poll-alerts'" - ); - unbind("poll-alerts", handleIncoming); - }; - }, [bind, unbind, handleIncoming]); - - console.log("useAlertPolling: Current poll alerts value:", pollAlerts); - return { data: pollAlerts }; -}; diff --git a/keep-ui/utils/hooks/useSearchAlerts.ts b/keep-ui/utils/hooks/useSearchAlerts.ts index 22a52cf63..d85a62d05 100644 --- a/keep-ui/utils/hooks/useSearchAlerts.ts +++ b/keep-ui/utils/hooks/useSearchAlerts.ts @@ -3,7 +3,7 @@ import { AlertDto } from "@/app/(keep)/alerts/models"; import { useDebouncedValue } from "./useDebouncedValue"; import { RuleGroupType, formatQuery } from "react-querybuilder"; import { useApi } from "@/shared/lib/hooks/useApi"; -import { useMemo, useEffect } from "react"; +import { useMemo, useEffect, useRef } from "react"; export const useSearchAlerts = ( args: { query: RuleGroupType; timeframe: number }, @@ -14,19 +14,23 @@ export const useSearchAlerts = ( // Create a stable key for our query const argsString = useMemo( () => JSON.stringify(args), + // eslint-disable-next-line react-hooks/exhaustive-deps [args.timeframe, JSON.stringify(args.query)] ); + const previousArgsStringRef = useRef(argsString); + const [debouncedArgsString] = useDebouncedValue(argsString, 2000); const debouncedArgs = JSON.parse(debouncedArgsString); const doesTimeframExceed14Days = Math.floor(args.timeframe / 86400) > 13; - const key = doesTimeframExceed14Days - ? null - : ["/alerts/search", debouncedArgsString]; + const key = + api.isReady() && !doesTimeframExceed14Days + ? ["/alerts/search", debouncedArgsString] + : null; - const swr = useSWR( + const { mutate, ...rest } = useSWR( key, async () => api.post(`/alerts/search`, { @@ -46,8 +50,11 @@ export const useSearchAlerts = ( // Clear data immediately when query changes, before debounce useEffect(() => { - swr.mutate(undefined, false); - }, [argsString]); // Not debouncedArgsString + if (argsString !== previousArgsStringRef.current) { + mutate(undefined, false); + previousArgsStringRef.current = argsString; + } + }, [argsString, mutate]); // Not debouncedArgsString - return swr; + return { ...rest, mutate }; }; diff --git a/keep-ui/utils/hooks/useTags.ts b/keep-ui/utils/hooks/useTags.ts index 7475aab36..cd422b633 100644 --- a/keep-ui/utils/hooks/useTags.ts +++ b/keep-ui/utils/hooks/useTags.ts @@ -1,7 +1,8 @@ import { SWRConfiguration } from "swr"; import useSWRImmutable from "swr/immutable"; import { useApi } from "@/shared/lib/hooks/useApi"; -import { Tag } from "@/app/(keep)/alerts/models"; + +import { Tag } from "@/entities/presets/model/types"; export const useTags = (options: SWRConfiguration = {}) => { const api = useApi(); diff --git a/keep-ui/utils/state.ts b/keep-ui/utils/state.ts deleted file mode 100644 index ded186d6a..000000000 --- a/keep-ui/utils/state.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useSWRConfig } from "swr"; - -export const useRevalidateMultiple = () => { - const { mutate } = useSWRConfig(); - return (keys: string[]) => - mutate( - (key) => typeof key === "string" && keys.some((k) => key.startsWith(k)) - ); -}; diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 077305614..321f38500 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -42,6 +42,7 @@ from sqlmodel import Session, SQLModel, col, or_, select, text from keep.api.consts import STATIC_PRESETS +from keep.api.core.config import config from keep.api.core.db_utils import create_db_engine, get_json_extract_field from keep.api.core.dependencies import SINGLE_TENANT_UUID @@ -91,6 +92,7 @@ "affected_services", "assignee", ] +KEEP_AUDIT_EVENTS_ENABLED = config("KEEP_AUDIT_EVENTS_ENABLED", cast=bool, default=True) def dispose_session(): @@ -170,20 +172,21 @@ def create_workflow_execution( # Ensure the object has an id session.flush() execution_id = workflow_execution.id - if fingerprint and event_type == "alert": - workflow_to_alert_execution = WorkflowToAlertExecution( - workflow_execution_id=execution_id, - alert_fingerprint=fingerprint, - event_id=event_id, - ) - session.add(workflow_to_alert_execution) - elif event_type == "incident": - workflow_to_incident_execution = WorkflowToIncidentExecution( - workflow_execution_id=execution_id, - alert_fingerprint=fingerprint, - incident_id=event_id, - ) - session.add(workflow_to_incident_execution) + if KEEP_AUDIT_EVENTS_ENABLED: + if fingerprint and event_type == "alert": + workflow_to_alert_execution = WorkflowToAlertExecution( + workflow_execution_id=execution_id, + alert_fingerprint=fingerprint, + event_id=event_id, + ) + session.add(workflow_to_alert_execution) + elif event_type == "incident": + workflow_to_incident_execution = WorkflowToIncidentExecution( + workflow_execution_id=execution_id, + alert_fingerprint=fingerprint, + incident_id=event_id, + ) + session.add(workflow_to_incident_execution) session.commit() return execution_id @@ -484,7 +487,7 @@ def get_last_workflow_execution_by_workflow_id( session.query(WorkflowExecution) .filter(WorkflowExecution.workflow_id == workflow_id) .filter(WorkflowExecution.tenant_id == tenant_id) - .filter(WorkflowExecution.started >= datetime.now() - timedelta(days=7)) + .filter(WorkflowExecution.started >= datetime.now() - timedelta(days=1)) .filter(WorkflowExecution.status == "success") .order_by(WorkflowExecution.started.desc()) .first() @@ -650,15 +653,17 @@ def get_consumer_providers() -> List[Provider]: def finish_workflow_execution(tenant_id, workflow_id, execution_id, status, error): with Session(engine) as session: workflow_execution = session.exec( - select(WorkflowExecution) - .where(WorkflowExecution.tenant_id == tenant_id) - .where(WorkflowExecution.workflow_id == workflow_id) - .where(WorkflowExecution.id == execution_id) + select(WorkflowExecution).where(WorkflowExecution.id == execution_id) ).first() # some random number to avoid collisions if not workflow_execution: logger.warning( - f"Failed to finish workflow execution {execution_id} for workflow {workflow_id}. Execution not found." + f"Failed to finish workflow execution {execution_id} for workflow {workflow_id}. Execution not found.", + extra={ + "tenant_id": tenant_id, + "workflow_id": workflow_id, + "execution_id": execution_id, + }, ) raise ValueError("Execution not found") workflow_execution.is_running = random.randint(1, 2147483647 - 1) # max int @@ -1616,6 +1621,9 @@ def get_previous_execution_id(tenant_id, workflow_id, workflow_execution_id): .where(WorkflowExecution.tenant_id == tenant_id) .where(WorkflowExecution.workflow_id == workflow_id) .where(WorkflowExecution.id != workflow_execution_id) + .where( + WorkflowExecution.started >= datetime.now() - timedelta(days=1) + ) # no need to check more than 1 day ago .order_by(WorkflowExecution.started.desc()) .limit(1) ).first() @@ -2193,25 +2201,22 @@ def get_linked_providers(tenant_id: str) -> List[Tuple[str, str, datetime]]: LIMIT_BY_ALERTS = 10000 with Session(engine) as session: - alerts_subquery = select(Alert).filter( - Alert.tenant_id == tenant_id, - Alert.provider_type != "group" - ).limit(LIMIT_BY_ALERTS).subquery() + alerts_subquery = ( + select(Alert) + .filter(Alert.tenant_id == tenant_id, Alert.provider_type != "group") + .limit(LIMIT_BY_ALERTS) + .subquery() + ) providers = session.exec( select( alerts_subquery.c.provider_type, alerts_subquery.c.provider_id, - func.max(alerts_subquery.c.timestamp).label("last_alert_timestamp") + func.max(alerts_subquery.c.timestamp).label("last_alert_timestamp"), ) .select_from(alerts_subquery) - .filter( - ~exists().where(Provider.id == alerts_subquery.c.provider_id) - ) - .group_by( - alerts_subquery.c.provider_type, - alerts_subquery.c.provider_id - ) + .filter(~exists().where(Provider.id == alerts_subquery.c.provider_id)) + .group_by(alerts_subquery.c.provider_type, alerts_subquery.c.provider_id) ).all() return providers diff --git a/keep/api/models/db/migrations/versions/2024-12-17-12-48_3d20d954e058.py b/keep/api/models/db/migrations/versions/2024-12-17-12-48_3d20d954e058.py new file mode 100644 index 000000000..14faa149c --- /dev/null +++ b/keep/api/models/db/migrations/versions/2024-12-17-12-48_3d20d954e058.py @@ -0,0 +1,52 @@ +"""Add index to WorkflowExecution + +Revision ID: 3d20d954e058 +Revises: 55cc64020f6d +Create Date: 2024-12-17 12:48:04.713649 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3d20d954e058" +down_revision = "55cc64020f6d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("workflowexecution", schema=None) as batch_op: + batch_op.create_index( + "idx_workflowexecution_tenant_workflow_id_timestamp", + ["tenant_id", "workflow_id", sa.desc("started")], + unique=False, + ) + if op.get_bind().dialect.name == "mysql": + batch_op.create_index( + "idx_workflowexecution_workflow_tenant_started_status", + [ + "workflow_id", + "tenant_id", + sa.desc("started"), + sa.text("status(255)"), + ], + unique=False, + ) + else: + batch_op.create_index( + "idx_workflowexecution_workflow_tenant_started_status", + ["workflow_id", "tenant_id", sa.desc("started"), "status"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("workflowexecution", schema=None) as batch_op: + batch_op.drop_index("idx_workflowexecution_workflow_tenant_started_status") + batch_op.drop_index("idx_workflowexecution_tenant_workflow_id_timestamp") + # ### end Alembic commands ### diff --git a/keep/api/models/db/workflow.py b/keep/api/models/db/workflow.py index f243b51f0..a6b434e2c 100644 --- a/keep/api/models/db/workflow.py +++ b/keep/api/models/db/workflow.py @@ -1,7 +1,9 @@ +import os from datetime import datetime from typing import List, Optional -from sqlalchemy import TEXT +import sqlalchemy +from sqlalchemy import TEXT, Index from sqlmodel import JSON, Column, Field, Relationship, SQLModel, UniqueConstraint @@ -26,9 +28,37 @@ class Config: orm_mode = True +def get_status_column(): + backend = ( + sqlalchemy.engine.url.make_url( + os.environ.get("DATABASE_CONNECTION_STRING") + ).get_backend_name() + if os.environ.get("DATABASE_CONNECTION_STRING") + else None + ) + return ( + sqlalchemy.text("status(255)") + if backend == "mysql" + else sqlalchemy.text("status") + ) + + class WorkflowExecution(SQLModel, table=True): __table_args__ = ( UniqueConstraint("workflow_id", "execution_number", "is_running", "timeslot"), + Index( + "idx_workflowexecution_tenant_workflow_id_timestamp", + "tenant_id", + "workflow_id", + "started", + ), + Index( + "idx_workflowexecution_workflow_tenant_started_status", + "workflow_id", + "tenant_id", + "started", + get_status_column(), + ), ) id: str = Field(default=None, primary_key=True) diff --git a/keep/api/tasks/process_event_task.py b/keep/api/tasks/process_event_task.py index 66c8fa035..09415a90d 100644 --- a/keep/api/tasks/process_event_task.py +++ b/keep/api/tasks/process_event_task.py @@ -467,11 +467,13 @@ def __handle_formatted_events( with tracer.start_as_current_span("process_event_notify_client"): pusher_client = get_pusher_client() if notify_client else None + if not pusher_client: + return # Get the notification cache pusher_cache = get_notification_cache() # Tell the client to poll alerts - if pusher_client and pusher_cache.should_notify(tenant_id, "poll-alerts"): + if pusher_cache.should_notify(tenant_id, "poll-alerts"): try: pusher_client.trigger( f"private-{tenant_id}", @@ -485,7 +487,6 @@ def __handle_formatted_events( if ( incidents - and pusher_client and pusher_cache.should_notify(tenant_id, "incident-change") ): try: @@ -499,8 +500,6 @@ def __handle_formatted_events( # Now we need to update the presets # send with pusher - if not pusher_client: - return try: presets = get_all_presets_dtos(tenant_id) @@ -515,32 +514,12 @@ def __handle_formatted_events( if not filtered_alerts: continue presets_do_update.append(preset_dto) - preset_dto.alerts_count = len(filtered_alerts) - # update noisy - if preset_dto.is_noisy: - firing_filtered_alerts = list( - filter( - lambda alert: alert.status == AlertStatus.FIRING.value, - filtered_alerts, - ) - ) - # if there are firing alerts, then do noise - if firing_filtered_alerts: - logger.info("Noisy preset is noisy") - preset_dto.should_do_noise_now = True - # else if at least one of the alerts has isNoisy and should fire: - elif any( - alert.isNoisy and alert.status == AlertStatus.FIRING.value - for alert in filtered_alerts - if hasattr(alert, "isNoisy") - ): - logger.info("Noisy preset is noisy") - preset_dto.should_do_noise_now = True + if pusher_cache.should_notify(tenant_id, "poll-presets"): try: pusher_client.trigger( f"private-{tenant_id}", - "async-presets", - json.dumps([p.dict() for p in presets_do_update], default=str), + "poll-presets", + json.dumps([p.name.lower() for p in presets_do_update], default=str), ) except Exception: logger.exception("Failed to send presets via pusher") diff --git a/keep/contextmanager/contextmanager.py b/keep/contextmanager/contextmanager.py index 2dad2563b..95b51cb8f 100644 --- a/keep/contextmanager/contextmanager.py +++ b/keep/contextmanager/contextmanager.py @@ -2,6 +2,7 @@ import logging import click +import json5 from pympler.asizeof import asizeof from keep.api.core.db import get_last_workflow_execution_by_workflow_id, get_session @@ -10,7 +11,13 @@ class ContextManager: - def __init__(self, tenant_id, workflow_id=None, workflow_execution_id=None): + def __init__( + self, + tenant_id, + workflow_id=None, + workflow_execution_id=None, + workflow: dict | None = None, + ): self.logger = logging.getLogger(__name__) self.logger_adapter = WorkflowLoggerAdapter( self.logger, self, tenant_id, workflow_id, workflow_execution_id @@ -37,16 +44,25 @@ def __init__(self, tenant_id, workflow_id=None, workflow_execution_id=None): # last workflow context self.last_workflow_execution_results = {} self.last_workflow_run_time = None - if self.workflow_id: + if self.workflow_id and workflow: try: - last_workflow_execution = get_last_workflow_execution_by_workflow_id( - tenant_id, workflow_id + # @tb: try to understand if the workflow tries to use last_workflow_results + # if so, we need to get the last workflow execution and load it into the context + workflow_str = json5.dumps(workflow) + last_workflow_results_in_workflow = ( + "last_workflow_results" in workflow_str ) - if last_workflow_execution is not None: - self.last_workflow_execution_results = ( - last_workflow_execution.results + if last_workflow_results_in_workflow: + last_workflow_execution = ( + get_last_workflow_execution_by_workflow_id( + tenant_id, workflow_id + ) ) - self.last_workflow_run_time = last_workflow_execution.started + if last_workflow_execution is not None: + self.last_workflow_execution_results = ( + last_workflow_execution.results + ) + self.last_workflow_run_time = last_workflow_execution.started except Exception: self.logger.exception("Failed to get last workflow execution") pass diff --git a/keep/parser/parser.py b/keep/parser/parser.py index 1fadd594f..d10e4d2ad 100644 --- a/keep/parser/parser.py +++ b/keep/parser/parser.py @@ -137,8 +137,7 @@ def _parse_workflow( self.logger.debug("Parsing workflow") workflow_id = self._get_workflow_id(tenant_id, workflow) context_manager = ContextManager( - tenant_id=tenant_id, - workflow_id=workflow_id, + tenant_id=tenant_id, workflow_id=workflow_id, workflow=workflow ) # Parse the providers (from the workflow yaml or from the providers directory) self._load_providers_config( diff --git a/keep/workflowmanager/workflowscheduler.py b/keep/workflowmanager/workflowscheduler.py index 5f550b05f..2fe05e1ed 100644 --- a/keep/workflowmanager/workflowscheduler.py +++ b/keep/workflowmanager/workflowscheduler.py @@ -17,6 +17,7 @@ from keep.api.core.db import get_workflow as get_workflow_db from keep.api.core.db import get_workflows_that_should_run from keep.api.models.alert import AlertDto, IncidentDto +from keep.api.utils.email_utils import KEEP_EMAILS_ENABLED, EmailTemplates, send_email from keep.providers.providers_factory import ProviderConfigurationException from keep.workflowmanager.workflow import Workflow, WorkflowStrategy from keep.workflowmanager.workflowstore import WorkflowStore @@ -588,42 +589,41 @@ def _finish_workflow_execution( status=status.value, error=error, ) - # get the previous workflow execution id - previous_execution = get_previous_execution_id( - tenant_id, workflow_id, workflow_execution_id - ) - # if error, send an email - if status == WorkflowStatus.ERROR and ( - previous_execution - is None # this means this is the first execution, for example - or previous_execution.status != WorkflowStatus.ERROR.value - ): - workflow = get_workflow_db(tenant_id=tenant_id, workflow_id=workflow_id) - try: - from keep.api.core.config import config - from keep.api.utils.email_utils import EmailTemplates, send_email - keep_platform_url = config( - "KEEP_PLATFORM_URL", default="https://platform.keephq.dev" - ) - error_logs_url = f"{keep_platform_url}/workflows/{workflow_id}/runs/{workflow_execution_id}" - self.logger.debug( - f"Sending email to {workflow.created_by} for failed workflow {workflow_id}" - ) - email_sent = send_email( - to_email=workflow.created_by, - template_id=EmailTemplates.WORKFLOW_RUN_FAILED, - workflow_id=workflow_id, - workflow_name=workflow.name, - workflow_execution_id=workflow_execution_id, - error=error, - url=error_logs_url, - ) - if email_sent: - self.logger.info( - f"Email sent to {workflow.created_by} for failed workflow {workflow_id}" + if KEEP_EMAILS_ENABLED: + # get the previous workflow execution id + previous_execution = get_previous_execution_id( + tenant_id, workflow_id, workflow_execution_id + ) + # if error, send an email + if status == WorkflowStatus.ERROR and ( + previous_execution + is None # this means this is the first execution, for example + or previous_execution.status != WorkflowStatus.ERROR.value + ): + workflow = get_workflow_db(tenant_id=tenant_id, workflow_id=workflow_id) + try: + keep_platform_url = config( + "KEEP_PLATFORM_URL", default="https://platform.keephq.dev" + ) + error_logs_url = f"{keep_platform_url}/workflows/{workflow_id}/runs/{workflow_execution_id}" + self.logger.debug( + f"Sending email to {workflow.created_by} for failed workflow {workflow_id}" + ) + email_sent = send_email( + to_email=workflow.created_by, + template_id=EmailTemplates.WORKFLOW_RUN_FAILED, + workflow_id=workflow_id, + workflow_name=workflow.name, + workflow_execution_id=workflow_execution_id, + error=error, + url=error_logs_url, + ) + if email_sent: + self.logger.info( + f"Email sent to {workflow.created_by} for failed workflow {workflow_id}" + ) + except Exception as e: + self.logger.error( + f"Failed to send email to {workflow.created_by} for failed workflow {workflow_id}: {e}" ) - except Exception as e: - self.logger.error( - f"Failed to send email to {workflow.created_by} for failed workflow {workflow_id}: {e}" - ) diff --git a/pyproject.toml b/pyproject.toml index 97541c9ec..8fc497f7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.32.0" +version = "0.32.1" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] packages = [{include = "keep"}] diff --git a/tests/conftest.py b/tests/conftest.py index aeaaf101d..18422f869 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,7 +157,7 @@ def mysql_container(docker_ip, docker_services): @pytest.fixture -def db_session(request): +def db_session(request, monkeypatch): # Create a database connection os.environ["DB_ECHO"] = "true" if ( @@ -168,6 +168,24 @@ def db_session(request): ): db_type = request.param.get("db") db_connection_string = request.getfixturevalue(f"{db_type}_container") + monkeypatch.setenv("DATABASE_CONNECTION_STRING", db_connection_string) + t = SQLModel.metadata.tables["workflowexecution"] + curr_index = next( + ( + index + for index in t.indexes + if index.name == "idx_workflowexecution_workflow_tenant_started_status" + ) + ) + t.indexes.remove(curr_index) + status_index = Index( + "idx_workflowexecution_workflow_tenant_started_status", + "workflow_id", + "tenant_id", + "started", + sqlalchemy.text("status(255)"), + ) + t.append_constraint(status_index) mock_engine = create_engine(db_connection_string) # sqlite else: