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 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 ? ( + user avatar + ) : ( + + + {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) => { - {image ? ( - user avatar - ) : ( - - - {getInitials(name ?? email)} - - - )}{" "} + {" "} {name ?? email} diff --git a/keep-ui/utils/hooks/useAlerts.ts b/keep-ui/utils/hooks/useAlerts.ts index 27363129c..27ce9555e 100644 --- a/keep-ui/utils/hooks/useAlerts.ts +++ b/keep-ui/utils/hooks/useAlerts.ts @@ -90,7 +90,10 @@ export const useAlerts = () => { const useMultipleFingerprintsAlertAudit = ( fingerprints: string[] | undefined, - options: SWRConfiguration = { revalidateOnFocus: true } + options: SWRConfiguration = { + revalidateOnFocus: true, + revalidateOnMount: false, + } ) => { return useSWR( () => @@ -112,7 +115,9 @@ export const useAlerts = () => { const useAlertAudit = ( fingerprint: string, - options: SWRConfiguration = { revalidateOnFocus: false } + options: SWRConfiguration = { + revalidateOnFocus: false, + } ) => { return useSWR( () => diff --git a/keep-ui/utils/hooks/useDashboards.ts b/keep-ui/utils/hooks/useDashboards.ts index 9db0bbaf3..7f3c81245 100644 --- a/keep-ui/utils/hooks/useDashboards.ts +++ b/keep-ui/utils/hooks/useDashboards.ts @@ -15,7 +15,10 @@ export const useDashboards = () => { const { data, error, mutate } = useSWR( session ? `${apiUrl}/dashboard` : null, - (url: string) => fetcher(url, session!.accessToken) + (url: string) => fetcher(url, session!.accessToken), + { + revalidateOnFocus: false, + } ); return { diff --git a/keep-ui/utils/hooks/useIncidents.ts b/keep-ui/utils/hooks/useIncidents.ts index f4c781303..b3ba58a26 100644 --- a/keep-ui/utils/hooks/useIncidents.ts +++ b/keep-ui/utils/hooks/useIncidents.ts @@ -11,6 +11,7 @@ import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; import { useWebsocket } from "./usePusher"; import { useCallback, useEffect } from "react"; +import { useAlerts } from "./useAlerts"; interface IncidentUpdatePayload { incident_id: string | null; @@ -134,6 +135,24 @@ export const useIncidentWorkflowExecutions = ( ); }; +export const usePollIncidentComments = (incidentId: string) => { + const { bind, unbind } = useWebsocket(); + const { useAlertAudit } = useAlerts(); + const { mutate: mutateIncidentActivity } = useAlertAudit(incidentId); + const handleIncoming = useCallback( + (data: IncidentUpdatePayload) => { + mutateIncidentActivity(); + }, + [mutateIncidentActivity] + ); + useEffect(() => { + bind("incident-comment", handleIncoming); + return () => { + unbind("incident-comment", handleIncoming); + }; + }, [bind, unbind, handleIncoming]); +}; + export const usePollIncidentAlerts = (incidentId: string) => { const { bind, unbind } = useWebsocket(); const { mutate } = useIncidentAlerts(incidentId); diff --git a/keep-ui/utils/hooks/useWorkflowExecutions.ts b/keep-ui/utils/hooks/useWorkflowExecutions.ts index 988cdd1d5..2763ff3aa 100644 --- a/keep-ui/utils/hooks/useWorkflowExecutions.ts +++ b/keep-ui/utils/hooks/useWorkflowExecutions.ts @@ -9,7 +9,11 @@ import useSWR, { SWRConfiguration } from "swr"; import { useApiUrl } from "./useConfig"; import { fetcher } from "utils/fetcher"; -export const useWorkflowExecutions = (options?: SWRConfiguration) => { +export const useWorkflowExecutions = ( + options: SWRConfiguration = { + revalidateOnFocus: false, + } +) => { const apiUrl = useApiUrl(); const { data: session } = useSession(); diff --git a/keep/api/core/db.py b/keep/api/core/db.py index fdb6b30bb..0d1c0e3c5 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -12,7 +12,7 @@ from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Tuple, Union, Callable +from typing import Any, Callable, Dict, List, Tuple, Union from uuid import uuid4 import numpy as np @@ -25,13 +25,13 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.orm import joinedload, selectinload, subqueryload -from sqlalchemy.sql import expression, exists +from sqlalchemy.sql import exists, expression from sqlmodel import Session, col, or_, select, text from keep.api.core.db_utils import create_db_engine, get_json_extract_field # This import is required to create the tables -from keep.api.models.alert import IncidentDtoIn, IncidentSorting, AlertStatus +from keep.api.models.alert import AlertStatus, IncidentDtoIn, IncidentSorting from keep.api.models.db.action import Action from keep.api.models.db.alert import * # pylint: disable=unused-wildcard-import from keep.api.models.db.dashboard import * # pylint: disable=unused-wildcard-import @@ -62,9 +62,10 @@ "severity", "sources", "affected_services", - "assignee" + "assignee", ] + @contextmanager def existed_or_new_session(session: Optional[Session] = None) -> Session: if session: @@ -810,6 +811,27 @@ def get_last_workflow_executions(tenant_id: str, limit=20): return execution_with_logs +def add_audit( + tenant_id: str, + fingerprint: str, + user_id: str, + action: AlertActionType, + description: str, +) -> AlertAudit: + with Session(engine) as session: + audit = AlertAudit( + tenant_id=tenant_id, + fingerprint=fingerprint, + user_id=user_id, + action=action.value, + description=description, + ) + session.add(audit) + session.commit() + session.refresh(audit) + return audit + + def _enrich_alert( session, tenant_id, @@ -1504,7 +1526,7 @@ def create_rule( grouping_criteria=None, group_description=None, require_approve=False, - resolve_on=ResolveOn.NEVER.value + resolve_on=ResolveOn.NEVER.value, ): grouping_criteria = grouping_criteria or [] with Session(engine) as session: @@ -2373,13 +2395,12 @@ def update_preset_options(tenant_id: str, preset_id: str, options: dict) -> Pres def assign_alert_to_incident( - alert_id: UUID | str, - incident: Incident, - tenant_id: str, - session: Optional[Session]=None): - return add_alerts_to_incident( - tenant_id, incident, [alert_id], session=session - ) + alert_id: UUID | str, + incident: Incident, + tenant_id: str, + session: Optional[Session] = None, +): + return add_alerts_to_incident(tenant_id, incident, [alert_id], session=session) def is_alert_assigned_to_incident( @@ -2506,21 +2527,26 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: if session.bind.dialect.name == "sqlite": sources_join = func.json_each(Incident.sources).table_valued("value") - affected_services_join = func.json_each(Incident.affected_services).table_valued("value") + affected_services_join = func.json_each( + Incident.affected_services + ).table_valued("value") query = ( select( - func.json_group_array(col(Incident.assignee).distinct()).label("assignees"), - func.json_group_array(sources_join.c.value.distinct()).label("sources"), - func.json_group_array(affected_services_join.c.value.distinct()).label("affected_services"), + func.json_group_array(col(Incident.assignee).distinct()).label( + "assignees" + ), + func.json_group_array(sources_join.c.value.distinct()).label( + "sources" + ), + func.json_group_array( + affected_services_join.c.value.distinct() + ).label("affected_services"), ) .select_from(Incident) .outerjoin(sources_join, True) .outerjoin(affected_services_join, True) - .filter( - Incident.tenant_id == tenant_id, - Incident.is_confirmed == True - ) + .filter(Incident.tenant_id == tenant_id, Incident.is_confirmed == True) ) results = session.exec(query).one_or_none() @@ -2535,22 +2561,27 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: elif session.bind.dialect.name == "mysql": - sources_join = func.json_table(Incident.sources, Column('value', String(127))).table_valued("value") - affected_services_join = func.json_table(Incident.affected_services, Column('value', String(127))).table_valued("value") + sources_join = func.json_table( + Incident.sources, Column("value", String(127)) + ).table_valued("value") + affected_services_join = func.json_table( + Incident.affected_services, Column("value", String(127)) + ).table_valued("value") query = ( select( - func.group_concat(col(Incident.assignee).distinct()).label("assignees"), + func.group_concat(col(Incident.assignee).distinct()).label( + "assignees" + ), func.group_concat(sources_join.c.value.distinct()).label("sources"), - func.group_concat(affected_services_join.c.value.distinct()).label("affected_services"), + func.group_concat(affected_services_join.c.value.distinct()).label( + "affected_services" + ), ) .select_from(Incident) .outerjoin(sources_join, True) .outerjoin(affected_services_join, True) - .filter( - Incident.tenant_id == tenant_id, - Incident.is_confirmed == True - ) + .filter(Incident.tenant_id == tenant_id, Incident.is_confirmed == True) ) results = session.exec(query).one_or_none() @@ -2561,26 +2592,33 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: return { "assignees": results.assignees.split(",") if results.assignees else [], "sources": results.sources.split(",") if results.sources else [], - "services": results.affected_services.split(",") if results.affected_services else [], + "services": ( + results.affected_services.split(",") + if results.affected_services + else [] + ), } elif session.bind.dialect.name == "postgresql": - sources_join = func.json_array_elements_text(Incident.sources).table_valued("value") - affected_services_join = func.json_array_elements_text(Incident.affected_services).table_valued("value") + sources_join = func.json_array_elements_text(Incident.sources).table_valued( + "value" + ) + affected_services_join = func.json_array_elements_text( + Incident.affected_services + ).table_valued("value") query = ( select( func.json_agg(col(Incident.assignee).distinct()).label("assignees"), func.json_agg(sources_join.c.value.distinct()).label("sources"), - func.json_agg(affected_services_join.c.value.distinct()).label("affected_services"), + func.json_agg(affected_services_join.c.value.distinct()).label( + "affected_services" + ), ) .select_from(Incident) .outerjoin(sources_join, True) .outerjoin(affected_services_join, True) - .filter( - Incident.tenant_id == tenant_id, - Incident.is_confirmed == True - ) + .filter(Incident.tenant_id == tenant_id, Incident.is_confirmed == True) ) results = session.exec(query).one_or_none() @@ -2594,6 +2632,7 @@ def get_incidents_meta_for_tenant(tenant_id: str) -> dict: } return {} + def apply_incident_filters(session: Session, filters: dict, query): for field_name, value in filters.items(): if field_name in ALLOWED_INCIDENT_FILTERS: @@ -2609,31 +2648,22 @@ def apply_incident_filters(session: Session, filters: dict, query): else: field = getattr(Incident, field_name) if isinstance(value, list): - query = query.filter( - col(field).in_(value) - ) + query = query.filter(col(field).in_(value)) else: - query = query.filter( - col(field) == value - ) + query = query.filter(col(field) == value) return query + def filter_query(session: Session, query, field, value): if session.bind.dialect.name in ["mysql", "postgresql"]: if isinstance(value, list): if session.bind.dialect.name == "mysql": - query = query.filter( - func.json_overlaps(field, func.json_array(value)) - ) + query = query.filter(func.json_overlaps(field, func.json_array(value))) else: - query = query.filter( - col(field).op('?|')(func.array(value)) - ) + query = query.filter(col(field).op("?|")(func.array(value))) else: - query = query.filter( - func.json_contains(field, value) - ) + query = query.filter(func.json_contains(field, value)) elif session.bind.dialect.name == "sqlite": json_each_alias = func.json_each(field).table_valued("value") @@ -2646,6 +2676,7 @@ def filter_query(session: Session, query, field, value): query = query.filter(subquery.exists()) return query + def get_last_incidents( tenant_id: str, limit: int = 25, @@ -2778,7 +2809,9 @@ def update_incident_from_dto_by_id( incident.user_generated_name = updated_incident_dto.user_generated_name incident.assignee = updated_incident_dto.assignee - incident.same_incident_in_the_past_id = updated_incident_dto.same_incident_in_the_past_id + incident.same_incident_in_the_past_id = ( + updated_incident_dto.same_incident_in_the_past_id + ) if generated_by_ai: incident.generated_summary = updated_incident_dto.user_summary @@ -2868,7 +2901,6 @@ def get_incident_alerts_and_links_by_incident_id( return query.all(), total_count - def get_incident_alerts_by_incident_id(*args, **kwargs) -> tuple[List[Alert], int]: """ Unpacking (List[(Alert, AlertToIncident)], int) to (List[Alert], int). @@ -2884,12 +2916,10 @@ def get_future_incidents_by_incident_id( offset: Optional[int] = None, ) -> tuple[List[Incident], int]: with Session(engine) as session: - query = ( - session.query( - Incident, - ).filter(Incident.same_incident_in_the_past_id == incident_id) - ) - + query = session.query( + Incident, + ).filter(Incident.same_incident_in_the_past_id == incident_id) + if limit: query = query.limit(limit) if offset: @@ -3025,7 +3055,9 @@ def add_alerts_to_incident( if not new_alert_ids: return incident - alerts_data_for_incident = get_alerts_data_for_incident(new_alert_ids, session) + alerts_data_for_incident = get_alerts_data_for_incident( + new_alert_ids, session + ) incident.sources = list( set(incident.sources if incident.sources else []) | set(alerts_data_for_incident["sources"]) @@ -3115,7 +3147,7 @@ def get_last_alerts_for_incidents( def remove_alerts_to_incident_by_incident_id( - tenant_id: str, incident_id: str | UUID, alert_ids: List[UUID] + tenant_id: str, incident_id: str | UUID, alert_ids: List[UUID] ) -> Optional[int]: with Session(engine) as session: incident = session.exec( @@ -3600,14 +3632,18 @@ def get_workflow_executions_for_incident_or_alert( results = session.execute(final_query).all() return results, total_count -def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Session] = None) -> bool: +def is_all_incident_alerts_resolved( + incident: Incident, session: Optional[Session] = None +) -> bool: if incident.alerts_count == 0: return False with existed_or_new_session(session) as session: - enriched_status_field = get_json_extract_field(session, AlertEnrichment.enrichments, "status") + enriched_status_field = get_json_extract_field( + session, AlertEnrichment.enrichments, "status" + ) status_field = get_json_extract_field(session, Alert.event, "status") subquery = ( @@ -3616,7 +3652,9 @@ def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Sessio status_field.label("status"), ) .select_from(Alert) - .outerjoin(AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint) + .outerjoin( + AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint + ) .join(AlertToIncident, AlertToIncident.alert_id == Alert.id) .where( AlertToIncident.deleted_at == NULL_FOR_DELETED_AT, @@ -3638,8 +3676,8 @@ def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Sessio subquery.c.enriched_status != AlertStatus.RESOLVED.value, and_( subquery.c.enriched_status.is_(None), - subquery.c.status != AlertStatus.RESOLVED.value - ) + subquery.c.status != AlertStatus.RESOLVED.value, + ), ) ) ) @@ -3648,46 +3686,52 @@ def is_all_incident_alerts_resolved(incident: Incident, session: Optional[Sessio return not not_resolved_exists -def is_last_incident_alert_resolved(incident: Incident, session: Optional[Session] = None) -> bool: +def is_last_incident_alert_resolved( + incident: Incident, session: Optional[Session] = None +) -> bool: return is_edge_incident_alert_resolved(incident, func.max, session) -def is_first_incident_alert_resolved(incident: Incident, session: Optional[Session] = None) -> bool: +def is_first_incident_alert_resolved( + incident: Incident, session: Optional[Session] = None +) -> bool: return is_edge_incident_alert_resolved(incident, func.min, session) -def is_edge_incident_alert_resolved(incident: Incident, direction: Callable, session: Optional[Session] = None) -> bool: +def is_edge_incident_alert_resolved( + incident: Incident, direction: Callable, session: Optional[Session] = None +) -> bool: if incident.alerts_count == 0: return False with existed_or_new_session(session) as session: - enriched_status_field = get_json_extract_field(session, AlertEnrichment.enrichments, "status") + enriched_status_field = get_json_extract_field( + session, AlertEnrichment.enrichments, "status" + ) status_field = get_json_extract_field(session, Alert.event, "status") finerprint, enriched_status, status = session.exec( - select( - Alert.fingerprint, - enriched_status_field, - status_field - ) + select(Alert.fingerprint, enriched_status_field, status_field) .select_from(Alert) - .outerjoin(AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint) + .outerjoin( + AlertEnrichment, Alert.fingerprint == AlertEnrichment.alert_fingerprint + ) .join(AlertToIncident, AlertToIncident.alert_id == Alert.id) .where( - AlertToIncident.deleted_at == NULL_FOR_DELETED_AT, AlertToIncident.incident_id == incident.id ) .group_by(Alert.fingerprint) .having(func.max(Alert.timestamp)) .order_by(direction(Alert.timestamp)) ).first() - + return ( enriched_status == AlertStatus.RESOLVED.value or (enriched_status is None and status == AlertStatus.RESOLVED.value) ) + def get_alerts_metrics_by_provider( tenant_id: str, start_date: Optional[datetime] = None, diff --git a/keep/api/models/db/alert.py b/keep/api/models/db/alert.py index 171c6f612..b46690734 100644 --- a/keep/api/models/db/alert.py +++ b/keep/api/models/db/alert.py @@ -1,8 +1,7 @@ import enum import logging -from typing import Optional from datetime import datetime -from typing import List +from typing import List, Optional from uuid import UUID, uuid4 from sqlalchemy import ForeignKey, UniqueConstraint @@ -143,14 +142,14 @@ class Incident(SQLModel, table=True): ), ) - same_incident_in_the_past: Optional['Incident'] = Relationship( + same_incident_in_the_past: Optional["Incident"] = Relationship( back_populates="same_incidents_in_the_future", sa_relationship_kwargs=dict( - remote_side='Incident.id', - ) + remote_side="Incident.id", + ), ) - same_incidents_in_the_future: list['Incident'] = Relationship( + same_incidents_in_the_future: list["Incident"] = Relationship( back_populates="same_incident_in_the_past", ) @@ -388,3 +387,4 @@ class AlertActionType(enum.Enum): COMMENT = "a comment was added to the alert" UNCOMMENT = "a comment was removed from the alert" MAINTENANCE = "Alert is in maintenance window" + INCIDENT_COMMENT = "A comment was added to the incident" diff --git a/keep/api/models/db/migrations/versions/2024-10-22-10-38_8438f041ee0e.py b/keep/api/models/db/migrations/versions/2024-10-22-10-38_8438f041ee0e.py new file mode 100644 index 000000000..eae0abfc5 --- /dev/null +++ b/keep/api/models/db/migrations/versions/2024-10-22-10-38_8438f041ee0e.py @@ -0,0 +1,32 @@ +"""add pulling_enabled + +Revision ID: 8438f041ee0e +Revises: 83c1020be97d +Create Date: 2024-10-22 10:38:29.857284 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8438f041ee0e" +down_revision = "83c1020be97d" +branch_labels = None +depends_on = None + + +def upgrade() -> 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/incidents.py b/keep/api/routes/incidents.py index 9160d9e62..bf2921bd8 100644 --- a/keep/api/routes/incidents.py +++ b/keep/api/routes/incidents.py @@ -6,13 +6,15 @@ from datetime import datetime from typing import List -from fastapi import APIRouter, Depends, HTTPException, Query, Response, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from pusher import Pusher from pydantic.types import UUID from keep.api.arq_pool import get_pool from keep.api.core.db import ( add_alerts_to_incident_by_incident_id, + add_audit, + change_incident_status_by_id, confirm_predicted_incident_by_id, create_incident_from_dto, delete_incident_by_id, @@ -21,27 +23,26 @@ get_incident_alerts_and_links_by_incident_id, get_incident_by_id, get_incident_unique_fingerprint_count, + get_incidents_meta_for_tenant, get_last_incidents, get_workflow_executions_for_incident_or_alert, remove_alerts_to_incident_by_incident_id, - change_incident_status_by_id, update_incident_from_dto_by_id, - get_incidents_meta_for_tenant, ) from keep.api.core.dependencies import get_pusher_client from keep.api.core.elastic import ElasticClient from keep.api.models.alert import ( AlertDto, + EnrichAlertRequestBody, IncidentDto, IncidentDtoIn, - IncidentStatusChangeDto, - IncidentStatus, - EnrichAlertRequestBody, - IncidentSorting, - IncidentSeverity, IncidentListFilterParamsDto, + IncidentSeverity, + IncidentSorting, + IncidentStatus, + IncidentStatusChangeDto, ) - +from keep.api.models.db.alert import AlertActionType, AlertAudit from keep.api.routes.alerts import _enrich_alert from keep.api.utils.enrichment_helpers import convert_db_alerts_to_dto_alerts from keep.api.utils.import_ee import mine_incidents_and_create_objects @@ -193,7 +194,6 @@ def get_all_incidents( if affected_services: filters["affected_services"] = affected_services - logger.info( "Fetching incidents from DB", extra={ @@ -437,7 +437,9 @@ def get_future_incidents_for_an_incident( offset=offset, incident_id=incident_id, ) - future_incidents = [IncidentDto.from_db_incident(incident) for incident in db_incidents] + future_incidents = [ + IncidentDto.from_db_incident(incident) for incident in db_incidents + ] logger.info( "Fetched future incidents from DB", extra={ @@ -524,7 +526,9 @@ async def add_alerts_to_incident( limit=len(alert_ids) + incident.alerts_count, ) - enriched_alerts_dto = convert_db_alerts_to_dto_alerts(db_alerts, with_incidents=True) + enriched_alerts_dto = convert_db_alerts_to_dto_alerts( + db_alerts, with_incidents=True + ) logger.info( "Fetched alerts from DB", extra={ @@ -726,3 +730,36 @@ def change_incident_status( new_incident_dto = IncidentDto.from_db_incident(incident) return new_incident_dto + + +@router.post("/{incident_id}/comment", description="Add incident audit activity") +def add_comment( + incident_id: UUID, + change: IncidentStatusChangeDto, + authenticated_entity: AuthenticatedEntity = Depends( + IdentityManagerFactory.get_auth_verifier(["write:incident"]) + ), + pusher_client: Pusher = Depends(get_pusher_client), +) -> AlertAudit: + extra = { + "tenant_id": authenticated_entity.tenant_id, + "commenter": authenticated_entity.email, + "comment": change.comment, + "incident_id": str(incident_id), + } + logger.info("Adding comment to incident", extra=extra) + comment = add_audit( + authenticated_entity.tenant_id, + str(incident_id), + authenticated_entity.email, + AlertActionType.INCIDENT_COMMENT, + change.comment, + ) + + if pusher_client: + pusher_client.trigger( + f"private-{authenticated_entity.tenant_id}", "incident-comment", {} + ) + + logger.info("Added comment to incident", extra=extra) + return comment 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 {