Skip to content

Commit

Permalink
feat(incident): activity tab (#2185)
Browse files Browse the repository at this point in the history
Signed-off-by: Tal <[email protected]>
Signed-off-by: Tal <[email protected]>
Co-authored-by: Shahar Glazner <[email protected]>
Co-authored-by: Kirill Chernakov <[email protected]>
  • Loading branch information
3 people authored Oct 21, 2024
1 parent 5a93f10 commit 7bf3c83
Show file tree
Hide file tree
Showing 17 changed files with 741 additions and 221 deletions.
6 changes: 1 addition & 5 deletions keep-ui/app/alerts/alert-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions keep-ui/app/incidents/[id]/incident-activity.css
Original file line number Diff line number Diff line change
@@ -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;
}
263 changes: 263 additions & 0 deletions keep-ui/app/incidents/[id]/incident-activity.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative h-full w-full flex items-center">
{activity.type === "alert" && (
<AlertSeverity
severity={(activity.initiator as AlertDto).severity}
marginLeft={false}
/>
)}
<span className="font-semibold mr-2.5">{title}</span>
<span className="text-gray-300">
{subTitle} <TimeAgo date={activity.timestamp + "Z"} />
</span>
{activity.text && (
<div className="absolute top-14 font-light text-gray-800">
{activity.text}
</div>
)}
</div>
);
}


export function IncidentActivityChronoItemComment({
incident,
mutator,
}: {
incident: IncidentDto;
mutator: KeyedMutator<AuditEvent[]>;
}) {
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 (
<div className="flex h-full w-full relative items-center">
<TextInput
value={comment}
onValueChange={setComment}
placeholder="Add a new comment..."
/>
<Button
color="orange"
variant="secondary"
className="ml-2.5"
disabled={!comment}
onClick={onSubmit}
>
Comment
</Button>
</div>
);
}

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 <Loading />;

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" ? (
<IncidentActivityChronoItemComment
mutator={mutateIncidentActivity}
incident={incident}
key={activity.id}
/>
) : (
<IncidentActivityChronoItem key={activity.id} activity={activity} />
)
);
const chronoIcons = activities?.map((activity, index) => {
if (activity.type === "comment" || activity.type === "newcomment") {
const user = users?.find((user) => user.email === activity.initiator);
return (
<UserAvatar
key={`icon-${activity.id}`}
image={user?.picture}
name={user?.name ?? user?.email ?? (activity.initiator as string)}
/>
);
} else {
const source = (activity.initiator as AlertDto).source[0];
const imagePath = `/icons/${source}-icon.png`;
return (
<Image
key={`icon-${activity.id}`}
alt={source}
height={24}
width={24}
title={source}
src={imagePath}
/>
);
}
});

return (
<Chrono
items={activities?.map((activity) => ({
id: activity.id,
title: activity.timestamp,
}))}
hideControls
disableToolbar
borderLessCards={true}
slideShow={false}
mode="VERTICAL"
cardWidth={600}
cardHeight={100}
allowDynamicUpdate={true}
disableAutoScrollOnClick={true}
>
{chronoContent}
<div className="chrono-icons">{chronoIcons}</div>
</Chrono>
);
}
Loading

0 comments on commit 7bf3c83

Please sign in to comment.