diff --git a/keep-ui/app/alerts/alert-actions.tsx b/keep-ui/app/alerts/alert-actions.tsx index 3306ab7c5..d6ca4e316 100644 --- a/keep-ui/app/alerts/alert-actions.tsx +++ b/keep-ui/app/alerts/alert-actions.tsx @@ -3,22 +3,17 @@ import { Button } from "@tremor/react"; import { getSession } from "next-auth/react"; import { getApiURL } from "utils/apiUrl"; import { AlertDto } from "./models"; +import { useAlerts } from "utils/hooks/useAlerts"; interface Props { selectedRowIds: string[]; - onDelete: ( - fingerprint: string, - lastReceived: Date, - restore?: boolean - ) => void; alerts: AlertDto[]; } -export default function AlertActions({ - selectedRowIds, - onDelete: callDelete, - alerts, -}: Props) { +export default function AlertActions({ selectedRowIds, alerts }: Props) { + const { useAllAlerts } = useAlerts(); + const { mutate } = useAllAlerts(); + const onDelete = async () => { const confirmed = confirm( `Are you sure you want to delete ${selectedRowIds.length} alert(s)?` @@ -50,7 +45,7 @@ export default function AlertActions({ body: JSON.stringify(body), }); if (res.ok) { - callDelete(fingerprint, alert.lastReceived); + await mutate(); } } } diff --git a/keep-ui/app/alerts/alert-assignee.tsx b/keep-ui/app/alerts/alert-assignee.tsx index bac1c534e..fdf38e457 100644 --- a/keep-ui/app/alerts/alert-assignee.tsx +++ b/keep-ui/app/alerts/alert-assignee.tsx @@ -1,15 +1,14 @@ import { useState } from "react"; -import { User } from "app/settings/models"; import { NameInitialsAvatar } from "react-name-initials-avatar"; +import { useUsers } from "utils/hooks/useUsers"; -export default function AlertAssignee({ - assignee, - users, -}: { +interface Props { assignee: string | undefined; - users: User[]; -}) { +} + +export default function AlertAssignee({ assignee }: Props) { const [imageError, setImageError] = useState(false); + const { data: users = [] } = useUsers({ revalidateOnFocus: false }); if (!assignee || users.length < 1) { return null; diff --git a/keep-ui/app/alerts/alert-columns-select.tsx b/keep-ui/app/alerts/alert-columns-select.tsx index d6a920bb6..daf7b11e2 100644 --- a/keep-ui/app/alerts/alert-columns-select.tsx +++ b/keep-ui/app/alerts/alert-columns-select.tsx @@ -19,10 +19,8 @@ import { Column } from "@tanstack/react-table"; interface AlertColumnsSelectProps { table: Table; - columnOrder: string[]; presetName?: string; isLoading: boolean; - setColumnVisibility: any; } export interface Option { @@ -93,9 +91,7 @@ const filterFixedPositionColumns = (column: Column) => { export default function AlertColumnsSelect({ table, presetName, - setColumnVisibility, isLoading, - columnOrder, }: AlertColumnsSelectProps) { const columnsOptions = table .getAllLeafColumns() @@ -106,7 +102,9 @@ export default function AlertColumnsSelect({ .filter((col) => col.getIsVisible() && filterFixedPositionColumns(col)) .map(convertColumnToOption) .sort( - (a, b) => columnOrder.indexOf(a.label) - columnOrder.indexOf(b.label) + (a, b) => + table.getState().columnOrder.indexOf(a.label) - + table.getState().columnOrder.indexOf(b.label) ); const onChange = (valueKeys: string[]) => { @@ -124,7 +122,7 @@ export default function AlertColumnsSelect({ getHiddenColumnsLocalStorageKey(presetName), JSON.stringify(hiddenColumns) ); - setColumnVisibility(hiddenColumns); + table.setColumnVisibility(hiddenColumns); saveColumnsOrder(valueKeys); }; diff --git a/keep-ui/app/alerts/alert-extra-payload.tsx b/keep-ui/app/alerts/alert-extra-payload.tsx index e08813577..339cb3459 100644 --- a/keep-ui/app/alerts/alert-extra-payload.tsx +++ b/keep-ui/app/alerts/alert-extra-payload.tsx @@ -15,21 +15,26 @@ export const getExtraPayloadNoKnownKeys = (alert: AlertDto) => { interface Props { alert: AlertDto; + isToggled: boolean; + setIsToggled: (newValue: boolean) => void; } -export default function AlertExtraPayload({ alert }: Props) { +export default function AlertExtraPayload({ + alert, + isToggled = false, + setIsToggled, +}: Props) { const ref = useRef(null); - const [isExpanded, setIsExpanded] = useState(false); - function handleAccordionToggle() { - setIsExpanded(!isExpanded); - } + const onAccordionToggle = () => { + setIsToggled(!isToggled); + }; useEffect(() => { - if (isExpanded && ref.current) { - ref.current.scrollIntoView({ behavior: "smooth", block: "end" }); + if (isToggled && ref.current) { + ref.current.scrollIntoView({ behavior: "smooth", block: "center" }); } - }, [isExpanded]); + }, [isToggled]); const { extraPayload, extraPayloadLength } = getExtraPayloadNoKnownKeys(alert); @@ -39,17 +44,15 @@ export default function AlertExtraPayload({ alert }: Props) { } return ( -
- - - Extra Payload - - -
-            {JSON.stringify(extraPayload, null, 2)}
-          
-
-
-
+ + + Extra Payload + + +
+          {JSON.stringify(extraPayload, null, 2)}
+        
+
+
); } diff --git a/keep-ui/app/alerts/alert-history.tsx b/keep-ui/app/alerts/alert-history.tsx index f802c903b..501d336d1 100644 --- a/keep-ui/app/alerts/alert-history.tsx +++ b/keep-ui/app/alerts/alert-history.tsx @@ -1,70 +1,63 @@ import { Dialog, Transition } from "@headlessui/react"; -import { Fragment } from "react"; +import { Fragment, useState } from "react"; import { AlertDto } from "./models"; -import { AlertTable } from "./alert-table"; +import { AlertTable, useAlertTableCols } from "./alert-table"; import { Button, Flex, Subtitle, Title, Divider } from "@tremor/react"; -import { User } from "app/settings/models"; -import { User as NextUser } from "next-auth"; import AlertHistoryCharts from "./alert-history-charts"; -import useSWR from "swr"; -import { getApiURL } from "utils/apiUrl"; -import { fetcher } from "utils/fetcher"; +import { useAlerts } from "utils/hooks/useAlerts"; import Loading from "app/loading"; +import { PaginationState } from "@tanstack/react-table"; +import { useRouter, useSearchParams } from "next/navigation"; interface Props { - isOpen: boolean; - closeModal: () => void; - selectedAlert: AlertDto | null; - users?: User[]; - currentUser: NextUser; - accessToken?: string; + alerts: AlertDto[]; } -export function AlertHistory({ - isOpen, - closeModal, - selectedAlert, - users = [], - currentUser, - accessToken, -}: Props) { - const apiUrl = getApiURL(); - const historyUrl = - selectedAlert && accessToken - ? `${apiUrl}/alerts/${selectedAlert.fingerprint}/history/?provider_id=${ - selectedAlert.providerId - }&provider_type=${selectedAlert.source ? selectedAlert.source[0] : ""}` - : null; - const { - data: alerts, - error, - isLoading, - } = useSWR(historyUrl, (url) => fetcher(url, accessToken!), { - revalidateOnFocus: false, +export function AlertHistory({ alerts }: Props) { + const [rowPagination, setRowPagination] = useState({ + pageIndex: 0, + pageSize: 10, }); - if (!selectedAlert || isLoading) { - return <>; - } + const router = useRouter(); - if (!alerts || error) { - return ; - } - alerts.forEach( - (alert) => (alert.lastReceived = new Date(alert.lastReceived)) - ); - alerts.sort((a, b) => b.lastReceived.getTime() - a.lastReceived.getTime()); - const lastReceivedData = alerts.map((alert) => alert.lastReceived); - const maxLastReceived: Date = new Date( - Math.max(...lastReceivedData.map((date) => date.getTime())) + const searchParams = useSearchParams(); + const selectedAlert = alerts.find((alert) => + searchParams ? searchParams.get("id") === alert.id : undefined ); - const minLastReceived: Date = new Date( - Math.min(...lastReceivedData.map((date) => date.getTime())) + + const { useAlertHistory } = useAlerts(); + const { data: alertHistory = [], isLoading } = useAlertHistory( + selectedAlert, + { + revalidateOnFocus: false, + } ); + const alertTableColumns = useAlertTableCols(); + + if (isLoading) { + return ; + } + + const alertsHistoryWithDate = alertHistory.map((alert) => ({ + ...alert, + lastReceived: new Date(alert.lastReceived), + })); + + const sortedHistoryAlert = alertsHistoryWithDate + .map((alert) => alert.lastReceived.getTime()); + + const maxLastReceived = new Date(Math.max(...sortedHistoryAlert)); + const minLastReceived = new Date(Math.min(...sortedHistoryAlert)); + return ( - - + + router.replace("/alerts", { scroll: false })} + >
- History of: {alerts[0]?.name} - Total alerts: {alerts.length} + History of: {alertsHistoryWithDate[0]?.name} + + Total alerts: {alertsHistoryWithDate.length} + First Occurence: {minLastReceived.toString()} @@ -104,23 +99,30 @@ export function AlertHistory({
- + {selectedAlert && ( + + )}
diff --git a/keep-ui/app/alerts/alert-menu.tsx b/keep-ui/app/alerts/alert-menu.tsx index 1e07b233f..8b7db7c8c 100644 --- a/keep-ui/app/alerts/alert-menu.tsx +++ b/keep-ui/app/alerts/alert-menu.tsx @@ -8,43 +8,34 @@ import { TrashIcon, UserPlusIcon, } from "@heroicons/react/24/outline"; -import { getSession } from "next-auth/react"; +import { useSession } from "next-auth/react"; import { getApiURL } from "utils/apiUrl"; import Link from "next/link"; -import { Provider, ProviderMethod } from "app/providers/providers"; +import { ProviderMethod } from "app/providers/providers"; import { AlertDto } from "./models"; import { AlertMethodTransition } from "./alert-method-transition"; -import { User as NextUser } from "next-auth"; import { useFloating } from "@floating-ui/react-dom"; -import { KeyedMutator } from "swr"; +import { useProviders } from "utils/hooks/useProviders"; +import { useAlerts } from "utils/hooks/useAlerts"; interface Props { alert: AlertDto; openHistory: () => void; - provider?: Provider; - mutate: KeyedMutator; - callDelete?: ( - fingerprint: string, - lastReceived: Date, - restore?: boolean - ) => void; - setAssignee?: ( - fingerprint: string, - lastReceived: Date, - unassign: boolean - ) => void; - currentUser: NextUser; } -export default function AlertMenu({ - alert, - provider, - openHistory, - mutate, - callDelete, - setAssignee, - currentUser, -}: Props) { +export default function AlertMenu({ alert, openHistory }: Props) { + const apiUrl = getApiURL(); + const { + data: { installed_providers: installedProviders } = { + installed_providers: [], + }, + } = useProviders(); + + const { useAllAlerts } = useAlerts(); + const { mutate } = useAllAlerts({ revalidateOnMount: false }); + + const { data: session } = useSession(); + const [isOpen, setIsOpen] = useState(false); const [method, setMethod] = useState(null); const { refs, x, y } = useFloating(); @@ -52,6 +43,8 @@ export default function AlertMenu({ const fingerprint = alert.fingerprint; const alertSource = alert.source![0]; + const provider = installedProviders.find((p) => p.type === alert.source[0]); + const DynamicIcon = (props: any) => ( { const confirmed = confirm( `Are you sure you want to ${ - alert.deleted.includes(alert.lastReceived.toISOString()) - ? "restore" - : "delete" + alert.deleted ? "restore" : "delete" } this alert?` ); if (confirmed) { - const session = await getSession(); - const apiUrl = getApiURL(); - const restore = alert.deleted.includes(alert.lastReceived.toISOString()); const body = { fingerprint: fingerprint, lastReceived: alert.lastReceived, - restore: restore, + restore: alert.deleted, }; const res = await fetch(`${apiUrl}/alerts`, { method: "DELETE", @@ -97,7 +85,7 @@ export default function AlertMenu({ body: JSON.stringify(body), }); if (res.ok) { - callDelete!(fingerprint, alert.lastReceived, restore); + await mutate(); } } }; @@ -108,8 +96,6 @@ export default function AlertMenu({ "After assiging this alert to yourself, you won't be able to unassign it until someone else assigns it to himself. Are you sure you want to continue?" ) ) { - const session = await getSession(); - const apiUrl = getApiURL(); const res = await fetch( `${apiUrl}/alerts/${fingerprint}/assign/${alert.lastReceived.toISOString()}`, { @@ -121,16 +107,19 @@ export default function AlertMenu({ } ); if (res.ok) { - setAssignee!(fingerprint, alert.lastReceived, unassign); + await mutate(); } } }; const isMethodEnabled = (method: ProviderMethod) => { - return method.scopes.every( - (scope) => - provider?.validatedScopes && provider.validatedScopes[scope] === true - ); + if (provider) { + return method.scopes.every( + (scope) => provider.validatedScopes[scope] === true + ); + } + + return false; }; const openMethodTransition = (method: ProviderMethod) => { @@ -138,7 +127,7 @@ export default function AlertMenu({ setIsOpen(true); }; - const assignee = alert.assignees ? [alert.lastReceived.toISOString()] : ""; + const canAssign = !alert.assignee; return ( <> @@ -209,7 +198,7 @@ export default function AlertMenu({ )} - {assignee !== currentUser.email && ( + {canAssign && ( {({ active }) => ( - {alert.deleted.includes( - alert.lastReceived.toISOString() - ) - ? "Restore" - : "Delete"} + {alert.deleted ? "Restore" : "Delete"} )} @@ -308,7 +293,6 @@ export default function AlertMenu({ }} method={method} alert={alert} - mutate={mutate} provider={provider} /> ) : ( diff --git a/keep-ui/app/alerts/alert-method-transition.tsx b/keep-ui/app/alerts/alert-method-transition.tsx index 8efe4328b..51ea7e6dc 100644 --- a/keep-ui/app/alerts/alert-method-transition.tsx +++ b/keep-ui/app/alerts/alert-method-transition.tsx @@ -19,7 +19,7 @@ import { DatePicker, } from "@tremor/react"; import AlertMethodResultsTable from "./alert-method-results-table"; -import { KeyedMutator } from "swr"; +import { useAlerts } from "utils/hooks/useAlerts"; interface Props { isOpen: boolean; @@ -27,7 +27,6 @@ interface Props { method: ProviderMethod | null; alert: AlertDto; provider?: Provider; - mutate: KeyedMutator; } export function AlertMethodTransition({ @@ -36,13 +35,15 @@ export function AlertMethodTransition({ method, provider, alert, - mutate, }: Props) { const [isLoading, setIsLoading] = useState(true); const [autoParams, setAutoParams] = useState<{ [key: string]: string }>({}); const [userParams, setUserParams] = useState<{ [key: string]: string }>({}); const [results, setResults] = useState(null); + const { useAllAlerts } = useAlerts(); + const { mutate } = useAllAlerts(); + const validateAndSetUserParams = ( key: string, value: string, @@ -119,8 +120,7 @@ export function AlertMethodTransition({ method: ProviderMethod, methodParams: { [key: string]: string }, userParams: { [key: string]: string }, - closeModal: () => void, - mutate: KeyedMutator + closeModal: () => void ) => { const session = await getSession(); const apiUrl = getApiURL(); @@ -189,12 +189,12 @@ export function AlertMethodTransition({ ) ) { // This means all method params are auto populated - invokeMethod(provider!, method!, newAutoParams, {}, closeModal, mutate); + invokeMethod(provider!, method!, newAutoParams, {}, closeModal); } else { setIsLoading(false); } } - }, [method, alert, provider, mutate, closeModal]); + }, [method, alert, provider, closeModal]); if (!method || !provider) { return <>; @@ -258,8 +258,7 @@ export function AlertMethodTransition({ method!, autoParams, userParams, - closeModal, - mutate + closeModal ) } disabled={!buttonEnabled()} diff --git a/keep-ui/app/alerts/alert-name.tsx b/keep-ui/app/alerts/alert-name.tsx index cfe9644cd..a1843e07e 100644 --- a/keep-ui/app/alerts/alert-name.tsx +++ b/keep-ui/app/alerts/alert-name.tsx @@ -8,6 +8,9 @@ import { import { Icon } from "@tremor/react"; import { AlertDto, AlertKnownKeys } from "./models"; import { Workflow } from "app/workflows/models"; +import { useRouter } from "next/navigation"; +import { useWorkflows } from "utils/hooks/useWorkflows"; +import { useMemo } from "react"; const getExtraPayloadNoKnownKeys = (alert: AlertDto) => Object.fromEntries( @@ -37,15 +40,12 @@ const getRelevantWorkflows = (alert: AlertDto, workflows: Workflow[]) => { interface Props { alert: AlertDto; - workflows: Workflow[]; - handleWorkflowClick: (workflows: Workflow[]) => void; } -export default function AlertName({ - alert, - workflows, - handleWorkflowClick, -}: Props) { +export default function AlertName({ alert }: Props) { + const router = useRouter(); + const { data: workflows = [] } = useWorkflows(); + const { name, url, @@ -57,7 +57,18 @@ export default function AlertName({ playbook_url, } = alert; - const relevantWorkflows = getRelevantWorkflows(alert, workflows); + const handleWorkflowClick = (workflows: Workflow[]) => { + if (workflows.length === 1) { + return router.push(`workflows/${workflows[0].id}`); + } + + return router.push("workflows"); + }; + + const relevantWorkflows = useMemo( + () => getRelevantWorkflows(alert, workflows), + [alert, workflows] + ); return (
@@ -104,7 +115,7 @@ export default function AlertName({ /> )} - {deleted.includes(lastReceived.toISOString()) && ( + {deleted && ( ; - mutate?: KeyedMutator; + isRefreshAllowed: boolean; } -export default function AlertPagination({ table, mutate }: Props) { - const [reloadLoading, setReloadLoading] = useState(false); +export default function AlertPagination({ table, isRefreshAllowed }: Props) { + const { useAllAlerts } = useAlerts(); + const { mutate, isValidating } = useAllAlerts(); + const pageIndex = table.getState().pagination.pageIndex; const pageCount = table.getPageCount(); + return (
@@ -50,19 +52,15 @@ export default function AlertPagination({ table, mutate }: Props) { color="orange" variant="secondary" /> - {mutate && ( + {isRefreshAllowed && (
Last received:{" "} - {lastReceivedAlertDate - ? getAlertLastReceieved(lastReceivedAlertDate) - : "N/A"} + {lastSubscribedDate ? getAlertLastReceieved(lastSubscribedDate) : "N/A"}
); diff --git a/keep-ui/app/alerts/alert-table-tab-panel.tsx b/keep-ui/app/alerts/alert-table-tab-panel.tsx new file mode 100644 index 000000000..61c4c4e0c --- /dev/null +++ b/keep-ui/app/alerts/alert-table-tab-panel.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { PaginationState, RowSelectionState } from "@tanstack/react-table"; +import AlertPresets, { Option } from "./alert-presets"; +import { AlertTable, useAlertTableCols } from "./alert-table"; +import { AlertDto, AlertKnownKeys, Preset } from "./models"; +import AlertActions from "./alert-actions"; +import { TabPanel } from "@tremor/react"; + +const getPresetAlerts = (alert: AlertDto, presetName: string): boolean => { + if (presetName === "Deleted") { + return alert.deleted === true; + } + + if (presetName === "Groups") { + return alert.group === true; + } + + if (presetName === "Feed") { + return alert.deleted === false; + } + + return true; +}; + +const getOptionAlerts = (alert: AlertDto, options: Option[]): boolean => + options.every((option) => { + const [key, value] = option.value.split("="); + + if (key && value) { + const lowercaseKey = key.toLowerCase() as keyof AlertDto; + const lowercaseValue = value.toLowerCase(); + + const alertValue = alert[lowercaseKey]; + + if (Array.isArray(alertValue)) { + return alertValue.every((v) => lowercaseValue.split(",").includes(v)); + } + + if (typeof alertValue === "string") { + return alertValue.toLowerCase().includes(lowercaseValue); + } + } + + return true; + }); + +const getPresetAndOptionsAlerts = ( + alert: AlertDto, + options: Option[], + presetName: string +) => getPresetAlerts(alert, presetName) && getOptionAlerts(alert, options); + +interface Props { + alerts: AlertDto[]; + preset: Preset; + isAsyncLoading: boolean; +} + +export default function AlertTableTabPanel({ + alerts, + preset, + isAsyncLoading, +}: Props) { + const [selectedOptions, setSelectedOptions] = useState( + preset.options + ); + + const [rowPagination, setRowPagination] = useState({ + pageSize: 10, + pageIndex: 0, + }); + + const [rowSelection, setRowSelection] = useState({}); + const selectedRowIds = Object.entries(rowSelection).reduce( + (acc, [alertId, isSelected]) => { + if (isSelected) { + return acc.concat(alertId); + } + return acc; + }, + [] + ); + + const sortedPresetAlerts = alerts + .filter((alert) => + getPresetAndOptionsAlerts(alert, selectedOptions, preset.name) + ) + .sort((a, b) => b.lastReceived.getTime() - a.lastReceived.getTime()); + + const additionalColsToGenerate = [ + ...new Set( + alerts + .flatMap((alert) => Object.keys(alert)) + .filter((key) => AlertKnownKeys.includes(key) === false) + ), + ]; + + const alertTableColumns = useAlertTableCols({ + additionalColsToGenerate: additionalColsToGenerate, + isCheckboxDisplayed: preset.name !== "Deleted", + isMenuDisplayed: true, + }); + + return ( + + {selectedRowIds.length ? ( + + ) : ( + + )} + + + ); +} diff --git a/keep-ui/app/alerts/alert-table.tsx b/keep-ui/app/alerts/alert-table.tsx index b66b5eaf2..a1a0b1d1d 100644 --- a/keep-ui/app/alerts/alert-table.tsx +++ b/keep-ui/app/alerts/alert-table.tsx @@ -7,14 +7,11 @@ import { Callout, } from "@tremor/react"; import { AlertsTableBody } from "./alerts-table-body"; -import { AlertDto } from "./models"; +import { AlertDto, AlertKnownKeys } from "./models"; import { CircleStackIcon, QuestionMarkCircleIcon, } from "@heroicons/react/24/outline"; -import { Provider } from "app/providers/providers"; -import { User } from "app/settings/models"; -import { User as NextUser } from "next-auth"; import { ColumnOrderState, OnChangeFn, @@ -25,194 +22,166 @@ import { useReactTable, VisibilityState, getPaginationRowModel, + PaginationState, + ColumnDef, } from "@tanstack/react-table"; import PushPullBadge from "@/components/ui/push-pulled-badge/push-pulled-badge"; import Image from "next/image"; -import { Workflow } from "app/workflows/models"; -import { useRouter } from "next/navigation"; import AlertName from "./alert-name"; import AlertAssignee from "./alert-assignee"; -import AlertMenu from "./alert-menu"; import AlertSeverity from "./alert-severity"; -import AlertExtraPayload, { - getExtraPayloadNoKnownKeys, -} from "./alert-extra-payload"; import { useEffect, useState } from "react"; import AlertColumnsSelect, { getColumnsOrderLocalStorageKey, getHiddenColumnsLocalStorageKey, } from "./alert-columns-select"; -import AlertTableCheckbox from "./alert-table-checkbox"; -import { KeyedMutator } from "swr"; -import { AlertHistory } from "./alert-history"; import AlertPagination from "./alert-pagination"; import { getAlertLastReceieved } from "utils/helpers"; +import AlertTableCheckbox from "./alert-table-checkbox"; +import AlertExtraPayload from "./alert-extra-payload"; +import AlertMenu from "./alert-menu"; +import { useRouter } from "next/navigation"; -const columnHelper = createColumnHelper(); - -interface Props { - alerts: AlertDto[]; - workflows?: any[]; - providers?: Provider[]; - mutate?: KeyedMutator; - isAsyncLoading?: boolean; - onDelete?: ( - fingerprint: string, - lastReceived: Date, - restore?: boolean - ) => void; - setAssignee?: ( - fingerprint: string, - lastReceived: Date, - unassign: boolean - ) => void; - users?: User[]; - currentUser: NextUser; - presetName?: string; - rowSelection?: RowSelectionState; - setRowSelection?: OnChangeFn; - columnsToExclude?: string[]; - accessToken?: string; -} - -const getExtraPayloadKeys = ( - alerts: AlertDto[], +export const getDefaultColumnVisibility = ( + columnVisibilityState: VisibilityState = {}, columnsToExclude: string[] ) => { - return Array.from( - new Set( - alerts - .map((alert) => { - const { extraPayload } = getExtraPayloadNoKnownKeys(alert); - return Object.keys(extraPayload).concat(columnsToExclude); - }) - .reduce((acc, keys) => [...acc, ...keys], []) - ) + const visibilityStateFromExcludedColumns = + columnsToExclude.reduce( + (acc, column) => ({ ...acc, [column]: false }), + {} + ); + + return { + ...columnVisibilityState, + ...visibilityStateFromExcludedColumns, + }; +}; + +export const getColumnsOrder = (presetName?: string): ColumnOrderState => { + if (presetName === undefined) { + return []; + } + + const columnOrderLocalStorage = localStorage.getItem( + getColumnsOrderLocalStorageKey(presetName) ); + + if (columnOrderLocalStorage) { + return JSON.parse(columnOrderLocalStorage); + } + + return []; }; -const getColumnsToHide = ( - presetName: string | undefined, - extraPayloadKeys: string[] -): { [key: string]: boolean } => { - const columnsToHideFromLocalStorage = localStorage.getItem( +export const getHiddenColumns = ( + presetName?: string, + columns?: ColumnDef[] +): VisibilityState => { + const defaultHidden = + columns + ?.filter((c) => c.id && !AlertKnownKeys.includes(c.id)) + .map((c) => c.id!) ?? []; + if (presetName === undefined) { + return getDefaultColumnVisibility({}, [ + "playbook_url", + "ack_status", + ...defaultHidden, + ]); + } + + const hiddenColumnsFromLocalStorage = localStorage.getItem( getHiddenColumnsLocalStorageKey(presetName) ); - return columnsToHideFromLocalStorage - ? JSON.parse(columnsToHideFromLocalStorage) - : extraPayloadKeys.reduce((obj, key) => { - obj[key] = false; - return obj; - }, {} as { [key: string]: boolean }); + + if (hiddenColumnsFromLocalStorage) { + return JSON.parse(hiddenColumnsFromLocalStorage); + } + + return getDefaultColumnVisibility({}, [ + "playbook_url", + "ack_status", + ...defaultHidden, + ]); }; -export function AlertTable({ - alerts, - workflows = [], - providers = [], - mutate, - isAsyncLoading = false, - onDelete, - setAssignee, - users = [], - currentUser, - presetName, - rowSelection, - setRowSelection, - columnsToExclude = [], - accessToken, -}: Props) { - const router = useRouter(); - const [isOpen, setIsOpen] = useState(false); - const [selectedAlert, setSelectedAlert] = useState(null); - const columnOrderLocalStorage = localStorage.getItem( - getColumnsOrderLocalStorageKey(presetName) - ); +const getPaginatedData = ( + alerts: AlertDto[], + { pageIndex, pageSize }: PaginationState +) => alerts.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); - const openModal = (alert: AlertDto) => { - setSelectedAlert(alert); - setIsOpen(true); - }; +const getDataPageCount = (dataLength: number, { pageSize }: PaginationState) => + Math.ceil(dataLength / pageSize); - const handleWorkflowClick = (workflows: Workflow[]) => { - if (workflows.length === 1) { - router.push(`workflows/${workflows[0].id}`); - } else { - router.push("workflows"); - } - }; +export const columnHelper = createColumnHelper(); - const enabledRowSelection = - presetName === "Deleted" || (isOpen && !presetName) - ? undefined - : rowSelection; +interface UseAlertTableCols { + additionalColsToGenerate?: string[]; + isCheckboxDisplayed?: boolean; + isMenuDisplayed?: boolean; +} - const checkboxColumn = enabledRowSelection - ? [ - columnHelper.display({ - id: "checkbox", - header: (context) => ( - - ), - cell: (context) => ( - - ), - }), - ] - : []; +export const useAlertTableCols = ({ + additionalColsToGenerate = [], + isCheckboxDisplayed, + isMenuDisplayed, +}: UseAlertTableCols = {}) => { + const router = useRouter(); + const [expandedToggles, setExpandedToggles] = useState({}); - const menuColumn = presetName - ? [ - columnHelper.display({ - id: "alertMenu", - meta: { - thClassName: "sticky right-0", - tdClassName: "sticky right-0", - }, - cell: (context) => ( - openModal(context.row.original)} - provider={providers.find( - (p) => p.type === context.row.original.source![0] - )} - mutate={mutate ?? (async () => undefined)} - callDelete={onDelete} - setAssignee={setAssignee} - currentUser={currentUser} - /> - ), - }), - ] - : []; + const filteredAndGeneratedCols = additionalColsToGenerate.map((colName) => + columnHelper.display({ + id: colName, + header: colName, + cell: (context) => { + const alertValue = context.row.original[colName as keyof AlertDto]; + + if (typeof alertValue === "object") { + return JSON.stringify(alertValue); + } - const defaultColumns = [ - ...checkboxColumn, + if (alertValue) { + return alertValue.toString(); + } + + return ""; + }, + }) + ) as ColumnDef[]; + + return [ + ...(isCheckboxDisplayed + ? [ + columnHelper.display({ + id: "checkbox", + header: (context) => ( + + ), + cell: (context) => ( + + ), + }), + ] + : ([] as ColumnDef[])), columnHelper.accessor("severity", { - header: () => "Severity", + header: "Severity", cell: (context) => , }), - columnHelper.accessor("name", { - header: () => "Name", - cell: (context) => ( - - ), + columnHelper.display({ + id: "name", + header: "Name", + cell: (context) => , }), columnHelper.accessor("description", { - header: () => "Description", + header: "Description", cell: (context) => (
(
Pushed @@ -262,74 +232,119 @@ export function AlertTable({ /> )), }), - columnHelper.accessor("assignees", { + columnHelper.accessor("assignee", { header: "Assignee", - cell: (context) => ( - - ), + cell: (context) => , }), columnHelper.display({ id: "extraPayload", header: "Extra Payload", - cell: (context) => , + cell: (context) => ( + + setExpandedToggles({ + ...expandedToggles, + [context.row.original.id]: newValue, + }) + } + /> + ), }), - ...menuColumn, - ]; + ...filteredAndGeneratedCols, + ...((isMenuDisplayed + ? [ + columnHelper.display({ + id: "alertMenu", + meta: { + thClassName: "sticky right-0", + tdClassName: "sticky right-0", + }, + cell: (context) => ( + + router.replace(`/alerts?id=${context.row.original.id}`, { + scroll: false, + }) + } + /> + ), + }), + ] + : []) as ColumnDef[]), + ] as ColumnDef[]; +}; - const extraPayloadKeys = getExtraPayloadKeys(alerts, columnsToExclude); - // Create all the necessary columns - const extraPayloadColumns = extraPayloadKeys.map((key) => - columnHelper.display({ - id: key, - header: key, - cell: (context) => { - const val = (context.row.original as any)[key]; - if (typeof val === "object") { - return JSON.stringify(val); - } - return (context.row.original as any)[key]?.toString() ?? ""; - }, - }) - ); +interface Props { + alerts: AlertDto[]; + columns: ColumnDef[]; + isAsyncLoading?: boolean; + presetName?: string; + columnsToExclude?: (keyof AlertDto)[]; + isMenuColDisplayed?: boolean; + isRefreshAllowed?: boolean; + rowSelection?: { + state: RowSelectionState; + onChange: OnChangeFn; + }; + rowPagination?: { + state: PaginationState; + onChange: OnChangeFn; + }; +} - const columns = [...defaultColumns, ...extraPayloadColumns]; +export function AlertTable({ + alerts, + columns, + isAsyncLoading = false, + presetName, + rowSelection, + rowPagination, + isRefreshAllowed = true, + columnsToExclude = [], +}: Props) { const [columnOrder, setColumnOrder] = useState( - columnOrderLocalStorage ? JSON.parse(columnOrderLocalStorage) : [] + getColumnsOrder(presetName) ); const [columnVisibility, setColumnVisibility] = useState( - // Defaultly exclude the extra payload columns from the default visibility - getColumnsToHide(presetName, extraPayloadKeys) + getHiddenColumns(presetName, columns) ); useEffect(() => { - const extraPayloadKeys = getExtraPayloadKeys(alerts, columnsToExclude); - setColumnVisibility(getColumnsToHide(presetName, extraPayloadKeys)); - }, [alerts]); + setColumnVisibility(getHiddenColumns(presetName, columns)); + }, [presetName, columns]); const table = useReactTable({ - data: alerts, + data: rowPagination + ? getPaginatedData(alerts, rowPagination.state) + : alerts, columns: columns, - onColumnOrderChange: setColumnOrder, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), state: { - columnVisibility, - columnOrder, - rowSelection: enabledRowSelection, + columnVisibility: getDefaultColumnVisibility( + columnVisibility, + columnsToExclude + ), + columnOrder: columnOrder, + rowSelection: rowSelection?.state, + pagination: rowPagination?.state, }, initialState: { pagination: { pageSize: 10 }, }, + getCoreRowModel: getCoreRowModel(), + pageCount: rowPagination + ? getDataPageCount(alerts.length, rowPagination.state) + : undefined, + getPaginationRowModel: rowPagination ? undefined : getPaginationRowModel(), + enableRowSelection: rowSelection !== undefined, + manualPagination: rowPagination !== undefined, + onPaginationChange: rowPagination?.onChange, + onColumnOrderChange: setColumnOrder, onColumnVisibilityChange: setColumnVisibility, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, + onRowSelectionChange: rowSelection?.onChange, + getRowId: (row) => row.fingerprint, }); return ( @@ -338,9 +353,7 @@ export function AlertTable({ )} {isAsyncLoading && ( @@ -360,7 +373,7 @@ export function AlertTable({ {headerGroup.headers.map((header) => ( - - setIsOpen(false)} - users={users} - currentUser={currentUser} - accessToken={accessToken} - /> + ); } diff --git a/keep-ui/app/alerts/alerts.client.tsx b/keep-ui/app/alerts/alerts.client.tsx index 39aaa9852..622889a60 100644 --- a/keep-ui/app/alerts/alerts.client.tsx +++ b/keep-ui/app/alerts/alerts.client.tsx @@ -4,63 +4,23 @@ import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; import Loading from "../loading"; import Alerts from "./alerts"; -import Pusher from "pusher-js"; -import { getApiURL } from "utils/apiUrl"; -import { useEffect, useState } from "react"; -import useSWR from "swr"; -import { InternalConfig } from "types/internal-config"; -import { fetcher } from "utils/fetcher"; export default function AlertsPage() { const { data: session, status } = useSession(); - const { - data: configData, - error, - isLoading, - } = useSWR("/api/config", fetcher, undefined); - const [pusherClient, setPusherClient] = useState(null); + const router = useRouter(); - const pusherDisabled = configData?.PUSHER_DISABLED === true; - useEffect(() => { - if ( - !isLoading && - configData && - configData.PUSHER_DISABLED !== true && - session && - pusherClient === null - ) { - const pusher = new Pusher(configData.PUSHER_APP_KEY, { - wsHost: configData.PUSHER_HOST, - wsPort: configData.PUSHER_PORT, - forceTLS: false, - disableStats: true, - enabledTransports: ["ws", "wss"], - cluster: configData.PUSHER_CLUSTER || "local", - channelAuthorization: { - transport: "ajax", - endpoint: `${getApiURL()}/pusher/auth`, - headers: { - Authorization: `Bearer ${session?.accessToken!}`, - }, - }, - }); - setPusherClient(pusher); - } - }, [session, pusherClient, configData, isLoading]); + if (status === "loading") { + return ; + } + + if (status === "unauthenticated") { + router.push("/signin"); + } - if (status === "loading" || isLoading) return ; - if (pusherClient === null && !pusherDisabled) return ; - if (status === "unauthenticated") router.push("/signin"); - if (session && !session.tenantId) router.push("/signin"); + if (session && !session.tenantId) { + router.push("/signin"); + } - return ( - - ); + return ; } diff --git a/keep-ui/app/alerts/alerts.tsx b/keep-ui/app/alerts/alerts.tsx index 02d311d7e..b3a3b5875 100644 --- a/keep-ui/app/alerts/alerts.tsx +++ b/keep-ui/app/alerts/alerts.tsx @@ -1,346 +1,79 @@ -import { - Card, - TabGroup, - TabList, - Tab, - TabPanels, - TabPanel, -} from "@tremor/react"; -import useSWR from "swr"; -import { fetcher } from "utils/fetcher"; -import { AlertTable } from "./alert-table"; -import { AlertDto, Preset } from "./models"; -import { getApiURL } from "utils/apiUrl"; -import { useEffect, useState } from "react"; -import Loading from "app/loading"; -import Pusher, { Channel } from "pusher-js"; -import { Workflow } from "app/workflows/models"; -import { ProvidersResponse } from "app/providers/providers"; -import zlib from "zlib"; +import { Card, TabGroup, TabList, Tab, TabPanels } from "@tremor/react"; +import { Preset } from "./models"; +import { useMemo } from "react"; import "./alerts.client.css"; -import { User as NextUser } from "next-auth"; -import { User } from "app/settings/models"; -import AlertPresets, { Option } from "./alert-presets"; -import AlertActions from "./alert-actions"; -import { RowSelectionState } from "@tanstack/react-table"; import AlertStreamline from "./alert-streamline"; +import { + getDefaultSubscriptionObj, + getFormatAndMergePusherWithEndpointAlerts, + useAlerts, +} from "utils/hooks/useAlerts"; +import { usePresets } from "utils/hooks/usePresets"; +import AlertTableTabPanel from "./alert-table-tab-panel"; +import { AlertHistory } from "./alert-history"; const defaultPresets: Preset[] = [ { name: "Feed", options: [] }, { name: "Deleted", options: [] }, - { name: "Groups", options: []} + { name: "Groups", options: [] }, ]; -export default function Alerts({ - accessToken, - tenantId, - pusher, - user, - pusherDisabled, -}: { - accessToken: string; - tenantId: string; - pusher: Pusher | null; - user: NextUser; - pusherDisabled: boolean; -}) { - const apiUrl = getApiURL(); - const [alerts, setAlerts] = useState([]); - const [showDeleted, setShowDeleted] = useState(false); - const [isSlowLoading, setIsSlowLoading] = useState(false); - const [tabIndex, setTabIndex] = useState(0); - const [selectedOptions, setSelectedOptions] = useState([]); - const [lastReceivedAlertDate, setLastReceivedAlertDate] = useState(); - const [selectedPreset, setSelectedPreset] = useState( - defaultPresets[0] // Feed - ); - const [channel, setChannel] = useState(null); - const [isAsyncLoading, setIsAsyncLoading] = useState(true); - const { data, isLoading, mutate } = useSWR( - `${apiUrl}/alerts?sync=${pusherDisabled ? "true" : "false"}`, - (url) => fetcher(url, accessToken), - { - revalidateOnFocus: false, - onLoadingSlow: () => setIsSlowLoading(true), - loadingTimeout: 5000, - } - ); - const { data: workflows } = useSWR( - `${apiUrl}/workflows`, - (url) => fetcher(url, accessToken), - { revalidateOnFocus: false } - ); - const { data: providers } = useSWR( - `${apiUrl}/providers`, - (url) => fetcher(url, accessToken), - { revalidateOnFocus: false } - ); - const { data: users } = useSWR( - `${apiUrl}/settings/users`, - (url) => fetcher(url, accessToken), - { revalidateOnFocus: false } - ); - const { data: presets = defaultPresets, mutate: presetsMutate } = useSWR< - Preset[] - >( - `${apiUrl}/preset`, - async (url) => { - const data = await fetcher(url, accessToken); - return [...defaultPresets, ...data]; - }, - { revalidateOnFocus: false } +export default function Alerts() { + const { useAllAlerts, useAllAlertsWithSubscription } = useAlerts(); + + const { data: endpointAlerts = [] } = useAllAlerts({ + revalidateOnFocus: false, + }); + + const { data: alertSubscription = getDefaultSubscriptionObj(true) } = + useAllAlertsWithSubscription(); + const { + alerts: pusherAlerts, + isAsyncLoading, + lastSubscribedDate, + pusherChannel, + } = alertSubscription; + + const { data: savedPresets = [] } = usePresets({ + revalidateOnFocus: false, + }); + const presets = [...defaultPresets, ...savedPresets] as const; + + const alerts = useMemo( + () => + getFormatAndMergePusherWithEndpointAlerts(endpointAlerts, pusherAlerts), + [endpointAlerts, pusherAlerts] ); - useEffect(() => { - if (data) { - data.forEach( - (alert) => (alert.lastReceived = new Date(alert.lastReceived)) - ); - setAlerts(data); - if (pusherDisabled) setIsAsyncLoading(false); - } - }, [data, pusherDisabled]); - - useEffect(() => { - if (!pusherDisabled && pusher) { - console.log("Connecting to pusher"); - const channelName = `private-${tenantId}`; - const pusherChannel = pusher.subscribe(channelName); - setChannel(pusherChannel); - pusherChannel.bind( - "async-alerts", - function (base64CompressedAlert: string) { - setLastReceivedAlertDate(new Date()); - const decompressedAlert = zlib.inflateSync( - Buffer.from(base64CompressedAlert, "base64") - ); - const newAlerts = JSON.parse( - new TextDecoder().decode(decompressedAlert) - ) as AlertDto[]; - setAlerts((prevAlerts) => { - // Create a map of the latest received times for the new alerts - const latestReceivedTimes = new Map(); - newAlerts.forEach((alert) => { - if (typeof alert.lastReceived === "string") - alert.lastReceived = new Date(alert.lastReceived); - latestReceivedTimes.set(alert.fingerprint, alert.lastReceived); - }); - - // Filter out previous alerts if they are already in the new alerts with a more recent lastReceived - const filteredPrevAlerts = prevAlerts.filter((prevAlert) => { - const newAlertReceivedTime = latestReceivedTimes.get( - prevAlert.fingerprint - ); - return ( - !newAlertReceivedTime || - prevAlert.lastReceived > newAlertReceivedTime - ); - }); - - // Filter out new alerts if their fingerprint is already in the filtered previous alerts - const filteredNewAlerts = newAlerts.filter((newAlert) => { - return !filteredPrevAlerts.some( - (prevAlert) => prevAlert.fingerprint === newAlert.fingerprint - ); - }); - - // Combine the filtered lists - return [...filteredNewAlerts, ...filteredPrevAlerts]; - }); - } - ); - - pusherChannel.bind("async-done", function () { - setIsAsyncLoading(false); - }); - - setTimeout(() => setIsAsyncLoading(false), 10000); // If we don't receive any alert in 10 seconds, we assume that the async process is done (#641) - - console.log("Connected to pusher"); - return () => { - pusher.unsubscribe(channelName); - }; - } else { - console.log("Pusher disabled"); - } - }, [pusher, tenantId, pusherDisabled]); - - if (isLoading) return ; - - const onDelete = ( - fingerprint: string, - lastReceived: Date, - restore: boolean = false - ) => { - setAlerts((prevAlerts) => - prevAlerts.map((alert) => { - if ( - alert.fingerprint === fingerprint && - alert.lastReceived == lastReceived - ) { - if (!restore) { - alert.deleted = [lastReceived.toISOString()]; - } else { - alert.deleted = []; - } - if (alert.assignees !== undefined) { - alert.assignees[lastReceived.toISOString()] = user.email; - } else { - alert.assignees = { [lastReceived.toISOString()]: user.email }; - } - } - return alert; - }) - ); - }; - - const setAssignee = ( - fingerprint: string, - lastReceived: Date, - unassign: boolean // Currently unused - ) => { - setAlerts((prevAlerts) => - prevAlerts.map((alert) => { - if (alert.fingerprint === fingerprint) { - if (alert.assignees !== undefined) { - alert.assignees[lastReceived.toISOString()] = user.email; - } else { - alert.assignees = { [lastReceived.toISOString()]: user.email }; - } - } - return alert; - }) - ); - }; - - const AlertTableTabPanel = ({ preset }: { preset: Preset }) => { - const [rowSelection, setRowSelection] = useState({}); - const selectedRowIds = Object.entries(rowSelection).reduce( - (acc, [alertId, isSelected]) => { - if (isSelected) { - return acc.concat(alertId); - } - return acc; - }, - [] - ); - - return ( - - {selectedRowIds.length ? ( - - ) : ( - { - onIndexChange(0); - presetsMutate(); - }} - isLoading={isAsyncLoading} - /> - )} - - - ); - }; - - function showDeletedAlert(alert: AlertDto): boolean { - return ( - showDeleted === alert.deleted.includes(alert.lastReceived.toISOString()) - ); - } - - function filterAlerts(alert: AlertDto): boolean { - if (selectedOptions.length === 0) { - return true; - } - return selectedOptions.every((option) => { - const optionSplit = option.value.split("="); - const key = optionSplit[0]; - const value = optionSplit[1]?.toLowerCase(); - if (key === "source") { - return alert.source?.every((v) => value.split(",").includes(v)); - } else if (typeof value === "string") { - return ((alert as any)[key] as string)?.toLowerCase().includes(value); - } - return false; - }); - } - - const currentStateAlerts = alerts - .filter((alert) => { - // Common condition to show deleted alerts - if (!showDeletedAlert(alert)) { - return false; - } - - // Conditional filtering based on selectedPreset.name - if (selectedPreset.name === "Groups") { - return alert.group === true; // Filter for grouped alerts - } else { - return filterAlerts(alert); // Use the original filterAlerts function - } - }) - .sort((a, b) => b.lastReceived.getTime() - a.lastReceived.getTime()); - - function onIndexChange(index: number) { - setTabIndex(index); - const preset = presets![index]; - if (preset.name === "Deleted") { - setShowDeleted(true); - } else { - setShowDeleted(false); - } - setSelectedOptions(preset.options); - setSelectedPreset(preset); - } - return ( - <> - - {!pusherDisabled && ( - - )} - - - {presets.map((preset, index) => ( - - {preset.name} - - ))} - - - {presets.map((preset) => ( - - ))} - - - - + + {pusherChannel && ( + + )} + {/* key is necessary to re-render tabs on preset delete */} + + + {presets.map((preset, index) => ( + + {preset.name} + + ))} + + + {presets.map((preset) => ( + + ))} + + + + ); } diff --git a/keep-ui/app/alerts/models.tsx b/keep-ui/app/alerts/models.tsx index 066a8c18f..597979220 100644 --- a/keep-ui/app/alerts/models.tsx +++ b/keep-ui/app/alerts/models.tsx @@ -18,7 +18,7 @@ export interface AlertDto { isDuplicate?: boolean; duplicateReason?: string; service?: string; - source?: string[]; + source: string[]; message?: string; description?: string; severity?: Severity; @@ -26,8 +26,8 @@ export interface AlertDto { pushed: boolean; generatorURL?: string; fingerprint: string; - deleted: string[]; - assignees?: { [lastReceived: string]: string }; + deleted: boolean; + assignee?: string; ticket_url: string; ticket_status?: string; playbook_url?: string; @@ -59,6 +59,10 @@ export const AlertKnownKeys = [ "playbook_url", "ack_status", "deleted", - "assignees", + "assignee", "providerId", + "checkbox", + "alertMenu", + "group", + "extraPayload", ]; diff --git a/keep-ui/app/providers/providers.tsx b/keep-ui/app/providers/providers.tsx index 72cd001cc..a8c5130ab 100644 --- a/keep-ui/app/providers/providers.tsx +++ b/keep-ui/app/providers/providers.tsx @@ -75,7 +75,7 @@ export interface Provider { provider_description?: string; oauth2_url?: string; scopes?: ProviderScope[]; - validatedScopes?: { [scopeName: string]: boolean | string }; + validatedScopes: { [scopeName: string]: boolean | string }; methods?: ProviderMethod[]; tags: ("alert" | "ticketing" | "messaging" | "data" | "queue")[]; } @@ -92,4 +92,5 @@ export const defaultProvider: Provider = { can_query: false, type: "", tags: [], + validatedScopes: {}, }; diff --git a/keep-ui/tsconfig.json b/keep-ui/tsconfig.json index 2d67adc24..2650f85f4 100644 --- a/keep-ui/tsconfig.json +++ b/keep-ui/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { - "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "target": "es6", + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -25,9 +21,7 @@ ], "baseUrl": ".", "paths": { - "@/components/*": [ - "./components/*" - ] + "@/components/*": ["./components/*"] } }, "include": [ @@ -37,7 +31,5 @@ ".next/types/**/*.ts", "pages/signin.tsx" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/keep-ui/utils/fetcher.ts b/keep-ui/utils/fetcher.ts index 69dde71b9..feb06741e 100644 --- a/keep-ui/utils/fetcher.ts +++ b/keep-ui/utils/fetcher.ts @@ -1,4 +1,4 @@ -export const fetcher = async (url: string, accessToken: string) => { +export const fetcher = async (url: string, accessToken: string | undefined) => { const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, @@ -7,7 +7,7 @@ export const fetcher = async (url: string, accessToken: string) => { // Ensure that the fetch was successful if (!response.ok) { - throw new Error('An error occurred while fetching the data.'); + throw new Error("An error occurred while fetching the data."); } // Parse and return the JSON data diff --git a/keep-ui/utils/hooks/useAlerts.ts b/keep-ui/utils/hooks/useAlerts.ts new file mode 100644 index 000000000..40c9d6d66 --- /dev/null +++ b/keep-ui/utils/hooks/useAlerts.ts @@ -0,0 +1,226 @@ +import { AlertDto } from "app/alerts/models"; +import { useSession } from "next-auth/react"; +import Pusher, { Channel } from "pusher-js"; +import zlib from "zlib"; +import useSWR, { SWRConfiguration } from "swr"; +import useSWRSubscription, { SWRSubscriptionOptions } from "swr/subscription"; +import { getApiURL } from "utils/apiUrl"; +import { fetcher } from "utils/fetcher"; +import { useConfig } from "./useConfig"; + +type AlertSubscription = { + alerts: AlertDto[]; + lastSubscribedDate: Date; + isAsyncLoading: boolean; + pusherChannel: Channel | null; +}; + +export const getFormatAndMergePusherWithEndpointAlerts = ( + endpointAlerts: AlertDto[], + pusherAlerts: AlertDto[] +): AlertDto[] => { + const pusherAlertsWithLastReceivedDate = pusherAlerts.map((pusherAlert) => ({ + ...pusherAlert, + lastReceived: new Date(pusherAlert.lastReceived), + })); + + const endpointAlertsWithLastReceivedDate = endpointAlerts.map( + (endpointAlert) => ({ + ...endpointAlert, + lastReceived: new Date(endpointAlert.lastReceived), + }) + ); + + // Create a map of the latest received times for the new alerts + const latestReceivedTimes = new Map( + pusherAlertsWithLastReceivedDate.map((alert) => [ + alert.fingerprint, + alert.lastReceived, + ]) + ); + + // Filter out previous alerts if they are already in the new alerts with a more recent lastReceived + const filteredEndpointAlerts = endpointAlertsWithLastReceivedDate.filter( + (endpointAlert) => { + const newAlertReceivedTime = latestReceivedTimes.get( + endpointAlert.fingerprint + ); + + if (newAlertReceivedTime === undefined) { + return true; + } + + return endpointAlert.lastReceived > newAlertReceivedTime; + } + ); + + // Filter out new alerts if their fingerprint is already in the filtered previous alerts + const filteredPusherAlerts = pusherAlertsWithLastReceivedDate.filter( + (pusherAlert) => + filteredEndpointAlerts.some( + (endpointAlert) => endpointAlert.fingerprint !== pusherAlert.fingerprint + ) + ); + + return filteredPusherAlerts.concat(filteredEndpointAlerts); +}; + +export const getDefaultSubscriptionObj = ( + isAsyncLoading: boolean = false, + pusherChannel: Channel | null = null +): AlertSubscription => ({ + alerts: [], + isAsyncLoading, + lastSubscribedDate: new Date(), + pusherChannel, +}); + +export const useAlerts = () => { + const apiUrl = getApiURL(); + + const { data: session } = useSession(); + const { data: configData } = useConfig(); + + const useAlertHistory = ( + selectedAlert?: AlertDto, + options?: SWRConfiguration + ) => { + return useSWR( + () => + selectedAlert && session + ? `${apiUrl}/alerts/${ + selectedAlert.fingerprint + }/history/?provider_id=${selectedAlert.providerId}&provider_type=${ + selectedAlert.source ? selectedAlert.source[0] : "" + }` + : null, + (url) => fetcher(url, session?.accessToken), + options + ); + }; + + const useAllAlerts = (options?: SWRConfiguration) => { + return useSWR( + () => (configData && session ? "alerts" : null), + () => + fetcher( + `${apiUrl}/alerts?sync=${ + configData?.PUSHER_DISABLED ? "true" : "false" + }`, + session?.accessToken + ), + options + ); + }; + + /** + * A hook that creates a Pusher websocket connection and listens to incoming alerts. + * + * Only the latest alerts are returned + * @returns {\{ data, error } + */ + const useAllAlertsWithSubscription = () => { + return useSWRSubscription( + // this check allows conditional fetching. If it is false, the hook doesn't run + () => + configData?.PUSHER_DISABLED === false && session ? "alerts" : null, + // next is responsible for pushing/overwriting data to the subscription cache + // the first arg is for any errors, which is returned by the {error} property + // and the second arg accepts either a new AlertSubscription object that overwrites any existing data + // or a function with a {data} arg that allows access to the existing cache + (_, { next }: SWRSubscriptionOptions) => { + if (configData === undefined || session === null) { + console.log("Pusher disabled"); + + return () => + next(null, { + alerts: [], + isAsyncLoading: false, + lastSubscribedDate: new Date(), + pusherChannel: null, + }); + } + + console.log("Connecting to pusher"); + const pusher = new Pusher(configData.PUSHER_APP_KEY, { + wsHost: configData.PUSHER_HOST, + wsPort: configData.PUSHER_PORT, + forceTLS: false, + disableStats: true, + enabledTransports: ["ws", "wss"], + cluster: configData.PUSHER_CLUSTER || "local", + channelAuthorization: { + transport: "ajax", + endpoint: `${apiUrl}/pusher/auth`, + headers: { + Authorization: `Bearer ${session.accessToken!}`, + }, + }, + }); + + const channelName = `private-${session.tenantId}`; + const pusherChannel = pusher.subscribe(channelName); + + pusherChannel.bind("async-alerts", (base64CompressedAlert: string) => { + const decompressedAlert = zlib.inflateSync( + Buffer.from(base64CompressedAlert, "base64") + ); + + const newAlerts: AlertDto[] = JSON.parse( + new TextDecoder().decode(decompressedAlert) + ); + + next(null, { + alerts: newAlerts, + lastSubscribedDate: new Date(), + isAsyncLoading: false, + pusherChannel, + }); + }); + + pusherChannel.bind("async-done", () => { + next(null, (data) => { + if (data) { + return { ...data, isAsyncLoading: false }; + } + + return { + alerts: [], + lastSubscribedDate: new Date(), + isAsyncLoading: false, + pusherChannel, + }; + }); + }); + + // If we don't receive any alert in 3.5 seconds, we assume that the async process is done (#641) + setTimeout(() => { + next(null, (data) => { + if (data) { + return { ...data, isAsyncLoading: false }; + } + + return { + alerts: [], + lastSubscribedDate: new Date(), + isAsyncLoading: false, + pusherChannel, + }; + }); + }, 3500); + + next(null, { + alerts: [], + lastSubscribedDate: new Date(), + isAsyncLoading: false, + pusherChannel, + }); + console.log("Connected to pusher"); + + return () => pusher.unsubscribe(channelName); + } + ); + }; + + return { useAlertHistory, useAllAlerts, useAllAlertsWithSubscription }; +}; diff --git a/keep-ui/utils/hooks/useConfig.ts b/keep-ui/utils/hooks/useConfig.ts new file mode 100644 index 000000000..d6a565844 --- /dev/null +++ b/keep-ui/utils/hooks/useConfig.ts @@ -0,0 +1,7 @@ +import useSWRImmutable from "swr/immutable"; +import { InternalConfig } from "types/internal-config"; +import { fetcher } from "utils/fetcher"; + +export const useConfig = () => { + return useSWRImmutable("/api/config", fetcher); +}; diff --git a/keep-ui/utils/hooks/usePresets.ts b/keep-ui/utils/hooks/usePresets.ts new file mode 100644 index 000000000..33b09faf2 --- /dev/null +++ b/keep-ui/utils/hooks/usePresets.ts @@ -0,0 +1,16 @@ +import { Preset } from "app/alerts/models"; +import { useSession } from "next-auth/react"; +import useSWR, { SWRConfiguration } from "swr"; +import { getApiURL } from "utils/apiUrl"; +import { fetcher } from "utils/fetcher"; + +export const usePresets = (options?: SWRConfiguration) => { + const apiUrl = getApiURL(); + const { data: session } = useSession(); + + return useSWR( + () => (session ? `${apiUrl}/preset` : null), + async (url) => fetcher(url, session?.accessToken), + options + ); +}; diff --git a/keep-ui/utils/hooks/useProviders.ts b/keep-ui/utils/hooks/useProviders.ts new file mode 100644 index 000000000..fee755b82 --- /dev/null +++ b/keep-ui/utils/hooks/useProviders.ts @@ -0,0 +1,16 @@ +import { useSession } from "next-auth/react"; +import { getApiURL } from "../apiUrl"; +import useSWR from "swr"; +import { ProvidersResponse } from "app/providers/providers"; +import { fetcher } from "../fetcher"; + +export const useProviders = () => { + const { data: session } = useSession(); + const apiUrl = getApiURL(); + + return useSWR( + () => (session ? `${apiUrl}/providers` : null), + (url) => fetcher(url, session?.accessToken), + { revalidateOnFocus: false, revalidateOnMount: false } + ); +}; diff --git a/keep-ui/utils/hooks/useUsers.ts b/keep-ui/utils/hooks/useUsers.ts new file mode 100644 index 000000000..e3d369ab7 --- /dev/null +++ b/keep-ui/utils/hooks/useUsers.ts @@ -0,0 +1,16 @@ +import { User } from "app/settings/models"; +import { useSession } from "next-auth/react"; +import useSWR, { SWRConfiguration } from "swr"; +import { getApiURL } from "utils/apiUrl"; +import { fetcher } from "utils/fetcher"; + +export const useUsers = (options?: SWRConfiguration) => { + const apiUrl = getApiURL(); + const { data: session } = useSession(); + + return useSWR( + () => (session ? `${apiUrl}/settings/users` : null), + (url) => fetcher(url, session?.accessToken), + options + ); +}; diff --git a/keep-ui/utils/hooks/useWorkflows.ts b/keep-ui/utils/hooks/useWorkflows.ts new file mode 100644 index 000000000..cc66065c9 --- /dev/null +++ b/keep-ui/utils/hooks/useWorkflows.ts @@ -0,0 +1,16 @@ +import { Workflow } from "app/workflows/models"; +import { useSession } from "next-auth/react"; +import useSWR from "swr"; +import { getApiURL } from "../apiUrl"; +import { fetcher } from "../fetcher"; + +export const useWorkflows = () => { + const { data: session } = useSession(); + const apiUrl = getApiURL(); + + return useSWR( + () => (session ? `${apiUrl}/workflows` : null), + (url) => fetcher(url, session?.accessToken), + { revalidateOnFocus: false, revalidateOnMount: false } + ); +}; diff --git a/keep/api/models/alert.py b/keep/api/models/alert.py index 3016e1cb3..1c5df5280 100644 --- a/keep/api/models/alert.py +++ b/keep/api/models/alert.py @@ -61,7 +61,8 @@ class AlertDto(BaseModel): fingerprint: str | None = ( None # The fingerprint of the alert (used for alert de-duplication) ) - deleted: list[str] = [] # Whether the alert is deleted or not + deleted: bool = False # Whether the alert has been deleted + assignee: str | None = None # The assignee of the alert providerId: str | None = None # The provider id group: bool = False # Whether the alert is a group alert @@ -72,11 +73,11 @@ def assign_fingerprint_if_none(cls, fingerprint, values): return fingerprint @validator("deleted", pre=True, always=True) - def validate_old_deleted(cls, deleted, values): - """This is a temporary validator to handle the old deleted field""" + def validate_deleted(cls, deleted, values): if isinstance(deleted, bool): - return [] - return deleted + return deleted + if isinstance(deleted, list): + return values.get("lastReceived") in deleted @root_validator(pre=True) def set_default_values(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -102,6 +103,8 @@ def set_default_values(cls, values: Dict[str, Any]) -> Dict[str, Any]: ) values["status"] = AlertStatus.FIRING + values.pop("assignees", None) + values.pop("deletedAt", None) return values class Config: diff --git a/keep/api/routes/alerts.py b/keep/api/routes/alerts.py index cfdd441eb..282771d0f 100644 --- a/keep/api/routes/alerts.py +++ b/keep/api/routes/alerts.py @@ -28,6 +28,7 @@ from keep.api.models.alert import AlertDto, DeleteRequestBody, EnrichAlertRequestBody from keep.api.models.db.alert import Alert, AlertRaw from keep.api.utils.email_utils import EmailTemplates, send_email +from keep.api.utils.enrichment_helpers import parse_and_enrich_deleted_and_assignees from keep.contextmanager.contextmanager import ContextManager from keep.providers.providers_factory import ProvidersFactory from keep.rulesengine.rulesengine import RulesEngine @@ -54,34 +55,24 @@ def __enrich_alerts(alerts: list[Alert]) -> list[AlertDto]: for alert in alerts: if alert.alert_enrichment: alert.event.update(alert.alert_enrichment.enrichments) - - # todo: what is this? :O - if alert.provider_type == "rules": - try: - alert_dto = AlertDto(**alert.event) - except Exception: - # should never happen but just in case - logger.exception( - "Failed to parse group alert", - extra={ - "alert": alert, - }, - ) - continue - else: - try: - alert_dto = AlertDto(**alert.event) - except Exception: - # should never happen but just in case - logger.exception( - "Failed to parse alert", - extra={ - "alert": alert, - }, + try: + alert_dto = AlertDto(**alert.event) + if alert.alert_enrichment: + parse_and_enrich_deleted_and_assignees( + alert_dto, alert.alert_enrichment.enrichments ) - continue - if alert_dto.providerId is None: - alert_dto.providerId = alert.provider_id + except Exception: + # should never happen but just in case + logger.exception( + "Failed to parse alert", + extra={ + "alert": alert, + }, + ) + continue + # enrich provider id when it's possible + if alert_dto.providerId is None: + alert_dto.providerId = alert.provider_id alerts_dto.append(alert_dto) return alerts_dto @@ -311,7 +302,7 @@ def get_alert_history( return enriched_alerts_dto -@router.delete("", description="Delete alert by name") +@router.delete("", description="Delete alert by finerprint and last received time") def delete_alert( delete_alert: DeleteRequestBody, authenticated_entity: AuthenticatedEntity = Depends(AuthVerifier(["delete:alert"])), @@ -331,30 +322,36 @@ def delete_alert( deleted_last_received = [] # the last received(s) that are deleted assignees_last_receievd = {} # the last received(s) that are assigned to someone + + # If we enriched before, get the enrichment enrichment = get_enrichment(tenant_id, delete_alert.fingerprint) if enrichment: - deleted_last_received = enrichment.enrichments.get("deleted", []) + deleted_last_received = enrichment.enrichments.get("deletedAt", []) assignees_last_receievd = enrichment.enrichments.get("assignees", {}) - # TODO: this is due to legacy deleted field that was a bool, remove in the future - if isinstance(deleted_last_received, bool): - deleted_last_received = [] if ( delete_alert.restore is True and delete_alert.lastReceived in deleted_last_received ): + # Restore deleted alert deleted_last_received.remove(delete_alert.lastReceived) - elif delete_alert.restore is False: + elif ( + delete_alert.restore is False + and delete_alert.lastReceived not in deleted_last_received + ): + # Delete the alert if it's not already deleted (wtf basically, shouldn't happen) deleted_last_received.append(delete_alert.lastReceived) if delete_alert.lastReceived not in assignees_last_receievd: + # auto-assign the deleting user to the alert assignees_last_receievd[delete_alert.lastReceived] = user_email + # overwrite the enrichment enrich_alert_db( tenant_id=tenant_id, fingerprint=delete_alert.fingerprint, enrichments={ - "deleted": deleted_last_received, + "deletedAt": deleted_last_received, "assignees": assignees_last_receievd, }, ) diff --git a/keep/api/utils/enrichment_helpers.py b/keep/api/utils/enrichment_helpers.py new file mode 100644 index 000000000..d53a6d2b5 --- /dev/null +++ b/keep/api/utils/enrichment_helpers.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from keep.api.models.alert import AlertDto + + +def javascript_iso_format(last_received: str) -> str: + """ + https://stackoverflow.com/a/63894149/12012756 + """ + dt = datetime.fromisoformat(last_received) + return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def parse_and_enrich_deleted_and_assignees(alert: AlertDto, enrichments: dict): + # tb: we'll need to refactor this at some point since its flaky + # assignees and deleted are special cases that we need to handle + # they are kept as a list of timestamps and we need to check if the + # timestamp of the alert is in the list, if it is, it means that the + # alert at that specific time was deleted or assigned. + # + # THIS IS MAINLY BECAUSE WE ALSO HAVE THE PULLED ALERTS, + # OTHERWISE, WE COULD'VE JUST UPDATE THE ALERT IN THE DB + deleted_last_received = enrichments.get( + "deletedAt", enrichments.get("deleted", []) + ) # "deleted" is for backward compatibility + if javascript_iso_format(alert.lastReceived) in deleted_last_received: + alert.deleted = True + assignees: dict = enrichments.get("assignees", {}) + assignee = assignees.get(alert.lastReceived) or assignees.get( + javascript_iso_format(alert.lastReceived) + ) + if assignee: + alert.assignee = assignee diff --git a/keep/providers/base/base_provider.py b/keep/providers/base/base_provider.py index d2bd10aa4..ca0afed67 100644 --- a/keep/providers/base/base_provider.py +++ b/keep/providers/base/base_provider.py @@ -19,6 +19,7 @@ from keep.api.core.db import enrich_alert, get_enrichments from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus +from keep.api.utils.enrichment_helpers import parse_and_enrich_deleted_and_assignees from keep.contextmanager.contextmanager import ContextManager from keep.providers.models.provider_config import ProviderConfig, ProviderScope from keep.providers.models.provider_method import ProviderMethod @@ -333,6 +334,9 @@ def get_alerts_by_fingerprint(self, tenant_id: str) -> dict[str, list[AlertDto]] alert_enrichment.alert_fingerprint ) for alert_to_enrich in alerts_to_enrich: + parse_and_enrich_deleted_and_assignees( + alert_to_enrich, alert_enrichment.enrichments + ) for enrichment in alert_enrichment.enrichments: # set the enrichment setattr( diff --git a/keep/providers/datadog_provider/datadog_provider.py b/keep/providers/datadog_provider/datadog_provider.py index 96d581c6d..3393e9822 100644 --- a/keep/providers/datadog_provider/datadog_provider.py +++ b/keep/providers/datadog_provider/datadog_provider.py @@ -71,7 +71,7 @@ class DatadogProviderAuthConfig: "sensitive": True, "hidden": True, }, - default_factory=lambda x: {}, + default_factory=dict, )