diff --git a/client/src/Components/v1/GenericFallback/index.jsx b/client/src/Components/v1/GenericFallback/index.jsx index 137cdd1da..188c09f37 100644 --- a/client/src/Components/v1/GenericFallback/index.jsx +++ b/client/src/Components/v1/GenericFallback/index.jsx @@ -40,13 +40,13 @@ const GenericFallback = ({ children }) => { [ path: "notifications", icon: , }, + { name: t("menu.checks"), path: "checks", icon: }, { name: t("menu.incidents"), path: "incidents", icon: }, { name: t("menu.statusPages"), path: "status", icon: }, diff --git a/client/src/Pages/v1/Incidents/index.jsx b/client/src/Pages/v1/Incidents/index.jsx index 3c0d7f60f..91cab2a40 100644 --- a/client/src/Pages/v1/Incidents/index.jsx +++ b/client/src/Pages/v1/Incidents/index.jsx @@ -18,13 +18,11 @@ import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; //Constants -const Incidents = () => { +const Checks = () => { // Redux state const { t } = useTranslation(); - const BREADCRUMBS = [ - { name: t("incidentsPageTitle", "Incidents"), path: "/incidents" }, - ]; + const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }]; // Local state const [selectedMonitor, setSelectedMonitor] = useState("0"); @@ -86,8 +84,8 @@ const Incidents = () => { disabled={isLoadingAcknowledge} > {selectedMonitor === "0" - ? t("incidentsPageActionResolveAll") - : t("incidentsPageActionResolveMonitor")} + ? t("checksPageActionResolveAllMonitor") + : t("checksPageActionResolveMonitor")} { ); }; -export default Incidents; +export default Checks; diff --git a/client/src/Pages/v1/Incidents2/Components/ActiveIncidentsPanel/index.jsx b/client/src/Pages/v1/Incidents2/Components/ActiveIncidentsPanel/index.jsx new file mode 100644 index 000000000..b6024ce53 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/ActiveIncidentsPanel/index.jsx @@ -0,0 +1,145 @@ +import PropTypes from "prop-types"; +import { Box, Stack, Typography } from "@mui/material"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; +import Incidents from "@/assets/icons/incidents.svg?react"; +import CheckIcon from "@/assets/icons/check-icon.svg?react"; +import SummaryCard from "../SummaryCard/index.jsx"; + +/** + * ActiveIncidentsPanel Component + * + * Displays a quick overview of currently active incidents. + * Shows only essential information: monitor name, status badge, and duration. + * + * @param {Object} props + * @param {Array} props.incidents - Array of active incidents + * @param {number} props.totalCount - Total count of active incidents (for badge) + * @param {boolean} props.isLoading - Loading state + * @param {Object} props.error - Error object if any + * @param {Function} props.onIncidentClick - Callback when incident is clicked (optional) + */ +const ActiveIncidentsPanel = ({ totalCount = 0, isLoading = false, error = null }) => { + const theme = useTheme(); + const { t } = useTranslation(); + + if (isLoading) { + return ; + } + + if (error) { + return ( + + + {t("incidentsPage.incidentsActivePanelError")} + + + ); + } + + if (!totalCount || totalCount === 0) { + return ( + + + + + + + + {t("incidentsPage.allSystemsAreOperational")} + + + + ); + } + + return ( + + + + + + + + {totalCount} + + + {t("incidentsPage.incidentsActivePanelTitle")} + + + + ); +}; + +ActiveIncidentsPanel.propTypes = { + totalCount: PropTypes.number, + isLoading: PropTypes.bool, + error: PropTypes.object, + onIncidentClick: PropTypes.func, +}; + +export default ActiveIncidentsPanel; diff --git a/client/src/Pages/v1/Incidents2/Components/IncidentTable/index.jsx b/client/src/Pages/v1/Incidents2/Components/IncidentTable/index.jsx new file mode 100644 index 000000000..8cac4e07b --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/IncidentTable/index.jsx @@ -0,0 +1,204 @@ +//Components +import DataTable from "@/Components/v1/Table/index.jsx"; +import TableSkeleton from "@/Components/v1/Table/skeleton.jsx"; +import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; +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"; + +//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"; + +const IncidentTable = ({ + incidents = [], + incidentsCount = 0, + isLoading = false, + networkError = false, + page = 0, + rowsPerPage = 10, + handleChangePage, + handleChangeRowsPerPage, + resolveIncident, + handleUpdateTrigger, +}) => { + const uiTimezone = useSelector((state) => state.ui.timezone); + + const { t } = useTranslation(); + const theme = useTheme(); + + const handleResolveIncident = async (incidentId) => { + try { + await resolveIncident(incidentId); + handleUpdateTrigger(); + } catch (error) { + console.error(t("incidentsPage.errorResolvingIncident"), error); + } + }; + + const headers = [ + { + id: "monitorName", + content: t("incidentsTableMonitorName"), + render: (row) => row.monitorName ?? "N/A", + }, + { + id: "status", + content: t("incidentsTableStatus"), + render: (row) => { + const status = row.status === true ? "down" : "up"; + const statusText = + row.status === true ? t("incidentsPage.active") : t("incidentsPage.resolved"); + return ( + + ); + }, + }, + { + id: "startTime", + content: t("incidentsPage.startTime"), + render: (row) => { + const formattedDate = formatDateWithTz( + row.startTime || row.createdAt, + "YYYY-MM-DD HH:mm:ss A", + uiTimezone + ); + return formattedDate; + }, + }, + { + id: "endTime", + content: t("incidentsPage.endTime"), + render: (row) => { + if (row.endTime) { + return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone); + } + return "-"; + }, + }, + { + id: "resolutionType", + content: t("incidentsPage.resolutionType"), + render: (row) => { + if (row.resolutionType) { + return ( + + {row.resolutionType} + + ); + } + return "-"; + }, + }, + { + id: "statusCode", + content: t("incidentsTableStatusCode"), + render: (row) => , + }, + { + id: "message", + content: t("incidentsTableMessage"), + render: (row) => row.message || "-", + }, + { + id: "action", + content: t("actions"), + render: (row) => { + if (row.status === true) { + return ( + + ); + } else { + return ( + + {t("incidentsPage.incidentsTableResolved")} + + ); + } + }, + }, + ]; + + if (isLoading) return ; + + if (networkError) { + return ( + + + + ); + } + + if (!isLoading && !networkError && incidents?.length === 0) { + return ( + + {t("incidentsTableNoIncidents", "No incidents found")} + + ); + } + + const incidentsData = Array.isArray(incidents) ? incidents : []; + + return ( + <> + + + + ); +}; + +IncidentTable.propTypes = { + incidents: PropTypes.array.isRequired, // Array of incident objects + incidentsCount: PropTypes.number.isRequired, // Total count for pagination + isLoading: PropTypes.bool.isRequired, // Loading state + networkError: PropTypes.bool, // Network error object + page: PropTypes.number.isRequired, // Current page number + rowsPerPage: PropTypes.number.isRequired, // Number of rows per page + handleChangePage: PropTypes.func.isRequired, // Handler for page change + handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change + resolveIncident: PropTypes.func.isRequired, + handleUpdateTrigger: PropTypes.func.isRequired, +}; + +export default IncidentTable; diff --git a/client/src/Pages/v1/Incidents2/Components/IncidentsSummaryPanel/index.jsx b/client/src/Pages/v1/Incidents2/Components/IncidentsSummaryPanel/index.jsx new file mode 100644 index 000000000..43071fa03 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/IncidentsSummaryPanel/index.jsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +// Hooks +import useFetchIncidents from "../../hooks/useFetchIncidents.js"; +import PropTypes from "prop-types"; +// Components +import { Grid } from "@mui/material"; +import ActiveIncidentsPanel from "../ActiveIncidentsPanel/index.jsx"; +import LatestIncidentsPanel from "../LatestIncidentsPanel/index.jsx"; +import StatisticsPanel from "../StatisticsPanel/index.jsx"; + +const IncidentsSummaryPanel = ({ updateTrigger }) => { + const { fetchIncidentSummary } = useFetchIncidents(); + const [summary, setSummary] = useState({ + totalActive: 0, + avgResolutionTimeHours: 0, + total: 0, + topMonitor: null, + totalManualResolutions: 0, + totalAutomaticResolutions: 0, + latestIncidents: [], + }); + + const [isLoadingSummary, setIsLoadingSummary] = useState(false); + const [summaryError, setSummaryError] = useState(null); + + useEffect(() => { + const fetchSummary = async () => { + setIsLoadingSummary(true); + setSummaryError(null); + + try { + const summaryData = await fetchIncidentSummary({ + limit: 3, + }); + + if (summaryData) { + setSummary(summaryData); + } else { + setSummaryError(new Error("Failed to fetch summary")); + } + } catch (error) { + console.error("Error fetching incident summary:", error); + setSummaryError(error); + } finally { + setIsLoadingSummary(false); + } + }; + + fetchSummary(); + }, [fetchIncidentSummary, updateTrigger]); + + return ( + <> + + + + + + + + + + + + + + ); +}; + +IncidentsSummaryPanel.propTypes = { + updateTrigger: PropTypes.bool.isRequired, +}; +export default IncidentsSummaryPanel; diff --git a/client/src/Pages/v1/Incidents2/Components/IncidentsSummaryPanel/skeleton.jsx b/client/src/Pages/v1/Incidents2/Components/IncidentsSummaryPanel/skeleton.jsx new file mode 100644 index 000000000..153cd7c25 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/IncidentsSummaryPanel/skeleton.jsx @@ -0,0 +1,20 @@ +import { Skeleton } from "@mui/material"; +import SummaryCard from "../SummaryCard/index.jsx"; + +/** + * Simple skeleton loader for summary panels + * Reusable for ActiveIncidentsPanel, LatestIncidentsPanel, and StatisticsPanel + */ +const PanelSkeleton = () => { + return ( + + + + ); +}; + +export default PanelSkeleton; diff --git a/client/src/Pages/v1/Incidents2/Components/LatestIncidentsPanel/IncidentItem.jsx b/client/src/Pages/v1/Incidents2/Components/LatestIncidentsPanel/IncidentItem.jsx new file mode 100644 index 000000000..c212c8e52 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/LatestIncidentsPanel/IncidentItem.jsx @@ -0,0 +1,153 @@ +import PropTypes from "prop-types"; +import { Box, Stack, Typography } from "@mui/material"; +import { useTheme } from "@emotion/react"; +import { StatusLabel } from "@/Components/v1/Label/index.jsx"; +import { getHumanReadableDuration } from "@/Utils/timeUtils.js"; +import Monitors from "@/assets/icons/monitors.svg?react"; +import AverageResponseIcon from "@/assets/icons/status-pages.svg?react"; +import { useTranslation } from "react-i18next"; + +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 iconWrapperStyle = { + px: theme.spacing(2), + display: "flex", + justifyContent: "center", + color: theme.palette.primary.contrastTextTertiary, + "& svg path": { + stroke: "currentColor", + }, + }; + + return ( + + + + + + + + + + {t("incidentsPage.incidentItemMonitor")}: + + + {incident.monitorName || t("incidentsPage.unknownMonitor")} + + + + + + + + + + + + {t("incidentsPage.incidentItemStatus")}: + + + + + + + + + + {duration} + + + + + ); +}; + +IncidentItem.propTypes = { + incident: PropTypes.shape({ + _id: PropTypes.string, + id: PropTypes.string, + monitorId: PropTypes.string, + monitorName: PropTypes.string, + status: PropTypes.bool, + startTime: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), + endTime: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), + resolutionType: PropTypes.oneOf(["automatic", "manual"]), + }), + onClick: PropTypes.func, +}; + +export default IncidentItem; diff --git a/client/src/Pages/v1/Incidents2/Components/LatestIncidentsPanel/index.jsx b/client/src/Pages/v1/Incidents2/Components/LatestIncidentsPanel/index.jsx new file mode 100644 index 000000000..21d2330c4 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/LatestIncidentsPanel/index.jsx @@ -0,0 +1,99 @@ +import PropTypes from "prop-types"; +import { Box, Stack, Typography, Divider } from "@mui/material"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; +import IncidentItem from "./IncidentItem.jsx"; +import SummaryCard from "../SummaryCard/index.jsx"; + +/** + * LatestIncidentsPanel Component + * + * Displays a quick overview of recent incidents (both active and resolved). + * Shows only essential information: monitor name, status, duration, and resolution type. + * + * @param {Object} props + * @param {Array} props.incidents - Array of recent incidents + * @param {boolean} props.isLoading - Loading state + * @param {Object} props.error - Error object if any + */ +const LatestIncidentsPanel = ({ incidents = [], isLoading = false, error = null }) => { + const theme = useTheme(); + const { t } = useTranslation(); + + if (isLoading) { + return ; + } + + if (error) { + return ( + + + + {t("incidentsPage.incidentsLatestPanelError")} + + + + ); + } + + return ( + + {!incidents || incidents.length === 0 ? ( + + + {t("incidentsPage.incidentsLatestPanelEmpty")} + + + ) : ( + + {incidents.map((incident, index) => ( + + + {index < incidents.length - 1 && } + + ))} + + )} + + ); +}; + +LatestIncidentsPanel.propTypes = { + incidents: PropTypes.arrayOf( + PropTypes.shape({ + _id: PropTypes.string, + monitorId: PropTypes.string, + monitorName: PropTypes.string, + status: PropTypes.bool, + duration: PropTypes.string, + resolutionType: PropTypes.oneOf(["automatic", "manual"]), + }) + ), + isLoading: PropTypes.bool, + error: PropTypes.object, + onIncidentClick: PropTypes.func, +}; + +export default LatestIncidentsPanel; diff --git a/client/src/Pages/v1/Incidents2/Components/OptionsHeader/index.jsx b/client/src/Pages/v1/Incidents2/Components/OptionsHeader/index.jsx new file mode 100644 index 000000000..3a0ca1985 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/OptionsHeader/index.jsx @@ -0,0 +1,138 @@ +// Components +import { Stack, Typography, Button, ButtonGroup } from "@mui/material"; +import Select from "@/Components/v1/Inputs/Select/index.jsx"; +import PropTypes from "prop-types"; + +//Utils +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; + +const OptionsHeader = ({ + shouldRender, + selectedMonitor = 0, + setSelectedMonitor, + monitors, + filter = "all", + setFilter, + dateRange = "all", + setDateRange, +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : []; + + const filterOptions = [ + { _id: "all", name: t("incidentsPage.incidentsOptionsHeaderFilterAll") }, + { _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") }, + { _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") }, + { _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") }, + ]; + + const stackStyles = { + direction: "row", + alignItems: "center", + gap: theme.spacing(6), + }; + + if (!shouldRender) return; + + return ( + + + + {t("incidentsOptionsHeader")} + + setFilter(e.target.value)} + items={filterOptions} + sx={{ + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastTextSecondary, + }} + /> + + + + {t("incidentsOptionsHeaderShow")} + + + + + + + + + + ); +}; + +OptionsHeader.propTypes = { + shouldRender: PropTypes.bool, + selectedMonitor: PropTypes.string, + setSelectedMonitor: PropTypes.func, + monitors: PropTypes.object, + filter: PropTypes.string, + setFilter: PropTypes.func, + dateRange: PropTypes.string, + setDateRange: PropTypes.func, +}; + +export default OptionsHeader; diff --git a/client/src/Pages/v1/Incidents2/Components/StatisticsPanel/index.jsx b/client/src/Pages/v1/Incidents2/Components/StatisticsPanel/index.jsx new file mode 100644 index 000000000..ede9c619a --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/StatisticsPanel/index.jsx @@ -0,0 +1,234 @@ +import PropTypes from "prop-types"; +import { Box, Stack, Typography } from "@mui/material"; +import { useTheme } from "@emotion/react"; +import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx"; +import { useTranslation } from "react-i18next"; +import { Divider } from "@mui/material"; +import Clock from "@/assets/icons/maintenance.svg?react"; +import Incidents from "@/assets/icons/incidents.svg?react"; +import ResolutionItem from "@/assets/icons/interval-check.svg?react"; +import NotificationIcon from "@/assets/icons/notifications.svg?react"; +import SummaryCard from "../SummaryCard/index.jsx"; + +/** + * StatisticsPanel Component + * + * Displays key metrics and statistics about incidents. + * + * @param {Object} props + * @param {Object} props.statistics - Statistics data object + * @param {number} props.statistics.total - Total incidents count + * @param {number} props.statistics.totalTrend - Trend percentage for total (optional) + * @param {string} props.statistics.mttr - Mean Time To Resolve (formatted string, e.g., "2h 15m") + * @param {number} props.statistics.mttrTrend - Trend percentage for MTTR (optional) + * @param {Object} props.statistics.topMonitor - Top monitor with most incidents + * @param {string} props.statistics.topMonitor.name - Monitor name + * @param {number} props.statistics.topMonitor.count - Incident count + * @param {number} props.statistics.topMonitor.percentage - Percentage of total + * @param {string} props.statistics.uptimeAffected - Total downtime (formatted string, e.g., "12h 45m") + * @param {number} props.statistics.availability - Availability percentage + * @param {boolean} props.isLoading - Loading state + * @param {Object} props.error - Error object if any + */ +const StatisticsPanel = ({ isLoading = false, error = null, summary = {} }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const totalResolutions = + (summary?.totalManualResolutions || 0) + (summary?.totalAutomaticResolutions || 0); + const automaticPercentage = + totalResolutions > 0 + ? (summary?.totalAutomaticResolutions / totalResolutions) * 100 + : 0; + + const iconWrapperStyle = { + display: "flex", + justifyContent: "center", + mx: theme.spacing(3), + color: theme.palette.primary.contrastTextTertiary, + "& svg path": { + stroke: "currentColor", + }, + }; + if (isLoading) { + return ; + } + + if (error) { + return ( + + + + {t("incidentsPage.incidentsStatisticsPanelError")} + + + + ); + } + + return ( + + + + + + + + + {t("incidentsPage.totalIncidents")} : {summary.total || 0} + + + + + + + + + + + {t("incidentsPage.mostAffectedMonitor")} :{" "} + {summary.topMonitor?.monitorName || t("incidentsPage.unknownMonitor")} + + + + + + + + + + + {t("incidentsPage.avgResolutionTime")} :{" "} + {summary.avgResolutionTimeHours || 0} {t("incidentsPage.hours")} + + + + + + + + + + + + + + {t("incidentsPage.resolutions")}: {totalResolutions} + + + + + + + + + {summary.totalAutomaticResolutions > 0 && ( + + {t("incidentsPage.automatic")} ({summary.totalAutomaticResolutions}) + + )} + {summary.totalManualResolutions > 0 && ( + + {t("incidentsPage.manual")} ({summary.totalManualResolutions}) + + )} + + + + + ); +}; + +StatisticsPanel.propTypes = { + summary: PropTypes.object, + isLoading: PropTypes.bool, + error: PropTypes.object, +}; + +export default StatisticsPanel; diff --git a/client/src/Pages/v1/Incidents2/Components/SummaryCard/index.jsx b/client/src/Pages/v1/Incidents2/Components/SummaryCard/index.jsx new file mode 100644 index 000000000..cb895dc50 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/Components/SummaryCard/index.jsx @@ -0,0 +1,68 @@ +import { Paper } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import PropTypes from "prop-types"; +import { Box, Stack, Typography, Divider } from "@mui/material"; +const SummaryCard = ({ children, isHighPriority = false, sx = {}, title = null }) => { + const theme = useTheme(); + return ( + + {title && ( + + + + {title} + + + + + )} + + {children} + + + ); +}; +SummaryCard.propTypes = { + children: PropTypes.node, + isHighPriority: PropTypes.bool, + sx: PropTypes.object, + title: PropTypes.string, +}; +export default SummaryCard; diff --git a/client/src/Pages/v1/Incidents2/hooks/useFetchIncidents.js b/client/src/Pages/v1/Incidents2/hooks/useFetchIncidents.js new file mode 100644 index 000000000..a67f6b7ee --- /dev/null +++ b/client/src/Pages/v1/Incidents2/hooks/useFetchIncidents.js @@ -0,0 +1,205 @@ +import { useState, useCallback } from "react"; +import { networkService } from "../../../../main.jsx"; +import { createToast } from "../../../../Utils/toastUtils.jsx"; +import { useTranslation } from "react-i18next"; +/** + * Hook to fetch and manage incidents + * + * Provides multiple functions to fetch incidents with different filters. + * + * @returns {Object} { + * incidents, + * incidentsCount, + * isLoading, + * networkError, + * fetchIncidents, + * fetchActiveIncidents, + * fetchResolvedIncidents, + * fetchIncidentsByTeam, + * fetchIncidentById, + * resolveIncident, + * fetchIncidentSummary + * } + */ +const useFetchIncidents = () => { + const [incidents, setIncidents] = useState(null); + const [incidentsCount, setIncidentsCount] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [networkError, setNetworkError] = useState(false); + const { t } = useTranslation(); + /** + * Generic function to fetch incidents by team with custom config + * + * @param {Object} config - Configuration object + * @param {string} [config.monitorId] - Filter by monitor ID + * @param {boolean} [config.status] - Filter by status (true=active, false=resolved) + * @param {string} [config.resolutionType] - Filter by resolution type (automatic/manual) + * @param {string} [config.sortOrder] - Sort order (asc/desc) + * @param {string} [config.dateRange] - Date range filter + * @param {number} [config.page] - Page number + * @param {number} [config.rowsPerPage] - Rows per page + */ + const fetchIncidentsByTeam = useCallback(async (config = {}) => { + try { + setIsLoading(true); + setNetworkError(false); + + const res = await networkService.getIncidentsByTeam(config); + + setIncidents(res.data?.data?.incidents || []); + setIncidentsCount(res.data?.data?.incidentsCount || 0); + } catch (error) { + setNetworkError(true); + console.error(t("incidentsPage.errorFetchingIncidents"), error); + createToast({ body: error.message || t("incidentsPage.errorFetchingIncidents") }); + } finally { + setIsLoading(false); + } + }, []); + + /** + * Fetch active (open) incidents - Default function + * + * @param {Object} config - Additional configuration + */ + const fetchActiveIncidents = useCallback( + async (config = {}) => { + await fetchIncidentsByTeam({ + status: true, + sortOrder: "desc", + ...config, + }); + }, + [fetchIncidentsByTeam] + ); + + /** + * Fetch resolved incidents + * + * @param {Object} config - Additional configuration + */ + const fetchResolvedIncidents = useCallback( + async (config = {}) => { + await fetchIncidentsByTeam({ + status: false, + sortOrder: "desc", + ...config, + }); + }, + [fetchIncidentsByTeam] + ); + + /** + * Fetch incidents with custom filters (alias for fetchIncidentsByTeam) + * + * @param {Object} config - Configuration object + */ + const fetchIncidents = useCallback( + async (config = {}) => { + await fetchIncidentsByTeam(config); + }, + [fetchIncidentsByTeam] + ); + + /** + * Fetch a single incident by ID + * + * @param {string} incidentId - The ID of the incident to fetch + * @returns {Promise} The incident object or null if not found + */ + const fetchIncidentById = useCallback(async (incidentId) => { + if (!incidentId) { + console.error(t("incidentsPage.noIncidentIdProvided")); + return null; + } + + try { + setIsLoading(true); + setNetworkError(false); + + const res = await networkService.getIncidentById(incidentId); + return res.data?.data || null; + } catch (error) { + setNetworkError(true); + console.error(t("incidentsPage.errorFetchingIncident"), error); + createToast({ body: error.message || t("incidentsPage.errorFetchingIncident") }); + return null; + } finally { + setIsLoading(false); + } + }, []); + + /** + * Resolve an incident manually + * + * @param {string} incidentId - The ID of the incident to resolve + * @param {Object} options - Resolution options + * @param {string} [options.comment] - Optional comment about the resolution + * @param {Function} [onSuccess] - Callback function to call on success + * @param {Function} [onError] - Callback function to call on error + */ + const resolveIncident = useCallback(async (incidentId, options = {}) => { + if (!incidentId) { + console.error(t("incidentsPage.noIncidentIdProvided")); + return; + } + + try { + setIsLoading(true); + setNetworkError(false); + + const res = await networkService.resolveIncidentManually(incidentId, options); + if (res.data?.success) { + createToast({ body: t("incidentsPage.incidentResolvedSuccessfully") }); + } else { + createToast({ body: t("incidentsPage.errorResolvingIncident") }); + } + } catch (error) { + setNetworkError(true); + console.error(t("incidentsPage.errorResolvingIncident"), error); + const errorMessage = + error.response?.data?.message || + error.message || + t("incidentsPage.errorResolvingIncident"); + createToast({ body: errorMessage }); + } finally { + setIsLoading(false); + } + }, []); + + /** + * Fetch incident summary + * + * @param {Object} config - Configuration object + * @param {number} [config.limit=10] - Number of latest incidents to return + * @returns {Promise} The summary object or null if error + */ + const fetchIncidentSummary = useCallback(async (config = {}) => { + try { + const res = await networkService.getIncidentSummary(config); + return res.data?.data || null; + } catch (error) { + console.error(t("incidentsPage.errorFetchingIncidentSummary"), error); + createToast({ + body: error.message || t("incidentsPage.errorFetchingIncidentSummary"), + }); + return null; + } + }, []); + + return { + incidents, + incidentsCount, + isLoading, + networkError, + fetchIncidents, + fetchActiveIncidents, + fetchResolvedIncidents, + fetchIncidentsByTeam, + fetchIncidentById, + resolveIncident, + fetchIncidentSummary, + }; +}; + +export default useFetchIncidents; diff --git a/client/src/Pages/v1/Incidents2/index.jsx b/client/src/Pages/v1/Incidents2/index.jsx new file mode 100644 index 000000000..2983a38e8 --- /dev/null +++ b/client/src/Pages/v1/Incidents2/index.jsx @@ -0,0 +1,150 @@ +// Components +import { Stack } from "@mui/material"; +import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; +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"; + +//Utils +import { useTheme } from "@emotion/react"; +import { useState, useEffect } from "react"; +import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx"; +import { useTranslation } from "react-i18next"; + +// Hooks +import useFetchIncidents from "./hooks/useFetchIncidents.js"; +import { useFetchMonitorsByTeamId } from "../../../Hooks/v1/monitorHooks.js"; + +const Incidents2 = () => { + const { t } = useTranslation(); + + const BREADCRUMBS = [ + { name: t("incidentsPageTitle", "Incidents"), path: "/incidents" }, + ]; + + const [selectedMonitor, setSelectedMonitor] = useState("0"); + const [filter, setFilter] = useState("all"); + const [dateRange, setDateRange] = useState("all"); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + const [monitorLookup, setMonitorLookup] = useState(undefined); + const [updateTrigger, setUpdateTrigger] = useState(false); + const handleUpdateTrigger = () => { + setUpdateTrigger((prev) => !prev); + }; + + const [monitors, , isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId( + {} + ); + + const { + incidents, + incidentsCount, + isLoading: isLoadingIncidents, + networkError: incidentsNetworkError, + fetchIncidents, + fetchActiveIncidents, + fetchResolvedIncidents, + resolveIncident, + } = useFetchIncidents(); + + const networkError = monitorsNetworkError || incidentsNetworkError; + + const theme = useTheme(); + + useEffect(() => { + setPage(0); + }, [selectedMonitor, filter, dateRange]); + + useEffect(() => { + const config = { + monitorId: selectedMonitor !== "0" ? selectedMonitor : undefined, + sortOrder: "desc", + dateRange, + page, + rowsPerPage, + }; + + if (filter === "active") { + fetchActiveIncidents(config); + } else if (filter === "resolved") { + fetchResolvedIncidents(config); + } else { + fetchIncidents(config); + } + }, [ + selectedMonitor, + filter, + dateRange, + page, + rowsPerPage, + updateTrigger, + fetchActiveIncidents, + fetchResolvedIncidents, + fetchIncidents, + ]); + + useEffect(() => { + const lookup = monitors?.reduce((acc, monitor) => { + acc[monitor._id] = { + _id: monitor._id, + name: monitor.name, + type: monitor.type, + }; + return acc; + }, {}); + setMonitorLookup(lookup); + }, [monitors]); + + if (networkError) { + return ( + + + + ); + } + + const handleChangePage = (_, newPage) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + return ( + + + + + + + + + + ); +}; + +export default Incidents2; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 45203ca40..255d913c1 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -29,7 +29,8 @@ import InfrastructureDetails from "../Pages/v1/Infrastructure/Details"; import ServerUnreachable from "../Pages/v1/ServerUnreachable.jsx"; // Incidents -import Incidents from "../Pages/v1/Incidents"; +import Checks from "../Pages/v1/Incidents"; +import Incidents2 from "../Pages/v1/Incidents2"; // Status pages import CreateStatus from "../Pages/v1/StatusPage/Create"; @@ -129,9 +130,13 @@ const Routes = () => { path="infrastructure/:monitorId" element={} /> + } + /> } + element={} /> { + const params = new URLSearchParams(); + if (config.monitorId) params.append("monitorId", config.monitorId); + if (config.status !== undefined) params.append("status", config.status); + if (config.resolutionType) params.append("resolutionType", config.resolutionType); + if (config.sortOrder) params.append("sortOrder", config.sortOrder); + if (config.dateRange) params.append("dateRange", config.dateRange); + if (config.page) params.append("page", config.page); + if (config.rowsPerPage) params.append("rowsPerPage", config.rowsPerPage); + return this.axiosInstance.get(`/incidents/team?${params.toString()}`); + }; + + getIncidentById = async (incidentId) => { + return this.axiosInstance.get(`/incidents/${incidentId}`); + }; + + resolveIncidentManually = async (incidentId, options = {}) => { + const body = {}; + if (options.comment) body.comment = options.comment; + return this.axiosInstance.put(`/incidents/${incidentId}/resolve`, body); + }; + + getIncidentSummary = async (config = {}) => { + const params = new URLSearchParams(); + if (config.limit) params.append("limit", config.limit); + const queryString = params.toString(); + return this.axiosInstance.get( + `/incidents/team/summary${queryString ? `?${queryString}` : ""}` + ); + }; } export default NetworkService; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e2e1a4faf..79834584d 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -163,6 +163,7 @@ "incidentsOptionsHeader": "Incidents for:", "incidentsOptionsHeaderFilterBy": "Filter by:", "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterActive": "Active", "incidentsOptionsHeaderFilterDown": "Down", "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", "incidentsOptionsHeaderShow": "Show:", @@ -463,7 +464,8 @@ "team": "Team", "logOut": "Log out", "notifications": "Notifications", - "logs": "Logs" + "logs": "Logs", + "checks": "Checks" }, "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", "state": "State", @@ -472,6 +474,7 @@ "commonSaving": "Saving...", "navControls": "Controls", "incidentsPageTitle": "Incidents", + "checksPageTitle": "Checks", "passwordPanel": { "passwordChangedSuccess": "Your password was changed successfully.", "passwordInputIncorrect": "Your password input was incorrect.", @@ -1062,7 +1065,9 @@ } }, "incidentsPageActionResolveMonitor": "Resolve monitor incidents", + "checksPageActionResolveMonitor": "Resolve monitor checks", "incidentsPageActionResolveAll": "Resolve all incidents", + "checksPageActionResolveAllMonitor": "Resolve all checks", "matchMethodOptions": { "equal": "Equal", "equalPlaceholder": "success", @@ -1136,5 +1141,51 @@ "packetsReceivedRate": "Packets Received Rate", "packetsSent": "Packets Sent", "rate": "Rate", - "selectInterface": "Select Interface" + "selectInterface": "Select Interface", + "incidentsPage": { + "title": "Incidents", + "description": "Manage incidents for your monitors. You can resolve incidents here.", + "resolved": "Resolved", + "active": "Active", + "startTime": "Start Time", + "endTime": "End Time", + "resolutionType": "Resolution Type", + "unresolved": "Unresolved", + "all": "All", + "filterByMonitor": "Filter by monitor", + "filterByStatus": "Filter by status", + "filterByType": "Filter by type", + "filterByDate": "Filter by date", + "filterByPriority": "Filter by priority", + "incidentsActivePanelError": "Error loading active incidents", + "allSystemsAreOperational": "All Systems Are Operational", + "incidentsActivePanelTitle": "Total Active Incidents", + "incidentsLatestPanelError": "Error loading latest incidents", + "incidentsLatestPanelTitle": "Latest Incidents", + "incidentsLatestPanelEmpty": "No recent incidents", + "incidentItemMonitor": "Monitor", + "incidentItemStatus": "Status", + "unknownMonitor": "Unknown Monitor", + "incidentsStatisticsPanelError": "Error loading statistics", + "incidentsStatisticsPanelTitle": "General Statistics", + "totalIncidents": "Total Incidents", + "mostAffectedMonitor": "Most Affected Monitor", + "avgResolutionTime": "Avg. Resolution Time", + "resolutions": "Resolutions", + "automatic": "Automatic", + "manual": "Manual", + "incidentResolvedSuccessfully": "Incident resolved successfully", + "incidentResolvedFailed": "Error resolving incident", + "errorFetchingIncidents": "Error fetching incidents", + "errorFetchingIncident": "Error fetching incident", + "noIncidentIdProvided": "No incident ID provided", + "errorFetchingIncidentSummary": "Error fetching incident summary", + "incidentsOptionsHeaderFilterManual": "Manual", + "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterActive": "Active", + "incidentsOptionsHeaderFilterResolved": "Resolved", + "incidentsTableResolved": "Closed", + "incidentsTableActionResolveManually": "Resolve Manually", + "hours": "hours" + } } diff --git a/server/src/controllers/v1/incidentController.js b/server/src/controllers/v1/incidentController.js index 27bb93ee8..d20e3cdb8 100644 --- a/server/src/controllers/v1/incidentController.js +++ b/server/src/controllers/v1/incidentController.js @@ -77,13 +77,20 @@ class IncidentController extends BaseController { * @function getIncidentSummary * @param {Object} req - Express request object * @param {Object} req.query - Query parameters - * @param {string} [req.query.dateRange] - Date range filter + * @param {number} [req.query.limit=10] - Number of latest incidents to return * @param {Object} req.user - Current authenticated user (from JWT) * @param {string} req.user.teamId - User's team ID * @param {Object} res - Express response object - * @returns {Promise} Success response with incidents summary + * @returns {Promise} Success response with incidents summary containing: + * - totalActive: Number of active incidents + * - avgResolutionTimeHours: Average time to resolve incidents in hours + * - topMonitor: Monitor with most incidents (monitorId, monitorName, incidentCount) + * - total: Total number of incidents + * - totalManualResolutions: Total incidents resolved manually + * - totalAutomaticResolutions: Total incidents resolved automatically + * - latestIncidents: Array of latest incidents created * @example - * GET /incidents/summary?dateRange=week + * GET /incidents/team/summary?limit=5 * // Requires JWT authentication */ getIncidentSummary = this.asyncHandler( diff --git a/server/src/db/v1/modules/incidentModule.js b/server/src/db/v1/modules/incidentModule.js index c007853d4..366e309f6 100644 --- a/server/src/db/v1/modules/incidentModule.js +++ b/server/src/db/v1/modules/incidentModule.js @@ -166,6 +166,38 @@ class IncidentModule { } }; + /** + * Get incidents by team with filtering + * + * STATUS PARAMETER CONTRACT (IMPORTANT - Frontend dependency): + * This method has an implicit contract with frontend's useFetchIncidents hook: + * + * - status === undefined: Frontend uses this to represent "all incidents" (filter === "all") + * Backend applies $or logic: { status: true } OR { status: false, endTime: >= dateRange } + * This shows all active incidents + resolved incidents within the date range + * + * - status === true: Frontend uses for "active incidents" (filter === "active") + * Backend shows all active incidents (no date filter applied) + * Active incidents are currently happening, so date range doesn't apply + * + * - status === false: Frontend uses for "resolved incidents" (filter === "resolved") + * Backend filters by endTime >= dateRange + * Only shows incidents that were resolved within the specified date range + * + * WARNING: Changing this logic will break frontend filtering behavior. + * The frontend depends on undefined status triggering the $or logic for "all incidents". + * This contract must be maintained when modifying either frontend or backend code. + * + * @param {Object} params - Query parameters + * @param {string} params.teamId - Team ID + * @param {string} [params.sortOrder] - Sort order (asc/desc) + * @param {string} [params.dateRange] - Date range filter + * @param {number} [params.page] - Page number + * @param {number} [params.rowsPerPage] - Rows per page + * @param {boolean|undefined} [params.status] - Status filter. undefined = all, true = active, false = resolved + * @param {string} [params.monitorId] - Monitor ID filter + * @param {string} [params.resolutionType] - Resolution type filter + */ getIncidentsByTeam = async ({ teamId, sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType }) => { try { page = Number.isFinite(parseInt(page)) ? parseInt(page) : 0; @@ -185,13 +217,30 @@ class IncidentModule { ...(statusBoolean !== undefined && { status: statusBoolean }), ...(monitorId && { monitorId: new ObjectId(monitorId) }), ...(resolutionType && { resolutionType }), - ...(dateRangeLookup[dateRange] && { - startTime: { - $gte: dateRangeLookup[dateRange], - }, - }), }; + // Date range filter logic (see contract documentation above): + // - Active incidents (statusBoolean === true): always show (no date filter) - they're currently happening + // - Resolved incidents (statusBoolean === false): filter by endTime (when they were resolved) + // - All incidents (statusBoolean === undefined): show all active + resolved in the range using $or + if (dateRangeLookup[dateRange]) { + const dateThreshold = dateRangeLookup[dateRange]; + if (statusBoolean === true) { + // Active incidents: show all active incidents regardless of when they started + // No date filter applied + } else if (statusBoolean === false) { + // Resolved incidents: only show if resolved in the range + matchStage.endTime = { $gte: dateThreshold }; + } else { + // All incidents: show all active + resolved incidents in the range + // This is the critical contract: undefined status triggers $or logic + matchStage.$or = [ + { status: true }, // All active incidents + { status: false, endTime: { $gte: dateThreshold } }, // Resolved in range + ]; + } + } + sortOrder = sortOrder === "asc" ? 1 : -1; const skip = page * rowsPerPage; @@ -199,6 +248,14 @@ class IncidentModule { const incidents = await this.Incident.aggregate([ { $match: matchStage }, { $sort: { startTime: sortOrder } }, + { + $lookup: { + from: "monitors", + localField: "monitorId", + foreignField: "_id", + as: "monitor", + }, + }, { $facet: { summary: [{ $count: "incidentsCount" }], @@ -211,7 +268,27 @@ class IncidentModule { $ifNull: [{ $arrayElemAt: ["$summary.incidentsCount", 0] }, 0], }, incidents: { - $ifNull: ["$incidents", []], + $map: { + input: { $ifNull: ["$incidents", []] }, + as: "incident", + in: { + $mergeObjects: [ + "$$incident", + { + monitorName: { + $let: { + vars: { + monitor: { $arrayElemAt: ["$$incident.monitor", 0] }, + }, + in: { + $ifNull: ["$$monitor.name", null], + }, + }, + }, + }, + ], + }, + }, }, }, }, @@ -225,18 +302,14 @@ class IncidentModule { } }; - getIncidentSummary = async ({ teamId, dateRange }) => { + getIncidentSummary = async ({ teamId, limit = 10 }) => { try { const matchStage = { teamId: new ObjectId(teamId), - ...(dateRangeLookup[dateRange] && { - startTime: { - $gte: dateRangeLookup[dateRange], - }, - }), }; - const summary = await this.Incident.aggregate([ + // Get basic counts and resolution types + const countsPipeline = [ { $match: matchStage }, { $group: { @@ -247,29 +320,135 @@ class IncidentModule { $cond: [{ $eq: ["$resolutionType", "manual"] }, 1, 0], }, }, + automaticResolutions: { + $sum: { + $cond: [{ $eq: ["$resolutionType", "automatic"] }, 1, 0], + }, + }, }, }, - ]); + ]; - const result = { - total: 0, - active: 0, - resolved: 0, - manual: 0, - }; + const counts = await this.Incident.aggregate(countsPipeline); - summary.forEach((item) => { - result.total += item.count; + // Calculate totals + let total = 0; + let active = 0; + let resolved = 0; + let manual = 0; + let automatic = 0; + + counts.forEach((item) => { + total += item.count; if (item._id === true) { - result.active = item.count; + active = item.count; } if (item._id === false) { - result.resolved = item.count; + resolved = item.count; } - result.manual += item.manualResolutions; + manual += item.manualResolutions; + automatic += item.automaticResolutions; }); - return result; + // Calculate average resolution time (in milliseconds) + const resolutionTimePipeline = [ + { $match: { ...matchStage, status: false, endTime: { $exists: true, $ne: null } } }, + { + $project: { + resolutionTime: { + $subtract: ["$endTime", "$startTime"], + }, + }, + }, + { + $group: { + _id: null, + avgResolutionTime: { $avg: "$resolutionTime" }, + }, + }, + ]; + + const resolutionTimeResult = await this.Incident.aggregate(resolutionTimePipeline); + const avgResolutionTimeMs = resolutionTimeResult[0]?.avgResolutionTime || 0; + const avgResolutionTimeHours = avgResolutionTimeMs / (1000 * 60 * 60); // Convert to hours + + // Get monitor with most incidents + const monitorPipeline = [ + { $match: matchStage }, + { + $group: { + _id: "$monitorId", + count: { $sum: 1 }, + }, + }, + { $sort: { count: -1 } }, + { $limit: 1 }, + { + $lookup: { + from: "monitors", + localField: "_id", + foreignField: "_id", + as: "monitor", + }, + }, + { + $project: { + monitorId: "$_id", + count: 1, + monitorName: { $arrayElemAt: ["$monitor.name", 0] }, + }, + }, + ]; + + const monitorResult = await this.Incident.aggregate(monitorPipeline); + const topMonitor = monitorResult[0] || null; + + // Get latest incidents + const latestIncidentsPipeline = [ + { $match: matchStage }, + { $sort: { createdAt: -1 } }, + { $limit: Math.max(1, parseInt(limit) || 10) }, + { + $lookup: { + from: "monitors", + localField: "monitorId", + foreignField: "_id", + as: "monitor", + }, + }, + { + $project: { + _id: 1, + monitorId: 1, + monitorName: { $arrayElemAt: ["$monitor.name", 0] }, + status: 1, + startTime: 1, + endTime: 1, + resolutionType: 1, + message: 1, + statusCode: 1, + createdAt: 1, + }, + }, + ]; + + const latestIncidents = await this.Incident.aggregate(latestIncidentsPipeline); + + return { + totalActive: active, + avgResolutionTimeHours: Math.round(avgResolutionTimeHours * 100) / 100, // Round to 2 decimal places + topMonitor: topMonitor + ? { + monitorId: topMonitor.monitorId, + monitorName: topMonitor.monitorName, + incidentCount: topMonitor.count, + } + : null, + total: total, + totalManualResolutions: manual, + totalAutomaticResolutions: automatic, + latestIncidents: latestIncidents, + }; } catch (error) { error.service = SERVICE_NAME; error.method = "getIncidentSummary"; diff --git a/server/src/service/v1/business/incidentService.js b/server/src/service/v1/business/incidentService.js index a472dbd57..71e60d279 100644 --- a/server/src/service/v1/business/incidentService.js +++ b/server/src/service/v1/business/incidentService.js @@ -215,11 +215,11 @@ class IncidentService { throw this.errorService.createBadRequestError("No team ID in request"); } - const { dateRange } = query || {}; + const { limit } = query || {}; const summary = await this.db.incidentModule.getIncidentSummary({ teamId, - dateRange, + limit, }); return summary;