diff --git a/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx b/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx new file mode 100644 index 000000000..e4dad7c3e --- /dev/null +++ b/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx @@ -0,0 +1,404 @@ +// Components +import { Stack, Typography, Grid, Box, Button, Divider } from "@mui/material"; +import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx"; +import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; +import SummaryCard from "../SummaryCard/index.jsx"; +import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx"; + +// Utils +import { useEffect, useState } from "react"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { formatDateWithTz } from "@/Utils/timeUtils"; +import { useSelector } from "react-redux"; +import PropTypes from "prop-types"; + +// Hooks +import useFetchIncidents from "../../hooks/useFetchIncidents.js"; +import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration.js"; + +const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { fetchIncidentById, resolveIncident } = useFetchIncidents(); + const [incident, setIncident] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false); + const uiTimezone = useSelector((state) => state.ui.timezone); + const isActive = incident?.status === true; + const duration = useGetIncidentsDuration(incident || null, isActive); + + const statusColor = isActive ? theme.palette.error.main : theme.palette.success.main; + const toCapitalLetter = (text) => + typeof text === "string" && text.length > 0 + ? text.charAt(0).toUpperCase() + text.slice(1) + : text; + + const KeyValueRow = ({ label, value, sx = {} }) => ( + + + {label} + + + + {value ?? "-"} + + + + ); + + useEffect(() => { + const loadIncident = async () => { + if (!incidentId || !open) { + setIncident(null); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const incidentData = await fetchIncidentById(incidentId); + setIncident(incidentData); + } catch (error) { + console.error("Error fetching incident:", error); + setIncident(null); + } finally { + setIsLoading(false); + } + }; + + loadIncident(); + }, [incidentId, open, fetchIncidentById]); + + const openResolveDialog = () => { + setIsResolveDialogOpen(true); + }; + + const closeResolveDialog = () => { + setIsResolveDialogOpen(false); + }; + + const handleAfterResolve = async () => { + if (!incidentId) return; + const updatedIncident = await fetchIncidentById(incidentId); + if (updatedIncident) { + setIncident(updatedIncident); + } + }; + + const renderContent = () => { + if (isLoading) { + return ( + + ); + } + + if (!incident) { + return ( + + ); + } + + return ( + + + + + + + + {isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")} + + + + + + {t("incidentsPage.incidentItemMonitor")}: + + + {incident?.monitorId?.name || t("incidentsPage.unknownMonitor")} + + + + + {t("incidentsPage.URL")}: + + + {incident?.monitorId?.url || "-"} + + + + + + + + + + + + + + {t("incidentsPage.timeline")} + + + + + {!isActive && ( + + )} + + + + + + + + + + + {isActive + ? t("incidentsPage.errorDetails") + : t("incidentsPage.resolutionDetails")} + + + + + + + + {incident?.message || "-"} + + + {!isActive && ( + <> + + + + {incident?.resolutionType === "manual" && ( + <> + + + + + )} + + )} + + + + + + + + ); + }; + + return ( + + + {renderContent()} + + {isActive && ( + + )} + + + + + + + ); +}; + +IncidentDetailsModal.propTypes = { + open: PropTypes.bool.isRequired, + incidentId: PropTypes.string, + onClose: PropTypes.func.isRequired, + onResolved: PropTypes.func, +}; + +export default IncidentDetailsModal; diff --git a/client/src/Pages/Incidents/Components/IncidentTable/IncidentActionsMenu.jsx b/client/src/Pages/Incidents/Components/IncidentTable/IncidentActionsMenu.jsx new file mode 100644 index 000000000..28b3a4f52 --- /dev/null +++ b/client/src/Pages/Incidents/Components/IncidentTable/IncidentActionsMenu.jsx @@ -0,0 +1,156 @@ +// Components +import IconButton from "@mui/material/IconButton"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import Icon from "@/Components/v1/Icon/index.jsx"; +import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx"; +import useFetchIncidents from "../../hooks/useFetchIncidents"; + +// Utils +import { useState } from "react"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { TypeToPathMap } from "@/Utils/monitorUtils.js"; +import { useNavigate } from "react-router-dom"; +import PropTypes from "prop-types"; + +const IncidentActionsMenu = ({ incident, onResolve, onOpenDetails }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const theme = useTheme(); + const { t } = useTranslation(); + const { fetchIncidentById } = useFetchIncidents(); + const navigate = useNavigate(); + const openMenu = (event) => { + event.preventDefault(); + event.stopPropagation(); + setAnchorEl(event.currentTarget); + }; + + const closeMenu = (e) => { + if (e) { + e.stopPropagation(); + } + setAnchorEl(null); + }; + + const openResolveDialog = (e) => { + closeMenu(e); + setIsDialogOpen(true); + }; + + const closeDialog = (e) => { + if (e) { + e.stopPropagation(); + } + setIsDialogOpen(false); + }; + + const isActive = incident.status === true; + const handleGoToDetails = (e) => { + e.stopPropagation(); + closeMenu(e); + if (onOpenDetails) { + onOpenDetails(incident.id); + } + }; + + const handleGoToMonitor = async (e) => { + e.stopPropagation(); + closeMenu(e); + try { + const incidentData = await fetchIncidentById(incident.id); + if (incidentData) { + const { type, _id: monitorId } = incidentData.monitorId; + const path = TypeToPathMap[type]; + if (path && monitorId) { + navigate(`/${path}/${monitorId}`); + } else { + console.error(`Monitor type not found`); + } + } else { + console.error("Monitor information not found in incident data"); + } + } catch (error) { + console.error("Error fetching incident details:", error); + } + }; + + return ( + <> + { + event.stopPropagation(); + openMenu(event); + }} + sx={{ + "&:focus": { + outline: "none", + }, + "& svg path": { + stroke: theme.palette.primary.contrastTextTertiary, + }, + }} + > + + + + + + {t("incidentsPage.incidentsTableActionDetails", "Details")} + + + {t("incidentsPage.incidentsTableActionGoToMonitor", "Go to monitor")} + + {isActive && ( + { + e.stopPropagation(); + openResolveDialog(e); + }} + > + {t("incidentsPage.incidentsTableActionResolveManually")} + + )} + + + + ); +}; + +IncidentActionsMenu.propTypes = { + incident: PropTypes.shape({ + id: PropTypes.string.isRequired, + status: PropTypes.bool.isRequired, + }).isRequired, + onResolve: PropTypes.func.isRequired, + onOpenDetails: PropTypes.func, +}; + +export default IncidentActionsMenu; diff --git a/client/src/Pages/Incidents/Components/IncidentTable/index.jsx b/client/src/Pages/Incidents/Components/IncidentTable/index.jsx index 670da4a81..7d23d8ebd 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable/index.jsx +++ b/client/src/Pages/Incidents/Components/IncidentTable/index.jsx @@ -6,13 +6,14 @@ import { StatusLabel } from "@/Components/v1/Label/index.jsx"; import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx"; import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx"; +import IncidentActionsMenu from "./IncidentActionsMenu.jsx"; //Utils import { formatDateWithTz } from "../../../../Utils/timeUtils.js"; import { useSelector } from "react-redux"; import PropTypes from "prop-types"; import { useTranslation } from "react-i18next"; -import { Button, Typography, useTheme } from "@mui/material"; +import { Typography, useTheme } from "@mui/material"; const IncidentTable = ({ monitors = [], @@ -26,15 +27,16 @@ const IncidentTable = ({ handleChangeRowsPerPage, resolveIncident, handleUpdateTrigger, + onOpenDetails, }) => { const uiTimezone = useSelector((state) => state.ui.timezone); const { t } = useTranslation(); const theme = useTheme(); - const handleResolveIncident = async (incidentId) => { + const handleResolveIncident = async (incidentId, options = {}) => { try { - await resolveIncident(incidentId); + await resolveIncident(incidentId, options); handleUpdateTrigger(); } catch (error) { console.error(t("incidentsPage.errorResolvingIncident"), error); @@ -46,7 +48,6 @@ const IncidentTable = ({ id: "monitorName", content: t("incidentsTableMonitorName"), render: (row) => { - console.log(monitors, row); const monitor = monitors.find((monitor) => monitor.id === row.monitorId); return monitor ? monitor.name : "N/A"; }, @@ -125,32 +126,13 @@ const IncidentTable = ({ id: "action", content: t("actions"), render: (row) => { - if (row.status === true) { - return ( - - ); - } else { - return ( - - {t("incidentsPage.incidentsTableResolved")} - - ); - } + return ( + + ); }, }, ]; @@ -204,6 +186,7 @@ IncidentTable.propTypes = { handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change resolveIncident: PropTypes.func.isRequired, handleUpdateTrigger: PropTypes.func.isRequired, + onOpenDetails: PropTypes.func, }; export default IncidentTable; diff --git a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx index 0ecb34791..7c6fb611a 100644 --- a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx +++ b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/IncidentItem.jsx @@ -5,36 +5,13 @@ import { StatusLabel } from "@/Components/v1/Label/index.jsx"; import { getHumanReadableDuration } from "@/Utils/timeUtils.js"; import Icon from "@/Components/v1/Icon"; import { useTranslation } from "react-i18next"; +import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration"; const IncidentItem = ({ incident }) => { const { t } = useTranslation(); const theme = useTheme(); const isActive = incident.status === true; - const calculateDuration = () => { - if (!incident.startTime) { - return "-"; - } - const startTime = new Date(incident.startTime); - const endTime = isActive - ? new Date() - : incident.endTime - ? new Date(incident.endTime) - : null; - - if (!endTime) { - return "-"; - } - - const durationMs = endTime.getTime() - startTime.getTime(); - - if (durationMs < 0) { - return "-"; - } - - return getHumanReadableDuration(durationMs); - }; - - const duration = calculateDuration(); + const duration = useGetIncidentsDuration(incident, isActive); const iconWrapperStyle = { display: "flex", justifyContent: "center", diff --git a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx index d24bcd0ef..ff1ef9417 100644 --- a/client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx +++ b/client/src/Pages/Incidents/Components/LatestIncidentsPanel/index.jsx @@ -84,7 +84,7 @@ const LatestIncidentsPanel = ({ incidents = [], isLoading = false, error = null ) : ( {incidents.map((incident, index) => ( - + {index < incidents.length - 1 && } diff --git a/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx b/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx index 3a0ca1985..8d7439576 100644 --- a/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx +++ b/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx @@ -26,6 +26,7 @@ const OptionsHeader = ({ { _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") }, { _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") }, { _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") }, + { _id: "automatic", name: t("incidentsPage.incidentsOptionsHeaderFilterAutomatic") }, ]; const stackStyles = { diff --git a/client/src/Pages/Incidents/Components/ResolveIncidentDialog/index.jsx b/client/src/Pages/Incidents/Components/ResolveIncidentDialog/index.jsx new file mode 100644 index 000000000..3e868b2dc --- /dev/null +++ b/client/src/Pages/Incidents/Components/ResolveIncidentDialog/index.jsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { Button, Stack } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@emotion/react"; +import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx"; +import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx"; + +const ResolveIncidentDialog = ({ + open, + incidentId, + onClose, + onResolve, + onAfterResolve, + onResolved, + isLoading = false, +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const [comment, setComment] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setComment(""); + } + }, [open]); + + const handleConfirm = async () => { + if (!incidentId) return; + const trimmed = comment.trim(); + try { + setIsSubmitting(true); + await onResolve(incidentId, trimmed ? { comment: trimmed } : {}); + if (onAfterResolve) { + await onAfterResolve(); + } + onResolved?.(); + onClose(); + } catch (error) { + console.error("Error resolving incident:", error); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + setComment(e.target.value)} + maxWidth="100%" + /> + + + + + + + ); +}; + +ResolveIncidentDialog.propTypes = { + open: PropTypes.bool.isRequired, + incidentId: PropTypes.string, + onClose: PropTypes.func.isRequired, + onResolve: PropTypes.func.isRequired, + onAfterResolve: PropTypes.func, + isLoading: PropTypes.bool, + onResolved: PropTypes.func, +}; + +export default ResolveIncidentDialog; diff --git a/client/src/Pages/Incidents/hooks/useFetchIncidents.js b/client/src/Pages/Incidents/hooks/useFetchIncidents.js index 54874bae7..e308c9319 100644 --- a/client/src/Pages/Incidents/hooks/useFetchIncidents.js +++ b/client/src/Pages/Incidents/hooks/useFetchIncidents.js @@ -72,6 +72,16 @@ const useFetchIncidents = () => { [fetchIncidentsByTeam] ); + const fetchIncidentsByResolutionType = useCallback( + async (resolutionType, config = {}) => { + await fetchIncidentsByTeam({ + resolutionType, + sortOrder: "desc", + ...config, + }); + }, + [fetchIncidentsByTeam] + ); /** * Fetch resolved incidents * @@ -198,6 +208,7 @@ const useFetchIncidents = () => { fetchIncidentById, resolveIncident, fetchIncidentSummary, + fetchIncidentsByResolutionType, }; }; diff --git a/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js b/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js new file mode 100644 index 000000000..e9b1da6d1 --- /dev/null +++ b/client/src/Pages/Incidents/hooks/useGetIncidentsDuration.js @@ -0,0 +1,25 @@ +import { getHumanReadableDuration } from "@/Utils/timeUtils.js"; +const useGetIncidentsDuration = (incident, isActive) => { + if (!incident?.startTime) { + return "-"; + } + const startTime = new Date(incident?.startTime); + const endTime = isActive + ? new Date() + : incident?.endTime + ? new Date(incident.endTime) + : null; + + if (!endTime) { + return "-"; + } + + const durationMs = endTime.getTime() - startTime.getTime(); + + if (durationMs < 0) { + return "-"; + } + + return getHumanReadableDuration(durationMs); +}; +export default useGetIncidentsDuration; diff --git a/client/src/Pages/Incidents/index.jsx b/client/src/Pages/Incidents/index.jsx index 4e68279c5..3937512b6 100644 --- a/client/src/Pages/Incidents/index.jsx +++ b/client/src/Pages/Incidents/index.jsx @@ -5,7 +5,7 @@ import GenericFallback from "@/Components/v1/GenericFallback/index.jsx"; import IncidentTable from "./Components/IncidentTable/index.jsx"; import OptionsHeader from "./Components/OptionsHeader/index.jsx"; import IncidentsSummaryPanel from "./Components/IncidentsSummaryPanel/index.jsx"; - +import IncidentDetailsModal from "./Components/IncidentDetailsModal/index.jsx"; //Utils import { useTheme } from "@emotion/react"; import { useState, useEffect } from "react"; @@ -30,6 +30,8 @@ const Incidents2 = () => { const [rowsPerPage, setRowsPerPage] = useState(10); const [monitorLookup, setMonitorLookup] = useState(undefined); const [updateTrigger, setUpdateTrigger] = useState(false); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [selectedIncidentId, setSelectedIncidentId] = useState(null); const handleUpdateTrigger = () => { setUpdateTrigger((prev) => !prev); }; @@ -47,6 +49,7 @@ const Incidents2 = () => { fetchActiveIncidents, fetchResolvedIncidents, resolveIncident, + fetchIncidentsByResolutionType, } = useFetchIncidents(); const networkError = monitorsNetworkError || incidentsNetworkError; @@ -70,6 +73,10 @@ const Incidents2 = () => { fetchActiveIncidents(config); } else if (filter === "resolved") { fetchResolvedIncidents(config); + } else if (filter === "manual") { + fetchIncidentsByResolutionType("manual", config); + } else if (filter === "automatic") { + fetchIncidentsByResolutionType("automatic", config); } else { fetchIncidents(config); } @@ -83,6 +90,7 @@ const Incidents2 = () => { fetchActiveIncidents, fetchResolvedIncidents, fetchIncidents, + fetchIncidentsByResolutionType, ]); useEffect(() => { @@ -113,6 +121,16 @@ const Incidents2 = () => { setRowsPerPage(parseInt(event.target.value, 10)); setPage(0); }; + const handleOpenDetails = (id) => { + if (!id) return; + setSelectedIncidentId(id); + setIsDetailsOpen(true); + }; + + const handleCloseDetails = () => { + setIsDetailsOpen(false); + setSelectedIncidentId(null); + }; return ( @@ -143,6 +161,13 @@ const Incidents2 = () => { handleChangeRowsPerPage={handleChangeRowsPerPage} resolveIncident={resolveIncident} handleUpdateTrigger={handleUpdateTrigger} + onOpenDetails={handleOpenDetails} + /> + ); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 63c4056a6..4f4447b86 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -249,6 +249,7 @@ "addMonitors": "Add monitors", "window": "window", "cancel": "Cancel", + "close": "Close", "message": "Message", "low": "low", "high": "high", @@ -1201,12 +1202,40 @@ "noIncidentIdProvided": "No incident ID provided", "errorFetchingIncidentSummary": "Error fetching incident summary", "incidentsOptionsHeaderFilterManual": "Manual", + "incidentsOptionsHeaderFilterAutomatic": "Automatic", "incidentsOptionsHeaderFilterAll": "All", "incidentsOptionsHeaderFilterActive": "Active", "incidentsOptionsHeaderFilterResolved": "Resolved", "incidentsTableResolved": "Closed", "incidentsTableActionResolveManually": "Resolve Manually", + "incidentsTableActionDetails": "Details", + "incidentsTableActionGoToMonitor": "Go to monitor", + "resolveIncidentDialogTitle": "Resolve Incident", + "resolveIncidentDialogDescription": "Are you sure you want to resolve this incident manually?", + "resolveIncidentDialogCommentLabel": "Comment (optional)", + "resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...", + "resolveIncidentDialogConfirm": "Resolve", "hours": "hours", + "loading": "Loading incident details...", + "incidentNotFound": "Incident not found", + "incidentDetails": "INCIDENT DETAILS", + "activeIncident": "ACTIVE INCIDENT", + "resolvedIncident": "RESOLVED INCIDENT", + "overview": "OVERVIEW", + "URL": "URL", + "incidentAnalysis": "INCIDENT ANALYSIS", + "timeline": "Timeline", + "startedAt": "Started at", + "endedAt": "Ended at", + "downtime": "Downtime", + "errorDetails": "Error details", + "resolutionDetails": "Resolution details", + "statusCode": "Status code", + "resolutionMethod": "Resolution method", + "resolvedBy": "Resolved by", + "comment": "Comment", + "noCommentProvided": "No comment provided", + "loadingIncidentDetails": "Loading incident details...", "none": "None" } }