From 3cf7243c93fd4fc26f1db6f141c640b41a3aaa6d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 20 Jan 2026 18:56:06 +0000 Subject: [PATCH] summary --- .../Components/IncidentTable/index.jsx | 9 +- .../Incidents2/hooks/useFetchIncidents.js | 3 +- client/src/Pages/Incidents2/index.jsx | 3 +- server/src/controllers/incidentController.ts | 16 +- .../incidents/IIncidentsRepository.ts | 9 +- .../incidents/MongoIncidentRepository.ts | 166 ++++++++++++++++-- .../src/service/business/incidentService.ts | 49 +++--- server/src/types/incident.ts | 29 +++ server/src/utils/{utils.js => utils.ts} | 6 +- 9 files changed, 237 insertions(+), 53 deletions(-) rename server/src/utils/{utils.js => utils.ts} (74%) diff --git a/client/src/Pages/Incidents2/Components/IncidentTable/index.jsx b/client/src/Pages/Incidents2/Components/IncidentTable/index.jsx index d613e15c7..670da4a81 100644 --- a/client/src/Pages/Incidents2/Components/IncidentTable/index.jsx +++ b/client/src/Pages/Incidents2/Components/IncidentTable/index.jsx @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { Button, Typography, useTheme } from "@mui/material"; const IncidentTable = ({ + monitors = [], incidents = [], incidentsCount = 0, isLoading = false, @@ -44,7 +45,11 @@ const IncidentTable = ({ { id: "monitorName", content: t("incidentsTableMonitorName"), - render: (row) => row.monitorName ?? "N/A", + render: (row) => { + console.log(monitors, row); + const monitor = monitors.find((monitor) => monitor.id === row.monitorId); + return monitor ? monitor.name : "N/A"; + }, }, { id: "status", @@ -130,7 +135,7 @@ const IncidentTable = ({ lineHeight: 1.2, }} onClick={() => { - handleResolveIncident(row._id); + handleResolveIncident(row.id); }} > {t("incidentsPage.incidentsTableActionResolveManually")} diff --git a/client/src/Pages/Incidents2/hooks/useFetchIncidents.js b/client/src/Pages/Incidents2/hooks/useFetchIncidents.js index ab22c42e0..54874bae7 100644 --- a/client/src/Pages/Incidents2/hooks/useFetchIncidents.js +++ b/client/src/Pages/Incidents2/hooks/useFetchIncidents.js @@ -45,9 +45,8 @@ const useFetchIncidents = () => { setNetworkError(false); const res = await networkService.getIncidentsByTeam(config); - setIncidents(res.data?.data?.incidents || []); - setIncidentsCount(res.data?.data?.incidentsCount || 0); + setIncidentsCount(res.data?.data?.count || 0); } catch (error) { setNetworkError(true); console.error(t("incidentsPage.errorFetchingIncidents"), error); diff --git a/client/src/Pages/Incidents2/index.jsx b/client/src/Pages/Incidents2/index.jsx index 2f31864bf..4e68279c5 100644 --- a/client/src/Pages/Incidents2/index.jsx +++ b/client/src/Pages/Incidents2/index.jsx @@ -88,7 +88,7 @@ const Incidents2 = () => { useEffect(() => { const lookup = monitors?.reduce((acc, monitor) => { acc[monitor.id] = { - _id: monitor.id, + id: monitor.id, name: monitor.name, type: monitor.type, }; @@ -132,6 +132,7 @@ const Incidents2 = () => { /> { try { - const summary = await this.incidentService.getIncidentSummary({ - teamId: req?.user?.teamId, - query: req?.query, - }); + const teamId = req.user?.teamId; + + if (!teamId) { + throw new AppError({ message: "Team ID is required", service: SERVICE_NAME, status: 400 }); + } + + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; + + const summary = await this.incidentService.getIncidentSummary(req?.user?.teamId, limit); return res.status(200).json({ success: true, diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/repositories/incidents/IIncidentsRepository.ts index dbdf2b05f..7c2b0179e 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/repositories/incidents/IIncidentsRepository.ts @@ -1,4 +1,5 @@ import type { Incident } from "@/types/index.js"; +import type { IncidentSummary } from "@/types/index.js"; export interface IIncidentsRepository { // create create(incident: Partial): Promise; @@ -7,17 +8,19 @@ export interface IIncidentsRepository { findActiveByMonitorId(monitorId: string, teamId: string): Promise; findByTeamId( teamId: string, - startDate: Date, - endDate: Date, + startDate: Date | undefined, page: number, rowsPerPage: number, sortOrder?: string, - status?: string, + status?: boolean, monitorId?: string, resolutionType?: string ): Promise; + findSummaryByTeamId(teamId: string, limit?: number): Promise; + countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise; // update updateById(incidentId: string, teamId: string, updateData: Partial): Promise; // delete + // other } diff --git a/server/src/repositories/incidents/MongoIncidentRepository.ts b/server/src/repositories/incidents/MongoIncidentRepository.ts index ead783e11..10cb47213 100644 --- a/server/src/repositories/incidents/MongoIncidentRepository.ts +++ b/server/src/repositories/incidents/MongoIncidentRepository.ts @@ -1,6 +1,6 @@ import { IncidentModel } from "@/db/models/index.js"; import type { IncidentDocument } from "@/db/models/Incident.js"; -import type { Incident } from "@/types/index.js"; +import type { Incident, IncidentSummary } from "@/types/index.js"; import type { IIncidentsRepository } from "@/repositories/index.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; @@ -20,6 +20,32 @@ class MongoIncidentRepository implements IIncidentsRepository { return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); }; + private buildMatchStage({ + teamId, + startDate, + status, + monitorId, + resolutionType, + }: { + teamId: string; + startDate: Date | undefined; + status?: boolean; + monitorId?: string; + resolutionType?: string; + }): Record { + const matchStage: Record = { + teamId: new mongoose.Types.ObjectId(teamId), + ...(status !== undefined && { status }), + ...(monitorId && { monitorId: new mongoose.Types.ObjectId(monitorId) }), + ...(resolutionType && { resolutionType }), + }; + + if (startDate) { + matchStage.createdAt = { $gte: startDate }; + } + return matchStage; + } + protected toEntity = (doc: IncidentDocument): Incident => { return { id: this.toStringId(doc._id), @@ -79,22 +105,15 @@ class MongoIncidentRepository implements IIncidentsRepository { findByTeamId = async ( teamId: string, - startDate: Date, - endDate: Date, + startDate: Date | undefined, page: number, rowsPerPage: number, sortOrder?: string, - status?: string, + status?: boolean, monitorId?: string, resolutionType?: string ): Promise => { - const matchStage: Record = { - teamId: new mongoose.Types.ObjectId(teamId), - ...(status !== undefined && { status: status }), - ...(monitorId && { monitorId: new mongoose.Types.ObjectId(monitorId) }), - ...(resolutionType && { resolutionType }), - createdAt: { $gte: startDate, $lte: endDate }, - }; + const matchStage = this.buildMatchStage({ teamId, startDate, status, monitorId, resolutionType }); const incidents = await IncidentModel.find(matchStage) .sort({ createdAt: sortOrder === "asc" ? 1 : -1 }) .skip(page * rowsPerPage) @@ -117,5 +136,130 @@ class MongoIncidentRepository implements IIncidentsRepository { } return this.toEntity(updatedIncident); }; + + countByTeamId = async ( + teamId: string, + startDate: Date | undefined, + status?: boolean, + monitorId?: string, + resolutionType?: string + ): Promise => { + const matchStage = this.buildMatchStage({ teamId, startDate, status, monitorId, resolutionType }); + return IncidentModel.countDocuments(matchStage); + }; + + findSummaryByTeamId = async (teamId: string, limit: number): Promise => { + const matchStage = { teamId: new mongoose.Types.ObjectId(teamId) }; + + const counts = await IncidentModel.aggregate([ + { $match: matchStage }, + { + $group: { + _id: "$status", + count: { $sum: 1 }, + manualResolutions: { + $sum: { $cond: [{ $eq: ["$resolutionType", "manual"] }, 1, 0] }, + }, + automaticResolutions: { + $sum: { $cond: [{ $eq: ["$resolutionType", "automatic"] }, 1, 0] }, + }, + }, + }, + ]); + + let total = 0; + let active = 0; + let manual = 0; + let automatic = 0; + + counts.forEach((item) => { + total += item.count; + if (item._id === true) { + active = item.count; + } + manual += item.manualResolutions; + automatic += item.automaticResolutions; + }); + + const resolutionTimeResult = await IncidentModel.aggregate([ + { $match: { ...matchStage, status: false, endTime: { $exists: true, $ne: null } } }, + { $project: { resolutionTime: { $subtract: ["$endTime", "$startTime"] } } }, + { $group: { _id: null, avgResolutionTime: { $avg: "$resolutionTime" } } }, + ]); + const avgResolutionTimeMs = resolutionTimeResult[0]?.avgResolutionTime || 0; + const avgResolutionTimeHours = Math.round((avgResolutionTimeMs / (1000 * 60 * 60) || 0) * 100) / 100; + + const monitorResult = await IncidentModel.aggregate([ + { $match: matchStage }, + { $group: { _id: "$monitorId", count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + { $limit: 1 }, + { + $lookup: { + from: "monitors", + localField: "_id", + foreignField: "_id", + as: "monitor", + }, + }, + { $project: { monitorId: "$_id", count: 1, monitorName: { $arrayElemAt: ["$monitor.name", 0] } } }, + ]); + + const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10); + const latestIncidents = await IncidentModel.aggregate([ + { $match: matchStage }, + { $sort: { createdAt: -1 } }, + { $limit: latestLimit }, + { + $lookup: { + from: "monitors", + localField: "monitorId", + foreignField: "_id", + as: "monitor", + }, + }, + { + $project: { + _id: 1, + monitorId: 1, + monitorName: { $arrayElemAt: ["$monitor.name", 0] }, + status: 1, + startTime: 1, + endTime: 1, + resolutionType: 1, + message: 1, + statusCode: 1, + createdAt: 1, + }, + }, + ]); + + return { + total, + totalActive: active, + totalManualResolutions: manual, + totalAutomaticResolutions: automatic, + avgResolutionTimeHours, + topMonitor: monitorResult[0] + ? { + monitorId: this.toStringId(monitorResult[0].monitorId), + monitorName: monitorResult[0].monitorName ?? null, + incidentCount: monitorResult[0].count, + } + : null, + latestIncidents: latestIncidents.map((incident) => ({ + id: this.toStringId(incident._id), + monitorId: this.toStringId(incident.monitorId), + monitorName: incident.monitorName ?? null, + status: incident.status, + startTime: this.toDateString(incident.startTime), + endTime: incident.endTime ? this.toDateString(incident.endTime) : null, + resolutionType: incident.resolutionType ?? null, + message: incident.message ?? null, + statusCode: incident.statusCode ?? null, + createdAt: this.toDateString(incident.createdAt), + })), + }; + }; } export default MongoIncidentRepository; diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index b2ad444d3..bc69eeed0 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "incidentService"; import type { Monitor } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; +import { ParseBoolean } from "@/utils/utils.js"; import type { IIncidentsRepository } from "@/repositories/index.js"; import type { Incident } from "@/types/index.js"; @@ -136,16 +137,13 @@ class IncidentService { const startDate = dateRangeLookup[dateRange]; - const endDate = new Date(); - const parsedPage = Number.isFinite(parseInt(page)) ? parseInt(page) : 0; const parsedRowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20; - const parsedStatus = status === "true" ? "true" : status === "false" ? "false" : undefined; + const parsedStatus = typeof status === "undefined" ? undefined : ParseBoolean(status); - const res = await this.incidentsRepository.findByTeamId( + const incidents = await this.incidentsRepository.findByTeamId( teamId, startDate, - endDate, parsedPage, parsedRowsPerPage, sortOrder, @@ -154,18 +152,9 @@ class IncidentService { resolutionType ); - // const result = await this.db.incidentModule.getIncidentsByTeam({ - // teamId, - // sortOrder, - // dateRange, - // page, - // rowsPerPage, - // status, - // monitorId, - // resolutionType, - // }); + const count = await this.incidentsRepository.countByTeamId(teamId, startDate, parsedStatus, monitorId, resolutionType); - return res; + return { incidents, count }; } catch (error: any) { this.logger.error({ service: SERVICE_NAME, @@ -178,18 +167,14 @@ class IncidentService { } }; - getIncidentSummary = async ({ teamId, query }: { teamId: string; query?: any }) => { + getIncidentSummary = async (teamId: string, limit?: string) => { try { if (!teamId) { - throw this.errorService.createBadRequestError("No team ID in request"); + throw this.errorService.createBadRequestError(" team ID in request"); } - const { limit } = query || {}; - - const summary = await this.db.incidentModule.getIncidentSummary({ - teamId, - limit, - }); + const parsedLimit = limit && Number.isFinite(parseInt(limit, 10)) ? parseInt(limit, 10) : 10; + const summary = await this.incidentsRepository.findSummaryByTeamId(teamId, parsedLimit); return summary; } catch (error: any) { @@ -256,7 +241,7 @@ class IncidentService { for (const item of resolveItems as any[]) { try { - await this.resolveIncident(item.monitor); + await this.resolveIncidentForMonitor(item.monitor); } catch (error: any) { this.logger.error({ service: SERVICE_NAME, @@ -378,6 +363,20 @@ class IncidentService { throw error; } }; + + private resolveIncidentForMonitor = async (monitor: Monitor) => { + if (!monitor?.id) { + return; + } + const incident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId); + if (!incident) { + return; + } + incident.status = false; + incident.endTime = new Date().toISOString(); + incident.resolutionType = "automatic"; + await this.incidentsRepository.updateById(incident.id, incident.teamId, incident); + }; } export default IncidentService; diff --git a/server/src/types/incident.ts b/server/src/types/incident.ts index 65186f08c..39dfb3d0d 100644 --- a/server/src/types/incident.ts +++ b/server/src/types/incident.ts @@ -18,3 +18,32 @@ export interface Incident { createdAt: string; updatedAt: string; } + +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: IncidentResolutionType; + 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[]; +} diff --git a/server/src/utils/utils.js b/server/src/utils/utils.ts similarity index 74% rename from server/src/utils/utils.js rename to server/src/utils/utils.ts index ee4ee1c6d..a82d8734f 100755 --- a/server/src/utils/utils.js +++ b/server/src/utils/utils.ts @@ -1,4 +1,4 @@ -const ParseBoolean = (value) => { +export const ParseBoolean = (value: boolean | string | null | undefined) => { if (value === true || value === "true") { return true; } else if (value === false || value === "false" || value === null || value === undefined) { @@ -6,7 +6,7 @@ const ParseBoolean = (value) => { } }; -const getTokenFromHeaders = (headers) => { +export const getTokenFromHeaders = (headers: Record) => { const authorizationHeader = headers.authorization; if (!authorizationHeader) throw new Error("No auth headers"); @@ -15,5 +15,3 @@ const getTokenFromHeaders = (headers) => { return parts[1]; }; - -export { ParseBoolean, getTokenFromHeaders };