-
Notifications
You must be signed in to change notification settings - Fork 699
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(incident): activity tab (#2185)
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
1 parent
5a93f10
commit 7bf3c83
Showing
17 changed files
with
741 additions
and
221 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.