Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(incident): activity tab #2185

Merged
merged 13 commits into from
Oct 21, 2024
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,
talboren marked this conversation as resolved.
Show resolved Hide resolved
}))}
hideControls
disableToolbar
borderLessCards={true}
slideShow={false}
mode="VERTICAL"
cardWidth={600}
cardHeight={100}
allowDynamicUpdate={true}
talboren marked this conversation as resolved.
Show resolved Hide resolved
disableAutoScrollOnClick={true}
>
{chronoContent}
<div className="chrono-icons">{chronoIcons}</div>
</Chrono>
);
}
Loading
Loading