diff --git a/client/src/pages/incidents/IncidentDetails.tsx b/client/src/pages/incidents/IncidentDetails.tsx new file mode 100644 index 000000000..32cd890a0 --- /dev/null +++ b/client/src/pages/incidents/IncidentDetails.tsx @@ -0,0 +1,120 @@ +import { BasePage, StatBox } from "@/components/design-elements"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useParams } from "react-router"; +import { useGet } from "@/hooks/UseApi"; +import type { ApiResponse } from "@/hooks/UseApi"; +import type { IIncident } from "@/types/incident"; +import { formatDateWithTz } from "@/utils/TimeUtils"; +import { useAppSelector } from "@/hooks/AppHooks"; +import prettyMilliseconds from "pretty-ms"; + +const IncidentDetailsPage = () => { + const theme = useTheme(); + const { id } = useParams<{ id: string }>(); + const uiTimezone = useAppSelector((state: any) => state.ui.timezone); + + const { response } = useGet>(`/incidents/${id}`); + const incident = response?.data || null; + + if (!incident) { + return null; + } + + const startMs = new Date(incident.startedAt).getTime(); + const endMs = incident.endedAt + ? new Date(incident.endedAt).getTime() + : Date.now(); + const duration = Math.max(0, endMs - startMs); + + return ( + + + + Incident Details + + + + + + + + + + + Resolution + + + + Status: {incident.resolved ? "Resolved" : "Unresolved"} + + {incident.resolved && ( + <> + + Type: {incident.resolutionType || "N/A"} + + + By: {incident.resolvedBy?.email || "N/A"} + + + )} + + {incident.resolved && incident.endedAt + ? `Resolved at: ${formatDateWithTz( + incident.endedAt, + "ddd, MMMM D, YYYY, HH:mm A", + uiTimezone + )}` + : `Ongoing since: ${formatDateWithTz( + incident.startedAt, + "ddd, MMMM D, YYYY, HH:mm A", + uiTimezone + )}`} + + + + Duration:{" "} + {prettyMilliseconds(duration, { secondsDecimalDigits: 0 })} + + + Note: {incident.resolutionNote || "N/A"} + + + + + ); +}; + +export default IncidentDetailsPage; diff --git a/client/src/pages/incidents/IncidentTable.tsx b/client/src/pages/incidents/IncidentTable.tsx index b00a8c68c..2cb019c7a 100644 --- a/client/src/pages/incidents/IncidentTable.tsx +++ b/client/src/pages/incidents/IncidentTable.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/utils/TimeUtils"; import { useAppSelector } from "@/hooks/AppHooks"; import type { IIncident } from "@/types/incident"; +import { useNavigate } from "react-router"; export const IncidentTable = ({ incidents, @@ -30,6 +31,7 @@ export const IncidentTable = ({ const { patch, loading: isPatching } = usePatch(); const { t } = useTranslation(); const uiTimezone = useAppSelector((state: any) => state.ui.timezone); + const navigate = useNavigate(); const getHeaders = (t: Function, uiTimezone: string) => { const headers: Header[] = [ @@ -150,7 +152,7 @@ export const IncidentTable = ({ headers={headers} data={incidents} onRowClick={(row) => { - alert(JSON.stringify(row, null, 2)); + navigate(`/incidents/${row._id}`); }} /> { element={} /> } /> - } /> + } /> } /> void; + getIncidentById: (req: Request, res: Response, next: NextFunction) => void; resolveIncident: (req: Request, res: Response, next: NextFunction) => void; } @@ -58,6 +59,34 @@ class IncidentsController implements IIncidentsController { } }; + getIncidentById = async (req: Request, res: Response, next: NextFunction) => { + try { + const userContext = req.user; + if (!userContext) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const teamId = userContext.currentTeamId; + if (!teamId) { + throw new ApiError("No team ID", 400); + } + + const { id } = req.params; + if (!id) { + throw new ApiError("No incident ID", 400); + } + + const incident = await this.incidentService.get(teamId, id); + if (!incident) { + throw new ApiError("Incident not found", 404); + } + + res.status(200).json({ message: "OK", data: incident }); + } catch (error) { + next(error); + } + }; + resolveIncident = async (req: Request, res: Response, next: NextFunction) => { try { const userContext = req.user; diff --git a/server/src/routes/incidents.ts b/server/src/routes/incidents.ts index 235ebac45..e65aa4e44 100644 --- a/server/src/routes/incidents.ts +++ b/server/src/routes/incidents.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import { ChecksController, IncidentsController } from "@/controllers/index.js"; +import { IncidentsController } from "@/controllers/index.js"; import { verifyToken } from "@/middleware/VerifyToken.js"; import { addUserContext } from "@/middleware/AddUserContext.js"; import { validateQuery, validateBody } from "@/middleware/validation.js"; @@ -8,6 +8,7 @@ import { verifyOrgPermission } from "@/middleware/VerifyPermission.js"; import { PERMISSIONS } from "@/types/permissions.js"; import { getIncidentsQuerySchema, + getIncidentQuerySchema, patchIncidentsBodySchema, } from "@/validation/index.js"; @@ -29,6 +30,13 @@ class IncidentsRoutes { validateQuery(getIncidentsQuerySchema), this.controller.getIncidents ); + this.router.get( + "/:id", + verifyToken, + addUserContext, + verifyOrgPermission([PERMISSIONS.incidents.read]), + this.controller.getIncidentById + ); this.router.patch( "/:id/resolve", diff --git a/server/src/services/business/IncidentService.ts b/server/src/services/business/IncidentService.ts index 8b786f3cb..0b70a2fa1 100644 --- a/server/src/services/business/IncidentService.ts +++ b/server/src/services/business/IncidentService.ts @@ -26,10 +26,7 @@ export interface IIncidentService { monitorId: mongoose.Types.ObjectId, startCheckId: mongoose.Types.ObjectId ) => Promise; - get: ( - teamId: mongoose.Types.ObjectId, - incidentId: mongoose.Types.ObjectId - ) => Promise; + get: (teamId: string, incidentId: string) => Promise; getAll: ( teamId: string, monitorId: string, @@ -191,10 +188,7 @@ class IncidentService implements IIncidentService { return incident; }; - get = async ( - teamId: mongoose.Types.ObjectId, - incidentId: mongoose.Types.ObjectId - ) => { + get = async (teamId: string, incidentId: string) => { const incident = await Incident.findOne({ _id: new mongoose.Types.ObjectId(incidentId), teamId: new mongoose.Types.ObjectId(teamId), diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index 5686505bb..f93ac0d70 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -517,6 +517,12 @@ export const profileSchema = z } ); +export const getIncidentQuerySchema = z.object({ + id: z.string().refine((val) => mongoose.Types.ObjectId.isValid(val), { + message: "Invalid MongoDB ObjectId", + }), +}); + export const getIncidentsQuerySchema = z.object({ resolutionType: z.enum(ResolutionTypes).optional(), resolved: z