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 (
+
+ );
+ }
+ });
+
+ 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 99ec12e2d..cc484e06a 100644
--- a/keep-ui/app/incidents/[id]/incident-info.tsx
+++ b/keep-ui/app/incidents/[id]/incident-info.tsx
@@ -306,14 +306,14 @@ export default function IncidentInformation({ incident }: Props) {
) : (
No linked incidents. Link same incident from the past to help
- the AI classifier. 🤔(
+ the AI classifier. 🤔 (
handleChangeSameIncidentInThePast(e, incident)
}
className="cursor-pointer text-orange-500"
>
- link
+ click to link
)
diff --git a/keep-ui/app/incidents/[id]/incident.tsx b/keep-ui/app/incidents/[id]/incident.tsx
index b8cfd78c7..a83360b73 100644
--- a/keep-ui/app/incidents/[id]/incident.tsx
+++ b/keep-ui/app/incidents/[id]/incident.tsx
@@ -13,7 +13,6 @@ import {
Title,
} from "@tremor/react";
import IncidentAlerts from "./incident-alerts";
-import { useRouter } from "next/navigation";
import IncidentTimeline from "./incident-timeline";
import { CiBellOn, CiChat2, CiViewTimeline } from "react-icons/ci";
import { IoIosGitNetwork } from "react-icons/io";
@@ -23,6 +22,8 @@ import IncidentWorkflowTable from "./incident-workflow-table";
import { TopologyMap } from "@/app/topology/ui/map";
import { TopologySearchProvider } from "@/app/topology/TopologySearchContext";
import { useState } from "react";
+import { FiActivity } from "react-icons/fi";
+import IncidentActivity from "./incident-activity";
interface Props {
incidentId: string;
@@ -33,8 +34,6 @@ export default function IncidentView({ incidentId }: Props) {
const { data: incident, isLoading, error } = useIncident(incidentId);
const [index, setIndex] = useState(0);
- const router = useRouter();
-
if (isLoading || !incident) return ;
if (error) return Incident does not exist.;
@@ -56,6 +55,15 @@ export default function IncidentView({ incidentId }: Props) {
color="orange"
className="sticky xl:-top-10 -top-4 bg-white z-10"
>
+
+ Activity
+
+ New
+
+
Alerts
Timeline
Topology
@@ -71,13 +79,16 @@ export default function IncidentView({ incidentId }: Props) {
+
+
+
-
+
+ ) => {
+ const checked = event.target.checked;
+ setFormValues((prevValues) => ({
+ ...prevValues,
+ pulling_enabled: checked,
+ }));
+ };
+
const validate = () => {
const errors = validateForm(formValues);
if (Object.keys(errors).length === 0) {
@@ -426,9 +444,13 @@ const ProviderForm = ({
submit(`${apiUrl}/providers/${provider.id}`, "PUT")
.then((data) => {
setIsLoading(false);
+ toast.success("Updated provider successfully", {
+ position: "top-left",
+ });
mutate();
})
.catch((error) => {
+ toast.error("Failed to update provider", { position: "top-left" });
const updatedFormErrors = error.toString();
setFormErrors(updatedFormErrors);
onFormChange(formValues, updatedFormErrors);
@@ -743,7 +765,12 @@ const ProviderForm = ({
/>
-
+ {installedProvidersMode && provider.last_pull_time && (
+
+ Provider last pull time:{" "}
+
+
+ )}
{provider.provisioned && (
+ {
+ // This is here because pulling is only enabled for providers we can get alerts from (e.g., support webhook)
+ }
+
+
{isLocalhost && (
@@ -931,21 +982,45 @@ const ProviderForm = ({
{provider.can_setup_webhook && installedProvidersMode && (
-
+ <>
+
+
+
+
+
+ >
)}
{provider.supports_webhook && (
@@ -41,13 +41,16 @@ export function UsersTable({
{/** Image */}
- {authType === AuthenticationType.AUTH0 || authType === AuthenticationType.KEYCLOAK
+ {authType === AuthenticationType.AUTH0 ||
+ authType === AuthenticationType.KEYCLOAK
? "Email"
: "Username"}
Name
Role
- {groupsAllowed && Groups}
+ {groupsAllowed && (
+ Groups
+ )}
Last Login
@@ -84,9 +87,7 @@ export function UsersTable({
{user.email}
- {user.ldap && (
- LDAP
- )}
+ {user.ldap && LDAP}
@@ -119,7 +120,11 @@ export function UsersTable({
)}
- {user.last_login ? new Date(user.last_login).toLocaleString() : "Never"}
+
+ {user.last_login
+ ? new Date(user.last_login).toLocaleString()
+ : "Never"}
+
{!isDisabled && user.email !== currentUserEmail && !user.ldap && (
diff --git a/keep-ui/app/topology/model/useTopology.ts b/keep-ui/app/topology/model/useTopology.ts
index 3a7c3a7a0..41f42354f 100644
--- a/keep-ui/app/topology/model/useTopology.ts
+++ b/keep-ui/app/topology/model/useTopology.ts
@@ -1,6 +1,6 @@
import { TopologyService } from "@/app/topology/model/models";
import { useSession } from "next-auth/react";
-import useSWR from "swr";
+import useSWR, { SWRConfiguration } from "swr";
import { fetcher } from "@/utils/fetcher";
import { useEffect } from "react";
import { buildTopologyUrl } from "@/app/topology/api";
@@ -14,15 +14,23 @@ type UseTopologyOptions = {
services?: string[];
environment?: string;
initialData?: TopologyService[];
+ options?: SWRConfiguration;
};
// TODO: ensure that hook is memoized so could be used multiple times in the tree without rerenders
-export const useTopology = ({
- providerIds,
- services,
- environment,
- initialData: fallbackData,
-}: UseTopologyOptions = {}) => {
+export const useTopology = (
+ {
+ providerIds,
+ services,
+ environment,
+ initialData: fallbackData,
+ options,
+ }: UseTopologyOptions = {
+ options: {
+ revalidateOnFocus: false,
+ },
+ }
+) => {
const { data: session } = useSession();
const apiUrl = useApiUrl();
const pollTopology = useTopologyPollingContext();
@@ -36,6 +44,7 @@ export const useTopology = ({
(url: string) => fetcher(url, session!.accessToken),
{
fallbackData,
+ ...options,
}
);
diff --git a/keep-ui/app/topology/model/useTopologyApplications.ts b/keep-ui/app/topology/model/useTopologyApplications.ts
index d1f1ed1c6..eb6c34b61 100644
--- a/keep-ui/app/topology/model/useTopologyApplications.ts
+++ b/keep-ui/app/topology/model/useTopologyApplications.ts
@@ -1,6 +1,6 @@
import { TopologyApplication } from "./models";
import { useApiUrl } from "utils/hooks/useConfig";
-import useSWR from "swr";
+import useSWR, { SWRConfiguration } from "swr";
import { fetcher } from "@/utils/fetcher";
import { useSession } from "next-auth/react";
import { useCallback, useMemo } from "react";
@@ -9,11 +9,16 @@ import { useRevalidateMultiple } from "@/utils/state";
type UseTopologyApplicationsOptions = {
initialData?: TopologyApplication[];
+ options?: SWRConfiguration;
};
-export function useTopologyApplications({
- initialData,
-}: UseTopologyApplicationsOptions = {}) {
+export function useTopologyApplications(
+ { initialData, options }: UseTopologyApplicationsOptions = {
+ options: {
+ revalidateOnFocus: false,
+ },
+ }
+) {
const apiUrl = useApiUrl();
const { data: session } = useSession();
const topologyBaseKey = useTopologyBaseKey();
@@ -25,6 +30,7 @@ export function useTopologyApplications({
(url: string) => fetcher(url, session!.accessToken),
{
fallbackData: initialData,
+ ...options,
}
);
diff --git a/keep-ui/app/topology/ui/map/service-node.tsx b/keep-ui/app/topology/ui/map/service-node.tsx
index 123303945..a15c7e3c7 100644
--- a/keep-ui/app/topology/ui/map/service-node.tsx
+++ b/keep-ui/app/topology/ui/map/service-node.tsx
@@ -107,7 +107,7 @@ export function ServiceNode({ data, selected }: NodeProps) {
onMouseEnter={() => setShowDetails(true)}
onMouseLeave={() => setShowDetails(false)}
>
- {data.display_name ?? data.service}
+ {data.display_name || data.service}
{alertCount > 0 && (
+ ((name.match(/(^\S\S?|\b\S)?/g) ?? []).join("").match(/(^\S|\S$)?/g) ?? [])
+ .join("")
+ .toUpperCase();
+
+export default function UserAvatar({ image, name }: Props) {
+ return image ? (
+
+ ) : (
+
+
+ {getInitials(name)}
+
+
+ );
+}
diff --git a/keep-ui/components/navbar/UserInfo.tsx b/keep-ui/components/navbar/UserInfo.tsx
index 0af88c1f2..e848579a3 100644
--- a/keep-ui/components/navbar/UserInfo.tsx
+++ b/keep-ui/components/navbar/UserInfo.tsx
@@ -14,11 +14,7 @@ import { VscDebugDisconnect } from "react-icons/vsc";
import DarkModeToggle from "app/dark-mode-toggle";
import { useFloating } from "@floating-ui/react";
import { Icon, Subtitle } from "@tremor/react";
-
-export const getInitials = (name: string) =>
- ((name.match(/(^\S\S?|\b\S)?/g) ?? []).join("").match(/(^\S|\S$)?/g) ?? [])
- .join("")
- .toUpperCase();
+import UserAvatar from "./UserAvatar";
type UserDropdownProps = {
session: Session;
@@ -38,21 +34,7 @@ const UserDropdown = ({ session }: UserDropdownProps) => {