incidents feature fron-end

This commit is contained in:
karenvicent committed 2025-12-17 18:51:34 -05:00
1 parent fec32380ae
commit fd12ca6e21
19 files changed
+1774 -42

No files matched your search

@@ -48,6 +48,7 @@ const getMenu = (t) => [
path: "notifications",
icon: <Notifications />,
},
{ name: t("menu.checks"), path: "checks", icon: <Docs /> },
{ name: t("menu.incidents"), path: "incidents", icon: <Incidents /> },
{ name: t("menu.statusPages"), path: "status", icon: <StatusPages /> },
+5 -7
View File
@@ -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")}
</Button>
</Box>
<StatusBoxes
@@ -117,4 +115,4 @@ const Incidents = () => {
);
};
export default Incidents;
export default Checks;
@@ -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 <PanelSkeleton />;
}
if (error) {
return (
<SummaryCard>
<Typography
color="error"
align="center"
>
{t("incidentsPage.incidentsActivePanelError")}
</Typography>
</SummaryCard>
);
}
if (!totalCount || totalCount === 0) {
return (
<SummaryCard>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
padding={theme.spacing(10)}
gap={theme.spacing(4)}
sx={{ flex: 1 }}
>
<Box
sx={{
color: theme.palette.success.main,
"& svg": {
width: 60,
height: 60,
"& path": { stroke: "currentColor", strokeWidth: 2 },
},
mb: theme.spacing(2),
}}
>
<CheckIcon />
</Box>
<Typography
variant="h1"
color={theme.palette.primary.contrastTextSecondary}
sx={{
textTransform: "uppercase",
fontWeight: 600,
textAlign: "center",
color: theme.palette.success.lowContrast,
letterSpacing: theme.spacing(0.4),
}}
>
{t("incidentsPage.allSystemsAreOperational")}
</Typography>
</Stack>
</SummaryCard>
);
}
return (
<SummaryCard isHighPriority={true}>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
spacing={theme.spacing(4)}
>
<Box
sx={{
color: theme.palette.error.lowContrast,
padding: theme.spacing(2),
"& svg": {
width: 60,
height: 60,
"& path": { stroke: "currentColor", strokeWidth: 2 },
},
}}
>
<Incidents />
</Box>
<Typography
variant="h1"
sx={{
fontSize: `calc(${theme.typography.h1.fontSize} * 2.5)`,
fontWeight: 700,
color: theme.palette.error.lowContrast,
lineHeight: 1,
}}
>
{totalCount}
</Typography>
<Typography
variant="h2"
sx={{
textTransform: "uppercase",
fontWeight: 700,
letterSpacing: theme.spacing(0.4),
paddingTop: theme.spacing(3),
}}
>
{t("incidentsPage.incidentsActivePanelTitle")}
</Typography>
</Stack>
</SummaryCard>
);
};
ActiveIncidentsPanel.propTypes = {
totalCount: PropTypes.number,
isLoading: PropTypes.bool,
error: PropTypes.object,
onIncidentClick: PropTypes.func,
};
export default ActiveIncidentsPanel;
@@ -0,0 +1,200 @@
//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) => {
await resolveIncident(incidentId);
handleUpdateTrigger();
};
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 (
<StatusLabel
status={status}
text={statusText}
/>
);
},
},
{
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 (
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
color:
row.resolutionType === "manual"
? theme.palette.accent.main
: theme.palette.success.main,
}}
>
{row.resolutionType}
</Typography>
);
}
return "-";
},
},
{
id: "statusCode",
content: t("incidentsTableStatusCode"),
render: (row) => <HttpStatusLabel status={row.statusCode} />,
},
{
id: "message",
content: t("incidentsTableMessage"),
render: (row) => row.message || "-",
},
{
id: "action",
content: t("actions"),
render: (row) => {
if (row.status === true) {
return (
<Button
variant="contained"
color="accent"
sx={{
minHeight: "max-content",
lineHeight: 1.2,
}}
onClick={() => {
handleResolveIncident(row._id);
}}
>
{t("incidentsPage.incidentsTableActionResolveManually")}
</Button>
);
} else {
return (
<Typography
variant="body2"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsPage.incidentsTableResolved")}
</Typography>
);
}
},
},
];
if (isLoading) return <TableSkeleton />;
if (networkError) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
if (!isLoading && incidents == undefined) {
return (
<GenericFallback>
{t("incidentsTableNoIncidents", "No incidents found")}
</GenericFallback>
);
}
const incidentsData = Array.isArray(incidents) ? incidents : [];
return (
<>
<DataTable
headers={headers}
data={incidentsData}
/>
<Pagination
paginationLabel={t("incidentsTablePaginationLabel", "Incidents")}
itemCount={incidentsCount || 0}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</>
);
};
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;
@@ -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 (
<>
<Grid
container
spacing={3}
>
<Grid
item
xs={12}
sm={4}
>
<ActiveIncidentsPanel
totalCount={summary.totalActive || 0}
isLoading={isLoadingSummary}
error={summaryError}
/>
</Grid>
<Grid
item
xs={12}
sm={4}
>
<LatestIncidentsPanel
incidents={summary.latestIncidents || []}
isLoading={isLoadingSummary}
error={summaryError}
/>
</Grid>
<Grid
item
xs={12}
sm={4}
>
<StatisticsPanel
summary={summary}
isLoading={isLoadingSummary}
error={summaryError}
/>
</Grid>
</Grid>
</>
);
};
IncidentsSummaryPanel.propTypes = {
updateTrigger: PropTypes.bool.isRequired,
};
export default IncidentsSummaryPanel;
@@ -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 (
<SummaryCard sx={{ minHeight: "25vh" }}>
<Skeleton
variant="rounded"
width="100%"
height="100%"
/>
</SummaryCard>
);
};
export default PanelSkeleton;
@@ -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 (
<Box
sx={{
cursor: "pointer",
width: "100%",
py: theme.spacing(0.5),
"&:hover": { opacity: 0.8 },
}}
>
<Stack
direction="column"
gap={theme.spacing(1.5)}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(1)}
>
<Box
sx={{
...iconWrapperStyle,
}}
>
<Monitors />
</Box>
<Stack
direction="row"
gap={theme.spacing(4)}
alignItems="baseline"
>
<Typography variant="body1">
{t("incidentsPage.incidentItemMonitor")}:
</Typography>
<Typography
variant="body1"
fontWeight={600}
>
{incident.monitorName || t("incidentsPage.unknownMonitor")}
</Typography>
</Stack>
</Stack>
<Stack
direction="row"
alignItems="center"
width="100%"
marginTop={theme.spacing(1)}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(3)}
>
<Box sx={{ ...iconWrapperStyle }}>
<AverageResponseIcon />
</Box>
<Stack
direction="row"
alignItems="center"
spacing={2}
>
<Typography variant="body1">
{t("incidentsPage.incidentItemStatus")}:
</Typography>
</Stack>
<StatusLabel
status={isActive ? "down" : "up"}
text={isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
customStyles={{
textTransform: "capitalize",
}}
/>
</Stack>
<Box sx={{ flexGrow: 1 }} />
<Typography
variant="body1"
fontWeight={500}
>
{duration}
</Typography>
</Stack>
</Stack>
</Box>
);
};
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;
@@ -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 <PanelSkeleton />;
}
if (error) {
return (
<SummaryCard>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
minHeight: 200,
}}
>
<Typography
color="error"
align="center"
>
{t("incidentsPage.incidentsLatestPanelError")}
</Typography>
</Box>
</SummaryCard>
);
}
return (
<SummaryCard title={t("incidentsPage.incidentsLatestPanelTitle")}>
{!incidents || incidents.length === 0 ? (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexGrow: 1,
}}
>
<Typography
variant="body2"
textAlign="center"
>
{t("incidentsPage.incidentsLatestPanelEmpty")}
</Typography>
</Box>
) : (
<Stack gap={theme.spacing(4)}>
{incidents.map((incident, index) => (
<Box key={incident._id}>
<IncidentItem incident={incident} />
{index < incidents.length - 1 && <Divider sx={{ mt: theme.spacing(2) }} />}
</Box>
))}
</Stack>
)}
</SummaryCard>
);
};
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;
@@ -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 (
<Stack
direction="row"
justifyContent="space-between"
>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeader")}
</Typography>
<Select
id="incidents-select-monitor"
placeholder={t("incidentsOptionsPlaceholderAllServers")}
value={selectedMonitor}
onChange={(e) => setSelectedMonitor(e.target.value)}
items={monitorNames}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
}}
maxWidth={250}
/>
</Stack>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeaderFilterBy")}
</Typography>
<Select
id="incidents-select-filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
items={filterOptions}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
}}
/>
</Stack>
<Stack {...stackStyles}>
<Typography
display="inline-block"
component="h1"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsOptionsHeaderShow")}
</Typography>
<ButtonGroup>
<Button
variant="group"
filled={(dateRange === "hour").toString()}
onClick={() => setDateRange("hour")}
>
{t("incidentsOptionsHeaderLastHour")}
</Button>
<Button
variant="group"
filled={(dateRange === "day").toString()}
onClick={() => setDateRange("day")}
>
{t("incidentsOptionsHeaderLastDay")}
</Button>
<Button
variant="group"
filled={(dateRange === "week").toString()}
onClick={() => setDateRange("week")}
>
{t("incidentsOptionsHeaderLastWeek")}
</Button>
<Button
variant="group"
filled={(dateRange === "all").toString()}
onClick={() => setDateRange("all")}
>
{t("incidentsOptionsHeaderFilterAll")}
</Button>
</ButtonGroup>
</Stack>
</Stack>
);
};
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;
@@ -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 + summary?.totalAutomaticResolutions;
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 <PanelSkeleton />;
}
if (error) {
return (
<SummaryCard>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
minHeight: 200,
}}
>
<Typography
color="error"
align="center"
>
{t("incidentsPage.incidentsStatisticsPanelError")}
</Typography>
</Box>
</SummaryCard>
);
}
return (
<SummaryCard title={t("incidentsPage.incidentsStatisticsPanelTitle")}>
<Stack gap={theme.spacing(4)}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(3)}
>
<Box
sx={{
...iconWrapperStyle,
}}
>
<NotificationIcon />
</Box>
<Box>
<Typography
variant="body1"
sx={{
fontWeight: 500,
lineHeight: 1.2,
}}
>
{t("incidentsPage.totalIncidents")} : {summary.total || 0}
</Typography>
</Box>
</Stack>
<Divider />
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Box sx={iconWrapperStyle}>
<Incidents />
</Box>
<Box>
<Typography
variant="body1"
sx={{
fontWeight: 500,
lineHeight: 1.2,
}}
>
{t("incidentsPage.mostAffectedMonitor")} :{" "}
{summary.topMonitor?.monitorName || t("incidentsPage.unknownMonitor")}
</Typography>
</Box>
</Stack>
<Divider />
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Box sx={iconWrapperStyle}>
<Clock />
</Box>
<Box>
<Typography
variant="body1"
sx={{
fontWeight: 600,
}}
>
{t("incidentsPage.avgResolutionTime")} :{" "}
{summary.avgResolutionTimeHours || 0} {t("incidentsPage.hours")}
</Typography>
</Box>
</Stack>
<Divider />
<Box padding={theme.spacing(2)}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
mb={theme.spacing(1.5)}
>
<Box sx={iconWrapperStyle}>
<ResolutionItem />
</Box>
<Box>
<Typography
variant="body1"
sx={{
fontWeight: 500,
lineHeight: 1.2,
}}
>
{t("incidentsPage.resolutions")}: {totalResolutions}
</Typography>
</Box>
</Stack>
<Box
sx={{
display: "flex",
height: 8,
borderRadius: 4,
overflow: "hidden",
bgcolor: theme.palette.accent.main,
width: "100%",
marginTop: theme.spacing(4),
}}
>
<Box
sx={{
width: `${automaticPercentage}%`,
bgcolor: theme.palette.warningSecondary.lowContrast,
height: "100%",
}}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
paddingTop: theme.spacing(4),
width: "100%",
}}
>
{summary.totalAutomaticResolutions > 0 && (
<Typography
variant="body2"
fontWeight={500}
color={theme.palette.warningSecondary.contrastText}
>
{t("incidentsPage.automatic")} ({summary.totalAutomaticResolutions})
</Typography>
)}
{summary.totalManualResolutions > 0 && (
<Typography
variant="body2"
fontWeight={500}
color={theme.palette.accent.main}
>
{t("incidentsPage.manual")} ({summary.totalManualResolutions})
</Typography>
)}
</Box>
</Box>
</Stack>
</SummaryCard>
);
};
StatisticsPanel.propTypes = {
summary: PropTypes.object,
isLoading: PropTypes.bool,
error: PropTypes.object,
};
export default StatisticsPanel;
@@ -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 (
<Paper
elevation={0}
sx={{
padding: theme.spacing(4),
borderRadius: 3,
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
backgroundColor: theme.palette.primary.main,
border: isHighPriority
? `${theme.spacing(1.5)} solid ${theme.palette.error.lowContrast}`
: `${theme.spacing(1)} solid ${theme.palette.divider}`,
boxShadow: theme.palette.tertiary.cardShadow,
color: theme.palette.primary.contrastTextTertiary,
fontSize: theme.typography.body1.fontSize,
...sx,
}}
>
{title && (
<Box>
<Stack
direction="row"
sx={{
padding: theme.spacing(2),
}}
>
<Typography
variant="h6"
sx={{
textTransform: "uppercase",
fontWeight: 700,
fontSize: theme.typography.h2.fontSize,
letterSpacing: theme.spacing(0.5),
}}
>
{title}
</Typography>
</Stack>
<Divider />
</Box>
)}
<Stack
mt={theme.spacing(4)}
paddingTop={theme.spacing(5)}
gap={theme.spacing(4)}
sx={{ height: "100%" }}
>
{children}
</Stack>
</Paper>
);
};
SummaryCard.propTypes = {
children: PropTypes.node,
isHighPriority: PropTypes.bool,
sx: PropTypes.object,
title: PropTypes.string,
};
export default SummaryCard;
@@ -0,0 +1,196 @@
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 = 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 = async (config = {}) => {
await fetchIncidentsByTeam({
status: true,
sortOrder: "desc",
...config,
});
};
/**
* Fetch resolved incidents
*
* @param {Object} config - Additional configuration
*/
const fetchResolvedIncidents = async (config = {}) => {
await fetchIncidentsByTeam({
status: false,
sortOrder: "desc",
...config,
});
};
/**
* Fetch incidents with custom filters (alias for fetchIncidentsByTeam)
*
* @param {Object} config - Configuration object
*/
const fetchIncidents = async (config = {}) => {
await fetchIncidentsByTeam(config);
};
/**
* Fetch a single incident by ID
*
* @param {string} incidentId - The ID of the incident to fetch
* @returns {Promise<Object|null>} The incident object or null if not found
*/
const fetchIncidentById = async (incidentId) => {
if (!incidentId) {
console.error(t("incidentsPage.noIcidentIdProvided"));
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 = async (incidentId, options = {}) => {
if (!incidentId) {
console.error(t("incidentsPage.noIcidentIdProvided"));
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<Object|null>} 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;
+140
View File
@@ -0,0 +1,140 @@
// 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(undefined);
const [filter, setFilter] = useState(undefined);
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]); // Refetch when filters or pagination change
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 (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
const handleChangePage = (_, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<IncidentsSummaryPanel updateTrigger={updateTrigger} />
<OptionsHeader
shouldRender={!isLoadingMonitors}
monitors={monitorLookup}
selectedMonitor={selectedMonitor}
setSelectedMonitor={setSelectedMonitor}
filter={filter}
setFilter={setFilter}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<IncidentTable
incidents={incidents || []}
incidentsCount={incidentsCount || 0}
isLoading={isLoadingIncidents}
networkError={incidentsNetworkError}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
resolveIncident={resolveIncident}
handleUpdateTrigger={handleUpdateTrigger}
/>
</Stack>
);
};
export default Incidents2;
+7 -2
View File
@@ -30,7 +30,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";
@@ -137,9 +138,13 @@ const Routes = () => {
path="infrastructure/:monitorId"
element={<InfrastructureDetails />}
/>
<Route
path="checks/:monitorId?"
element={<Checks />}
/>
<Route
path="incidents/:monitorId?"
element={<Incidents />}
element={<Incidents2 />}
/>
<Route
+31
View File
@@ -1145,6 +1145,37 @@ class NetworkService {
},
});
}
getIncidentsByTeam = async (config) => {
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;
+53 -2
View File
@@ -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",
"noIcidentIdProvided": "No incident ID provided",
"errorFetchingIncidentSummary": "Error fetching incident summary",
"incidentsOptionsHeaderFilterManual": "Manual",
"incidentsOptionsHeaderFilterAll": "All",
"incidentsOptionsHeaderFilterActive": "Active",
"incidentsOptionsHeaderFilterResolved": "Resolved",
"incidentsTableResolved": "Closed",
"incidentsTableActionResolveManually": "Resolve Manually",
"hours": "hours"
}
}
@@ -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<Object>} Success response with incidents summary
* @returns {Promise<Object>} 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(
+172 -26
View File
@@ -185,13 +185,29 @@ class IncidentModule {
...(statusBoolean !== undefined && { status: statusBoolean }),
...(monitorId && { monitorId: new ObjectId(monitorId) }),
...(resolutionType && { resolutionType }),
...(dateRangeLookup[dateRange] && {
startTime: {
$gte: dateRangeLookup[dateRange],
},
}),
};
// Date range filter:
// - Active incidents: always show (no date filter) - they're currently happening
// - Resolved incidents: filter by endTime (when they were resolved)
// - All incidents: show all active + resolved in the range
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
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 +215,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 +235,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 +269,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 +287,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: parseInt(limit) },
{
$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";
@@ -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;