From 7bf3c836c50104906e2f3d79ecd52e1c960b7ba1 Mon Sep 17 00:00:00 2001 From: Tal Date: Mon, 21 Oct 2024 16:20:21 +0300 Subject: [PATCH 1/2] feat(incident): activity tab (#2185) Signed-off-by: Tal Signed-off-by: Tal Co-authored-by: Shahar Glazner Co-authored-by: Kirill Chernakov --- keep-ui/app/alerts/alert-timeline.tsx | 6 +- .../app/incidents/[id]/incident-activity.css | 53 ++++ .../app/incidents/[id]/incident-activity.tsx | 263 ++++++++++++++++++ keep-ui/app/incidents/[id]/incident-info.tsx | 176 ++++++++---- keep-ui/app/incidents/[id]/incident.tsx | 19 +- keep-ui/app/settings/auth/users-table.tsx | 23 +- keep-ui/app/topology/model/useTopology.ts | 23 +- .../topology/model/useTopologyApplications.ts | 14 +- keep-ui/components/navbar/UserAvatar.tsx | 29 ++ keep-ui/components/navbar/UserInfo.tsx | 22 +- keep-ui/utils/hooks/useAlerts.ts | 9 +- keep-ui/utils/hooks/useDashboards.ts | 5 +- keep-ui/utils/hooks/useIncidents.ts | 41 ++- keep-ui/utils/hooks/useWorkflowExecutions.ts | 6 +- keep/api/core/db.py | 200 +++++++------ keep/api/models/db/alert.py | 12 +- keep/api/routes/incidents.py | 61 +++- 17 files changed, 741 insertions(+), 221 deletions(-) create mode 100644 keep-ui/app/incidents/[id]/incident-activity.css create mode 100644 keep-ui/app/incidents/[id]/incident-activity.tsx create mode 100644 keep-ui/components/navbar/UserAvatar.tsx 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 + <> +
+ + +
+ + )} {provider.supports_webhook && ( ) { onMouseEnter={() => setShowDetails(true)} onMouseLeave={() => setShowDetails(false)} > - {data.display_name ?? data.service} + {data.display_name || data.service} {alertCount > 0 && ( None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("provider", schema=None) as batch_op: + batch_op.add_column( + sa.Column("pulling_enabled", sa.Boolean(), nullable=False, default=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("provider", schema=None) as batch_op: + batch_op.drop_column("pulling_enabled") + # ### end Alembic commands ### diff --git a/keep/api/models/db/provider.py b/keep/api/models/db/provider.py index 37d8d05fa..33e200784 100644 --- a/keep/api/models/db/provider.py +++ b/keep/api/models/db/provider.py @@ -20,6 +20,7 @@ class Provider(SQLModel, table=True): sa_column=Column(JSON) ) # scope name is key and value is either True if validated or string with error message, e.g: {"read": True, "write": "error message"} consumer: bool = False + pulling_enabled: bool = True last_pull_time: Optional[datetime] provisioned: bool = Field(default=False) diff --git a/keep/api/models/provider.py b/keep/api/models/provider.py index 93810817c..5c15bfc22 100644 --- a/keep/api/models/provider.py +++ b/keep/api/models/provider.py @@ -39,6 +39,7 @@ class Provider(BaseModel): methods: list[ProviderMethod] = [] installed_by: str | None = None installation_time: datetime | None = None + pulling_enabled: bool = True last_pull_time: datetime | None = None docs: str | None = None tags: list[ diff --git a/keep/api/routes/preset.py b/keep/api/routes/preset.py index a812d6be1..cfa701681 100644 --- a/keep/api/routes/preset.py +++ b/keep/api/routes/preset.py @@ -1,16 +1,18 @@ +import json import logging import os import uuid from datetime import datetime from typing import Optional + from fastapi import ( APIRouter, BackgroundTasks, Depends, HTTPException, + Query, Request, Response, - Query ) from pydantic import BaseModel from sqlmodel import Session, select @@ -24,7 +26,6 @@ update_provider_last_pull_time, ) from keep.api.models.alert import AlertDto -from keep.api.models.time_stamp import TimeStampFilter from keep.api.models.db.preset import ( Preset, PresetDto, @@ -33,6 +34,7 @@ Tag, TagDto, ) +from keep.api.models.time_stamp import TimeStampFilter from keep.api.tasks.process_event_task import process_event from keep.api.tasks.process_topology_task import process_topology from keep.contextmanager.contextmanager import ContextManager @@ -41,7 +43,6 @@ from keep.providers.base.base_provider import BaseTopologyProvider from keep.providers.providers_factory import ProvidersFactory from keep.searchengine.searchengine import SearchEngine -import json router = APIRouter() logger = logging.getLogger(__name__) @@ -86,6 +87,10 @@ def pull_data_from_providers( "trace_id": trace_id, } + if not provider.pulling_enabled: + logger.debug("Pulling is disabled for this provider", extra=extra) + continue + if provider.last_pull_time is not None: now = datetime.now() days_passed = (now - provider.last_pull_time).days @@ -173,9 +178,7 @@ def pull_data_from_providers( # Function to handle the time_stamp query parameter and parse it -def _get_time_stamp_filter( - time_stamp: Optional[str] = Query(None) -) -> TimeStampFilter: +def _get_time_stamp_filter(time_stamp: Optional[str] = Query(None)) -> TimeStampFilter: if time_stamp: try: # Parse the JSON string @@ -186,6 +189,7 @@ def _get_time_stamp_filter( raise HTTPException(status_code=400, detail="Invalid time_stamp format") return TimeStampFilter() + @router.get( "", description="Get all presets for tenant", @@ -195,7 +199,7 @@ def get_presets( IdentityManagerFactory.get_auth_verifier(["read:preset"]) ), session: Session = Depends(get_session), - time_stamp: TimeStampFilter = Depends(_get_time_stamp_filter) + time_stamp: TimeStampFilter = Depends(_get_time_stamp_filter), ) -> list[PresetDto]: tenant_id = authenticated_entity.tenant_id logger.info(f"Getting all presets {time_stamp}") @@ -224,7 +228,9 @@ def get_presets( # get the number of alerts + noisy alerts for each preset search_engine = SearchEngine(tenant_id=tenant_id) # get the preset metatada - presets_dto = search_engine.search_preset_alerts(presets=presets_dto, time_stamp=time_stamp) + presets_dto = search_engine.search_preset_alerts( + presets=presets_dto, time_stamp=time_stamp + ) return presets_dto diff --git a/keep/api/routes/providers.py b/keep/api/routes/providers.py index e30f7c349..e746ce839 100644 --- a/keep/api/routes/providers.py +++ b/keep/api/routes/providers.py @@ -490,6 +490,7 @@ async def install_provider( provider_id = provider_info.pop("provider_id") provider_name = provider_info.pop("provider_name") provider_type = provider_info.pop("provider_type", None) or provider_id + pulling_enabled = provider_info.pop("pulling_enabled", True) except KeyError as e: raise HTTPException( status_code=400, detail=f"Missing required field: {e.args[0]}" @@ -507,6 +508,7 @@ async def install_provider( provider_name, provider_type, provider_info, + pulling_enabled=pulling_enabled, ) return JSONResponse(status_code=200, content=result) except HTTPException as e: diff --git a/keep/providers/providers_factory.py b/keep/providers/providers_factory.py index 079467200..fb5d9bdd6 100644 --- a/keep/providers/providers_factory.py +++ b/keep/providers/providers_factory.py @@ -411,6 +411,7 @@ def get_installed_providers( provider_copy.installation_time = p.installation_time provider_copy.last_pull_time = p.last_pull_time provider_copy.provisioned = p.provisioned + provider_copy.pulling_enabled = p.pulling_enabled try: provider_auth = {"name": p.name} if include_details: diff --git a/keep/providers/providers_service.py b/keep/providers/providers_service.py index dad1d8d37..453a2ebcf 100644 --- a/keep/providers/providers_service.py +++ b/keep/providers/providers_service.py @@ -47,6 +47,7 @@ def install_provider( provider_config: Dict[str, Any], provisioned: bool = False, validate_scopes: bool = True, + pulling_enabled: bool = True, ) -> Dict[str, Any]: provider_unique_id = uuid.uuid4().hex logger.info( @@ -95,6 +96,7 @@ def install_provider( validatedScopes=validated_scopes, consumer=provider.is_consumer, provisioned=provisioned, + pulling_enabled=pulling_enabled, ) try: session.add(provider_model) @@ -148,6 +150,8 @@ def update_provider( if provider.provisioned: raise HTTPException(403, detail="Cannot update a provisioned provider") + pulling_enabled = provider_info.pop("pulling_enabled", True) + provider_config = { "authentication": provider_info, "name": provider.name, @@ -171,6 +175,7 @@ def update_provider( provider.installed_by = updated_by provider.validatedScopes = validated_scopes + provider.pulling_enabled = pulling_enabled session.commit() return {