create incidents action menu and detail page
This commit is contained in:
1 parent
668ea7418d
commit
1c01dcaf31
7 files changed
+428
-56
No files matched your search
@@ -0,0 +1,204 @@
|
||||
// Components
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Settings from "../../../../../assets/icons/settings-bold.svg?react";
|
||||
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
|
||||
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
|
||||
import { Button, Stack } from "@mui/material";
|
||||
import useFetchIncidents from "../../hooks/useFetchIncidents";
|
||||
|
||||
// Utils
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { TypeToPathMap } from "@/Utils/monitorUtils.js";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const IncidentActionsMenu = ({ incident, onResolve }) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { fetchIncidentById } = useFetchIncidents();
|
||||
const openMenu = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const closeMenu = (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const openResolveDialog = (e) => {
|
||||
closeMenu(e);
|
||||
setComment("");
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setComment("");
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmResolve = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onResolve) {
|
||||
const options = comment.trim() ? { comment: comment.trim() } : {};
|
||||
onResolve(incident._id, options);
|
||||
}
|
||||
setComment("");
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const isActive = incident.status === true;
|
||||
const handleGoToDetails = (e) => {
|
||||
e.stopPropagation();
|
||||
closeMenu(e);
|
||||
navigate(`/incidents/details/${incident._id}`);
|
||||
};
|
||||
|
||||
const handleGoToMonitor = async (e) => {
|
||||
e.stopPropagation();
|
||||
closeMenu(e);
|
||||
try {
|
||||
const incidentData = await fetchIncidentById(incident._id);
|
||||
console.log(incidentData);
|
||||
if (incidentData) {
|
||||
const { type, _id: monitorId } = incidentData.monitorId;
|
||||
const path = TypeToPathMap[type];
|
||||
if (path && monitorId) {
|
||||
navigate(`/${path}/${monitorId}`);
|
||||
} else {
|
||||
console.error(`Monitor type not found`);
|
||||
}
|
||||
} else {
|
||||
console.error("Monitor information not found in incident data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching incident details:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="incident actions"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openMenu(event);
|
||||
}}
|
||||
sx={{
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
"& svg path": {
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
</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 },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isActive && (
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(e);
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableActionResolveManually")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleGoToDetails}>
|
||||
{t("incidentsPage.incidentsTableActionDetails", "Details")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleGoToMonitor}>
|
||||
{t("incidentsPage.incidentsTableActionGoToMonitor", "Go to monitor")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
//onClick={handleResolve}
|
||||
>
|
||||
Add comment
|
||||
</MenuItem>
|
||||
{/* Future options will be added here */}
|
||||
</Menu>
|
||||
<GenericDialog
|
||||
open={isDialogOpen}
|
||||
theme={theme}
|
||||
title={t("incidentsPage.resolveIncidentDialogTitle")}
|
||||
description={t("incidentsPage.resolveIncidentDialogDescription")}
|
||||
onClose={closeDialog}
|
||||
>
|
||||
<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={closeDialog}
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleConfirmResolve}
|
||||
>
|
||||
{t("incidentsPage.resolveIncidentDialogConfirm")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GenericDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentActionsMenu.propTypes = {
|
||||
incident: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
status: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
onResolve: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default IncidentActionsMenu;
|
||||
@@ -6,13 +6,14 @@ import { StatusLabel } from "@/Components/v1/Label/index.jsx";
|
||||
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
import IncidentActionsMenu from "./IncidentActionsMenu.jsx";
|
||||
|
||||
//Utils
|
||||
import { formatDateWithTz } from "../../../../../Utils/timeUtils.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Typography, useTheme } from "@mui/material";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
|
||||
const IncidentTable = ({
|
||||
incidents = [],
|
||||
@@ -31,9 +32,9 @@ const IncidentTable = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleResolveIncident = async (incidentId) => {
|
||||
const handleResolveIncident = async (incidentId, options = {}) => {
|
||||
try {
|
||||
await resolveIncident(incidentId);
|
||||
await resolveIncident(incidentId, options);
|
||||
handleUpdateTrigger();
|
||||
} catch (error) {
|
||||
console.error(t("incidentsPage.errorResolvingIncident"), error);
|
||||
@@ -120,32 +121,12 @@ const IncidentTable = ({
|
||||
id: "action",
|
||||
content: t("actions"),
|
||||
render: (row) => {
|
||||
if (row.status === true) {
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
sx={{
|
||||
minHeight: "max-content",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleResolveIncident(row._id);
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableActionResolveManually")}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableResolved")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IncidentActionsMenu
|
||||
incident={row}
|
||||
onResolve={handleResolveIncident}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,40 +2,16 @@ import PropTypes from "prop-types";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtils.js";
|
||||
import Monitors from "@/assets/icons/monitors.svg?react";
|
||||
import AverageResponseIcon from "@/assets/icons/status-pages.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration";
|
||||
|
||||
const IncidentItem = ({ incident }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isActive = incident.status === true;
|
||||
const calculateDuration = () => {
|
||||
if (!incident.startTime) {
|
||||
return "-";
|
||||
}
|
||||
const startTime = new Date(incident.startTime);
|
||||
const endTime = isActive
|
||||
? new Date()
|
||||
: incident.endTime
|
||||
? new Date(incident.endTime)
|
||||
: null;
|
||||
|
||||
if (!endTime) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
if (durationMs < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return getHumanReadableDuration(durationMs);
|
||||
};
|
||||
|
||||
const duration = calculateDuration();
|
||||
const duration = useGetIncidentsDuration(incident, isActive);
|
||||
const iconWrapperStyle = {
|
||||
px: theme.spacing(2),
|
||||
display: "flex",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Components
|
||||
import { Stack, Typography, Grid, Box } from "@mui/material";
|
||||
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import SummaryCard from "../Components/SummaryCard";
|
||||
import useGetIncidentsDuration from "../hooks/useGetIncidentsDuration";
|
||||
|
||||
// Utils
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useFetchIncidents from "../hooks/useFetchIncidents";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Constants
|
||||
const BREADCRUMBS = [
|
||||
{ name: "incidents", path: "/incidents" },
|
||||
{ name: "details", path: "" },
|
||||
];
|
||||
|
||||
const IncidentDetails = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { incidentId } = useParams();
|
||||
const { fetchIncidentById } = useFetchIncidents();
|
||||
const [incident, setIncident] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isActive = incident?.status === true;
|
||||
const duration = useGetIncidentsDuration(incident || null, isActive);
|
||||
useEffect(() => {
|
||||
const loadIncident = async () => {
|
||||
if (incidentId) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const incidentData = await fetchIncidentById(incidentId);
|
||||
console.log(incidentData);
|
||||
setIncident(incidentData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching incident:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadIncident();
|
||||
}, [incidentId, fetchIncidentById]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<GenericFallback
|
||||
isLoading={true}
|
||||
message={t("incidentsPage.loading", "Loading incident details...")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!incident) {
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<GenericFallback
|
||||
isLoading={false}
|
||||
message={t("incidentsPage.incidentNotFound", "Incident not found")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<Typography variant="h2">
|
||||
{t("incidentsPage.incidentDetails", "Incident Details")}
|
||||
</Typography>
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<SummaryCard
|
||||
title="Downtime duration"
|
||||
isHighPriority={isActive}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.lowContrast,
|
||||
color: theme.palette.primary.contrastTextSecondary,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{duration}</Typography>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<SummaryCard title="Started at">
|
||||
<Typography variant="h6">{incident?.startTime}</Typography>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
{!isActive && (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<SummaryCard title="Ended at">
|
||||
<Typography variant="h6">{incident?.endTime}</Typography>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<SummaryCard title="Error details">
|
||||
<Typography variant="h6">{incident?.statusCode}</Typography>
|
||||
<Typography variant="h6">{incident?.message}</Typography>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<SummaryCard title="Total Checks Failed">
|
||||
<Typography variant="h6">{incident?.checks.length}</Typography>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
{!isActive && (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
>
|
||||
<SummaryCard title="Resolution Method">
|
||||
<Typography variant="h6">{incident?.resolutionType}</Typography>
|
||||
{incident?.resolutionType === "manual" && (
|
||||
<Stack>
|
||||
<Typography variant="h6">
|
||||
Resolved by {incident?.resolvedBy?.email}
|
||||
</Typography>
|
||||
{incident?.comment && (
|
||||
<Typography variant="h6">Comment: {incident?.comment}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentDetails;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtils.js";
|
||||
const useGetIncidentsDuration = (incident, isActive) => {
|
||||
if (!incident?.startTime) {
|
||||
return "-";
|
||||
}
|
||||
const startTime = new Date(incident?.startTime);
|
||||
const endTime = isActive
|
||||
? new Date()
|
||||
: incident?.endTime
|
||||
? new Date(incident.endTime)
|
||||
: null;
|
||||
|
||||
if (!endTime) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
if (durationMs < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return getHumanReadableDuration(durationMs);
|
||||
};
|
||||
export default useGetIncidentsDuration;
|
||||
@@ -31,6 +31,7 @@ import ServerUnreachable from "../Pages/v1/ServerUnreachable.jsx";
|
||||
// Incidents
|
||||
import Checks from "../Pages/v1/Incidents";
|
||||
import Incidents2 from "../Pages/v1/Incidents2";
|
||||
import IncidentDetails from "../Pages/v1/Incidents2/Details";
|
||||
|
||||
// Status pages
|
||||
import CreateStatus from "../Pages/v1/StatusPage/Create";
|
||||
@@ -134,6 +135,10 @@ const Routes = () => {
|
||||
path="checks/:monitorId?"
|
||||
element={<Checks />}
|
||||
/>
|
||||
<Route
|
||||
path="incidents/details/:incidentId"
|
||||
element={<IncidentDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="incidents/:monitorId?"
|
||||
element={<Incidents2 />}
|
||||
|
||||
@@ -1193,6 +1193,16 @@
|
||||
"incidentsOptionsHeaderFilterResolved": "Resolved",
|
||||
"incidentsTableResolved": "Closed",
|
||||
"incidentsTableActionResolveManually": "Resolve Manually",
|
||||
"hours": "hours"
|
||||
"incidentsTableActionDetails": "Details",
|
||||
"incidentsTableActionGoToMonitor": "Go to monitor",
|
||||
"resolveIncidentDialogTitle": "Resolve Incident",
|
||||
"resolveIncidentDialogDescription": "Are you sure you want to resolve this incident manually?",
|
||||
"resolveIncidentDialogCommentLabel": "Comment (optional)",
|
||||
"resolveIncidentDialogCommentPlaceholder": "Add a comment about the resolution...",
|
||||
"resolveIncidentDialogConfirm": "Resolve",
|
||||
"hours": "hours",
|
||||
"loading": "Loading incident details...",
|
||||
"incidentNotFound": "Incident not found",
|
||||
"incidentDetails": "Incident Details"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user