This commit is contained in:
Alex Holliday
2026-01-20 18:56:06 +00:00
parent adb2a383cf
commit 3cf7243c93
9 changed files with 237 additions and 53 deletions
@@ -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")}
@@ -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);
+2 -1
View File
@@ -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 = () => {
/>
<IncidentTable
monitors={monitors || []}
incidents={incidents || []}
incidentsCount={incidentsCount || 0}
isLoading={isLoadingIncidents}
+11 -5
View File
@@ -1,3 +1,4 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "incidentController";
@@ -18,7 +19,7 @@ class IncidentController {
try {
const result = await this.incidentService.getIncidentsByTeam({
teamId: req?.user?.teamId,
query: req?.query,
query: req?.query?.limit,
});
return res.status(200).json({
@@ -33,10 +34,15 @@ class IncidentController {
getIncidentSummary = async (req: Request, res: Response, next: NextFunction) => {
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,
@@ -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<Incident>): Promise<Incident>;
@@ -7,17 +8,19 @@ export interface IIncidentsRepository {
findActiveByMonitorId(monitorId: string, teamId: string): Promise<Incident | null>;
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<Incident[]>;
findSummaryByTeamId(teamId: string, limit?: number): Promise<IncidentSummary>;
countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise<number>;
// update
updateById(incidentId: string, teamId: string, updateData: Partial<Incident>): Promise<Incident>;
// delete
// other
}
@@ -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<string, any> {
const matchStage: Record<string, any> = {
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<Incident[]> => {
const matchStage: Record<string, any> = {
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<number> => {
const matchStage = this.buildMatchStage({ teamId, startDate, status, monitorId, resolutionType });
return IncidentModel.countDocuments(matchStage);
};
findSummaryByTeamId = async (teamId: string, limit: number): Promise<IncidentSummary> => {
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;
+24 -25
View File
@@ -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;
+29
View File
@@ -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[];
}
@@ -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<string, string>) => {
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 };