diff --git a/.github/workflows/test-pr-e2e.yml b/.github/workflows/test-pr-e2e.yml index 2e07c6d62..9ebaa40d3 100644 --- a/.github/workflows/test-pr-e2e.yml +++ b/.github/workflows/test-pr-e2e.yml @@ -8,6 +8,10 @@ on: - 'keep-ui/**' - 'tests/**' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + env: PYTHON_VERSION: 3.11 STORAGE_MANAGER_DIRECTORY: /tmp/storage-manager diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml index cf3a29870..9ff789064 100644 --- a/.github/workflows/test-pr.yml +++ b/.github/workflows/test-pr.yml @@ -6,6 +6,9 @@ on: pull_request: paths: - 'keep/**' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true # MySQL server and Elasticsearch for testing env: PYTHON_VERSION: 3.11 diff --git a/LICENSE b/LICENSE index 442fd8f34..ff7b6827b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,10 @@ -MIT License - Copyright (c) 2024 Keep +Portions of this software are licensed as follows: + +* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE". +* Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below. + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/docker/Dockerfile.dev.api b/docker/Dockerfile.dev.api index 254428846..5b3f12b70 100644 --- a/docker/Dockerfile.dev.api +++ b/docker/Dockerfile.dev.api @@ -23,4 +23,4 @@ ENV PATH="/venv/bin:${PATH}" ENV VIRTUAL_ENV="/venv" -ENTRYPOINT ["gunicorn", "keep.api.api:get_app", "--bind" , "0.0.0.0:8080" , "--workers", "1" , "-k" , "uvicorn.workers.UvicornWorker", "-c", "./keep/api/config.py", "--reload"] +CMD ["gunicorn", "keep.api.api:get_app", "--bind" , "0.0.0.0:8080" , "--workers", "1" , "-k" , "uvicorn.workers.UvicornWorker", "-c", "./keep/api/config.py", "--reload"] diff --git a/ee/LICENSE b/ee/LICENSE new file mode 100644 index 000000000..3395cf32d --- /dev/null +++ b/ee/LICENSE @@ -0,0 +1,35 @@ +The Keep Enterprise Edition (EE) license (the Enterprise License) +Copyright (c) 2024-present Keep Alerting LTD + +With regard to the Keep Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Keep Subscription Terms of Service, available +(if not available, it's impossible to comply) +at https://www.keephq.dev/terms-of-service (the "The Enterprise Terms”), or other +agreement governing the use of the Software, as agreed by you and Keep, +and otherwise have a valid Keep Enterprise Edition subscription for the +correct number of user seats. Subject to the foregoing sentence, you are free to +modify this Software and publish patches to the Software. You agree that Keep +and/or its licensors (as applicable) retain all right, title and interest in and +to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Keep Enterprise Edition subscription for the correct +number of user seats. You agree that Keep and/or its licensors (as applicable) retain +all right, title and interest in and to all such modifications. You are not +granted any other rights beyond what is expressly stated herein. Subject to the +foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Keep Software, those +components are licensed under the original license provided by the owner of the +applicable component. \ No newline at end of file diff --git a/ee/experimental/__init__.py b/ee/experimental/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ee/experimental/incident_utils.py b/ee/experimental/incident_utils.py new file mode 100644 index 000000000..975e64ff2 --- /dev/null +++ b/ee/experimental/incident_utils.py @@ -0,0 +1,148 @@ +import numpy as np +import pandas as pd +import networkx as nx + +from typing import List + +from keep.api.models.db.alert import Alert + + +def mine_incidents(alerts: List[Alert], incident_sliding_window_size: int=6*24*60*60, statistic_sliding_window_size: int=60*60, + jaccard_threshold: float=0.0, fingerprint_threshold: int=1): + """ + Mine incidents from alerts. + """ + + alert_dict = { + 'fingerprint': [alert.fingerprint for alert in alerts], + 'timestamp': [alert.timestamp for alert in alerts], + } + alert_df = pd.DataFrame(alert_dict) + mined_incidents = shape_incidents(alert_df, 'fingerprint', incident_sliding_window_size, statistic_sliding_window_size, + jaccard_threshold, fingerprint_threshold) + + return [ + { + "incident_fingerprint": incident['incident_fingerprint'], + "alerts": [alert for alert in alerts if alert.fingerprint in incident['alert_fingerprints']], + } + for incident in mined_incidents + ] + + +def get_batched_alert_counts(alerts: pd.DataFrame, unique_alert_identifier: str, sliding_window_size: int) -> np.ndarray: + """ + Get the number of alerts in a sliding window. + """ + + resampled_alert_counts = alerts.set_index('timestamp').resample( + f'{sliding_window_size//2}s')[unique_alert_identifier].value_counts().unstack(fill_value=0) + rolling_counts = resampled_alert_counts.rolling( + window=f'{sliding_window_size}s', min_periods=1).sum() + alert_counts = rolling_counts.to_numpy() + + return alert_counts + + +def get_batched_alert_occurrences(alerts: pd.DataFrame, unique_alert_identifier: str, sliding_window_size: int) -> np.ndarray: + """ + Get the occurrence of alerts in a sliding window. + """ + + alert_counts = get_batched_alert_counts( + alerts, unique_alert_identifier, sliding_window_size) + alert_occurences = np.where(alert_counts > 0, 1, 0) + + return alert_occurences + + +def get_jaccard_scores(P_a: np.ndarray, P_aa: np.ndarray) -> np.ndarray: + """ + Calculate the Jaccard similarity scores between alerts. + """ + + P_a_matrix = P_a[:, None] + P_a + union_matrix = P_a_matrix - P_aa + + with np.errstate(divide='ignore', invalid='ignore'): + jaccard_matrix = np.where(union_matrix != 0, P_aa / union_matrix, 0) + + np.fill_diagonal(jaccard_matrix, 1) + + return jaccard_matrix + + +def get_alert_jaccard_matrix(alerts: pd.DataFrame, unique_alert_identifier: str, sliding_window_size: int) -> np.ndarray: + """ + Calculate the Jaccard similarity scores between alerts. + """ + + alert_occurrences = get_batched_alert_occurrences( + alerts, unique_alert_identifier, sliding_window_size) + alert_probabilities = np.mean(alert_occurrences, axis=0) + joint_alert_occurrences = np.dot(alert_occurrences.T, alert_occurrences) + pairwise_alert_probabilities = joint_alert_occurrences / \ + alert_occurrences.shape[0] + + return get_jaccard_scores(alert_probabilities, pairwise_alert_probabilities) + + +def build_graph_from_occurrence(occurrence_row: pd.DataFrame, jaccard_matrix: np.ndarray, unique_alert_identifiers: List[str], + jaccard_threshold: float = 0.05) -> nx.Graph: + """ + Build a weighted graph using alert occurrence matrix and Jaccard coefficients. + """ + + present_indices = np.where(occurrence_row > 0)[0] + + G = nx.Graph() + + for idx in present_indices: + alert_desc = unique_alert_identifiers[idx] + G.add_node(alert_desc) + + for i in present_indices: + for j in present_indices: + if i != j and jaccard_matrix[i, j] >= jaccard_threshold: + alert_i = unique_alert_identifiers[i] + alert_j = unique_alert_identifiers[j] + G.add_edge(alert_i, alert_j, weight=jaccard_matrix[i, j]) + + return G + +def shape_incidents(alerts: pd.DataFrame, unique_alert_identifier: str, incident_sliding_window_size: int, statistic_sliding_window_size: int, + jaccard_threshold: float = 0.2, fingerprint_threshold: int = 5) -> List[dict]: + """ + Shape incidents from alerts. + """ + + incidents = [] + incident_number = 0 + + resampled_alert_counts = alerts.set_index('timestamp').resample( + f'{incident_sliding_window_size//2}s')[unique_alert_identifier].value_counts().unstack(fill_value=0) + jaccard_matrix = get_alert_jaccard_matrix( + alerts, unique_alert_identifier, statistic_sliding_window_size) + + for idx in range(resampled_alert_counts.shape[0]): + graph = build_graph_from_occurrence( + resampled_alert_counts.iloc[idx], jaccard_matrix, resampled_alert_counts.columns, jaccard_threshold=jaccard_threshold) + max_component = max(nx.connected_components(graph), key=len) + + min_starts_at = resampled_alert_counts.index[idx] + max_starts_at = min_starts_at + \ + pd.Timedelta(seconds=incident_sliding_window_size) + + local_alerts = alerts[(alerts['timestamp'] >= min_starts_at) & ( + alerts['timestamp'] <= max_starts_at)] + local_alerts = local_alerts[local_alerts[unique_alert_identifier].isin( + max_component)] + + if len(max_component) > fingerprint_threshold: + + incidents.append({ + 'incident_fingerprint': f'Incident #{incident_number}', + 'alert_fingerprints': local_alerts[unique_alert_identifier].unique().tolist(), + }) + + return incidents \ No newline at end of file diff --git a/keep-ui/app/ai/ai.tsx b/keep-ui/app/ai/ai.tsx new file mode 100644 index 000000000..632facaab --- /dev/null +++ b/keep-ui/app/ai/ai.tsx @@ -0,0 +1,149 @@ +"use client"; +import { Card, List, ListItem, Title, Subtitle } from "@tremor/react"; +import { useAIStats } from "utils/hooks/useAIStats"; +import { useSession } from "next-auth/react"; +import { getApiURL } from "utils/apiUrl"; +import { toast } from "react-toastify"; +import { useEffect, useState, useRef, FormEvent } from "react"; + +export default function Ai() { + const { data: aistats, isLoading } = useAIStats(); + const { data: session } = useSession(); + const [text, setText] = useState(""); + const [newText, setNewText] = useState("Mine incidents"); + const [animate, setAnimate] = useState(false); + const onlyOnce = useRef(false); + + useEffect(() => { + let index = 0; + + const interval = setInterval(() => { + setText(newText.slice(0, index + 1)); + index++; + + if (index === newText.length) { + clearInterval(interval); + } + }, 100); + + return () => { + clearInterval(interval); + }; + }, [newText]); + + const mineIncidents = async (e: FormEvent) => { + e.preventDefault(); + setAnimate(true); + setNewText("Mining πŸš€πŸš€πŸš€ ..."); + const apiUrl = getApiURL(); + const response = await fetch(`${apiUrl}/incidents/mine`, { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + }), + }); + if (!response.ok) { + toast.error( + "Failed to mine incidents, please contact us if this issue persists." + ); + } + setAnimate(false); + setNewText("Mine incidents"); + }; + + return ( +
+
+
+ AI Correlation + + Correlating alerts to incidents based on past alerts, incidents, and + the other data. + +
+
+ +
+
πŸ‘‹ You are almost there!
+ AI Correlation is coming soon. Make sure you have enough data collected to prepare. +
+ + + + Connect an incident source to dump incidents, or create 10 + incidents manually + + + {aistats?.incidents_count && + aistats?.incidents_count >= 10 ? ( +
βœ…
+ ) : ( +
⏳
+ )} +
+
+ + Collect 100 alerts + + {aistats?.alerts_count && aistats?.alerts_count >= 100 ? ( +
βœ…
+ ) : ( +
⏳
+ )} +
+
+ + Collect alerts for more than 3 days + + {aistats?.first_alert_datetime && new Date(aistats.first_alert_datetime) < new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) ? ( +
βœ…
+ ) : ( +
⏳
+ )} +
+
+
+
+ {(aistats?.is_mining_enabled && )} +
+
+
+ ); +} diff --git a/keep-ui/app/ai/model.ts b/keep-ui/app/ai/model.ts new file mode 100644 index 000000000..a0d51d359 --- /dev/null +++ b/keep-ui/app/ai/model.ts @@ -0,0 +1,6 @@ +export interface AIStats { + alerts_count: number; + incidents_count: number; + first_alert_datetime?: Date; + is_mining_enabled: boolean; +} diff --git a/keep-ui/app/ai/page.tsx b/keep-ui/app/ai/page.tsx new file mode 100644 index 000000000..d2eb3d533 --- /dev/null +++ b/keep-ui/app/ai/page.tsx @@ -0,0 +1,11 @@ +import AI from "./ai"; + +export default function Page() { + return ; +} + +export const metadata = { + title: "Keep - AI Correlation", + description: + "Correlate Alerts and Incidents with AI to identify patterns and trends.", +}; diff --git a/keep-ui/app/alerts/alert-actions.tsx b/keep-ui/app/alerts/alert-actions.tsx index 178bdfeab..f636500ba 100644 --- a/keep-ui/app/alerts/alert-actions.tsx +++ b/keep-ui/app/alerts/alert-actions.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Button } from "@tremor/react"; import { getSession } from "next-auth/react"; import { getApiURL } from "utils/apiUrl"; @@ -7,6 +8,7 @@ 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"; interface Props { selectedRowIds: string[]; @@ -26,6 +28,7 @@ export default function AlertActions({ const { mutate: presetsMutator } = useAllPresets({ revalidateOnFocus: false, }); + const [isIncidentSelectorOpen, setIsIncidentSelectorOpen] = useState(false); const selectedAlerts = alerts.filter((_alert, index) => selectedRowIds.includes(index.toString()) @@ -75,6 +78,18 @@ export default function AlertActions({ } } + const showIncidentSelector = () => { + setIsIncidentSelectorOpen(true); + } + const hideIncidentSelector = () => { + setIsIncidentSelectorOpen(false); + } + + const handleSuccessfulAlertsAssociation = () => { + hideIncidentSelector(); + clearRowSelection(); + } + return (
+ +
); } diff --git a/keep-ui/app/alerts/alert-associate-incident-modal.tsx b/keep-ui/app/alerts/alert-associate-incident-modal.tsx new file mode 100644 index 000000000..0f391d5a0 --- /dev/null +++ b/keep-ui/app/alerts/alert-associate-incident-modal.tsx @@ -0,0 +1,120 @@ +import React, {FormEvent, useState} from "react"; +import { useSession } from "next-auth/react"; +import { AlertDto } from "./models"; +import Modal from "@/components/ui/Modal"; +import { useIncidents } from "../../utils/hooks/useIncidents"; +import Loading from "../loading"; +import {Button, Divider, Select, SelectItem, Title} from "@tremor/react"; +import {useRouter} from "next/navigation"; +import {getApiURL} from "../../utils/apiUrl"; +import {toast} from "react-toastify"; + +interface AlertAssociateIncidentModalProps { + isOpen: boolean; + handleSuccess: () => void; + handleClose: () => void; + alerts: Array; +} + +const AlertAssociateIncidentModal = ({ + isOpen, + handleSuccess, + handleClose, + alerts, +}: AlertAssociateIncidentModalProps) => { + + const { data: incidents, isLoading, mutate } = useIncidents(true, 100); + const [selectedIncident, setSelectedIncident] = useState(null); + // get the token + const { data: session } = useSession(); + const router = useRouter(); + // if this modal should not be open, do nothing + if (!alerts) return null; + const handleAssociateAlerts = async (e: FormEvent) => { + e.preventDefault(); + const apiUrl = getApiURL(); + const response = await fetch( + `${apiUrl}/incidents/${selectedIncident}/alerts`, + { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(alerts.map(({ event_id }) => event_id)), + }); + if (response.ok) { + handleSuccess(); + await mutate(); + toast.success("Alerts associated with incident successfully"); + } else { + toast.error( + "Failed to associated alerts with incident, please contact us if this issue persists." + ); + } + } + + + return ( + +
+ {isLoading ? ( + + ) : incidents && incidents.items.length > 0 ? ( +
+ + +
+ +
+ +
+ ) : ( +
+
+ No Incidents Yet +
+ +
+ )} +
+
+ ); +}; + +export default AlertAssociateIncidentModal; diff --git a/keep-ui/app/alerts/alert-graph-viz.tsx b/keep-ui/app/alerts/alert-graph-viz.tsx index df7b96c0b..8352c83ab 100644 --- a/keep-ui/app/alerts/alert-graph-viz.tsx +++ b/keep-ui/app/alerts/alert-graph-viz.tsx @@ -170,4 +170,4 @@ const GraphVisualization: React.FC = ({ demoMode }) => ); }; -export default GraphVisualization; +export default GraphVisualization; \ No newline at end of file diff --git a/keep-ui/app/alerts/alerts.tsx b/keep-ui/app/alerts/alerts.tsx index 0974e61d6..440ee0ff6 100644 --- a/keep-ui/app/alerts/alerts.tsx +++ b/keep-ui/app/alerts/alerts.tsx @@ -14,7 +14,7 @@ import AlertDismissModal from "./alert-dismiss-modal"; import { ViewAlertModal } from "./ViewAlertModal"; import { useRouter, useSearchParams } from "next/navigation"; import AlertChangeStatusModal from "./alert-change-status-modal"; -import { usePusher } from "utils/hooks/usePusher"; +import { useAlertPolling } from "utils/hooks/usePusher"; const defaultPresets: Preset[] = [ { @@ -84,7 +84,7 @@ export default function Alerts({ presetName }: AlertsProps) { const selectedPreset = presets.find( (preset) => preset.name.toLowerCase() === decodeURIComponent(presetName) ); - const { data: pusher } = usePusher(); + const { data: pollAlerts } = useAlertPolling(); const { data: alerts = [], isLoading: isAsyncLoading, @@ -101,10 +101,10 @@ export default function Alerts({ presetName }: AlertsProps) { }, [searchParams, alerts]); useEffect(() => { - if (pusher?.pollAlerts) { + if (pollAlerts) { mutateAlerts(); } - }, [mutateAlerts, pusher?.pollAlerts]); + }, [mutateAlerts, pollAlerts]); if (selectedPreset === undefined) { return null; diff --git a/keep-ui/app/alerts/models.tsx b/keep-ui/app/alerts/models.tsx index 4d425636e..1347813c9 100644 --- a/keep-ui/app/alerts/models.tsx +++ b/keep-ui/app/alerts/models.tsx @@ -34,6 +34,7 @@ export enum Status { export interface AlertDto { id: string; + event_id: string; name: string; status: Status; lastReceived: Date; diff --git a/keep-ui/app/incidents/IncidentPlaceholder.tsx b/keep-ui/app/incidents/IncidentPlaceholder.tsx new file mode 100644 index 000000000..ff5a4ff8d --- /dev/null +++ b/keep-ui/app/incidents/IncidentPlaceholder.tsx @@ -0,0 +1,38 @@ +import { Fragment } from "react"; +import { Button, Subtitle, Title } from "@tremor/react"; + + +interface Props { + setIsFormOpen: (value: boolean) => void; +} + + +export const IncidentPlaceholder = ({ + setIsFormOpen, +}: Props) => { + + const onCreateButtonClick = () => { + setIsFormOpen(true); + }; + + return ( + +
+
+ No Incidents Yet + + Create incidents manually to enable AI detection + +
+ +
+ +
+ ); +}; diff --git a/keep-ui/app/incidents/[id]/incident-alert-menu.tsx b/keep-ui/app/incidents/[id]/incident-alert-menu.tsx new file mode 100644 index 000000000..aaa43ab28 --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident-alert-menu.tsx @@ -0,0 +1,56 @@ +import { TrashIcon } from "@radix-ui/react-icons"; +import { Icon } from "@tremor/react"; +import { AlertDto } from "app/alerts/models"; +import { useSession } from "next-auth/react"; +import { toast } from "react-toastify"; +import { getApiURL } from "utils/apiUrl"; +import { useIncidentAlerts } from "utils/hooks/useIncidents"; + +interface Props { + incidentId: string; + alert: AlertDto; +} +export default function IncidentAlertMenu({ incidentId, alert }: Props) { + const apiUrl = getApiURL(); + const { data: session } = useSession(); + const { mutate } = useIncidentAlerts(incidentId); + + function onRemove() { + if (confirm("Are you sure you want to remove correlation?")) { + fetch(`${apiUrl}/incidents/${incidentId}/alerts`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([alert.event_id]), + }).then((response) => { + if (response.ok) { + toast.success("Alert removed from incident successfully", { + position: "top-right", + }); + mutate(); + } else { + toast.error( + "Failed to remove alert from incident, please contact us if this issue persists.", + { + position: "top-right", + } + ); + } + }); + } + } + + return ( +
+ +
+ ); +} diff --git a/keep-ui/app/incidents/[id]/incident-alerts.tsx b/keep-ui/app/incidents/[id]/incident-alerts.tsx new file mode 100644 index 000000000..a6058720f --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident-alerts.tsx @@ -0,0 +1,174 @@ +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + Callout, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from "@tremor/react"; +import Image from "next/image"; +import AlertSeverity from "app/alerts/alert-severity"; +import { AlertDto } from "app/alerts/models"; +import Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { getAlertLastReceieved } from "utils/helpers"; +import { + useIncidentAlerts, + usePollIncidentAlerts, +} from "utils/hooks/useIncidents"; +import AlertName from "app/alerts/alert-name"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import IncidentAlertMenu from "./incident-alert-menu"; + +interface Props { + incidentId: string; +} + +const columnHelper = createColumnHelper(); + +export default function IncidentAlerts({ incidentId }: Props) { + const { data: alerts } = useIncidentAlerts(incidentId); + usePollIncidentAlerts(incidentId); + + const columns = [ + columnHelper.accessor("severity", { + id: "severity", + header: "Severity", + minSize: 100, + cell: (context) => , + }), + columnHelper.display({ + id: "name", + header: "Name", + minSize: 330, + cell: (context) => , + }), + columnHelper.accessor("description", { + id: "description", + header: "Description", + minSize: 100, + cell: (context) => ( +
+
{context.getValue()}
+
+ ), + }), + columnHelper.accessor("status", { + id: "status", + minSize: 100, + header: "Status", + }), + columnHelper.accessor("lastReceived", { + id: "lastReceived", + header: "Last Received", + minSize: 100, + cell: (context) => ( + {getAlertLastReceieved(context.getValue())} + ), + }), + columnHelper.accessor("source", { + id: "source", + header: "Source", + minSize: 100, + cell: (context) => + (context.getValue() ?? []).map((source, index) => ( + {source} + )), + }), + columnHelper.display({ + id: "remove", + header: "", + cell: (context) => ( + + ), + }), + ]; + + const table = useReactTable({ + columns: columns, + data: alerts ?? [], + getCoreRowModel: getCoreRowModel(), + }); + return ( + <> + {(alerts ?? []).length === 0 && ( + + Alerts will show up here as they are correlated into this incident. + + )} + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + {alerts && alerts.length > 0 && ( + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + )} + { + // Skeleton + (alerts ?? []).length === 0 && ( + + {Array(5) + .fill("") + .map((index) => ( + + {columns.map((c) => ( + + + + ))} + + ))} + + ) + } +
+ + ); +} diff --git a/keep-ui/app/incidents/[id]/incident-info.tsx b/keep-ui/app/incidents/[id]/incident-info.tsx new file mode 100644 index 000000000..858a78397 --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident-info.tsx @@ -0,0 +1,80 @@ +import {Button, Title} from "@tremor/react"; +import { IncidentDto } from "../model"; +import CreateOrUpdateIncident from "../create-or-update-incident"; +import Modal from "@/components/ui/Modal"; +import React, {useState} from "react"; +import {MdModeEdit} from "react-icons/md"; +import {useIncident} from "../../../utils/hooks/useIncidents"; +// import { RiSparkling2Line } from "react-icons/ri"; + +interface Props { + incident: IncidentDto; +} + +export default function IncidentInformation({ incident }: Props) { + + const { mutate } = useIncident(incident.id); + const [isFormOpen, setIsFormOpen] = useState(false); + + const handleCloseForm = () => { + setIsFormOpen(false); + }; + + const handleStartEdit = () => { + setIsFormOpen(true); + }; + + const handleFinishEdit = () => { + setIsFormOpen(false); + mutate(); + }; + + return ( +
+
+
+ βš”οΈ Incident Information +
+
{incident.name}
+

Description: {incident.description}

+

Started at: {incident.start_time?.toISOString() ?? "N/A"}

+ {/* + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad + minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in + culpa qui officia deserunt mollit anim id est laborum. + */} +
+ + + +
+ ); +} diff --git a/keep-ui/app/incidents/[id]/incident.tsx b/keep-ui/app/incidents/[id]/incident.tsx new file mode 100644 index 000000000..770db93d9 --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident.tsx @@ -0,0 +1,75 @@ +"use client"; +import Loading from "app/loading"; +import { useIncident } from "utils/hooks/useIncidents"; +import IncidentInformation from "./incident-info"; +import { + Card, + Icon, + Subtitle, + Tab, + TabGroup, + TabList, + TabPanel, + TabPanels, + Title, +} from "@tremor/react"; +import IncidentAlerts from "./incident-alerts"; +import { ArrowUturnLeftIcon } from "@heroicons/react/24/outline"; +import { useRouter } from "next/navigation"; + +interface Props { + incidentId: string; +} + +export default function IncidentView({ incidentId }: Props) { + const { data: incident, isLoading, error } = useIncident(incidentId); + const router = useRouter(); + + if (isLoading || !incident) return ; + if (error) return Incident does not exist.; + + return ( + <> +
+
+ Incident Management + + Understand, manage and triage your incidents faster with Keep. + +
+ router.back()} + /> +
+ +
+
+
+ +
+
+ + + Alerts + Timeline + Topology + + + + + + Coming Soon... + Coming Soon... + + +
+
+
+
+ + ); +} diff --git a/keep-ui/app/incidents/[id]/layout.tsx b/keep-ui/app/incidents/[id]/layout.tsx new file mode 100644 index 000000000..6f304e530 --- /dev/null +++ b/keep-ui/app/incidents/[id]/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: any }) { + return
{children}
; +} diff --git a/keep-ui/app/incidents/[id]/page.tsx b/keep-ui/app/incidents/[id]/page.tsx new file mode 100644 index 000000000..8169e32be --- /dev/null +++ b/keep-ui/app/incidents/[id]/page.tsx @@ -0,0 +1,15 @@ +import IncidentView from "./incident"; + +type PageProps = { + params: { id: string }; + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export const metadata = { + title: "Keep - Incident", + description: "Incident view", +}; + +export default function Page(props: PageProps) { + return ; +} diff --git a/keep-ui/app/incidents/create-or-update-incident.tsx b/keep-ui/app/incidents/create-or-update-incident.tsx new file mode 100644 index 000000000..da35b65e2 --- /dev/null +++ b/keep-ui/app/incidents/create-or-update-incident.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { + TextInput, + Textarea, + Divider, + Subtitle, + Text, + Button, +} from "@tremor/react"; +import { useSession } from "next-auth/react"; +import { FormEvent, useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import { getApiURL } from "utils/apiUrl"; +import { IncidentDto } from "./model"; +import { useIncidents } from "utils/hooks/useIncidents"; + +interface Props { + incidentToEdit: IncidentDto | null; + editCallback: (rule: IncidentDto | null) => void; +} + +export default function CreateOrUpdateIncident({ + incidentToEdit, + editCallback, +}: Props) { + const { data: session } = useSession(); + const { mutate } = useIncidents(true, 20); + const [incidentName, setIncidentName] = useState(""); + const [incidentDescription, setIncidentDescription] = useState(""); + const [incidentAssignee, setIncidentAssignee] = useState(""); + const editMode = incidentToEdit !== null; + + + useEffect(() => { + if (incidentToEdit) { + setIncidentName(incidentToEdit.name); + setIncidentDescription(incidentToEdit.description ?? ""); + setIncidentAssignee(incidentToEdit.assignee ?? ""); + } + }, [incidentToEdit]); + + const clearForm = () => { + setIncidentName(""); + setIncidentDescription(""); + setIncidentAssignee(""); + }; + + const addIncident = async (e: FormEvent) => { + e.preventDefault(); + const apiUrl = getApiURL(); + const response = await fetch(`${apiUrl}/incidents`, { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: incidentName, + description: incidentDescription, + assignee: incidentAssignee, + }), + }); + if (response.ok) { + exitEditMode(); + await mutate(); + toast.success("Incident created successfully"); + } else { + toast.error( + "Failed to create incident, please contact us if this issue persists." + ); + } + }; + + // This is the function that will be called on submitting the form in the editMode, it sends a PUT request to the backend. + const updateIncident = async (e: FormEvent) => { + e.preventDefault(); + const apiUrl = getApiURL(); + const response = await fetch( + `${apiUrl}/incidents/${incidentToEdit?.id}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: incidentName, + description: incidentDescription, + assignee: incidentAssignee, + }), + } + ); + if (response.ok) { + exitEditMode(); + await mutate(); + toast.success("Incident updated successfully"); + } else { + toast.error( + "Failed to update incident, please contact us if this issue persists." + ); + } + }; + + // If the Incident is successfully updated or the user cancels the update we exit the editMode and set the editRule in the incident.tsx to null. + const exitEditMode = () => { + editCallback(null); + clearForm(); + }; + + const submitEnabled = (): boolean => { + return ( + !!incidentName && + !!incidentDescription + ); + }; + + return ( +
+ Incident Metadata +
+ + Name* + + +
+
+ Description* +