diff --git a/keep-ui/app/alerts/alert-timeline.tsx b/keep-ui/app/alerts/alert-timeline.tsx index db584d624..02fe484e0 100644 --- a/keep-ui/app/alerts/alert-timeline.tsx +++ b/keep-ui/app/alerts/alert-timeline.tsx @@ -5,11 +5,7 @@ import Image from "next/image"; import { ArrowPathIcon } from "@heroicons/react/24/outline"; import { AlertDto } from "./models"; import { AuditEvent } from "utils/hooks/useAlerts"; - -const getInitials = (name: string) => - ((name.match(/(^\S\S?|\b\S)?/g) ?? []).join("").match(/(^\S|\S$)?/g) ?? []) - .join("") - .toUpperCase(); +import { getInitials } from "@/components/navbar/UserAvatar"; const formatTimestamp = (timestamp: Date | string) => { const date = new Date(timestamp); diff --git a/keep-ui/app/incidents/[id]/incident-activity.css b/keep-ui/app/incidents/[id]/incident-activity.css new file mode 100644 index 000000000..5b4fc518d --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident-activity.css @@ -0,0 +1,53 @@ +.using-icon { + width: unset !important; + height: unset !important; + background: none !important; +} + +.rc-card { + filter: unset !important; +} + +.active { + color: unset !important; + background: unset !important; + border: unset !important; +} + +:focus { + outline: unset !important; +} + +li[class^="VerticalItemWrapper-"] { + margin: unset !important; +} + +[class^="TimelineTitleWrapper-"] { + display: none !important; +} + +[class^="TimelinePointWrapper-"] { + width: 5% !important; +} + +[class^="TimelineVerticalWrapper-"] + li + [class^="TimelinePointWrapper-"]::before { + background: lightgray !important; + width: 0.5px; +} + +[class^="TimelineVerticalWrapper-"] li [class^="TimelinePointWrapper-"]::after { + background: lightgray !important; + width: 0.5px; +} + +[class^="TimelineVerticalWrapper-"] + li:nth-of-type(1) + [class^="TimelinePointWrapper-"]::before { + display: none; +} + +.vertical-item-row { + justify-content: unset !important; +} diff --git a/keep-ui/app/incidents/[id]/incident-activity.tsx b/keep-ui/app/incidents/[id]/incident-activity.tsx new file mode 100644 index 000000000..2ab11e50f --- /dev/null +++ b/keep-ui/app/incidents/[id]/incident-activity.tsx @@ -0,0 +1,263 @@ +import { AlertDto } from "@/app/alerts/models"; +import { IncidentDto } from "../models"; +import { Chrono } from "react-chrono"; +import { useUsers } from "@/utils/hooks/useUsers"; +import Image from "next/image"; +import UserAvatar from "@/components/navbar/UserAvatar"; +import "./incident-activity.css"; +import AlertSeverity from "@/app/alerts/alert-severity"; +import TimeAgo from "react-timeago"; +import { Button, TextInput } from "@tremor/react"; +import { + useIncidentAlerts, + usePollIncidentComments, +} from "@/utils/hooks/useIncidents"; +import { AuditEvent, useAlerts } from "@/utils/hooks/useAlerts"; +import Loading from "@/app/loading"; +import { useCallback, useState, useEffect } from "react"; +import { getApiURL } from "@/utils/apiUrl"; +import { useSession } from "next-auth/react"; +import { KeyedMutator } from "swr"; +import { toast } from "react-toastify"; + +interface IncidentActivity { + id: string; + type: "comment" | "alert" | "newcomment"; + text?: string; + timestamp: string; + initiator?: string | AlertDto; +} + +export function IncidentActivityChronoItem({ activity }: { activity: any }) { + const title = + typeof activity.initiator === "string" + ? activity.initiator + : activity.initiator?.name; + const subTitle = + typeof activity.initiator === "string" + ? " Added a comment. " + : (activity.initiator?.status === "firing" ? " triggered" : " resolved") + + ". "; + return ( +
+ {activity.type === "alert" && ( + + )} + {title} + + {subTitle} + + {activity.text && ( +
+ {activity.text} +
+ )} +
+ ); +} + + +export function IncidentActivityChronoItemComment({ + incident, + mutator, +}: { + incident: IncidentDto; + mutator: KeyedMutator; +}) { + const [comment, setComment] = useState(""); + const apiUrl = getApiURL(); + const { data: session } = useSession(); + + const onSubmit = useCallback(async () => { + const response = await fetch(`${apiUrl}/incidents/${incident.id}/comment`, { + method: "POST", + headers: { + Authorization: `Bearer ${session?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: incident.status, + comment: comment, + }), + }); + if (response.ok) { + toast.success("Comment added!", { position: "top-right" }); + setComment(""); + mutator(); + } else { + toast.error("Failed to add comment", { position: "top-right" }); + } + }, [ + apiUrl, + incident.id, + incident.status, + comment, + session?.accessToken, + mutator, + ]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key === "Enter" && + (event.metaKey || event.ctrlKey) && + comment + ) { + onSubmit(); + } + }, + [onSubmit, comment] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [comment, handleKeyDown]); + + return ( +
+ + +
+ ); +} + +export default function IncidentActivity({ + incident, +}: { + incident: IncidentDto; +}) { + const { data: session } = useSession(); + const { useMultipleFingerprintsAlertAudit, useAlertAudit } = useAlerts(); + const { data: alerts, isLoading: alertsLoading } = useIncidentAlerts( + incident.id + ); + const { data: auditEvents, isLoading: auditEventsLoading } = + useMultipleFingerprintsAlertAudit(alerts?.items.map((m) => m.fingerprint)); + const { + data: incidentEvents, + isLoading: incidentEventsLoading, + mutate: mutateIncidentActivity, + } = useAlertAudit(incident.id); + + const { data: users, isLoading: usersLoading } = useUsers(); + usePollIncidentComments(incident.id); + + if ( + usersLoading || + incidentEventsLoading || + auditEventsLoading || + alertsLoading + ) + return ; + + const newCommentActivity = { + id: "newcomment", + type: "newcomment", + timestamp: new Date().toISOString(), + initiator: session?.user.email, + }; + + const auditActivities = + auditEvents + ?.concat(incidentEvents || []) + .sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ) + .map((auditEvent) => { + const _type = + auditEvent.action === "A comment was added to the incident" // @tb: I wish this was INCIDENT_COMMENT and not the text.. + ? "comment" + : "alert"; + return { + id: auditEvent.id, + type: _type, + initiator: + _type === "comment" + ? auditEvent.user_id + : alerts?.items.find( + (a) => a.fingerprint === auditEvent.fingerprint + ), + text: _type === "comment" ? auditEvent.description : "", + timestamp: auditEvent.timestamp, + } as IncidentActivity; + }) || []; + + const activities = [newCommentActivity, ...auditActivities]; + + const chronoContent = activities?.map((activity, index) => + activity.type === "newcomment" ? ( + + ) : ( + + ) + ); + const chronoIcons = activities?.map((activity, index) => { + if (activity.type === "comment" || activity.type === "newcomment") { + const user = users?.find((user) => user.email === activity.initiator); + return ( + + ); + } else { + const source = (activity.initiator as AlertDto).source[0]; + const imagePath = `/icons/${source}-icon.png`; + return ( + {source} + ); + } + }); + + return ( + ({ + id: activity.id, + title: activity.timestamp, + }))} + hideControls + disableToolbar + borderLessCards={true} + slideShow={false} + mode="VERTICAL" + cardWidth={600} + cardHeight={100} + allowDynamicUpdate={true} + disableAutoScrollOnClick={true} + > + {chronoContent} +
{chronoIcons}
+
+ ); +} diff --git a/keep-ui/app/incidents/[id]/incident-info.tsx b/keep-ui/app/incidents/[id]/incident-info.tsx index b8b1314de..87c9c35fe 100644 --- a/keep-ui/app/incidents/[id]/incident-info.tsx +++ b/keep-ui/app/incidents/[id]/incident-info.tsx @@ -1,10 +1,13 @@ -import {Badge, Button, Icon, Title} from "@tremor/react"; +import { Badge, Button, Icon, Title } from "@tremor/react"; import { IncidentDto } from "../models"; import CreateOrUpdateIncident from "../create-or-update-incident"; import Modal from "@/components/ui/Modal"; import React, { useState } from "react"; -import {MdBlock, MdDone, MdModeEdit, MdPlayArrow} from "react-icons/md"; -import { useIncident, useIncidentFutureIncidents } from "@/utils/hooks/useIncidents"; +import { MdBlock, MdDone, MdModeEdit, MdPlayArrow } from "react-icons/md"; +import { + useIncident, + useIncidentFutureIncidents, +} from "@/utils/hooks/useIncidents"; import { deleteIncident, @@ -19,7 +22,7 @@ import classNames from "classnames"; import { IoChevronDown } from "react-icons/io5"; import IncidentChangeStatusModal from "@/app/incidents/incident-change-status-modal"; import ChangeSameIncidentInThePast from "@/app/incidents/incident-change-same-in-the-past"; -import {STATUS_ICONS} from "@/app/incidents/statuses"; +import { STATUS_ICONS } from "@/app/incidents/statuses"; import remarkRehype from "remark-rehype"; import rehypeRaw from "rehype-raw"; import Markdown from "react-markdown"; @@ -29,11 +32,13 @@ interface Props { incident: IncidentDto; } -function FollowingIncident({incidentId}: {incidentId: string}) { +function FollowingIncident({ incidentId }: { incidentId: string }) { const { data: incident } = useIncident(incidentId); return ( ); } @@ -49,13 +54,11 @@ function Summary({ collapsable?: boolean; className?: string; }) { - - const formatedSummary = + const formatedSummary = ( + {summary} + ); if (collapsable) { return ( @@ -125,7 +128,10 @@ export default function IncidentInformation({ incident }: Props) { setChangeStatusIncident(incident); }; - const handleChangeSameIncidentInThePast = (e: React.MouseEvent, incident: IncidentDto) => { + const handleChangeSameIncidentInThePast = ( + e: React.MouseEvent, + incident: IncidentDto + ) => { e.preventDefault(); e.stopPropagation(); setChangeSameIncidentInThePast(incident); @@ -133,8 +139,12 @@ export default function IncidentInformation({ incident }: Props) { const formatString = "dd, MMM yyyy - HH:mm.ss 'UTC'"; const summary = incident.user_summary || incident.generated_summary; - const { data: same_incident_in_the_past } = useIncident(incident.same_incident_in_the_past_id); - const { data: same_incidents_in_the_future } = useIncidentFutureIncidents(incident.id); + const { data: same_incident_in_the_past } = useIncident( + incident.same_incident_in_the_past_id + ); + const { data: same_incidents_in_the_future } = useIncidentFutureIncidents( + incident.id + ); const severity = incident.severity; let severityColor; @@ -150,19 +160,18 @@ export default function IncidentInformation({ incident }: Props) { {incident.is_confirmed ? "⚔️ " : "Possible "}Incident