Merge pull request #3230 from bluewave-labs/feat/v2-incidents
feat: v2 incidents
This commit is contained in:
39 files changed
+1887
-3935
No files matched your search
Generated
+554
-1719
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+6
-3
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user