Merge pull request #3230 from bluewave-labs/feat/v2-incidents

feat: v2 incidents
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-02-02 10:51:29 -08:00
commit 6be1eb0a3e
39 files changed
+1887 -3935

No files matched your search

+554 -1719
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -17,10 +17,10 @@
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "6.0.0-dev.240424162023-9968b4889d",
"@mui/material": "6.4.11",
"@mui/x-charts": "^7.5.1",
"@mui/x-date-pickers": "7.29.0",
"@mui/lab": "7.0.0",
"@mui/material": "7.3.7",
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"axios": "^1.7.4",
"dayjs": "1.11.13",
@@ -30,7 +30,7 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^6.0.0",
"mui-color-input": "^7.0.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
@@ -98,7 +98,7 @@ export const ValueLabel = ({ value, text }: { value: ValueType; text: string })
borderRadius="50%"
marginRight="5px"
/>
{transformedText}
<Typography>{transformedText}</Typography>
</BaseBox>
);
};
@@ -1,5 +1,5 @@
import Stack from "@mui/material/Stack";
import Grid2 from "@mui/material/Grid2";
import Grid2 from "@mui/material/Grid";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
+22 -5
View File
@@ -1,4 +1,5 @@
import Dialog from "@mui/material/Dialog";
import type { DialogProps } from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
@@ -6,6 +7,7 @@ import DialogTitle from "@mui/material/DialogTitle";
import { Button } from "@/Components/v2/inputs";
import { typographyLevels } from "@/Utils/Theme/v2Palette";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
export const DialogInput = ({
open,
@@ -16,6 +18,11 @@ export const DialogInput = ({
confirmColor = "primary",
cancelColor = "error",
loading = false,
cancelText,
confirmText,
children,
maxWidth,
fullWidth = false,
}: {
open: boolean;
title?: string;
@@ -25,13 +32,23 @@ export const DialogInput = ({
confirmColor?: "error" | "primary";
cancelColor?: "error" | "primary";
loading?: boolean;
cancelText?: string;
confirmText?: string;
children?: ReactNode;
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
}) => {
const { t } = useTranslation();
return (
<Dialog open={open}>
<DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>
<Dialog
open={open}
maxWidth={maxWidth}
fullWidth={fullWidth}
>
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
<DialogContent>
<DialogContentText>{content}</DialogContentText>
{content && <DialogContentText>{content}</DialogContentText>}
{children}
</DialogContent>
<DialogActions>
<Button
@@ -40,7 +57,7 @@ export const DialogInput = ({
color={cancelColor}
onClick={onCancel}
>
{t("common.buttons.cancel")}
{cancelText ?? t("common.buttons.cancel")}
</Button>
<Button
loading={loading}
@@ -48,7 +65,7 @@ export const DialogInput = ({
color={confirmColor}
onClick={onConfirm}
>
{t("common.buttons.confirm")}
{confirmText ?? t("common.buttons.confirm")}
</Button>
</DialogActions>
</Dialog>
@@ -8,8 +8,9 @@ import type { TooltipProps } from "recharts";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
interface HistogramPageSpeedScoresTooltipProps
extends Partial<TooltipProps<number, string>> {
interface HistogramPageSpeedScoresTooltipProps extends Partial<
TooltipProps<number, string>
> {
config: Record<string, ConfigItem>;
}
export const HistogramPageSpeedScoresTooltip = ({
@@ -1,134 +0,0 @@
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 Icon from "@/Components/v1/Icon";
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 title={t("incidentsPage.incidentsActivePanelTitle")}>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
padding={theme.spacing(6)}
gap={theme.spacing(2)}
sx={{ flex: 1 }}
>
<Box
sx={{
color: theme.palette.success.main,
"& svg": {
width: 24,
height: 24,
"& path": { stroke: "currentColor", strokeWidth: 2 },
},
mb: theme.spacing(1),
}}
>
<Icon
name="CheckCircle"
size={60}
/>
</Box>
<Typography
sx={{
fontSize: 13,
textTransform: "uppercase",
fontWeight: 500,
textAlign: "center",
color: theme.palette.success.lowContrast,
}}
>
{t("incidentsPage.allSystemsAreOperational")}
</Typography>
</Stack>
</SummaryCard>
);
}
return (
<SummaryCard title={t("incidentsPage.incidentsActivePanelTitle")}>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
gap={theme.spacing(2)}
sx={{ flex: 1 }}
>
<Box
sx={{
color: theme.palette.error.lowContrast,
"& svg": {
width: 24,
height: 24,
"& path": { stroke: "currentColor", strokeWidth: 2 },
},
}}
>
<Icon
name="AlertTriangle"
size={60}
/>
</Box>
<Typography
sx={{
fontSize: 32,
fontWeight: 600,
color: theme.palette.error.lowContrast,
lineHeight: 1,
}}
>
{totalCount}
</Typography>
</Stack>
</SummaryCard>
);
};
ActiveIncidentsPanel.propTypes = {
totalCount: PropTypes.number,
isLoading: PropTypes.bool,
error: PropTypes.object,
onIncidentClick: PropTypes.func,
};
export default ActiveIncidentsPanel;
@@ -0,0 +1,196 @@
import Stack from "@mui/material/Stack";
import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import { BaseBox, ValueLabel } from "@/Components/v2/design-elements";
import { useTranslation } from "react-i18next";
import type { Incident } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
import { useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { getIncidentsDuration } from "@/Pages/Incidents/utils";
interface CardDetailsProps {
incident: Incident | null;
monitor: Monitor | null;
sx?: object;
}
export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
if (!incident) {
return null;
}
console.log(incident);
return (
<Stack
gap={theme.spacing(4)}
sx={sx}
>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.title")}
</Typography>
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.overview")}
</Typography>
<Divider />
<Grid
container
spacing={theme.spacing(4)}
alignItems="center"
>
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
<Grid size={10}>
<ValueLabel
value={incident.status ? "negative" : "positive"}
text={
incident.status
? t("common.labels.active")
: t("common.labels.resolved")
}
/>
</Grid>
{monitor && (
<>
<Grid size={2}>{t("pages.incidents.dialog.details.monitor")}</Grid>
<Grid size={10}>
<Typography>{monitor.name ?? "N/A"}</Typography>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.url")}</Typography>
</Grid>
<Grid size={10}>
<Typography>{monitor.url ?? "N/A"}</Typography>
</Grid>
</>
)}
</Grid>
</Stack>
</BaseBox>
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.analysis")}
</Typography>
<Divider />
<Grid
container
spacing={theme.spacing(2)}
>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
</Grid>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.detailsLabel")}</Typography>
</Grid>
<Grid size={6}>
<Divider></Divider>
</Grid>
<Grid size={6}>
<Divider></Divider>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.startedAt")}</Typography>
</Grid>
<Grid size={4}>
<Typography>
{formatDateWithTz(incident.startTime, "D MMM YYYY, h:mm A", uiTimezone)}
</Typography>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.statusCode")}</Typography>
</Grid>
<Grid size={4}>
<Typography>{incident.statusCode ?? "N/A"}</Typography>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.downtime")}</Typography>
</Grid>
<Grid size={4}>
<Typography>{getIncidentsDuration(incident)}</Typography>
</Grid>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.message")}</Typography>
</Grid>
<Grid size={4}>
<Typography>{incident.message ?? "N/A"}</Typography>
</Grid>
</Grid>
</Stack>
</BaseBox>
{!incident.status && (
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.resolutionDetails")}
</Typography>
<Divider />
<Grid
container
spacing={theme.spacing(2)}
alignItems="center"
>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.resolvedAt")}</Typography>
</Grid>
<Grid size={10}>
<Typography>
{incident.endTime
? formatDateWithTz(incident.endTime, "D MMM YYYY, h:mm A", uiTimezone)
: "N/A"}
</Typography>
</Grid>
<Grid size={2}>
<Typography>
{t("pages.incidents.dialog.details.resolutionType")}
</Typography>
</Grid>
<Grid size={10}>
<Typography>
{incident.resolutionType
? t(
`pages.incidents.dialog.details.resolutionTypes.${incident.resolutionType}`
)
: "N/A"}
</Typography>
</Grid>
{incident.resolvedBy && (
<>
<Grid size={2}>
<Typography>
{t("pages.incidents.dialog.details.resolvedBy")}
</Typography>
</Grid>
<Grid size={10}>
<Typography>
{incident.resolvedByEmail ?? incident.resolvedBy}
</Typography>
</Grid>
</>
)}
{incident.comment && (
<>
<Grid size={2}>
<Typography>{t("pages.incidents.dialog.details.comment")}</Typography>
</Grid>
<Grid size={10}>
<Typography>{incident.comment}</Typography>
</Grid>
</>
)}
</Grid>
</Stack>
</BaseBox>
)}
</Stack>
);
};
@@ -0,0 +1,247 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
import { BaseBox, ValueLabel } from "@/Components/v2/design-elements";
import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react";
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
import { getIncidentsDuration } from "@/Pages/Incidents/utils";
interface SummaryItemProps {
icon: React.ReactNode;
label: string;
value: string | number;
}
const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(2)}
>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
{icon}
<Typography variant="body2">{label}</Typography>
</Stack>
<Typography
variant="body2"
fontWeight={600}
>
{value}
</Typography>
</Stack>
);
};
interface SummaryCardProps {
title: string;
sx?: React.CSSProperties;
}
export const SummaryCard = ({
title,
sx,
children,
}: React.PropsWithChildren<SummaryCardProps>) => {
const theme = useTheme();
return (
<BaseBox
sx={{ ...sx }}
padding={8}
flex={1}
>
<Stack
gap={theme.spacing(4)}
height="100%"
>
<Typography
component="h2"
sx={{
textTransform: "uppercase",
fontWeight: 500,
fontSize: 13,
}}
>
{title}
</Typography>
<Divider />
{children}
</Stack>
</BaseBox>
);
};
interface SummaryCardActiveIncidentsProps {
summary?: IncidentSummary | null;
}
export const SummaryCardActiveIncidents = ({
summary,
}: SummaryCardActiveIncidentsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!summary) return null;
const activeCount = summary.totalActive;
const hasActive = activeCount > 0;
const color = hasActive ? theme.palette.error.main : theme.palette.success.main;
const icon = hasActive ? (
<TriangleAlert color={color} />
) : (
<CircleCheck color={color} />
);
const msg = t("pages.incidents.summaryCard.activeIncidents.active", {
count: activeCount,
});
return (
<SummaryCard
title={t("pages.incidents.summaryCard.activeIncidents.title")}
sx={{ height: "100%" }}
>
<Stack
flex={1}
alignItems="center"
justifyContent="center"
gap={theme.spacing(4)}
>
{icon}
<Typography>{msg}</Typography>
</Stack>
</SummaryCard>
);
};
const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) => {
const theme = useTheme();
const { t } = useTranslation();
const duration = getIncidentsDuration(incident);
return (
<Grid
container
alignItems="center"
spacing={2}
sx={{
width: "100%",
py: theme.spacing(0.5),
}}
>
<Grid
size={{ xs: 12, lg: 5 }}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing(2),
}}
>
<Globe />
<Typography
variant="body1"
fontWeight={500}
noWrap
>
{incident.monitorName ?? t("common.labels.na")}
</Typography>
</Grid>
<Grid
size={{ xs: 12, md: 6, lg: 3 }}
sx={{
display: "flex",
}}
>
<ValueLabel
value={incident.status ? "negative" : "positive"}
text={incident.status ? t("common.labels.active") : t("common.labels.resolved")}
/>
</Grid>
<Grid
size={{ xs: 12, md: 6, lg: 4 }}
sx={{
textAlign: { xs: "left", md: "right" },
fontWeight: 500,
}}
>
<Typography variant="body1">{duration}</Typography>
</Grid>
</Grid>
);
};
interface SummaryCardLatestIncidentsProps {
summary?: IncidentSummary | null;
}
export const SummaryCardLatestIncidents = ({
summary,
}: SummaryCardLatestIncidentsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const latestIncidents = summary?.latestIncidents ?? [];
return (
<SummaryCard title={t("pages.incidents.summaryCard.latestIncidents.title")}>
<Stack gap={theme.spacing(4)}>
{latestIncidents.slice(0, 3).map((incident, index) => (
<Box key={incident.id}>
<SummaryIncidentItem incident={incident} />
{index < latestIncidents.length - 1 && (
<Divider sx={{ mt: theme.spacing(2) }} />
)}
</Box>
))}
</Stack>
</SummaryCard>
);
};
interface SummaryCardStatsProps {
summary?: IncidentSummary | null;
}
export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => {
const { t } = useTranslation();
if (!summary) return null;
const mostAffected =
!summary.total || summary.total === 0
? t("common.labels.na")
: summary.topMonitor?.monitorName || t("common.labels.na");
return (
<SummaryCard title={t("pages.incidents.summaryCard.incidentStats.title")}>
<SummaryItem
icon={<Bell size={18} />}
label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")}
value={summary?.total || 0}
/>
<SummaryItem
icon={<TriangleAlert size={18} />}
label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")}
value={mostAffected}
/>
<SummaryItem
icon={<Wrench size={18} />}
label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")}
value={
summary.total > 0
? `${summary.avgResolutionTimeHours || 0} hours`
: t("common.labels.na")
}
/>
</SummaryCard>
);
};
@@ -0,0 +1,83 @@
import Stack from "@mui/material/Stack";
import { Select, Button } from "@/Components/v2/inputs";
import MenuItem from "@mui/material/MenuItem";
import useMediaQuery from "@mui/material/useMediaQuery";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
const resolutionTypes = ["manual", "automatic"];
interface ControlsIncidentFilterProps {
monitors?: Monitor[];
selectedMonitor: string;
setSelectedMonitor: React.Dispatch<React.SetStateAction<string>>;
selectedResolutionType: string;
setSelectedResolutionType: React.Dispatch<React.SetStateAction<string>>;
onClearFilters: () => void;
}
export const ControlsIncidentFilter = ({
monitors,
selectedMonitor,
setSelectedMonitor,
selectedResolutionType,
setSelectedResolutionType,
onClearFilters,
}: ControlsIncidentFilterProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
selectedMonitor !== "0" ||
(selectedResolutionType !== "" && selectedResolutionType !== "all");
return (
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(2)}
>
<Select
placeholder={t("pages.incidents.filters.monitor")}
value={selectedMonitor}
onChange={(e) => setSelectedMonitor(e.target.value)}
>
<MenuItem value="0">
<Typography>{t("pages.incidents.filters.allMonitors")}</Typography>
</MenuItem>
{monitors?.map((monitor) => (
<MenuItem
key={monitor.id}
value={monitor.id}
>
<Typography>{monitor.name}</Typography>
</MenuItem>
))}
</Select>
<Select
placeholder={t("pages.incidents.filters.resolutionType")}
value={selectedResolutionType}
onChange={(e) => setSelectedResolutionType(e.target.value)}
>
{resolutionTypes.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform="capitalize">
{t(`pages.incidents.filters.resolutionTypes.${type}`)}
</Typography>
</MenuItem>
))}
</Select>
{isFilterActive && (
<Button
variant="contained"
onClick={onClearFilters}
>
{t("pages.incidents.filters.clearFilters")}
</Button>
)}
</Stack>
);
};
@@ -0,0 +1,41 @@
import { Dialog } from "@/Components/v2/inputs";
import { CardDetails } from "@/Pages/Incidents/Components/CardDetails";
import { useTranslation } from "react-i18next";
import type { Incident } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
interface DialogIncidentDetailsProps {
open: boolean;
incident: Incident | null;
monitor: Monitor | null;
onClose: () => void;
onResolve: () => void;
}
export const DialogIncidentDetails = ({
open,
incident,
monitor,
onClose,
onResolve,
}: DialogIncidentDetailsProps) => {
const { t } = useTranslation();
return (
<Dialog
open={open}
onCancel={onClose}
onConfirm={onResolve}
cancelText={t("common.buttons.cancel")}
confirmText={t("pages.incidents.dialog.details.resolve")}
maxWidth="sm"
fullWidth
>
<CardDetails
incident={incident}
monitor={monitor}
/>
</Dialog>
);
};
@@ -0,0 +1,70 @@
import { useState, useEffect } from "react";
import Box from "@mui/material/Box";
import { Dialog, TextField } from "@/Components/v2/inputs";
import { usePut } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface DialogResolutionProps {
open: boolean;
incidentId: string | null;
onClose: () => void;
onResolved?: () => void;
}
export const DialogResolution = ({
open,
incidentId,
onClose,
onResolved,
}: DialogResolutionProps) => {
const { t } = useTranslation();
const theme = useTheme();
const [comment, setComment] = useState("");
const { put: resolveIncident, loading: isResolving } = usePut();
useEffect(() => {
if (open) {
setComment("");
}
}, [open]);
const handleCancel = () => {
onClose();
};
const handleConfirm = async () => {
if (!incidentId) return;
const body = comment.trim() ? { comment: comment.trim() } : {};
const result = await resolveIncident(`/incidents/${incidentId}/resolve`, body);
if (result) {
onClose();
onResolved?.();
}
};
return (
<Dialog
open={open}
title={t("incidentsPage.resolveIncidentDialogTitle")}
onCancel={handleCancel}
onConfirm={handleConfirm}
confirmText={t("incidentsPage.resolveIncidentDialogConfirm")}
confirmColor="error"
cancelColor="primary"
loading={isResolving}
maxWidth="sm"
fullWidth
>
<Box sx={{ mt: theme.spacing(4) }}>
<TextField
fieldLabel={t("incidentsPage.resolveIncidentDialogCommentLabel")}
placeholder={t("incidentsPage.resolveIncidentDialogCommentPlaceholder")}
value={comment}
onChange={(e) => setComment(e.target.value)}
fullWidth
/>
</Box>
</Dialog>
);
};
@@ -1,413 +0,0 @@
// Components
import { Stack, Typography, Grid, Box, Button, Divider } from "@mui/material";
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import SummaryCard from "../SummaryCard/index.jsx";
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
// Utils
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
// Hooks
import useFetchIncidents from "../../hooks/useFetchIncidents.js";
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration.js";
const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => {
const theme = useTheme();
const { t } = useTranslation();
const { fetchIncidentById, resolveIncident } = useFetchIncidents();
const [incidentData, setIncidentData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false);
const uiTimezone = useSelector((state) => state.ui.timezone);
const isActive = incidentData?.incident?.status === true;
const duration = useGetIncidentsDuration(incidentData?.incident || null, isActive);
const statusColor = isActive ? theme.palette.error.main : theme.palette.success.main;
const toCapitalLetter = (text) =>
typeof text === "string" && text.length > 0
? text.charAt(0).toUpperCase() + text.slice(1)
: text;
const KeyValueRow = ({ label, value, sx = {} }) => (
<Grid
container
spacing={2}
alignItems="flex-start"
sx={{ ...sx }}
>
<Grid
item
xs={4}
>
<Typography
variant="body1"
sx={{ whiteSpace: "nowrap" }}
>
{label}
</Typography>
</Grid>
<Grid
item
xs
sx={{ minWidth: 0 }}
>
<Typography
variant="body1"
textAlign="right"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value ?? "-"}
</Typography>
</Grid>
</Grid>
);
useEffect(() => {
const loadIncident = async () => {
if (!incidentId || !open) {
setIncidentData(null);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const incidentData = await fetchIncidentById(incidentId);
setIncidentData(incidentData);
} catch (error) {
console.error("Error fetching incident:", error);
setIncidentData(null);
} finally {
setIsLoading(false);
}
};
loadIncident();
}, [incidentId, open, fetchIncidentById]);
const openResolveDialog = () => {
setIsResolveDialogOpen(true);
};
const closeResolveDialog = () => {
setIsResolveDialogOpen(false);
};
const handleAfterResolve = async () => {
if (!incidentId) return;
const updatedIncident = await fetchIncidentById(incidentId);
if (updatedIncident) {
setIncidentData(updatedIncident);
}
};
const renderContent = () => {
if (isLoading) {
return (
<GenericFallback
isLoading={true}
message={t("incidentsPage.loadingIncidentDetails")}
/>
);
}
if (!incidentData) {
return (
<GenericFallback
isLoading={false}
message={t("incidentsPage.incidentNotFound")}
/>
);
}
return (
<Stack gap={theme.spacing(8)}>
<Grid
container
spacing={3}
>
<Grid
item
xs={12}
md={12}
>
<SummaryCard title={t("incidentsPage.overview")}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
sx={{ mb: theme.spacing(3) }}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: statusColor,
}}
/>
<Typography
variant="body2"
sx={{
color: statusColor,
fontWeight: 700,
letterSpacing: 0.4,
}}
>
{isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
</Typography>
</Stack>
<Stack
direction="column"
alignItems="start"
gap={theme.spacing(3)}
>
<Stack
direction="row"
gap={2}
sx={{ alignItems: "baseline" }}
>
<Typography
variant="body1"
fontWeight={500}
sx={{ minWidth: 70 }}
>
{t("incidentsPage.incidentItemMonitor")}
</Typography>
<Typography
variant="body1"
sx={{ flex: 1 }}
>
{incidentData?.monitor?.name || t("incidentsPage.unknownMonitor")}
</Typography>
</Stack>
<Stack
direction="row"
gap={2}
sx={{ alignItems: "baseline" }}
>
<Typography
variant="body1"
fontWeight={500}
sx={{ minWidth: 70 }}
>
{t("incidentsPage.URL")}
</Typography>
<Typography
variant="body1"
sx={{ flex: 1, wordBreak: "break-word", fontFamily: "monospace" }}
>
{incidentData?.monitor?.url || "-"}
</Typography>
</Stack>
</Stack>
</SummaryCard>
</Grid>
</Grid>
<Grid
container
spacing={3}
>
<Grid
item
xs={12}
sm={12}
>
<SummaryCard title={t("incidentsPage.incidentAnalysis")}>
<Grid
container
spacing={3}
alignItems="stretch"
>
<Grid
item
xs={12}
sm={6}
>
<Stack gap={theme.spacing(2)}>
<Typography
variant="subtitle1"
fontWeight={700}
>
{t("incidentsPage.timeline")}
</Typography>
<Divider sx={{ mt: 2 }} />
<KeyValueRow
label={t("incidentsPage.startedAt")}
value={
formatDateWithTz(
incidentData?.incident?.startTime,
"D MMM YYYY, h:mm A",
uiTimezone
) || "-"
}
sx={{ paddingBottom: theme.spacing(3) }}
/>
{!isActive && (
<KeyValueRow
label={t("incidentsPage.endedAt")}
value={
formatDateWithTz(
incidentData?.incident?.endTime,
"D MMM YYYY, h:mm A",
uiTimezone
) || "-"
}
sx={{ paddingBottom: theme.spacing(3) }}
/>
)}
<KeyValueRow
label={t("incidentsPage.downtime")}
value={duration || "-"}
/>
</Stack>
</Grid>
<Grid
item
sm="auto"
sx={{ display: { xs: "none", sm: "flex" } }}
>
<Divider
orientation="vertical"
flexItem
/>
</Grid>
<Grid
item
xs={12}
sm
>
<Stack gap={theme.spacing(2)}>
<Typography
variant="subtitle1"
sx={{ letterSpacing: 0.6, fontWeight: 700 }}
>
{isActive
? t("incidentsPage.errorDetails")
: t("incidentsPage.resolutionDetails")}
</Typography>
<Divider sx={{ mt: 2 }} />
<KeyValueRow
label={t("incidentsPage.statusCode")}
value={incidentData?.incident?.statusCode ?? "-"}
/>
<KeyValueRow
label={t("incidentsPage.message")}
value={incidentData?.incident?.message ?? "-"}
/>
{!isActive && (
<>
<KeyValueRow
label={t("incidentsPage.resolutionMethod")}
value={
toCapitalLetter(incidentData?.incident?.resolutionType) || "-"
}
/>
{incidentData?.incident?.resolutionType === "manual" && (
<KeyValueRow
label={t("incidentsPage.comment")}
value={
incidentData?.incident.comment?.trim()
? incidentData.incident.comment
: t("incidentsPage.noCommentProvided")
}
/>
)}
</>
)}
</Stack>
</Grid>
</Grid>
</SummaryCard>
</Grid>
</Grid>
</Stack>
);
};
return (
<GenericDialog
open={open}
onClose={onClose}
theme={theme}
title={t("incidentsPage.incidentDetails")}
width={{ xs: "95%", md: "45%", lg: "40%", xl: "35%" }}
>
<Stack
gap={theme.spacing(6)}
sx={{ maxHeight: "70vh", overflowY: "auto", pr: 1 }}
>
{renderContent()}
{!isActive &&
incidentData?.incident?.resolutionType === "manual" &&
incidentData?.user?.email && (
<Typography
variant="body2"
sx={{
fontStyle: "italic",
}}
>
{t("incidentsPage.resolvedBy")}: {incidentData.user.email}
</Typography>
)}
<Stack
direction="row"
justifyContent="flex-end"
gap={2}
sx={{ pt: theme.spacing(3) }}
>
{isActive && (
<Button
variant="outlined"
color="error"
onClick={openResolveDialog}
>
{t("incidentsPage.incidentsTableActionResolveManually")}
</Button>
)}
<Button
variant="contained"
color="secondary"
onClick={onClose}
>
{t("close")}
</Button>
</Stack>
<ResolveIncidentDialog
open={isResolveDialogOpen}
incidentId={incidentId}
onClose={closeResolveDialog}
onResolve={resolveIncident}
onAfterResolve={handleAfterResolve}
onResolved={onResolved}
/>
</Stack>
</GenericDialog>
);
};
IncidentDetailsModal.propTypes = {
open: PropTypes.bool.isRequired,
incidentId: PropTypes.string,
onClose: PropTypes.func.isRequired,
onResolved: PropTypes.func,
};
export default IncidentDetailsModal;
@@ -0,0 +1,221 @@
import { Table } from "@/Components/v2/design-elements";
import { Pagination } from "@/Components/v2/design-elements/Table";
import type { Header } from "@/Components/v2/design-elements/Table";
import { ValueLabel } from "@/Components/v2/design-elements";
import type { ValueType } from "@/Components/v2/design-elements/StatusLabel";
import { ActionsMenu } from "@/Components/v2/actions-menu";
import type { Incident } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
import type { ActionMenuItem } from "@/Components/v2/actions-menu";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
interface IncidentsTableProps {
title?: string;
incidents?: Incident[];
monitors?: Monitor[];
incidentsCount: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
onOpenDetails?: (incidentId: string) => void;
onResolve?: (incidentId: string) => void;
}
export const IncidentsTable = ({
incidents,
monitors,
incidentsCount,
page,
setPage,
rowsPerPage,
setRowsPerPage,
onOpenDetails,
onResolve,
}: IncidentsTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const getActions = (incident: Incident): ActionMenuItem[] => {
const monitor = monitors?.find((m) => m.id === incident.monitorId);
const isActive = incident.status === true;
const actions: ActionMenuItem[] = [
{
id: "details",
label: t("pages.incidents.table.actions.details"),
action: () => onOpenDetails?.(incident.id),
closeMenu: true,
},
{
id: "goToMonitor",
label: t("pages.incidents.table.actions.goToMonitor"),
action: () => {
if (monitor) {
const path = TypeToPathMap[monitor.type as keyof typeof TypeToPathMap];
if (path && monitor.id) {
navigate(`/${path}/${monitor.id}`);
}
}
},
closeMenu: true,
},
];
if (isActive) {
actions.push({
id: "resolve",
label: t("pages.incidents.table.actions.resolveManually"),
action: () => onResolve?.(incident.id),
closeMenu: true,
});
}
return actions;
};
const getHeaders = (): Header<Incident>[] => {
return [
{
id: "monitorName",
content: t("common.table.headers.monitor"),
render: (row) => {
const monitor = monitors?.find((m) => m.id === row.monitorId);
return monitor?.name || "N/A";
},
},
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
const isActive = row.status === true;
return (
<ValueLabel
value={isActive ? "negative" : "positive"}
text={
isActive
? t("pages.incidents.table.status.active")
: t("pages.incidents.table.status.resolved")
}
/>
);
},
},
{
id: "startTime",
content: t("pages.incidents.table.headers.startTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
},
},
{
id: "endTime",
content: t("pages.incidents.table.headers.endTime"),
render: (row) => {
if (row.endTime) {
return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
}
return "-";
},
},
{
id: "resolutionType",
content: t("pages.incidents.table.headers.resolutionType"),
render: (row) => {
if (row.resolutionType) {
return (
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
color:
row.resolutionType === "manual"
? theme.palette.warning.main
: theme.palette.success.main,
}}
>
{row.resolutionType}
</Typography>
);
}
return "-";
},
},
{
id: "statusCode",
content: t("pages.incidents.table.headers.statusCode"),
render: (row) => {
const code = row.statusCode;
if (!code) return "N/A";
let value: ValueType = "neutral";
if (code < 300) value = "positive";
else if (code >= 400) value = "negative";
return (
<ValueLabel
value={value}
text={String(code)}
/>
);
},
},
{
id: "message",
content: t("common.table.headers.message"),
render: (row) => row.message || "-",
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => <ActionsMenu items={getActions(row)} />,
},
];
};
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
if (!incidents || !monitors) {
return null;
}
return (
<Box>
<Table
headers={getHeaders()}
data={incidents}
onRowClick={(row) => onOpenDetails?.(row.id)}
emptyViewText={t("common.table.empty")}
/>
<Pagination
component="div"
count={incidentsCount}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
@@ -1,156 +0,0 @@
// Components
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Icon from "@/Components/v1/Icon/index.jsx";
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
// Utils
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
const IncidentActionsMenu = ({ incident, monitor, onResolve, onOpenDetails }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const openMenu = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const closeMenu = (e) => {
if (e) {
e.stopPropagation();
}
setAnchorEl(null);
};
const openResolveDialog = (e) => {
closeMenu(e);
setIsDialogOpen(true);
};
const closeDialog = (e) => {
if (e) {
e.stopPropagation();
}
setIsDialogOpen(false);
};
const isActive = incident.status === true;
const handleGoToDetails = (e) => {
e.stopPropagation();
closeMenu(e);
if (onOpenDetails) {
onOpenDetails(incident.id);
}
};
const handleGoToMonitor = async (e) => {
e.stopPropagation();
closeMenu(e);
try {
if (monitor) {
const path = TypeToPathMap[monitor.type];
if (path && monitor.id) {
navigate(`/${path}/${monitor.id}`);
} else {
console.error(`Monitor type not found`);
}
} else {
console.error("Monitor information not found in incident data");
}
} catch (error) {
console.error("Error fetching incident details:", error);
}
};
return (
<>
<IconButton
aria-label="incident actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Icon
name="Settings"
size={20}
/>
</IconButton>
<Menu
className="incident-actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={closeMenu}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": {
p: theme.spacing(2.5),
backgroundColor: theme.palette.primary.main,
},
"& li": { m: 0, color: theme.palette.primary.contrastTextSecondary },
},
},
}}
>
<MenuItem onClick={handleGoToDetails}>
{t("incidentsPage.incidentsTableActionDetails", "Details")}
</MenuItem>
<MenuItem onClick={handleGoToMonitor}>
{t("incidentsPage.incidentsTableActionGoToMonitor", "Go to monitor")}
</MenuItem>
{isActive && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openResolveDialog(e);
}}
>
{t("incidentsPage.incidentsTableActionResolveManually")}
</MenuItem>
)}
</Menu>
<ResolveIncidentDialog
open={isDialogOpen}
incidentId={incident.id}
onClose={closeDialog}
onResolve={onResolve}
/>
</>
);
};
IncidentActionsMenu.propTypes = {
incident: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.bool.isRequired,
}).isRequired,
monitor: PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
}).isRequired,
onResolve: PropTypes.func.isRequired,
onOpenDetails: PropTypes.func,
};
export default IncidentActionsMenu;
@@ -1,193 +0,0 @@
//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";
import IncidentActionsMenu from "./IncidentActionsMenu.jsx";
//Utils
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { Typography, useTheme } from "@mui/material";
const IncidentTable = ({
monitors = [],
incidents = [],
incidentsCount = 0,
isLoading = false,
networkError = false,
page = 0,
rowsPerPage = 10,
handleChangePage,
handleChangeRowsPerPage,
resolveIncident,
handleUpdateTrigger,
onOpenDetails,
}) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const { t } = useTranslation();
const theme = useTheme();
const handleResolveIncident = async (incidentId, options = {}) => {
try {
await resolveIncident(incidentId, options);
handleUpdateTrigger();
} catch (error) {
console.error(t("incidentsPage.errorResolvingIncident"), error);
}
};
const headers = [
{
id: "monitorName",
content: t("incidentsTableMonitorName"),
render: (row) => {
const monitor = monitors.find((monitor) => monitor.id === row.monitorId);
return monitor ? monitor.name : "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) => {
return (
<IncidentActionsMenu
incident={row}
monitor={monitors.find((monitor) => monitor.id === row.monitorId)}
onResolve={handleResolveIncident}
onOpenDetails={onOpenDetails}
/>
);
},
},
];
if (isLoading) return <TableSkeleton />;
if (networkError) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
if (!isLoading && !networkError && incidents?.length === 0) {
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,
onOpenDetails: PropTypes.func,
};
export default IncidentTable;
@@ -1,100 +0,0 @@
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={"16px"}
>
<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;
@@ -1,20 +0,0 @@
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;
@@ -1,115 +0,0 @@
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/timeUtilsLegacy.js";
import Icon from "@/Components/v1/Icon";
import { useTranslation } from "react-i18next";
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration";
import { Grid } from "@mui/material";
const IncidentItem = ({ incident }) => {
const { t } = useTranslation();
const theme = useTheme();
const isActive = incident.status === true;
const duration = useGetIncidentsDuration(incident, isActive);
const iconWrapperStyle = {
display: "flex",
justifyContent: "center",
mx: theme.spacing(2),
color: theme.palette.primary.contrastTextTertiary,
"& svg": {
width: 18,
height: 18,
},
"& svg path": {
stroke: "currentColor",
strokeWidth: 1.5,
},
};
return (
<Grid
container
alignItems="center"
spacing={2}
sx={{
width: "100%",
py: theme.spacing(0.5),
}}
>
<Grid
item
xs={12}
lg={5}
sx={{
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
}}
>
<Box sx={iconWrapperStyle}>
<Icon
name="Globe"
size={18}
/>
</Box>
<Typography
variant="body1"
fontWeight={500}
noWrap
>
{incident.monitorName || t("incidentsPage.unknownMonitor")}
</Typography>
</Grid>
<Grid
item
xs={12}
md={6}
lg={3}
sx={{
display: "flex",
justifyContent: { xs: "flex-start", md: "center" },
}}
>
<StatusLabel
status={isActive ? "down" : "up"}
text={isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
customStyles={{
textTransform: "capitalize",
}}
/>
</Grid>
<Grid
item
xs={12}
md={6}
lg={4}
sx={{
textAlign: { xs: "left", md: "right" },
fontWeight: 500,
}}
>
<Typography variant="body1">{duration}</Typography>
</Grid>
</Grid>
);
};
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;
@@ -1,114 +0,0 @@
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";
import Icon from "@/Components/v1/Icon";
/**
* 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 ? (
<Stack
direction="column"
alignItems="center"
justifyContent="center"
padding={theme.spacing(6)}
gap={theme.spacing(2)}
sx={{ flex: 1 }}
>
<Box sx={{ mb: theme.spacing(1) }}>
<Icon
name="Check"
size={24}
color={theme.palette.success.main}
strokeWidth={2}
/>
</Box>
<Typography
sx={{
fontSize: 13,
textTransform: "uppercase",
fontWeight: 500,
textAlign: "center",
color: theme.palette.success.lowContrast,
}}
>
{t("incidentsPage.incidentsLatestPanelEmpty")}
</Typography>
</Stack>
) : (
<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;
@@ -1,139 +0,0 @@
// 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") },
{ _id: "automatic", name: t("incidentsPage.incidentsOptionsHeaderFilterAutomatic") },
];
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;
@@ -1,101 +0,0 @@
import { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Button, Stack } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
const ResolveIncidentDialog = ({
open,
incidentId,
onClose,
onResolve,
onAfterResolve,
onResolved,
isLoading = false,
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [comment, setComment] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (open) {
setComment("");
}
}, [open]);
const handleConfirm = async () => {
if (!incidentId) return;
const trimmed = comment.trim();
try {
setIsSubmitting(true);
await onResolve(incidentId, trimmed ? { comment: trimmed } : {});
if (onAfterResolve) {
await onAfterResolve();
}
onResolved?.();
onClose();
} catch (error) {
console.error("Error resolving incident:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<GenericDialog
open={open}
theme={theme}
title={t("incidentsPage.resolveIncidentDialogTitle")}
description={t("incidentsPage.resolveIncidentDialogDescription")}
onClose={onClose}
>
<Stack gap={theme.spacing(4)}>
<TextInput
label={t("incidentsPage.resolveIncidentDialogCommentLabel")}
placeholder={t("incidentsPage.resolveIncidentDialogCommentPlaceholder")}
value={comment}
onChange={(e) => setComment(e.target.value)}
maxWidth="100%"
/>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(4)}
justifyContent="flex-end"
>
<Button
variant="contained"
color="secondary"
onClick={onClose}
disabled={isLoading || isSubmitting}
>
{t("cancel", "Cancel")}
</Button>
<Button
variant="contained"
color="error"
onClick={handleConfirm}
disabled={isLoading || isSubmitting}
>
{t("incidentsPage.resolveIncidentDialogConfirm")}
</Button>
</Stack>
</Stack>
</GenericDialog>
);
};
ResolveIncidentDialog.propTypes = {
open: PropTypes.bool.isRequired,
incidentId: PropTypes.string,
onClose: PropTypes.func.isRequired,
onResolve: PropTypes.func.isRequired,
onAfterResolve: PropTypes.func,
isLoading: PropTypes.bool,
onResolved: PropTypes.func,
};
export default ResolveIncidentDialog;
@@ -1,168 +0,0 @@
import PropTypes from "prop-types";
import { Box, Stack, Typography, Divider } from "@mui/material";
import { useTheme } from "@emotion/react";
import PanelSkeleton from "../IncidentsSummaryPanel/skeleton.jsx";
import { useTranslation } from "react-i18next";
import Icon from "@/Components/v1/Icon";
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 iconWrapperStyle = {
display: "flex",
justifyContent: "center",
mx: theme.spacing(2),
color: theme.palette.primary.contrastTextTertiary,
"& svg": {
width: 18,
height: 18,
},
"& svg path": {
stroke: "currentColor",
strokeWidth: 1.5,
},
};
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>
);
}
const getMostAffectedMonitor = () => {
if (!summary.total || summary.total === 0) {
return t("incidentsPage.none");
}
return summary.topMonitor?.monitorName || t("incidentsPage.none");
};
const rowStyle = {
py: theme.spacing(0.5),
minHeight: 32,
};
return (
<SummaryCard title={t("incidentsPage.incidentsStatisticsPanelTitle")}>
<Stack gap={theme.spacing(4)}>
<Box>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
sx={rowStyle}
>
<Box sx={iconWrapperStyle}>
<Icon
name="Bell"
size={18}
/>
</Box>
<Typography
variant="body1"
fontWeight={500}
>
{t("incidentsPage.totalIncidents")}: {summary.total || 0}
</Typography>
</Stack>
<Divider sx={{ mt: theme.spacing(2) }} />
</Box>
<Box>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
sx={rowStyle}
>
<Box sx={iconWrapperStyle}>
<Icon
name="AlertTriangle"
size={18}
/>
</Box>
<Typography
variant="body1"
fontWeight={500}
>
{t("incidentsPage.mostAffectedMonitor")}: {getMostAffectedMonitor()}
</Typography>
</Stack>
<Divider sx={{ mt: theme.spacing(2) }} />
</Box>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
sx={rowStyle}
>
<Box sx={iconWrapperStyle}>
<Icon
name="Wrench"
size={18}
/>
</Box>
<Typography
variant="body1"
fontWeight={500}
>
{t("incidentsPage.avgResolutionTime")}:{" "}
{summary.total > 0
? `${summary.avgResolutionTimeHours || 0} ${t("incidentsPage.hours")}`
: "N/A"}
</Typography>
</Stack>
</Stack>
</SummaryCard>
);
};
StatisticsPanel.propTypes = {
summary: PropTypes.object,
isLoading: PropTypes.bool,
error: PropTypes.object,
};
export default StatisticsPanel;
@@ -1,64 +0,0 @@
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: "16px",
borderRadius: theme.shape.borderRadius,
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
backgroundColor: theme.palette.primary.main,
border: `1px solid ${theme.palette.primary.lowContrast}`,
color: theme.palette.primary.contrastTextTertiary,
fontSize: theme.typography.body1.fontSize,
...sx,
}}
>
{title && (
<Box>
<Stack
direction="row"
sx={{
padding: theme.spacing(2),
}}
>
<Typography
component="h2"
sx={{
textTransform: "uppercase",
fontWeight: 500,
fontSize: 13,
color: theme.palette.primary.contrastTextSecondary,
}}
>
{title}
</Typography>
</Stack>
<Divider />
</Box>
)}
<Stack
mt={theme.spacing(2)}
paddingTop={theme.spacing(2)}
gap={theme.spacing(2)}
sx={{ height: "100%" }}
>
{children}
</Stack>
</Paper>
);
};
SummaryCard.propTypes = {
children: PropTypes.node,
isHighPriority: PropTypes.bool,
sx: PropTypes.object,
title: PropTypes.string,
};
export default SummaryCard;
@@ -1,215 +0,0 @@
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?.count || 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]
);
const fetchIncidentsByResolutionType = useCallback(
async (resolutionType, config = {}) => {
await fetchIncidentsByTeam({
resolutionType,
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<Object|null>} 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<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,
fetchIncidentsByResolutionType,
};
};
export default useFetchIncidents;
-178
View File
@@ -1,178 +0,0 @@
// 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";
import IncidentDetailsModal from "./Components/IncidentDetailsModal/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";
import { useParams } from "react-router-dom";
// Hooks
import useFetchIncidents from "./hooks/useFetchIncidents.js";
import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks.js";
const Incidents2 = () => {
const { monitorId } = useParams();
const { t } = useTranslation();
const BREADCRUMBS = [
{ name: t("incidentsPageTitle", "Incidents"), path: "/incidents" },
];
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "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 [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [selectedIncidentId, setSelectedIncidentId] = useState(null);
const handleUpdateTrigger = () => {
setUpdateTrigger((prev) => !prev);
};
const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
{}
);
const {
incidents,
incidentsCount,
isLoading: isLoadingIncidents,
networkError: incidentsNetworkError,
fetchIncidents,
fetchActiveIncidents,
fetchResolvedIncidents,
resolveIncident,
fetchIncidentsByResolutionType,
} = 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 if (filter === "manual") {
fetchIncidentsByResolutionType("manual", config);
} else if (filter === "automatic") {
fetchIncidentsByResolutionType("automatic", config);
} else {
fetchIncidents(config);
}
}, [
selectedMonitor,
filter,
dateRange,
page,
rowsPerPage,
updateTrigger,
fetchActiveIncidents,
fetchResolvedIncidents,
fetchIncidents,
fetchIncidentsByResolutionType,
]);
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);
};
const handleOpenDetails = (id) => {
if (!id) return;
setSelectedIncidentId(id);
setIsDetailsOpen(true);
};
const handleCloseDetails = () => {
setIsDetailsOpen(false);
setSelectedIncidentId(null);
};
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
monitors={monitors || []}
incidents={incidents || []}
incidentsCount={incidentsCount || 0}
isLoading={isLoadingIncidents}
networkError={incidentsNetworkError}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
resolveIncident={resolveIncident}
handleUpdateTrigger={handleUpdateTrigger}
onOpenDetails={handleOpenDetails}
/>
<IncidentDetailsModal
open={isDetailsOpen}
incidentId={selectedIncidentId}
onClose={handleCloseDetails}
onResolved={handleUpdateTrigger}
/>
</Stack>
);
};
export default Incidents2;
+245
View File
@@ -0,0 +1,245 @@
import { BasePage } from "@/Components/v2/design-elements";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import { useTranslation } from "react-i18next";
import {
SummaryCardActiveIncidents,
SummaryCardLatestIncidents,
SummaryCardStats,
} from "@/Pages/Incidents/Components/CardSummary";
import { IncidentsTable } from "@/Pages/Incidents/Components/IncidentTable";
import { DialogResolution } from "@/Pages/Incidents/Components/DialogResolution";
import { DialogIncidentDetails } from "@/Pages/Incidents/Components/DialogIncidentDetails";
import { HeaderTimeRange } from "@/Components/v2/common";
import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter";
import { useGet } from "@/Hooks/UseApi";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
import { useTheme } from "@mui/material";
const IncidentsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const { monitorId } = useParams();
// Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState("recent");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
const [activeIncidentsRowsPerPage, setActiveIncidentsRowsPerPage] = useState(5);
// Resolve dialog state
const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false);
const [resolveIncidentId, setResolveIncidentId] = useState<string | null>(null);
// Details dialog state
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [selectedIncident, setSelectedIncident] = useState<Incident | null>(null);
const [selectedIncidentMonitor, setSelectedIncidentMonitor] = useState<Monitor | null>(
null
);
// Build incidents URL with query params
const incidentsUrl = useMemo(() => {
const params = new URLSearchParams();
if (selectedMonitor !== "0") params.append("monitorId", selectedMonitor);
// Always show only resolved incidents (active ones are in the separate table above)
params.append("status", "false");
if (filter === "manual" || filter === "automatic")
params.append("resolutionType", filter);
params.append("sortOrder", "desc");
if (dateRange !== "all") params.append("dateRange", dateRange);
params.append("page", String(page));
params.append("rowsPerPage", String(rowsPerPage));
return `/incidents/team?${params.toString()}`;
}, [selectedMonitor, filter, dateRange, page, rowsPerPage]);
// Fetch incidents
const {
data: incidentsData,
isLoading: isLoadingIncidents,
refetch: refetchIncidents,
} = useGet<IncidentsResponse>(
incidentsUrl,
{},
{ keepPreviousData: true, refreshInterval: 10000 }
);
// Fetch monitors for lookup
const { data: monitorsData } = useGet<Monitor[]>("/monitors/team");
// Build active incidents URL (always fetch all active, no date filter)
const activeIncidentsUrl = useMemo(() => {
const params = new URLSearchParams();
if (selectedMonitor !== "0") params.append("monitorId", selectedMonitor);
params.append("status", "true");
params.append("sortOrder", "desc");
params.append("page", String(activeIncidentsPage));
params.append("rowsPerPage", String(activeIncidentsRowsPerPage));
return `/incidents/team?${params.toString()}`;
}, [selectedMonitor, activeIncidentsPage, activeIncidentsRowsPerPage]);
// Fetch active incidents
const { data: activeIncidentsData, refetch: refetchActiveIncidents } =
useGet<IncidentsResponse>(
activeIncidentsUrl,
{},
{ keepPreviousData: true, refreshInterval: 10000 }
);
// Fetch incident summary
const { data: summaryData, refetch: refetchSummary } = useGet<IncidentSummary>(
"/incidents/team/summary"
);
// Reset page when filters change
useEffect(() => {
setPage(0);
}, [selectedMonitor, filter, dateRange]);
// Derived state
const incidents = incidentsData?.incidents ?? [];
const incidentsCount = incidentsData?.count ?? 0;
const activeIncidents = activeIncidentsData?.incidents ?? [];
const activeIncidentsCount = activeIncidentsData?.count ?? 0;
const handleClearFilters = () => {
setSelectedMonitor("0");
setFilter("all");
};
const handleOpenDetails = (incidentId: string) => {
const incident =
incidents.find((i) => i.id === incidentId) ??
activeIncidents.find((i) => i.id === incidentId) ??
null;
const monitor = monitorsData?.find((m) => m.id === incident?.monitorId) ?? null;
setIsDetailsDialogOpen(true);
setSelectedIncident(incident);
setSelectedIncidentMonitor(monitor);
};
const handleDetailsClose = () => {
setIsDetailsDialogOpen(false);
};
const handleDetailsResolve = () => {
if (selectedIncident) {
handleDetailsClose();
handleResolve(selectedIncident.id);
}
};
const handleResolve = (incidentId: string) => {
setResolveIncidentId(incidentId);
setIsResolveDialogOpen(true);
};
const handleResolveClose = () => {
setIsResolveDialogOpen(false);
setResolveIncidentId(null);
};
const handleResolved = () => {
refetchIncidents();
refetchActiveIncidents();
refetchSummary();
};
return (
<BasePage>
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
>
<SummaryCardActiveIncidents summary={summaryData} />
<SummaryCardLatestIncidents summary={summaryData} />
<SummaryCardStats summary={summaryData} />
</Stack>
<ControlsIncidentFilter
monitors={monitorsData ?? undefined}
selectedMonitor={selectedMonitor}
setSelectedMonitor={setSelectedMonitor}
selectedResolutionType={filter}
setSelectedResolutionType={setFilter}
onClearFilters={handleClearFilters}
/>
{activeIncidentsCount > 0 ? (
<>
<Typography
variant="h6"
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
>
{t("pages.incidents.table.activeIncidents")}
</Typography>
<IncidentsTable
incidents={activeIncidents}
monitors={monitorsData ?? undefined}
incidentsCount={activeIncidentsCount}
page={activeIncidentsPage}
setPage={setActiveIncidentsPage}
rowsPerPage={activeIncidentsRowsPerPage}
setRowsPerPage={setActiveIncidentsRowsPerPage}
onOpenDetails={handleOpenDetails}
onResolve={handleResolve}
/>
<Divider />
</>
) : (
<Typography
variant="body1"
sx={{ mb: theme.spacing(4), color: theme.palette.success.main }}
>
{t("pages.incidents.summaryCard.activeIncidents.active_zero")}
</Typography>
)}
<Typography
variant="h6"
sx={{ mb: theme.spacing(4), textTransform: "uppercase" }}
>
{t("pages.incidents.table.resolvedIncidents")}
</Typography>
<HeaderTimeRange
dateRange={dateRange}
setDateRange={setDateRange}
isLoading={isLoadingIncidents}
/>
<IncidentsTable
incidents={incidents}
monitors={monitorsData ?? undefined}
incidentsCount={incidentsCount}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
onOpenDetails={handleOpenDetails}
onResolve={handleResolve}
/>
<DialogResolution
open={isResolveDialogOpen}
incidentId={resolveIncidentId}
onClose={handleResolveClose}
onResolved={handleResolved}
/>
<DialogIncidentDetails
open={isDetailsDialogOpen}
incident={selectedIncident}
monitor={selectedIncidentMonitor}
onClose={handleDetailsClose}
onResolve={handleDetailsResolve}
/>
</BasePage>
);
};
export default IncidentsPage;
@@ -1,10 +1,14 @@
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
const useGetIncidentsDuration = (incident, isActive) => {
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryItem) => {
if (!incident?.startTime) {
return "-";
}
const startTime = new Date(incident?.startTime);
const endTime = isActive
const endTime = incident.status
? new Date()
: incident?.endTime
? new Date(incident.endTime)
@@ -22,4 +26,3 @@ const useGetIncidentsDuration = (incident, isActive) => {
return getHumanReadableDuration(durationMs);
};
export default useGetIncidentsDuration;
@@ -1,4 +1,4 @@
import Grid from "@mui/material/Grid2";
import Grid from "@mui/material/Grid";
import { HistogramInfrastructure } from "@/Components/v2/monitors";
import { useTranslation } from "react-i18next";
@@ -1,4 +1,4 @@
import Grid from "@mui/material/Grid2";
import Grid from "@mui/material/Grid";
import { HistogramInfrastructure } from "@/Components/v2/monitors";
import { useTranslation } from "react-i18next";
+8 -2
View File
@@ -33,7 +33,7 @@ import ServerUnreachable from "../Pages/ServerUnreachable.jsx";
import Checks from "../Pages/Checks/index";
// Incidents
import Incidents from "../Pages/Incidents/index.jsx";
import Incidents from "../Pages/Incidents/";
// Status pages
import CreateStatus from "../Pages/StatusPage/Create/index.jsx";
@@ -216,7 +216,13 @@ const Routes = () => {
/>
<Route
path="incidents/:monitorId?"
element={<Incidents />}
element={
<>
<ThemeProvider theme={v2theme}>
<Incidents />
</ThemeProvider>
</>
}
/>
<Route
+50
View File
@@ -0,0 +1,50 @@
export interface Incident {
id: string;
monitorId: string;
teamId: string;
startTime: string;
endTime: string | null;
status: boolean;
message?: string | null;
statusCode?: number | null;
resolutionType: "automatic" | "manual" | null;
resolvedBy?: string | null;
resolvedByEmail?: string | null;
comment?: string | null;
createdAt: string;
updatedAt: string;
}
export interface IncidentsResponse {
incidents: Incident[];
count: number;
}
export interface IncidentSummaryTopMonitor {
monitorId: string;
monitorName: string | null;
incidentCount: number;
}
export interface IncidentSummaryItem {
id: string;
monitorId: string;
monitorName: string | null;
status: boolean;
startTime: string;
endTime: string | null;
resolutionType: "automatic" | "manual" | null;
message: string | null;
statusCode: number | null;
createdAt: string;
}
export interface IncidentSummary {
total: number;
totalActive: number;
totalManualResolutions: number;
totalAutomaticResolutions: number;
avgResolutionTimeHours: number;
topMonitor: IncidentSummaryTopMonitor | null;
latestIncidents: IncidentSummaryItem[];
}
+104 -69
View File
@@ -6,7 +6,10 @@
"details": "Details"
},
"labels": {
"responseTime": "Response time"
"responseTime": "Response time",
"active": "Active",
"resolved": "Resolved",
"na": "N/A"
},
"buttons": {
"toggleTheme": "Toggles light & dark",
@@ -21,6 +24,7 @@
"save": "Save",
"test": "Test"
},
"alerts": {
"pageSpeedApiKey": {
"content": "Warning: You haven't added a Google PageSpeed API key yet. Visit <settingsLink>Settings</settingsLink> to add one. Without it, the PageSpeed monitor won't function."
@@ -87,6 +91,12 @@
"day": "Day",
"week": "Week",
"month": "Month"
},
"description": {
"recent": "Showing statistics for past 2 hours.",
"day": "Showing statistics for past 24 hours.",
"week": "Showing statistics for past 7 days.",
"month": "Showing statistics for past 30 days."
}
}
},
@@ -221,6 +231,15 @@
}
},
"pageSpeed": {
"fallback": {
"checks": [
"Report on the user experience of a page",
"Help analyze webpage speed",
"Give suggestions on how the page can be improved"
],
"title": "A PageSpeed monitor is used to:",
"actionButton": "Let's create your first PageSpeed monitor!"
},
"table": {
"headers": {
"pageSpeedScore": "PageSpeed score"
@@ -244,6 +263,15 @@
}
},
"infrastructure": {
"fallback": {
"checks": [
"Track the performance of your servers",
"Identify bottlenecks and optimize usage",
"Ensure reliability with real-time monitoring"
],
"title": "An infrastructure monitor is used to:",
"actionButton": "Let's create your first infrastructure monitor!"
},
"table": {
"headers": {
"cpu": "CPU",
@@ -350,22 +378,82 @@
"title": "Matrix configuration"
}
}
},
"incidents": {
"summaryCard": {
"activeIncidents": {
"title": "Active Incidents",
"active_zero": "No active incidents",
"active_one": "{{count}} Active incident",
"active_other": "{{count}} Active incidents"
},
"latestIncidents": { "title": "Latest Incidents" },
"incidentStats": {
"title": "Incident Statistics",
"totalIncidents": "Total incidents",
"mostAffectedMonitor": "Most affected monitor",
"avgResolutionTime": "Average resolution time"
}
},
"table": {
"activeIncidents": "Active Incidents",
"resolvedIncidents": "Resolved Incidents",
"headers": {
"startTime": "Start Time",
"endTime": "End Time",
"resolutionType": "Resolution Type",
"statusCode": "Status Code"
},
"status": {
"active": "Active",
"resolved": "Resolved",
"na": "N/A"
},
"actions": {
"details": "Details",
"goToMonitor": "Go to monitor",
"resolveManually": "Resolve Manually"
}
},
"filters": {
"monitor": "Monitor",
"allMonitors": "All monitors",
"resolutionType": "Resolution type",
"resolutionTypes": {
"automatic": "Automatic",
"manual": "Manual"
},
"clearFilters": "Clear Filters"
},
"dialog": {
"details": {
"title": "Incident details",
"resolve": "Resolve Incident",
"overview": "Overview",
"status": "Status:",
"monitor": "Monitor:",
"url": "URL:",
"analysis": "Incident analysis",
"timeline": "Timeline",
"detailsLabel": "Details",
"startedAt": "Started at:",
"statusCode": "Status code:",
"downtime": "Downtime:",
"message": "Message:",
"resolutionDetails": "Resolution details",
"resolvedAt": "Resolved at:",
"resolutionType": "Type:",
"resolvedBy": "Resolved by:",
"comment": "Comment:",
"resolutionTypes": {
"automatic": "Automatic",
"manual": "Manual"
}
}
}
}
},
"incidentsTableNoIncidents": "No incidents recorded",
"incidentsTablePaginationLabel": "incidents",
"incidentsTableMonitorName": "Monitor Name",
"incidentsTableStatus": "Status",
"incidentsTableStatusCode": "Status Code",
"incidentsTableMessage": "Message",
"incidentsOptionsHeader": "Incidents for:",
"incidentsOptionsHeaderFilterBy": "Filter by:",
"incidentsOptionsHeaderFilterAll": "All",
"incidentsOptionsHeaderShow": "Show:",
"incidentsOptionsHeaderLastHour": "Last hour",
"incidentsOptionsHeaderLastDay": "Last day",
"incidentsOptionsHeaderLastWeek": "Last week",
"incidentsOptionsPlaceholderAllServers": "All servers",
"used": "Used",
"total": "Total",
"status": "Status",
@@ -548,7 +636,6 @@
"statusBreadCrumbsDetails": "Details",
"commonSaving": "Saving...",
"navControls": "Controls",
"incidentsPageTitle": "Incidents",
"passwordPanel": {
"currentPassword": "Current password",
"newPassword": "New password",
@@ -902,61 +989,9 @@
},
"chooseGame": "Choose game",
"incidentsPage": {
"resolved": "Resolved",
"active": "Active",
"startTime": "Start Time",
"endTime": "End Time",
"resolutionType": "Resolution Type",
"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",
"unknownMonitor": "Unknown Monitor",
"incidentsStatisticsPanelError": "Error loading statistics",
"incidentsStatisticsPanelTitle": "General Statistics",
"totalIncidents": "Total Incidents",
"mostAffectedMonitor": "Most Affected Monitor",
"avgResolutionTime": "Avg. Resolution Time",
"incidentResolvedSuccessfully": "Incident resolved successfully",
"errorFetchingIncidents": "Error fetching incidents",
"errorFetchingIncident": "Error fetching incident",
"noIncidentIdProvided": "No incident ID provided",
"errorFetchingIncidentSummary": "Error fetching incident summary",
"incidentsOptionsHeaderFilterManual": "Manual",
"incidentsOptionsHeaderFilterAutomatic": "Automatic",
"incidentsOptionsHeaderFilterAll": "All",
"incidentsOptionsHeaderFilterActive": "Active",
"incidentsOptionsHeaderFilterResolved": "Resolved",
"incidentsTableActionResolveManually": "Resolve Manually",
"incidentsTableActionDetails": "Details",
"incidentsTableActionGoToMonitor": "Go to monitor",
"resolveIncidentDialogTitle": "Resolve Incident",
"resolveIncidentDialogDescription": "Are you sure you want to resolve this incident manually?",
"resolveIncidentDialogCommentLabel": "Comment (optional)",
"resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...",
"resolveIncidentDialogConfirm": "Resolve",
"hours": "hours",
"incidentNotFound": "Incident not found",
"incidentDetails": "INCIDENT DETAILS",
"overview": "OVERVIEW",
"URL": "URL",
"incidentAnalysis": "INCIDENT ANALYSIS",
"timeline": "Timeline",
"startedAt": "Started at",
"endedAt": "Ended at",
"downtime": "Downtime",
"errorDetails": "Error details",
"resolutionDetails": "Resolution details",
"statusCode": "Status code",
"resolutionMethod": "Resolution method",
"resolvedBy": "Resolved by",
"comment": "Comment",
"noCommentProvided": "No comment provided",
"loadingIncidentDetails": "Loading incident details...",
"none": "None",
"message": "Message"
"resolveIncidentDialogConfirm": "Resolve"
}
}
@@ -105,6 +105,12 @@ const requireUserId = (userId?: string): string => {
}
return userId;
};
const requireUserEmail = (userEmail?: string): string => {
if (!userEmail) {
throw new AppError({ message: "User email is required", status: 400 });
}
return userEmail;
};
export {
fetchMonitorCertificate,
@@ -116,4 +122,5 @@ export {
parseSortOrder,
requireTeamId,
requireUserId,
requireUserEmail,
};
+12 -14
View File
@@ -1,6 +1,6 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
import { requireTeamId } from "./controllerUtils.js";
import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js";
const SERVICE_NAME = "incidentController";
@@ -44,16 +44,11 @@ class IncidentController {
getIncidentSummary = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", service: SERVICE_NAME, status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
const summary = await this.incidentService.getIncidentSummary(req?.user?.teamId, limit);
const summary = await this.incidentService.getIncidentSummary(teamId, limit);
return res.status(200).json({
success: true,
msg: "Incident summary retrieved successfully",
@@ -86,12 +81,15 @@ class IncidentController {
resolveIncidentManually = async (req: Request, res: Response, next: NextFunction) => {
try {
const resolvedIncident = await this.incidentService.resolveIncident(
req?.params?.incidentId,
req?.user?.id,
req?.user?.teamId,
req?.body?.comment
);
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const userEmail = requireUserEmail(req.user?.email);
const incidentId = req.params?.incidentId;
if (!incidentId) {
throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 });
}
const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, req?.body?.comment, userEmail);
return res.status(200).json({
success: true,
+4
View File
@@ -64,6 +64,10 @@ const IncidentSchema = new Schema<IncidentDocument>(
ref: "User",
default: null,
},
resolvedByEmail: {
type: String,
default: null,
},
comment: {
type: String,
default: null,
@@ -58,6 +58,7 @@ class MongoIncidentRepository implements IIncidentsRepository {
statusCode: doc.statusCode ?? null,
resolutionType: doc.resolutionType ?? null,
resolvedBy: doc.resolvedBy ? this.toStringId(doc.resolvedBy) : null,
resolvedByEmail: doc.resolvedByEmail ?? null,
comment: doc.comment ?? null,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
@@ -6,7 +6,7 @@ import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from
import type { Incident } from "@/types/index.js";
const dateRangeLookup: Record<string, Date | undefined> = {
recent: new Date(new Date().setDate(new Date().getDate() - 2)),
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
hour: new Date(new Date().setHours(new Date().getHours() - 1)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
@@ -71,7 +71,7 @@ class IncidentService {
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
};
resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string) => {
resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => {
try {
if (!incidentId) {
throw new AppError({ message: "No incident ID in request", service: SERVICE_NAME, method: "resolveIncident" });
@@ -98,6 +98,7 @@ class IncidentService {
incident.resolutionType = "manual";
incident.status = false;
incident.resolvedBy = userId;
incident.resolvedByEmail = userEmail || null;
incident.comment = comment || null;
incident.endTime = Date.now().toString();
+1
View File
@@ -14,6 +14,7 @@ export interface Incident {
statusCode?: number | null;
resolutionType: IncidentResolutionType;
resolvedBy?: string | null;
resolvedByEmail?: string | null;
comment?: string | null;
createdAt: string;
updatedAt: string;