Merge upstream/develop into fix/3120-data-leak

This commit is contained in:
Craig Lawson
2026-01-15 11:06:27 +00:00
309 changed files with 8413 additions and 4468 deletions
+20
View File
@@ -0,0 +1,20 @@
import type { Config } from "jest";
const config: Config = {
rootDir: ".",
testEnvironment: "node",
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }],
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testMatch: ["<rootDir>/test/**/*.test.ts"],
setupFilesAfterEnv: [],
collectCoverageFrom: ["src/**/*.ts"],
coveragePathIgnorePatterns: ["/node_modules/", "/test/"],
clearMocks: true,
};
export default config;
+3406 -353
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -5,7 +5,7 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "c8 mocha",
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias",
@@ -58,19 +58,27 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/dockerode": "^4.0.0",
"@types/express": "5.0.3",
"@types/gamedig": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/jmespath": "^0.15.2",
"@types/jsonwebtoken": "9.0.10",
"@types/mjml": "^4.7.4",
"@types/multer": "^2.0.0",
"@types/nodemailer": "7.0.1",
"@types/papaparse": "^5.5.2",
"@types/ping": "0.4.4",
"c8": "10.1.3",
"chai": "5.2.0",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
"globals": "^15.14.0",
"mocha": "11.1.0",
"jest": "^30.2.0",
"nodemon": "^3.1.11",
"prettier": "^3.3.3",
"sinon": "19.0.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsc-alias": "1.8.16",
"tsx": "4.20.5",
"typescript": "5.9.2"
+176
View File
@@ -0,0 +1,176 @@
import mongoose from "mongoose";
import { MonitorModel } from "../dist/db/models/Monitor.js";
import { CheckModel } from "../dist/db/models/Check.js";
const DEFAULT_MONITOR_ID = "000000000000000000000001";
const DEFAULT_TEAM_ID = "0000000000000000000000aa";
const DEFAULT_USER_ID = "0000000000000000000000bb";
const DEFAULT_MONITOR_TYPE = "http";
const DEFAULT_TOTAL = 1_000_000;
const DEFAULT_BATCH_SIZE = 5_000;
const parseObjectId = (value, fallback) => {
try {
return new mongoose.Types.ObjectId(value || fallback);
} catch (error) {
console.warn(`Invalid ObjectId '${value}', falling back to '${fallback}'.`);
return new mongoose.Types.ObjectId(fallback);
}
};
async function ensureMonitor({ monitorId, teamId, userId, type }) {
const existing = await MonitorModel.findById(monitorId);
if (existing) {
return existing;
}
console.log(`Monitor ${monitorId.toString()} not found, creating it.`);
const monitor = new MonitorModel({
_id: monitorId,
userId,
teamId,
name: `Seed Monitor ${monitorId.toString()}`,
description: "Synthetic monitor for performance testing",
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 60,
type,
ignoreTlsErrors: false,
url: "https://example.com",
isActive: true,
interval: 60000,
alertThreshold: 5,
cpuAlertThreshold: 5,
memoryAlertThreshold: 5,
diskAlertThreshold: 5,
tempAlertThreshold: 5,
selectedDisks: [],
});
await monitor.save();
return monitor;
}
async function run() {
const mongoUri = process.env.MONGO_URI ?? "mongodb://localhost:27017/uptime_db";
const monitorId = parseObjectId(process.env.MONITOR_ID ?? DEFAULT_MONITOR_ID, DEFAULT_MONITOR_ID);
const teamId = parseObjectId("69648b0578209af45f9ffe30");
const userId = parseObjectId("69648b0678209af45f9ffe32");
const monitorType = process.env.MONITOR_TYPE ?? DEFAULT_MONITOR_TYPE;
const total = Number(process.env.CHECK_TOTAL ?? DEFAULT_TOTAL);
const batchSize = Number(process.env.CHECK_BATCH_SIZE ?? DEFAULT_BATCH_SIZE);
console.log(`Connecting to MongoDB at ${mongoUri}`);
await mongoose.connect(mongoUri);
await ensureMonitor({ monitorId, teamId, userId, type: monitorType });
console.log(`Seeding ${total} checks for monitor ${monitorId.toString()} (team ${teamId.toString()}) in batches of ${batchSize}.`);
const docs = [];
const startTime = Date.now();
for (let i = 0; i < total; i += 1) {
const baseTime = Date.now() - (total - i) * 1000;
const createdAt = new Date(baseTime);
docs.push({
metadata: {
monitorId,
teamId,
type: monitorType,
},
status: i % 50 !== 0,
statusCode: i % 50 !== 0 ? 200 : 500,
responseTime: Math.floor(Math.random() * 1000),
message: i % 50 !== 0 ? "OK" : "Error",
expiry: createdAt,
createdAt,
updatedAt: createdAt,
timings: {
start: baseTime,
socket: baseTime,
lookup: baseTime,
connect: baseTime,
secureConnect: baseTime,
upload: baseTime,
response: baseTime + 40,
end: baseTime + 45,
phases: {
wait: 0,
dns: 1,
tcp: 2,
tls: 4,
request: 0,
firstByte: 30,
download: 5,
total: 45,
},
},
cpu: {
physical_core: 8,
logical_core: 16,
frequency: 3600,
temperature: [50 + Math.random() * 10],
free_percent: 40,
usage_percent: Math.random() * 100,
},
memory: {
total_bytes: 32 * 1024 ** 3,
available_bytes: 16 * 1024 ** 3,
used_bytes: 16 * 1024 ** 3,
usage_percent: Math.random() * 100,
},
disk: [
{
device: "/dev/sda1",
mountpoint: "/",
read_speed_bytes: Math.random() * 10_000_000,
write_speed_bytes: Math.random() * 10_000_000,
total_bytes: 512 * 1024 ** 3,
free_bytes: 128 * 1024 ** 3,
usage_percent: Math.random() * 100,
},
],
host: {
os: "linux",
platform: "ubuntu",
kernel_version: "5.15.0",
},
net: [
{
name: "eth0",
bytes_sent: Math.random() * 10_000_000,
bytes_recv: Math.random() * 10_000_000,
packets_sent: Math.random() * 1_000_000,
packets_recv: Math.random() * 1_000_000,
err_in: 0,
err_out: 0,
drop_in: 0,
drop_out: 0,
fifo_in: 0,
fifo_out: 0,
},
],
errors: i % 50 === 0 ? [{ metric: ["uptime"], err: "500" }] : [],
});
if (docs.length === batchSize) {
await CheckModel.insertMany(docs, { ordered: false });
console.log(`Inserted ${i + 1} / ${total}`);
docs.length = 0;
}
}
if (docs.length > 0) {
await CheckModel.insertMany(docs, { ordered: false });
}
await mongoose.disconnect();
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`Finished inserting ${total} checks in ${duration}s`);
}
run().catch((error) => {
console.error("Failed to seed checks", error);
process.exit(1);
});
-1
View File
@@ -14,7 +14,6 @@ import { sanitizeBody, sanitizeQuery } from "./middleware/v1/sanitization.js";
export const createApp = ({ services, controllers, envSettings, frontendPath, openApiSpec }) => {
const allowedOrigin = envSettings.clientHost;
const app = express();
app.use(generalApiLimiter);
// Static files
-47
View File
@@ -1,47 +0,0 @@
import { createCommonDependencies } from "../controllers/v1/baseController.js";
// Services
// V1 Controllers
import MonitorController from "../controllers/v1/monitorController.js";
import AuthController from "../controllers/v1/authController.js";
import SettingsController from "../controllers/v1/settingsController.js";
import CheckController from "../controllers/v1/checkController.js";
import InviteController from "../controllers/v1/inviteController.js";
import MaintenanceWindowController from "../controllers/v1/maintenanceWindowController.js";
import QueueController from "../controllers/v1/queueController.js";
import LogController from "../controllers/v1/logController.js";
import StatusPageController from "../controllers/v1/statusPageController.js";
import NotificationController from "../controllers/v1/notificationController.js";
import DiagnosticController from "../controllers/v1/diagnosticController.js";
import IncidentController from "../controllers/v1/incidentController.js";
export const initializeControllers = (services) => {
const controllers = {};
const commonDependencies = createCommonDependencies(services.db, services.errorService, services.logger, services.stringService);
// V1
controllers.authController = new AuthController(commonDependencies, {
settingsService: services.settingsService,
emailService: services.emailService,
jobQueue: services.jobQueue,
userService: services.userService,
});
controllers.monitorController = new MonitorController(services.monitorService);
controllers.settingsController = new SettingsController(services.settingsService, services.emailService, services.db);
controllers.checkController = new CheckController(services.checkService);
controllers.inviteController = new InviteController(services.inviteService);
controllers.maintenanceWindowController = new MaintenanceWindowController(services.maintenanceWindowService);
controllers.queueController = new QueueController(services.jobQueue);
controllers.logController = new LogController(services.logger);
controllers.statusPageController = new StatusPageController(services.db);
controllers.notificationController = new NotificationController(services.notificationService, services.db);
controllers.diagnosticController = new DiagnosticController(services.diagnosticService);
controllers.incidentController = new IncidentController(services.incidentService);
return controllers;
};
+35
View File
@@ -0,0 +1,35 @@
import MonitorController from "../controllers/monitorController.js";
import AuthController from "../controllers/authController.js";
import SettingsController from "../controllers/settingsController.js";
import CheckController from "../controllers/checkController.js";
import InviteController from "../controllers/inviteController.js";
import MaintenanceWindowController from "../controllers/maintenanceWindowController.js";
import QueueController from "../controllers/queueController.js";
import LogController from "../controllers/logController.js";
import StatusPageController from "../controllers/statusPageController.js";
import NotificationController from "../controllers/notificationController.js";
import DiagnosticController from "../controllers/diagnosticController.js";
import IncidentController from "../controllers/incidentController.js";
export const initializeControllers = (services: any) => {
const controllers: Record<string, any> = {};
controllers.authController = new AuthController(services.userService);
controllers.monitorController = new MonitorController(services.monitorService);
controllers.settingsController = new SettingsController(services.settingsService, services.emailService, services.db);
controllers.checkController = new CheckController(services.checkService);
controllers.inviteController = new InviteController(services.inviteService);
controllers.maintenanceWindowController = new MaintenanceWindowController(services.maintenanceWindowService);
controllers.queueController = new QueueController(services.jobQueue);
controllers.logController = new LogController(services.logger);
controllers.statusPageController = new StatusPageController(services.db);
controllers.notificationController = new NotificationController(services.notificationService, services.db);
controllers.diagnosticController = new DiagnosticController(services.diagnosticService);
controllers.incidentController = new IncidentController(services.incidentService);
return controllers;
};
@@ -2,7 +2,7 @@ import { verifyJWT } from "../middleware/v1/verifyJWT.js";
import { authApiLimiter } from "../middleware/v1/rateLimiter.js";
import AuthRoutes from "../routes/v1/authRoute.js";
import InviteRoutes from "../routes/v1//inviteRoute.js";
import InviteRoutes from "../routes/v1/inviteRoute.js";
import MonitorRoutes from "../routes/v1/monitorRoute.js";
import CheckRoutes from "../routes/v1/checkRoute.js";
import SettingsRoutes from "../routes/v1/settingsRoute.js";
@@ -10,12 +10,12 @@ import MaintenanceWindowRoutes from "../routes/v1/maintenanceWindowRoute.js";
import StatusPageRoutes from "../routes/v1/statusPageRoute.js";
import QueueRoutes from "../routes/v1/queueRoute.js";
import LogRoutes from "../routes/v1/logRoutes.js";
import DiagnosticRoutes from "../routes/v1//diagnosticRoute.js";
import DiagnosticRoutes from "../routes/v1/diagnosticRoute.js";
import NotificationRoutes from "../routes/v1/notificationRoute.js";
import IncidentRoutes from "../routes/v1/incidentRoute.js";
export const setupRoutes = (app, controllers) => {
export const setupRoutes = (app: any, controllers: Record<string, any>) => {
// V1
const authRoutes = new AuthRoutes(controllers.authController);
const monitorRoutes = new MonitorRoutes(controllers.monitorController);
@@ -1,23 +1,23 @@
import ServiceRegistry from "../service/v1/system/serviceRegistry.js";
import TranslationService from "../service/v1/system/translationService.js";
import StringService from "../service/v1/system/stringService.js";
import MongoDB from "../db/v1/MongoDB.js";
import NetworkService from "../service/v1/infrastructure/networkService.js";
import EmailService from "../service/v1/infrastructure/emailService.js";
import BufferService from "../service/v1/infrastructure/bufferService.js";
import StatusService from "../service/v1/infrastructure/statusService.js";
import NotificationUtils from "../service/v1/infrastructure/notificationUtils.js";
import NotificationService from "../service/v1/infrastructure/notificationService.js";
import ErrorService from "../service/v1/infrastructure/errorService.js";
import SuperSimpleQueueHelper from "../service/v1/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import SuperSimpleQueue from "../service/v1/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/v1/business/userService.js";
import CheckService from "../service/v1/business/checkService.js";
import DiagnosticService from "../service/v1/business/diagnosticService.js";
import InviteService from "../service/v1/business/inviteService.js";
import MaintenanceWindowService from "../service/v1/business/maintenanceWindowService.js";
import MonitorService from "../service/v1/business/monitorService.js";
import IncidentService from "../service/v1/business/incidentService.js";
import ServiceRegistry from "../service/system/serviceRegistry.js";
import TranslationService from "../service/system/translationService.js";
import StringService from "../service/system/stringService.js";
import MongoDB from "../db/MongoDB.js";
import NetworkService from "../service/infrastructure/networkService.js";
import EmailService from "../service/infrastructure/emailService.js";
import BufferService from "../service/infrastructure/bufferService.js";
import StatusService from "../service/infrastructure/statusService.js";
import NotificationUtils from "../service/infrastructure/notificationUtils.js";
import NotificationService from "../service/infrastructure/notificationService.js";
import ErrorService from "../service/infrastructure/errorService.js";
import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/business/userService.js";
import CheckService from "../service/business/checkService.js";
import DiagnosticService from "../service/business/diagnosticService.js";
import InviteService from "../service/business/inviteService.js";
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
import { MonitorService } from "@/service/index.js";
import IncidentService from "../service/business/incidentService.js";
import papaparse from "papaparse";
import axios from "axios";
import got from "got";
@@ -34,9 +34,8 @@ const { compile } = pkg;
import mjml2html from "mjml";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games } from "gamedig";
import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
import { GameDig } from "gamedig";
import { fileURLToPath } from "url";
import { ObjectId } from "mongodb";
@@ -47,33 +46,35 @@ import { GenerateAvatarImage } from "../utils/imageProcessing.js";
import { ParseBoolean } from "../utils/utils.js";
// Models
import Check from "../db/v1/models/Check.js";
import Monitor from "../db/v1/models/Monitor.js";
import User from "../db/v1/models/User.js";
import InviteToken from "../db/v1/models/InviteToken.js";
import StatusPage from "../db/v1/models/StatusPage.js";
import Team from "../db/v1/models/Team.js";
import MaintenanceWindow from "../db/v1/models/MaintenanceWindow.js";
import MonitorStats from "../db/v1/models/MonitorStats.js";
import Notification from "../db/v1/models/Notification.js";
import RecoveryToken from "../db/v1/models/RecoveryToken.js";
import AppSettings from "../db/v1/models/AppSettings.js";
import Incident from "../db/v1/models/Incident.js";
import Monitor from "../db/models/Monitor.js";
import User from "../db/models/User.js";
import InviteToken from "../db/models/InviteToken.js";
import StatusPage from "../db/models/StatusPage.js";
import Team from "../db/models/Team.js";
import MaintenanceWindow from "../db/models/MaintenanceWindow.js";
import MonitorStats from "../db/models/MonitorStats.js";
import Notification from "../db/models/Notification.js";
import RecoveryToken from "../db/models/RecoveryToken.js";
import AppSettings from "../db/models/AppSettings.js";
import Incident from "../db/models/Incident.js";
import InviteModule from "../db/v1/modules/inviteModule.js";
import CheckModule from "../db/v1/modules/checkModule.js";
import StatusPageModule from "../db/v1/modules/statusPageModule.js";
import UserModule from "../db/v1/modules/userModule.js";
import MaintenanceWindowModule from "../db/v1/modules/maintenanceWindowModule.js";
import MonitorModule from "../db/v1/modules/monitorModule.js";
import NotificationModule from "../db/v1/modules/notificationModule.js";
import RecoveryModule from "../db/v1/modules/recoveryModule.js";
import SettingsModule from "../db/v1/modules/settingsModule.js";
import IncidentModule from "../db/v1/modules/incidentModule.js";
import InviteModule from "../db/modules/inviteModule.js";
import CheckModule from "../db/modules/checkModule.js";
import StatusPageModule from "../db/modules/statusPageModule.js";
import UserModule from "../db/modules/userModule.js";
import MaintenanceWindowModule from "../db/modules/maintenanceWindowModule.js";
import MonitorModule from "../db/modules/monitorModule.js";
import NotificationModule from "../db/modules/notificationModule.js";
import RecoveryModule from "../db/modules/recoveryModule.js";
import SettingsModule from "../db/modules/settingsModule.js";
import IncidentModule from "../db/modules/incidentModule.js";
export const initializeServices = async ({ logger, envSettings, settingsService }) => {
// repositories
import { MongoMonitorsRepository, MongoChecksRepository, MongoMonitorStatsRepository } from "@/repositories/index.js";
export const initializeServices = async ({ logger, envSettings, settingsService }: { logger: any; envSettings: any; settingsService: any }) => {
const serviceRegistry = new ServiceRegistry({ logger });
ServiceRegistry.instance = serviceRegistry;
(ServiceRegistry as any).instance = serviceRegistry;
const translationService = new TranslationService(logger);
await translationService.initialize();
@@ -81,7 +82,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
const stringService = new StringService(translationService);
// Create DB
const checkModule = new CheckModule({ logger, Check, Monitor, User });
const checkModule = new CheckModule({ logger, Monitor, User });
const inviteModule = new InviteModule({ InviteToken, crypto, stringService });
const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService, AppSettings });
const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService });
@@ -89,7 +90,6 @@ export const initializeServices = async ({ logger, envSettings, settingsService
const monitorModule = new MonitorModule({
Monitor,
MonitorStats,
Check,
stringService,
fs,
path,
@@ -120,6 +120,11 @@ export const initializeServices = async ({ logger, envSettings, settingsService
await db.connect();
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository();
const monitorStatsRepository = new MongoMonitorStatsRepository();
const networkService = new NetworkService({
axios,
got,
@@ -146,7 +151,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
const bufferService = new BufferService({ db, logger, envSettings, incidentService });
const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService });
const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService, monitorsRepository });
const notificationUtils = new NotificationUtils({
stringService,
@@ -176,6 +181,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
db,
logger,
helper: superSimpleQueueHelper,
monitorsRepository,
});
// Business services
@@ -212,7 +218,6 @@ export const initializeServices = async ({ logger, envSettings, settingsService
});
const monitorService = new MonitorService({
db,
settingsService,
jobQueue: superSimpleQueue,
stringService,
emailService,
@@ -220,6 +225,9 @@ export const initializeServices = async ({ logger, envSettings, settingsService
logger,
errorService,
games,
monitorsRepository,
checksRepository,
monitorStatsRepository,
});
const services = {
+233
View File
@@ -0,0 +1,233 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/AppError.js";
import {
registrationBodyValidation,
loginValidation,
editUserBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "@/validation/joi.js";
const SERVICE_NAME = "authController";
class AuthController {
static SERVICE_NAME = SERVICE_NAME;
private userService: any;
constructor(userService: any) {
this.userService = userService;
}
get serviceName() {
return AuthController.SERVICE_NAME;
}
registerUser = async (req: Request, res: Response, next: NextFunction) => {
try {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await registrationBodyValidation.validateAsync(req.body);
const { user, token } = await this.userService.registerUser(req.body, req.file);
res.status(200).json({
success: true,
msg: "User registered successfully",
data: { user, token },
});
} catch (error) {
next(error);
}
};
loginUser = async (req: Request, res: Response, next: NextFunction) => {
try {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
return res.status(200).json({
success: true,
msg: "User logged in successfully",
data: {
user,
token,
},
});
} catch (error) {
next(error);
}
};
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
await editUserBodyValidation.validateAsync(req.body);
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
res.status(200).json({
success: true,
msg: "User updated successfully",
data: updatedUser,
});
} catch (error) {
next(error);
}
};
checkSuperadminExists = async (req: Request, res: Response, next: NextFunction) => {
try {
const superAdminExists = await this.userService.checkSuperadminExists();
return res.status(200).json({
success: true,
msg: "Superadmin existence checked successfully",
data: superAdminExists,
});
} catch (error) {
next(error);
}
};
requestRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
await recoveryValidation.validateAsync(req.body);
const email = req?.body?.email;
const msgId = await this.userService.requestRecovery(email);
return res.status(200).json({
success: true,
msg: "Password recovery email sent successfully",
data: msgId,
});
} catch (error) {
next(error);
}
};
validateRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
await recoveryTokenBodyValidation.validateAsync(req.body);
await this.userService.validateRecovery(req.body.recoveryToken);
return res.status(200).json({
success: true,
msg: "Recovery token is valid",
});
} catch (error) {
next(error);
}
};
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
await newPasswordValidation.validateAsync(req.body);
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
return res.status(200).json({
success: true,
msg: "Password has been reset successfully",
data: { user, token },
});
} catch (error) {
next(error);
}
};
deleteUser = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.userService.deleteUser(req.user);
return res.status(200).json({
success: true,
msg: "User deleted successfully",
});
} catch (error) {
next(error);
}
};
getAllUsers = async (req: Request, res: Response, next: NextFunction) => {
try {
const allUsers = await this.userService.getAllUsers();
return res.status(200).json({
success: true,
msg: "Users retrieved successfully",
data: allUsers,
});
} catch (error) {
next(error);
}
};
getUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getUserByIdParamValidation.validateAsync(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
if (!userId) {
throw new Error("No user ID in request");
}
if (!roles || roles.length === 0) {
throw new Error("No roles in request");
}
const user = await this.userService.getUserById(roles, userId);
return res.status(200).json({ success: true, msg: "ok", data: user });
} catch (error) {
next(error);
}
};
editUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const userId = req.params.userId;
const user = { ...req.body };
await editUserByIdParamValidation.validateAsync(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user._id) {
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
} else {
await editUserByIdBodyValidation.validateAsync(req.body);
}
await this.userService.editUserById(userId, user);
return res.status(200).json({ success: true, msg: "ok" });
} catch (error) {
next(error);
}
};
editUserPasswordById = async (req: Request, res: Response, next: NextFunction) => {
try {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const userId = req.params.userId;
await editUserByIdParamValidation.validateAsync(req.params);
await editUserPasswordByIdBodyValidation.validateAsync(req.body);
const updatedPassword = req.body.password;
await this.userService.setPasswordByUserId(userId, updatedPassword);
return res.status(200).json({ success: true, msg: "Password reset successfully" });
} catch (error) {
next(error);
}
};
}
export default AuthController;
+111
View File
@@ -0,0 +1,111 @@
import { AppError } from "@/utils/AppError.js";
import { type MonitorType, MonitorTypes } from "@/types/index.js";
const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<any> => {
const monitorUrl = new URL(monitor.url);
const hostname = monitorUrl.hostname;
const cert = await sslChecker(hostname);
// Throw an error if no cert or if cert.validTo is not present
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
return cert;
};
const requireString = (value: unknown, fieldName: string): string => {
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
throw new AppError({ message: `${fieldName} is required`, status: 400 });
};
const optionalString = (value: unknown, fieldName: string): string | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "string") {
return value;
}
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
};
const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") {
throw new AppError({ message: "Monitor type must be a string", status: 400 });
}
if (!MonitorTypes.includes(input as MonitorType)) {
throw new AppError({ message: `Invalid monitor type: ${input}`, status: 400 });
}
return input as MonitorType;
};
if (value === undefined) {
return undefined;
}
if (Array.isArray(value)) {
return value.map((entry) => parseSingle(entry));
}
return parseSingle(value);
};
const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
if (value === undefined) {
return undefined;
}
if (value === "asc" || value === "desc") {
return value;
}
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
};
const requireTeamId = (teamId?: string): string => {
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
return teamId;
};
export {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
};
@@ -5,26 +5,35 @@ import {
getMonitorByIdQueryValidation,
getMonitorsByTeamIdParamValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
} from "../../validation/joi.js";
} from "@/validation/joi.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate } from "./controllerUtils.js";
import {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
} from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/service/index.js";
const SERVICE_NAME = "monitorController";
class MonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: any;
private monitorService: IMonitorService;
constructor(monitorService: any) {
constructor(monitorService: IMonitorService) {
this.monitorService = monitorService;
}
@@ -33,33 +42,17 @@ class MonitorController {
}
async verifyTeamAccess(teamId: string, monitorId: string) {
const monitor = await this.monitorService.getMonitorById(monitorId);
if (!monitor.teamId.equals(teamId)) {
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
if (monitor.teamId !== teamId) {
throw new AppError({ message: "Access denied", status: 403 });
}
}
getAllMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const monitors = await this.monitorService.getAllMonitors();
return res.status(200).json({
success: true,
msg: "Retrieved all monitors successfully",
data: monitors,
});
} catch (error) {
next(error);
}
};
getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => {
try {
await getCertificateParamValidation.validateAsync(req.params);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const { monitorId } = req.params;
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
const certificate = await fetchMonitorCertificate(sslChecker, monitor);
@@ -77,15 +70,10 @@ class MonitorController {
getUptimeDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const monitorId = req?.params?.monitorId;
const dateRange = req?.query?.dateRange;
const normalize = req?.query?.normalize;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const normalize = optionalBoolean(req?.query?.normalize, "normalize");
const teamId = requireTeamId(req?.user?.teamId);
const data = await this.monitorService.getUptimeDetailsById({
teamId,
@@ -103,50 +91,14 @@ class MonitorController {
}
};
getMonitorStatsById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorStatsByIdParamValidation.validateAsync(req.params);
await getMonitorStatsByIdQueryValidation.validateAsync(req.query);
let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query;
const monitorId = req?.params?.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorStats = await this.monitorService.getMonitorStatsById({
teamId,
monitorId,
limit,
sortOrder,
dateRange,
numToDisplay,
normalize,
});
return res.status(200).json({
success: true,
msg: "Monitor stats retrieved successfully",
data: monitorStats,
});
} catch (error) {
next(error);
}
};
getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = req?.params?.monitorId;
const dateRange = req?.query?.dateRange;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.getHardwareDetailsById({
teamId,
@@ -163,18 +115,40 @@ class MonitorController {
next(error);
}
};
getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getHardwareDetailsByIdParamValidation.validateAsync(req.params);
await getHardwareDetailsByIdQueryValidation.validateAsync(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.getPageSpeedDetailsById({
teamId,
monitorId,
dateRange,
});
return res.status(200).json({
success: true,
msg: "Page speed details retrieved successfully",
data: monitor,
});
} catch (error) {
next(error);
}
};
getMonitorById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorByIdParamValidation.validateAsync(req.params);
await getMonitorByIdQueryValidation.validateAsync(req.query);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: req?.params?.monitorId });
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
return res.status(200).json({
success: true,
@@ -190,10 +164,10 @@ class MonitorController {
try {
await createMonitorBodyValidation.validateAsync(req.body);
const userId = req?.user?._id;
const teamId = req?.user?.teamId;
const userId = requireString(req?.user?._id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.createMonitor({ teamId, userId, body: req.body });
const monitor = await this.monitorService.createMonitor(teamId, userId, req.body);
return res.status(200).json({
success: true,
@@ -219,19 +193,15 @@ class MonitorController {
throw new AppError({ message: "File is empty", status: 400 });
}
const userId = req?.user?._id;
const teamId = req?.user?.teamId;
if (!userId || !teamId) {
throw new AppError({ message: "Missing userId or teamId", status: 400 });
}
const userId = requireString(req?.user?._id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const fileData = req?.file?.buffer?.toString("utf-8");
if (!fileData) {
throw new AppError({ message: "Cannot get file from buffer", status: 400 });
}
const monitors = await this.monitorService.createBulkMonitors({ fileData, userId, teamId });
const monitors = await this.monitorService.createBulkMonitors(fileData, userId, teamId);
return res.status(200).json({
success: true,
@@ -246,11 +216,8 @@ class MonitorController {
deleteMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorByIdParamValidation.validateAsync(req.params);
const monitorId = req.params.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const deletedMonitor = await this.monitorService.deleteMonitor({ teamId, monitorId });
@@ -266,10 +233,7 @@ class MonitorController {
deleteAllMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req?.user?.teamId);
const deletedCount = await this.monitorService.deleteAllMonitors({ teamId });
@@ -286,12 +250,8 @@ class MonitorController {
try {
await getMonitorByIdParamValidation.validateAsync(req.params);
await editMonitorBodyValidation.validateAsync(req.body);
const monitorId = req?.params?.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: req.body });
@@ -309,11 +269,8 @@ class MonitorController {
try {
await pauseMonitorParamValidation.validateAsync(req.params);
const monitorId = req.params.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitor = await this.monitorService.pauseMonitor({ teamId, monitorId });
@@ -329,7 +286,8 @@ class MonitorController {
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const { _id, teamId } = req.user;
const _id = requireString(req?.user?._id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const demoMonitors = await this.monitorService.addDemoMonitors({ userId: _id, teamId });
return res.status(200).json({
@@ -365,10 +323,11 @@ class MonitorController {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
let { limit, type, page, rowsPerPage, filter, field, order } = req.query;
const teamId = req?.user?.teamId;
const teamId = requireTeamId(req?.user?.teamId);
const type = parseMonitorTypeFilter(req.query?.type);
const filter = optionalString(req.query?.filter, "filter");
const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, limit, type, page, rowsPerPage, filter, field, order });
const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter });
return res.status(200).json({
success: true,
@@ -385,12 +344,9 @@ class MonitorController {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
const explain = req?.query?.explain;
const type = req?.query?.type;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const explain = optionalBoolean(req?.query?.explain, "explain");
const type = parseMonitorTypeFilter(req?.query?.type);
const teamId = requireTeamId(req?.user?.teamId);
const result = await this.monitorService.getMonitorsAndSummaryByTeamId({ teamId, type, explain });
@@ -406,14 +362,17 @@ class MonitorController {
getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
await getMonitorsByTeamIdParamValidation.validateAsync(req.params);
await getMonitorsByTeamIdQueryValidation.validateAsync(req.query);
await getMonitorsWithChecksQueryValidation.validateAsync(req.query);
const explain = req?.query?.explain;
let { limit, type, page, rowsPerPage, filter, field, order } = req.query;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const explain = optionalBoolean(req?.query?.explain, "explain");
const limit = optionalNumber(req?.query?.limit, "limit");
const page = optionalNumber(req?.query?.page, "page");
const rowsPerPage = optionalNumber(req?.query?.rowsPerPage, "rowsPerPage");
const filter = optionalString(req?.query?.filter, "filter");
const field = optionalString(req?.query?.field, "field");
const order = parseSortOrder(req?.query?.order);
const type = parseMonitorTypeFilter(req?.query?.type);
const teamId = requireTeamId(req?.user?.teamId);
const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({
teamId,
@@ -1,77 +0,0 @@
import { createAnnouncementValidation } from "../../validation/joi.js";
import BaseController from "./baseController.js";
const SERVICE_NAME = "announcementController";
/**
* Controller for managing announcements in the system.
* This class handles the creation of new announcements.
*
* @class AnnouncementController
*/
class AnnouncementController extends BaseController {
static SERVICE_NAME = SERVICE_NAME;
constructor(commonDependencies) {
super(commonDependencies);
this.createAnnouncement = this.createAnnouncement.bind(this);
this.getAnnouncement = this.getAnnouncement.bind(this);
}
get serviceName() {
return AnnouncementController.SERVICE_NAME;
}
/**
* Handles the creation of a new announcement.
*
* @async
* @param {Object} req - The request object, containing the announcement data in the body.
* @param {Object} res - The response object used to send the result back to the client.
* @param {Function} next - The next middleware function in the stack for error handling.
*
* @returns {Promise<void>} A promise that resolves once the response is sent.
*/
createAnnouncement = asyncHandler(
async (req, res, next) => {
await createAnnouncementValidation.validateAsync(req.body);
const { title, message } = req.body;
const announcementData = {
title: title.trim(),
message: message.trim(),
userId: req.user._id,
};
const newAnnouncement = await this.db.createAnnouncement(announcementData);
return res.success({
msg: this.stringService.createAnnouncement,
data: newAnnouncement,
});
},
SERVICE_NAME,
"createAnnouncement"
);
/**
* Handles retrieving announcements with pagination.
*
* @async
* @param {Object} res - The response object used to send the result back to the client.
* - `data`: The list of announcements to be sent back to the client.
* - `msg`: A message about the success of the request.
* @param {Function} next - The next middleware function in the stack for error handling.
*/
getAnnouncement = asyncHandler(
async (req, res, next) => {
const allAnnouncements = await this.db.getAnnouncements();
return res.success({
msg: this.stringService.getAnnouncement,
data: allAnnouncements,
});
},
SERVICE_NAME,
"getAnnouncement"
);
}
export default AnnouncementController;
-478
View File
@@ -1,478 +0,0 @@
import BaseController from "./baseController.js";
import {
registrationBodyValidation,
loginValidation,
editUserBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "../../validation/joi.js";
const SERVICE_NAME = "authController";
/**
* Authentication Controller
*
* Handles all authentication-related HTTP requests including user registration,
* login, password recovery, and user management operations.
*
* @class AuthController
* @description Manages user authentication and authorization operations
*/
class AuthController extends BaseController {
static SERVICE_NAME = SERVICE_NAME;
/**
* Creates an instance of AuthController.
*
* @param {Object} commonDependencies - Common dependencies injected into the controller
* @param {Object} dependencies - The dependencies required by the controller
* @param {Object} dependencies.settingsService - Service for application settings
* @param {Object} dependencies.emailService - Service for email operations
* @param {Object} dependencies.jobQueue - Service for job queue operations
* @param {Object} dependencies.userService - User business logic service
*/
constructor(commonDependencies, { settingsService, emailService, jobQueue, userService }) {
super(commonDependencies);
this.settingsService = settingsService;
this.emailService = emailService;
this.jobQueue = jobQueue;
this.userService = userService;
}
get serviceName() {
return AuthController.SERVICE_NAME;
}
/**
* Registers a new user in the system.
*
* @async
* @function registerUser
* @param {Object} req - Express request object
* @param {Object} req.body - Request body containing user registration data
* @param {string} req.body.firstName - User's first name
* @param {string} req.body.lastName - User's last name
* @param {string} req.body.email - User's email address (will be converted to lowercase)
* @param {string} req.body.password - User's password
* @param {string} [req.body.inviteToken] - Invite token for registration (required if superadmin exists)
* @param {string} [req.body.teamId] - Team ID (auto-assigned if superadmin)
* @param {Array<string>} [req.body.role] - User roles (auto-assigned if superadmin)
* @param {Object} [req.file] - Profile image file uploaded via multer
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with user data and JWT token
* @throws {Error} 422 - Validation error if request body is invalid
* @throws {Error} 409 - Conflict if user already exists
* @example
* // Register first user (becomes superadmin)
* POST /auth/register
* {
* "firstName": "John",
* "lastName": "Doe",
* "email": "john@example.com",
* "password": "SecurePass123!"
* }
*
* // Register subsequent user (requires invite token)
* POST /auth/register
* {
* "firstName": "Jane",
* "lastName": "Smith",
* "email": "jane@example.com",
* "password": "SecurePass123!",
* "inviteToken": "abc123..."
* }
*/
registerUser = this.asyncHandler(
async (req, res) => {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await registrationBodyValidation.validateAsync(req.body);
const { user, token } = await this.userService.registerUser(req.body, req.file);
res.success({
msg: this.stringService.authCreateUser,
data: { user, token },
});
},
SERVICE_NAME,
"registerUser"
);
/**
* Authenticates a user and returns a JWT token.
*
* @async
* @function loginUser
* @param {Object} req - Express request object
* @param {Object} req.body - Request body containing login credentials
* @param {string} req.body.email - User's email address (will be converted to lowercase)
* @param {string} req.body.password - User's password
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with user data and JWT token
* @throws {Error} 422 - Validation error if request body is invalid
* @throws {Error} 401 - Unauthorized if credentials are incorrect
* @example
* POST /auth/login
* {
* "email": "john@example.com",
* "password": "SecurePass123!"
* }
*/
loginUser = this.asyncHandler(
async (req, res) => {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
return res.success({
msg: this.stringService.authLoginUser,
data: {
user,
token,
},
});
},
SERVICE_NAME,
"loginUser"
);
/**
* Updates the current user's profile information.
*
* @async
* @function editUser
* @param {Object} req - Express request object
* @param {Object} req.body - Request body containing user update data
* @param {string} [req.body.firstName] - Updated first name
* @param {string} [req.body.lastName] - Updated last name
* @param {string} [req.body.password] - Current password (required for password change)
* @param {string} [req.body.newPassword] - New password (required for password change)
* @param {boolean} [req.body.deleteProfileImage] - Flag to delete profile image
* @param {Object} [req.file] - New profile image file
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with updated user data
* @throws {Error} 422 - Validation error if request body is invalid
* @throws {Error} 403 - Forbidden if current password is incorrect
* @example
* PUT /auth/user
* {
* "firstName": "John Updated",
* "lastName": "Doe Updated"
* }
*
* // Change password
* PUT /auth/user
* {
* "password": "OldPass123!",
* "newPassword": "NewPass123!"
* }
*/
editUser = this.asyncHandler(
async (req, res) => {
await editUserBodyValidation.validateAsync(req.body);
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
res.success({
msg: this.stringService.authUpdateUser,
data: updatedUser,
});
},
SERVICE_NAME,
"editUser"
);
/**
* Checks if a superadmin account exists in the system.
*
* @async
* @function checkSuperadminExists
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with boolean indicating superadmin existence
* @example
* GET /auth/users/superadmin
* // Response: { "data": true } or { "data": false }
*/
checkSuperadminExists = this.asyncHandler(
async (req, res) => {
const superAdminExists = await this.userService.checkSuperadminExists();
return res.success({
msg: this.stringService.authAdminExists,
data: superAdminExists,
});
},
SERVICE_NAME,
"checkSuperadminExists"
);
/**
* Initiates password recovery process by sending a recovery email.
*
* @async
* @function requestRecovery
* @param {Object} req - Express request object
* @param {Object} req.body - Request body containing email
* @param {string} req.body.email - Email address for password recovery
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with message ID
* @throws {Error} 422 - Validation error if email is invalid
* @throws {Error} 404 - Not found if user doesn't exist
* @example
* POST /auth/recovery/request
* {
* "email": "john@example.com"
* }
*/
requestRecovery = this.asyncHandler(
async (req, res) => {
await recoveryValidation.validateAsync(req.body);
const email = req?.body?.email;
const msgId = await this.userService.requestRecovery(email);
return res.success({
msg: this.stringService.authCreateRecoveryToken,
data: msgId,
});
},
SERVICE_NAME,
"requestRecovery"
);
/**
* Validates a password recovery token.
*
* @async
* @function validateRecovery
* @param {Object} req - Express request object
* @param {Object} req.body - Request body containing recovery token
* @param {string} req.body.recoveryToken - Recovery token to validate
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response if token is valid
* @throws {Error} 422 - Validation error if token format is invalid
* @throws {Error} 400 - Bad request if token is invalid or expired
* @example
* POST /auth/recovery/validate
* {
* "recoveryToken": "abc123..."
* }
*/
validateRecovery = this.asyncHandler(
async (req, res) => {
await recoveryTokenBodyValidation.validateAsync(req.body);
await this.userService.validateRecovery(req.body.recoveryToken);
return res.success({
msg: this.stringService.authVerifyRecoveryToken,
});
},
SERVICE_NAME,
"validateRecovery"
);
/**
* Resets user password using a valid recovery token.
*
* @async
* @function resetPassword
* @param {Object} req - Express request object
* @param {Object} req.body - Request body containing new password and recovery token
* @param {string} req.body.password - New password
* @param {string} req.body.recoveryToken - Valid recovery token
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with user data and JWT token
* @throws {Error} 422 - Validation error if password format is invalid
* @throws {Error} 400 - Bad request if token is invalid or expired
* @example
* POST /auth/recovery/reset
* {
* "password": "NewSecurePass123!",
* "recoveryToken": "abc123..."
* }
*/
resetPassword = this.asyncHandler(
async (req, res) => {
await newPasswordValidation.validateAsync(req.body);
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
return res.success({
msg: this.stringService.authResetPassword,
data: { user, token },
});
},
SERVICE_NAME,
"resetPassword"
);
/**
* Deletes the current user's account and associated data.
*
* @async
* @function deleteUser
* @param {Object} req - Express request object
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {string} req.user._id - User ID
* @param {string} req.user.email - User email
* @param {string} req.user.teamId - User's team ID
* @param {Array<string>} req.user.role - User roles
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response confirming user deletion
* @throws {Error} 400 - Bad request if user is demo user
* @throws {Error} 404 - Not found if user doesn't exist
* @example
* DELETE /auth/user
* // Requires JWT authentication
*/
deleteUser = this.asyncHandler(
async (req, res) => {
await this.userService.deleteUser(req.user);
return res.success({
msg: this.stringService.authDeleteUser,
});
},
SERVICE_NAME,
"deleteUser"
);
/**
* Retrieves all users in the system (admin/superadmin only).
*
* @async
* @function getAllUsers
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with array of users
* @throws {Error} 403 - Forbidden if user doesn't have admin/superadmin role
* @example
* GET /auth/users
* // Requires JWT authentication with admin/superadmin role
*/
getAllUsers = this.asyncHandler(
async (req, res) => {
const allUsers = await this.userService.getAllUsers();
return res.success({
msg: this.stringService.authGetAllUsers,
data: allUsers,
});
},
SERVICE_NAME,
"getAllUsers"
);
/**
* Retrieves a specific user by ID (superadmin only).
*
* @async
* @function getUserById
* @param {Object} req - Express request object
* @param {Object} req.params - URL parameters
* @param {string} req.params.userId - ID of the user to retrieve
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {Array<string>} req.user.role - Current user's roles
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response with user data
* @throws {Error} 422 - Validation error if userId is invalid
* @throws {Error} 403 - Forbidden if user doesn't have superadmin role
* @throws {Error} 404 - Not found if user doesn't exist
* @example
* GET /auth/users/507f1f77bcf86cd799439011
* // Requires JWT authentication with superadmin role
*/
getUserById = this.asyncHandler(
async (req, res) => {
await getUserByIdParamValidation.validateAsync(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
if (!userId) {
throw new Error("No user ID in request");
}
if (!roles || roles.length === 0) {
throw new Error("No roles in request");
}
const user = await this.userService.getUserById(roles, userId);
return res.success({ msg: "ok", data: user });
},
SERVICE_NAME,
"getUserById"
);
/**
* Updates a specific user by ID (superadmin only).
*
* @async
* @function editUserById
* @param {Object} req - Express request object
* @param {Object} req.params - URL parameters
* @param {string} req.params.userId - ID of the user to update
* @param {Object} req.body - Request body containing user update data
* @param {string} [req.body.firstName] - Updated first name
* @param {string} [req.body.lastName] - Updated last name
* @param {Array<string>} [req.body.role] - Updated user roles
* @param {Object} req.user - Current authenticated user (from JWT)
* @param {string} req.user._id - Current user's ID
* @param {Array<string>} req.user.role - Current user's roles
* @param {Object} res - Express response object
* @returns {Promise<Object>} Success response confirming user update
* @throws {Error} 422 - Validation error if parameters or body are invalid
* @throws {Error} 403 - Forbidden if user doesn't have superadmin role
* @throws {Error} 404 - Not found if user doesn't exist
* @example
* PUT /auth/users/507f1f77bcf86cd799439011
* {
* "firstName": "Updated Name",
* "role": ["admin"]
* }
* // Requires JWT authentication with superadmin role
*/
editUserById = this.asyncHandler(
async (req, res) => {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw this.errorService.createError("Unauthorized", 403);
}
const userId = req.params.userId;
const user = { ...req.body };
await editUserByIdParamValidation.validateAsync(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user._id) {
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
} else {
await editUserByIdBodyValidation.validateAsync(req.body);
}
await this.userService.editUserById(userId, user);
return res.success({ msg: "ok" });
},
SERVICE_NAME,
"editUserById"
);
editUserPasswordById = this.asyncHandler(
async (req, res) => {
const roles = req?.user?.role;
if (!roles.includes("superadmin")) {
throw this.errorService.createError("Unauthorized", 403);
}
const userId = req.params.userId;
await editUserByIdParamValidation.validateAsync(req.params);
await editUserPasswordByIdBodyValidation.validateAsync(req.body);
const updatedPassword = req.body.password;
await this.userService.setPasswordByUserId(userId, updatedPassword);
return res.success({ msg: "Password reset successfully" });
},
SERVICE_NAME,
"editUserPasswordById"
);
}
export default AuthController;
@@ -1,83 +0,0 @@
import { AppError } from "../../service/v1/infrastructure/errorService.js";
export const createCommonDependencies = (db, errorService, logger, stringService) => {
return {
db,
errorService,
logger,
stringService,
};
};
class BaseController {
constructor({ db, logger, errorService, ...additionalDependencies }) {
this.db = db;
this.logger = logger;
this.errorService = errorService;
Object.assign(this, additionalDependencies);
this.asyncHandler = (fn, serviceName, methodName) => {
return async (req, res, next) => {
try {
await fn(req, res, next);
} catch (error) {
// Handle validation errors
if (error.isJoi) {
const validationError = this.errorService.createValidationError(error.message, error.details, serviceName, methodName);
return next(validationError);
}
if (error.name === "ValidationError") {
const validationError = this.errorService.createValidationError("Database validation failed", error.errors, serviceName, methodName);
return next(validationError);
}
if (error.name === "CastError") {
const notFoundError = this.errorService.createNotFoundError(
"Invalid resource identifier",
{ field: error.path, value: error.value },
serviceName,
methodName
);
return next(notFoundError);
}
if (error.code === "11000") {
const conflictError = this.errorService.createConflictError("Resource already exists", {
originalError: error.message,
code: error.code,
});
conflictError.service = serviceName;
conflictError.method = methodName;
return next(conflictError);
}
if (error instanceof AppError) {
error.service = error.service || serviceName;
error.method = error.method || methodName;
return next(error);
}
if (error.status) {
const appError = this.errorService.createError(error.message, error.status, serviceName, methodName, {
originalError: error.message,
stack: error.stack,
});
return next(appError);
}
// For unknown errors, create a server error
const appError = this.errorService.createServerError(error.message || "An unexpected error occurred", {
originalError: error.message,
stack: error.stack,
});
appError.service = serviceName;
appError.method = methodName;
appError.stack = error.stack; // Preserve original stack
return next(appError);
}
};
};
}
}
export default BaseController;
@@ -1,12 +0,0 @@
const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<any> => {
const monitorUrl = new URL(monitor.url);
const hostname = monitorUrl.hostname;
const cert = await sslChecker(hostname);
// Throw an error if no cert or if cert.validTo is not present
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
return cert;
};
export { fetchMonitorCertificate };
@@ -11,12 +11,9 @@ class MongoDB {
inviteModule,
statusPageModule,
userModule,
hardwareCheckModule,
maintenanceWindowModule,
monitorModule,
networkCheckModule,
notificationModule,
pageSpeedCheckModule,
recoveryModule,
settingsModule,
incidentModule,
@@ -26,15 +23,12 @@ class MongoDB {
this.userModule = userModule;
this.inviteModule = inviteModule;
this.recoveryModule = recoveryModule;
this.pageSpeedCheckModule = pageSpeedCheckModule;
this.hardwareCheckModule = hardwareCheckModule;
this.checkModule = checkModule;
this.maintenanceWindowModule = maintenanceWindowModule;
this.monitorModule = monitorModule;
this.notificationModule = notificationModule;
this.settingsModule = settingsModule;
this.statusPageModule = statusPageModule;
this.networkCheckModule = networkCheckModule;
this.incidentModule = incidentModule;
}
@@ -0,0 +1,10 @@
import Monitor from "@/db/models/Monitor.js";
async function migrateStatusWindowThreshold() {
const monitors = await Monitor.find({ statusWindowThreshold: { $lt: 1 } });
for (const monitor of monitors) {
monitor.statusWindowThreshold = monitor.statusWindowThreshold * 100;
await monitor.save();
}
}
export { migrateStatusWindowThreshold };
@@ -0,0 +1,95 @@
import mongoose from "mongoose";
const CHECKS_COLLECTION = "checks";
const BACKUP_COLLECTION = "checks_backup";
const BATCH_SIZE = 1000;
const getDb = () => {
const db = mongoose.connection.db;
if (!db) {
throw new Error("Database connection is not initialized");
}
return db;
};
const backupAndDropExistingCollection = async () => {
const db = getDb();
const collections = await db.listCollections({ name: CHECKS_COLLECTION }).toArray();
if (collections.length === 0) {
return false;
}
await db
.collection(CHECKS_COLLECTION)
.aggregate([{ $match: {} }, { $out: BACKUP_COLLECTION }])
.toArray();
await db.collection(CHECKS_COLLECTION).drop();
return true;
};
const createTimeSeriesCollection = async () => {
const db = getDb();
const existing = await db.listCollections({ name: CHECKS_COLLECTION }).toArray();
if (existing.length === 0) {
await db.createCollection(CHECKS_COLLECTION, {
timeseries: {
timeField: "createdAt",
metaField: "metadata",
granularity: "seconds",
},
});
}
await db
.collection(CHECKS_COLLECTION)
.createIndexes([
{ key: { updatedAt: 1 } },
{ key: { "metadata.monitorId": 1, updatedAt: 1 } },
{ key: { "metadata.monitorId": 1, updatedAt: -1 } },
{ key: { "metadata.teamId": 1, updatedAt: -1 } },
]);
};
const migrateBackupData = async () => {
const db = getDb();
const backupExists = await db.listCollections({ name: BACKUP_COLLECTION }).toArray();
if (backupExists.length === 0) {
return;
}
const source = db.collection(BACKUP_COLLECTION);
const target = db.collection(CHECKS_COLLECTION);
const cursor = source.find();
const operations: any[] = [];
while (await cursor.hasNext()) {
const doc = await cursor.next();
if (!doc) {
continue;
}
const metadata = {
monitorId: doc.monitorId,
teamId: doc.teamId,
type: doc.type,
};
operations.push({ insertOne: { document: { ...doc, metadata } } });
if (operations.length >= BATCH_SIZE) {
await target.bulkWrite(operations);
operations.length = 0;
}
}
if (operations.length) {
await target.bulkWrite(operations);
}
};
export const convertChecksToTimeSeries = async () => {
const backedUp = await backupAndDropExistingCollection();
await createTimeSeriesCollection();
if (backedUp) {
await migrateBackupData();
}
};
+47
View File
@@ -0,0 +1,47 @@
import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js";
import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js";
import MigrationModel from "../models/Migration.js";
type MigrationEntry = {
name: string;
execute: () => Promise<void>;
};
const migrations: MigrationEntry[] = [
{ name: "0001_migrateStatusWindowThreshold", execute: migrateStatusWindowThreshold },
{ name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries },
];
const runMigrations = async (logger?: { info: Function; error: Function }) => {
try {
logger?.info({ message: "Running migrations", service: "Migrations" });
for (const migration of migrations) {
const exists = await MigrationModel.findOne({ name: migration.name, status: "completed" });
if (exists) {
logger?.info({ message: `Skipping ${migration.name}`, service: "Migrations" });
continue;
}
try {
await migration.execute();
await MigrationModel.findOneAndUpdate(
{ name: migration.name },
{ status: "completed", completedAt: new Date(), error: undefined },
{ upsert: true }
);
logger?.info({ message: `Completed ${migration.name}`, service: "Migrations" });
} catch (error) {
const err = error as Error;
await MigrationModel.findOneAndUpdate({ name: migration.name }, { status: "failed", error: err?.message }, { upsert: true });
throw error;
}
}
logger?.info({ message: "Migrations completed", service: "Migrations" });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Migration failed", service: "Migrations", details: err?.message, stack: err?.stack });
throw error;
}
};
export { runMigrations };
+299
View File
@@ -0,0 +1,299 @@
import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/types/monitor.js";
import type {
Check,
CheckAudits,
CheckCaptureInfo,
CheckCpuInfo,
CheckDiskInfo,
CheckErrorInfo,
CheckHostInfo,
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
CheckTimings,
CheckTimingPhases,
ILighthouseAudit,
} from "@/types/check.js";
type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
monitorId: Types.ObjectId;
teamId: Types.ObjectId;
type: MonitorType;
};
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
metadata: CheckMetadataDocument;
expiry: Date;
ackAt?: Date | null;
createdAt: Date;
updatedAt: Date;
__v: number;
};
interface CheckDocument extends CheckDocumentBase {
_id: Types.ObjectId;
}
const timingPhasesSchema = new Schema<CheckTimingPhases>(
{
wait: { type: Number, default: 0 },
dns: { type: Number, default: 0 },
tcp: { type: Number, default: 0 },
tls: { type: Number, default: 0 },
request: { type: Number, default: 0 },
firstByte: { type: Number, default: 0 },
download: { type: Number, default: 0 },
total: { type: Number, default: 0 },
},
{ _id: false }
);
const timingsSchema = new Schema<CheckTimings>(
{
start: { type: Number, default: 0 },
socket: { type: Number, default: 0 },
lookup: { type: Number, default: 0 },
connect: { type: Number, default: 0 },
secureConnect: { type: Number, default: 0 },
upload: { type: Number, default: 0 },
response: { type: Number, default: 0 },
end: { type: Number, default: 0 },
phases: {
type: timingPhasesSchema,
default: () => ({}),
},
},
{ _id: false }
);
const cpuSchema = new Schema<CheckCpuInfo>(
{
physical_core: { type: Number, default: 0 },
logical_core: { type: Number, default: 0 },
frequency: { type: Number, default: 0 },
temperature: { type: [Number], default: [] },
free_percent: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
},
{ _id: false }
);
const memorySchema = new Schema<CheckMemoryInfo>(
{
total_bytes: { type: Number, default: 0 },
available_bytes: { type: Number, default: 0 },
used_bytes: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
},
{ _id: false }
);
const diskSchema = new Schema<CheckDiskInfo>(
{
device: { type: String, default: "" },
mountpoint: { type: String, default: "" },
read_speed_bytes: { type: Number, default: 0 },
write_speed_bytes: { type: Number, default: 0 },
total_bytes: { type: Number, default: 0 },
free_bytes: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
},
{ _id: false }
);
const hostSchema = new Schema<CheckHostInfo>(
{
os: { type: String, default: "" },
platform: { type: String, default: "" },
kernel_version: { type: String, default: "" },
},
{ _id: false }
);
const errorSchema = new Schema<CheckErrorInfo>(
{
metric: { type: [String], default: [] },
err: { type: String, default: "" },
},
{ _id: false }
);
const captureSchema = new Schema<CheckCaptureInfo>(
{
version: { type: String, default: "" },
mode: { type: String, default: "" },
},
{ _id: false }
);
const networkInterfaceSchema = new Schema<CheckNetworkInterfaceInfo>(
{
name: { type: String, default: "" },
bytes_sent: { type: Number, default: 0 },
bytes_recv: { type: Number, default: 0 },
packets_sent: { type: Number, default: 0 },
packets_recv: { type: Number, default: 0 },
err_in: { type: Number, default: 0 },
err_out: { type: Number, default: 0 },
drop_in: { type: Number, default: 0 },
drop_out: { type: Number, default: 0 },
fifo_in: { type: Number, default: 0 },
fifo_out: { type: Number, default: 0 },
},
{ _id: false }
);
const lighthouseAuditSchema = new Schema<ILighthouseAudit>(
{
id: { type: String },
title: { type: String },
score: { type: Number },
displayValue: { type: String },
numericValue: { type: Number },
numericUnit: { type: String },
},
{ _id: false }
);
const auditsSchema = new Schema<CheckAudits>(
{
cls: { type: lighthouseAuditSchema, default: undefined },
si: { type: lighthouseAuditSchema, default: undefined },
fcp: { type: lighthouseAuditSchema, default: undefined },
lcp: { type: lighthouseAuditSchema, default: undefined },
tbt: { type: lighthouseAuditSchema, default: undefined },
},
{ _id: false }
);
const metadataSchema = new Schema<CheckMetadataDocument>(
{
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
required: true,
immutable: true,
index: true,
},
teamId: {
type: Schema.Types.ObjectId,
ref: "Team",
required: true,
immutable: true,
index: true,
},
type: {
type: String,
enum: MonitorTypes,
required: true,
index: true,
},
},
{ _id: false }
);
const CheckSchema = new Schema<CheckDocument>(
{
metadata: {
type: metadataSchema,
required: true,
},
status: {
type: Boolean,
index: true,
},
responseTime: {
type: Number,
},
timings: {
type: timingsSchema,
default: undefined,
},
statusCode: {
type: Number,
index: true,
},
message: {
type: String,
},
expiry: {
type: Date,
default: Date.now,
},
ack: {
type: Boolean,
default: false,
},
ackAt: {
type: Date,
default: undefined,
},
cpu: {
type: cpuSchema,
default: () => ({}),
},
memory: {
type: memorySchema,
default: () => ({}),
},
disk: {
type: [diskSchema],
default: () => [],
},
host: {
type: hostSchema,
default: () => ({}),
},
errors: {
type: [errorSchema],
default: () => [],
},
capture: {
type: captureSchema,
default: () => ({}),
},
net: {
type: [networkInterfaceSchema],
default: () => [],
},
accessibility: {
type: Number,
},
bestPractices: {
type: Number,
},
seo: {
type: Number,
},
performance: {
type: Number,
},
audits: {
type: auditsSchema,
default: undefined,
},
},
{
timestamps: true,
strict: false,
timeseries: {
timeField: "createdAt",
metaField: "metadata",
granularity: "seconds",
},
}
);
CheckSchema.index({ updatedAt: 1 });
CheckSchema.index({ "metadata.monitorId": 1, updatedAt: 1 });
CheckSchema.index({ "metadata.monitorId": 1, updatedAt: -1 });
CheckSchema.index({ "metadata.monitorId": 1, createdAt: -1 });
CheckSchema.index({ "metadata.teamId": 1, updatedAt: -1 });
CheckSchema.index({ "metadata.teamId": 1, createdAt: -1 });
const CheckModel = model<CheckDocument>("Check", CheckSchema);
export type { CheckDocument, CheckMetadataDocument };
export { CheckModel };
export default CheckModel;
+13
View File
@@ -0,0 +1,13 @@
import mongoose from "mongoose";
const MigrationSchema = new mongoose.Schema(
{
name: { type: String, required: true, unique: true },
status: { type: String, enum: ["completed", "failed"], default: "completed" },
completedAt: { type: Date },
error: { type: String },
},
{ timestamps: true }
);
export default mongoose.model("Migration", MigrationSchema);
+48 -24
View File
@@ -1,18 +1,49 @@
import mongoose from "mongoose";
import { Schema, model, Types, type UpdateQuery } from "mongoose";
import type { Monitor, MonitorMatchMethod, MonitorThresholds } from "@/types/monitor.js";
import { MonitorTypes } from "@/types/monitor.js";
import Check from "./Check.js";
import MonitorStats from "./MonitorStats.js";
import StatusPage from "./StatusPage.js";
const MonitorSchema = mongoose.Schema(
type MonitorDocumentBase = Omit<
Monitor,
"id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "createdAt" | "updatedAt"
> & {
statusWindow: boolean[];
notifications: Types.ObjectId[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
thresholds?: MonitorThresholds;
};
interface MonitorDocument extends MonitorDocumentBase {
_id: Types.ObjectId;
userId: Types.ObjectId;
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const thresholdsSchema = new Schema<MonitorThresholds>(
{
usage_cpu: { type: Number },
usage_memory: { type: Number },
usage_disk: { type: Number },
usage_temperature: { type: Number },
},
{ _id: false }
);
const MonitorSchema = new Schema<MonitorDocument>(
{
userId: {
type: mongoose.Schema.Types.ObjectId,
type: Schema.Types.ObjectId,
ref: "User",
immutable: true,
required: true,
},
teamId: {
type: mongoose.Schema.Types.ObjectId,
type: Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
@@ -43,7 +74,7 @@ const MonitorSchema = mongoose.Schema(
type: {
type: String,
required: true,
enum: ["http", "ping", "pagespeed", "hardware", "docker", "port", "game"],
enum: MonitorTypes,
},
ignoreTlsErrors: {
type: Boolean,
@@ -71,7 +102,6 @@ const MonitorSchema = mongoose.Schema(
default: true,
},
interval: {
// in milliseconds
type: Number,
default: 60000,
},
@@ -81,7 +111,7 @@ const MonitorSchema = mongoose.Schema(
},
notifications: [
{
type: mongoose.Schema.Types.ObjectId,
type: Schema.Types.ObjectId,
ref: "Notification",
},
],
@@ -89,13 +119,7 @@ const MonitorSchema = mongoose.Schema(
type: String,
},
thresholds: {
type: {
usage_cpu: { type: Number },
usage_memory: { type: Number },
usage_disk: { type: Number },
usage_temperature: { type: Number },
},
_id: false,
type: thresholdsSchema,
},
alertThreshold: {
type: Number,
@@ -137,7 +161,7 @@ const MonitorSchema = mongoose.Schema(
trim: true,
maxLength: 50,
default: null,
set: function (value) {
set(value: string | null) {
return value && value.trim() ? value.trim() : null;
},
},
@@ -148,7 +172,6 @@ const MonitorSchema = mongoose.Schema(
);
MonitorSchema.pre("findOneAndDelete", async function (next) {
// Delete checks and stats
try {
const doc = await this.model.findOne(this.getFilter());
@@ -157,20 +180,17 @@ MonitorSchema.pre("findOneAndDelete", async function (next) {
}
await Check.deleteMany({ monitorId: doc._id });
// Deal with status pages
await StatusPage.updateMany({ monitors: doc?._id }, { $pull: { monitors: doc?._id } });
await MonitorStats.deleteMany({ monitorId: doc?._id.toString() });
next();
} catch (error) {
next(error);
next(error as Error);
}
});
MonitorSchema.pre("deleteMany", async function (next) {
const filter = this.getFilter();
const monitors = await this.model.find(filter).select(["_id", "type"]).lean();
const monitors = (await this.model.find(filter).select(["_id", "type"]).lean()) as { _id: Types.ObjectId }[];
for (const monitor of monitors) {
await Check.deleteMany({ monitorId: monitor._id });
@@ -197,8 +217,8 @@ MonitorSchema.pre("save", function (next) {
});
MonitorSchema.pre("findOneAndUpdate", function (next) {
const update = this.getUpdate();
if (update.alertThreshold) {
const update = this.getUpdate() as UpdateQuery<MonitorDocument> | null;
if (update && !Array.isArray(update) && update.alertThreshold !== undefined) {
update.cpuAlertThreshold = update.alertThreshold;
update.memoryAlertThreshold = update.alertThreshold;
update.diskAlertThreshold = update.alertThreshold;
@@ -209,4 +229,8 @@ MonitorSchema.pre("findOneAndUpdate", function (next) {
MonitorSchema.index({ teamId: 1, type: 1 });
export default mongoose.model("Monitor", MonitorSchema);
const MonitorModel = model<MonitorDocument>("Monitor", MonitorSchema);
export type { MonitorDocument };
export { MonitorModel };
export default MonitorModel;
+63
View File
@@ -0,0 +1,63 @@
import { Schema, model, type Types } from "mongoose";
import type { MonitorStats as MonitorStatsEntity } from "@/types/monitorStats.js";
type MonitorStatsDocumentBase = Omit<MonitorStatsEntity, "id" | "monitorId" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
};
interface MonitorStatsDocument extends MonitorStatsDocumentBase {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
{
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
immutable: true,
index: true,
required: true,
},
avgResponseTime: {
type: Number,
default: 0,
},
totalChecks: {
type: Number,
default: 0,
},
totalUpChecks: {
type: Number,
default: 0,
},
totalDownChecks: {
type: Number,
default: 0,
},
uptimePercentage: {
type: Number,
default: 0,
},
lastCheckTimestamp: {
type: Number,
default: 0,
},
lastResponseTime: {
type: Number,
default: 0,
},
timeOfLastFailure: {
type: Number,
default: undefined,
},
},
{ timestamps: true }
);
const MonitorStatsModel = model<MonitorStatsDocument>("MonitorStats", MonitorStatsSchema);
export type { MonitorStatsDocument };
export { MonitorStatsModel };
export default MonitorStatsModel;
+8
View File
@@ -0,0 +1,8 @@
export * from "@/db/models/Monitor.js";
export { default as MonitorModel } from "@/db/models/Monitor.js";
export * from "@/db/models/Check.js";
export { default as CheckModel } from "@/db/models/Check.js";
export * from "@/db/models/MonitorStats.js";
export { default as MonitorStatsModel } from "@/db/models/MonitorStats.js";
@@ -1,4 +1,6 @@
import { ObjectId } from "mongodb";
import mongoose from "mongoose";
import { CheckModel } from "@/db/models/index.js";
import { buildChecksSummaryByTeamIdPipeline } from "./checkModuleQueries.js";
const SERVICE_NAME = "checkModule";
@@ -12,18 +14,15 @@ const dateRangeLookup = {
};
class CheckModule {
constructor({ logger, Check, HardwareCheck, PageSpeedCheck, Monitor, User }) {
constructor({ logger, Monitor, User }) {
this.logger = logger;
this.Check = Check;
this.HardwareCheck = HardwareCheck;
this.PageSpeedCheck = PageSpeedCheck;
this.Monitor = Monitor;
this.User = User;
}
createChecks = async (checks) => {
try {
await this.Check.insertMany(checks, { ordered: false });
await CheckModel.insertMany(checks, { ordered: false, runValidators: true });
} catch (error) {
error.service = SERVICE_NAME;
error.method = "createCheck";
@@ -41,7 +40,7 @@ class CheckModule {
// Match
const matchStage = {
monitorId: new ObjectId(monitorId),
"metadata.monitorId": new ObjectId(monitorId),
...(typeof status !== "undefined" && { status }),
...(typeof ack !== "undefined" && ackStage),
...(dateRangeLookup[dateRange] && {
@@ -79,7 +78,7 @@ class CheckModule {
skip = page * rowsPerPage;
}
const checks = await this.Check.aggregate([
const checks = await CheckModel.aggregate([
{ $match: matchStage },
{ $sort: { createdAt: sortOrder } },
{
@@ -115,7 +114,7 @@ class CheckModule {
const ackStage = ack === "true" ? { ack: true } : { $or: [{ ack: false }, { ack: { $exists: false } }] };
const matchStage = {
teamId: new ObjectId(teamId),
"metadata.teamId": new ObjectId(teamId),
status: false,
...(typeof ack !== "undefined" && ackStage),
...(dateRangeLookup[dateRange] && {
@@ -170,7 +169,7 @@ class CheckModule {
},
];
const checks = await this.Check.aggregate(aggregatePipeline);
const checks = await CheckModel.aggregate(aggregatePipeline);
return checks[0];
} catch (error) {
error.service = SERVICE_NAME;
@@ -181,7 +180,11 @@ class CheckModule {
ackCheck = async (checkId, teamId, ack) => {
try {
const updatedCheck = await this.Check.findOneAndUpdate({ _id: checkId, teamId: teamId }, { $set: { ack, ackAt: new Date() } }, { new: true });
const updatedCheck = await CheckModel.findOneAndUpdate(
{ _id: checkId, "metadata.teamId": teamId },
{ $set: { ack, ackAt: new Date() } },
{ new: true }
);
if (!updatedCheck) {
throw new Error("Check not found");
@@ -197,7 +200,11 @@ class CheckModule {
ackAllChecks = async (monitorId, teamId, ack, path) => {
try {
const updatedChecks = await this.Check.updateMany(path === "monitor" ? { monitorId } : { teamId }, { $set: { ack, ackAt: new Date() } });
const filter =
path === "monitor"
? { "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) }
: { "metadata.teamId": new mongoose.Types.ObjectId(teamId) };
const updatedChecks = await CheckModel.updateMany(filter, { $set: { ack, ackAt: new Date() } });
return updatedChecks.modifiedCount;
} catch (error) {
error.service = SERVICE_NAME;
@@ -209,9 +216,9 @@ class CheckModule {
getChecksSummaryByTeamId = async ({ teamId }) => {
try {
const matchStage = {
teamId: new ObjectId(teamId),
"metadata.teamId": new ObjectId(teamId),
};
const checks = await this.Check.aggregate(buildChecksSummaryByTeamIdPipeline({ matchStage }));
const checks = await CheckModel.aggregate(buildChecksSummaryByTeamIdPipeline({ matchStage }));
return checks[0].summary;
} catch (error) {
error.service = SERVICE_NAME;
@@ -221,7 +228,7 @@ class CheckModule {
};
deleteChecks = async (monitorId) => {
try {
const result = await this.Check.deleteMany({ monitorId });
const result = await CheckModel.deleteMany({ "metadata.monitorId": monitorId });
return result.deletedCount;
} catch (error) {
error.service = SERVICE_NAME;
@@ -237,7 +244,7 @@ class CheckModule {
const monitorIds = teamMonitors.map((monitor) => monitor._id);
// Delete all checks for these monitors in one operation
const deleteResult = await this.Check.deleteMany({ monitorId: { $in: monitorIds } });
const deleteResult = await CheckModel.deleteMany({ "metadata.monitorId": { $in: monitorIds } });
return deleteResult.deletedCount;
} catch (error) {
@@ -249,7 +256,7 @@ class CheckModule {
updateChecksTTL = async (teamId, ttl) => {
try {
await this.Check.collection.dropIndex("expiry_1");
await CheckModel.collection.dropIndex("expiry_1");
} catch (error) {
this.logger.error({
message: error.message,
@@ -260,9 +267,9 @@ class CheckModule {
}
try {
await this.Check.collection.createIndex(
await CheckModel.collection.createIndex(
{ expiry: 1 },
{ expireAfterSeconds: ttl } // TTL in seconds, adjust as necessary
{ expireAfterSeconds: ttl, partialFilterExpression: { "metadata.mode": { $exists: true } } }
);
} catch (error) {
error.service = SERVICE_NAME;
@@ -1,20 +1,23 @@
import {
buildUptimeDetailsPipeline,
buildHardwareDetailsPipeline,
buildMonitorSummaryByTeamIdPipeline,
buildMonitorsByTeamIdPipeline,
buildMonitorsAndSummaryByTeamIdPipeline,
buildMonitorsWithChecksByTeamIdPipeline,
buildFilteredMonitorsByTeamIdPipeline,
getHardwareStats,
getUpChecks,
getAggregateData,
} from "./monitorModuleQueries.js";
import { CheckModel } from "@/db/models/index.js";
const SERVICE_NAME = "monitorModule";
class MonitorModule {
constructor({ Monitor, MonitorStats, Check, stringService, fs, path, fileURLToPath, ObjectId, NormalizeData, NormalizeDataUptimeDetails }) {
constructor({ Monitor, MonitorStats, stringService, fs, path, fileURLToPath, ObjectId, NormalizeData, NormalizeDataUptimeDetails }) {
this.Monitor = Monitor;
this.MonitorStats = MonitorStats;
this.Check = Check;
this.stringService = stringService;
this.fs = fs;
this.path = path;
@@ -123,15 +126,18 @@ class MonitorModule {
//Helper
getMonitorChecks = async (monitorId, dateRange, sortOrder) => {
const objectId = new this.ObjectId(monitorId);
const indexSpec = {
monitorId: 1,
updatedAt: sortOrder, // This will be 1 or -1
"metadata.monitorId": 1,
updatedAt: sortOrder,
};
const matchBase = { "metadata.monitorId": objectId };
const [checksAll, checksForDateRange] = await Promise.all([
this.Check.find({ monitorId }).sort({ createdAt: sortOrder }).hint(indexSpec).lean(),
this.Check.find({
monitorId,
CheckModel.find(matchBase).sort({ createdAt: sortOrder }).hint(indexSpec).lean(),
CheckModel.find({
...matchBase,
createdAt: { $gte: dateRange.start, $lte: dateRange.end },
})
.hint(indexSpec)
@@ -188,17 +194,6 @@ class MonitorModule {
};
};
getAllMonitors = async () => {
try {
const monitors = await this.Monitor.find();
return monitors;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getAllMonitors";
throw error;
}
};
getMonitorById = async (monitorId) => {
try {
const monitor = await this.Monitor.findById(monitorId);
@@ -238,7 +233,7 @@ class MonitorModule {
const dateString = formatLookup[dateRange];
const results = await this.Check.aggregate(buildUptimeDetailsPipeline(monitorId, dates, dateString));
const results = await CheckModel.aggregate(buildUptimeDetailsPipeline(monitorId, dates, dateString));
const monitorData = results[0];
@@ -258,48 +253,6 @@ class MonitorModule {
}
};
getMonitorStatsById = async ({ monitorId, sortOrder, dateRange, numToDisplay, normalize }) => {
try {
// Get monitor, if we can't find it, abort with error
const monitor = await this.Monitor.findById(monitorId);
if (monitor === null || monitor === undefined) {
throw new Error(this.stringService.getDbFindMonitorById(monitorId));
}
// Get query params
const sort = sortOrder === "asc" ? 1 : -1;
// Get Checks for monitor in date range requested
const dates = this.getDateRange(dateRange);
const { checksAll, checksForDateRange } = await this.getMonitorChecks(monitorId, dates, sort);
// Build monitor stats
const monitorStats = {
...monitor.toObject(),
uptimeDuration: this.calculateUptimeDuration(checksAll),
lastChecked: this.getLastChecked(checksAll),
latestResponseTime: this.getLatestResponseTime(checksAll),
periodIncidents: this.getIncidents(checksForDateRange),
periodTotalChecks: checksForDateRange.length,
checks: this.processChecksForDisplay(this.NormalizeData, checksForDateRange, numToDisplay, normalize),
};
if (monitor.type === "http" || monitor.type === "ping" || monitor.type === "docker" || monitor.type === "port" || monitor.type === "game") {
// HTTP/PING Specific stats
monitorStats.periodAvgResponseTime = this.getAverageResponseTime(checksForDateRange);
monitorStats.periodUptime = this.getUptimePercentage(checksForDateRange);
const groupedChecks = this.groupChecksByTime(checksForDateRange, dateRange);
monitorStats.aggregateData = Object.values(groupedChecks).map(this.calculateGroupStats);
}
return monitorStats;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getMonitorStatsById";
throw error;
}
};
getHardwareDetailsById = async ({ monitorId, dateRange }) => {
try {
const monitor = await this.Monitor.findById(monitorId);
@@ -313,9 +266,17 @@ class MonitorModule {
};
const dateString = formatLookup[dateRange];
const hardwareStats = await this.Check.aggregate(buildHardwareDetailsPipeline(monitor, dates, dateString));
const [aggregateData, upChecksCount, metrics] = await Promise.all([
getAggregateData(monitorId, dates),
getUpChecks(monitorId, dates),
getHardwareStats(monitorId, dates, dateString),
]);
const stats = hardwareStats[0];
const stats = {
aggregateData: aggregateData,
upChecks: upChecksCount,
checks: metrics,
};
return {
...monitor.toObject(),
@@ -328,47 +289,33 @@ class MonitorModule {
}
};
getMonitorsByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId }) => {
limit = parseInt(limit);
page = parseInt(page);
rowsPerPage = parseInt(rowsPerPage);
if (field === undefined) {
field = "name";
order = "asc";
}
// Build match stage
const matchStage = { teamId: new this.ObjectId(teamId) };
if (type !== undefined) {
matchStage.type = Array.isArray(type) ? { $in: type } : type;
}
const summaryResult = await this.Monitor.aggregate(buildMonitorSummaryByTeamIdPipeline({ matchStage }));
const summary = summaryResult[0];
const monitors = await this.Monitor.aggregate(buildMonitorsByTeamIdPipeline({ matchStage, field, order }));
const filteredMonitors = await this.Monitor.aggregate(
buildFilteredMonitorsByTeamIdPipeline({
matchStage,
filter,
page,
rowsPerPage,
field,
order,
limit,
type,
})
);
const normalizedFilteredMonitors = filteredMonitors.map((monitor) => {
if (!monitor.checks) {
return monitor;
getMonitorsByTeamId = async ({ teamId, type, filter }) => {
try {
const matchStage = { teamId: new this.ObjectId(teamId) };
if (type !== undefined) {
matchStage.type = Array.isArray(type) ? { $in: type } : type;
}
monitor.checks = this.NormalizeData(monitor.checks, 10, 100);
return monitor;
});
return { summary, monitors, filteredMonitors: normalizedFilteredMonitors };
if (filter !== undefined && filter !== null && filter !== "") {
matchStage.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }];
}
const monitors = await this.Monitor.find(matchStage)
.sort({ name: 1 })
.select({
_id: 1,
name: 1,
type: 1,
url: 1,
status: 1,
isActive: 1,
teamId: 1,
})
.lean();
return monitors;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getMonitorsByTeamId";
throw error;
}
};
getMonitorsAndSummaryByTeamId = async ({ type, explain, teamId }) => {
+740
View File
@@ -0,0 +1,740 @@
import { ObjectId } from "mongodb";
import { CheckModel } from "@/db/models/index.js";
const buildUptimeDetailsPipeline = (monitorId, dates, dateString) => {
return [
{
$match: {
"metadata.monitorId": new ObjectId(monitorId),
updatedAt: { $gte: dates.start, $lte: dates.end },
},
},
{
$sort: {
updatedAt: 1,
},
},
{
$facet: {
// For the response time chart, should return checks for date window
// Grouped by: {day: hour}, {week: day}, {month: day}
uptimePercentage: [
{
$group: {
_id: null,
upChecks: {
$sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] },
},
totalChecks: { $sum: 1 },
},
},
{
$project: {
_id: 0,
percentage: {
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
},
},
},
],
groupedAvgResponseTime: [
{
$group: {
_id: null,
avgResponseTime: {
$avg: "$responseTime",
},
},
},
],
groupedChecks: [
{
$group: {
_id: {
$dateToString: {
format: dateString,
date: "$createdAt",
},
},
avgResponseTime: {
$avg: "$responseTime",
},
totalChecks: {
$sum: 1,
},
},
},
{
$sort: {
_id: 1,
},
},
],
// Up checks grouped by: {day: hour}, {week: day}, {month: day}
groupedUpChecks: [
{
$match: {
status: true,
},
},
{
$group: {
_id: {
$dateToString: {
format: dateString,
date: "$createdAt",
},
},
totalChecks: {
$sum: 1,
},
avgResponseTime: {
$avg: "$responseTime",
},
},
},
{
$sort: { _id: 1 },
},
],
// Down checks grouped by: {day: hour}, {week: day}, {month: day} for the date window
groupedDownChecks: [
{
$match: {
status: false,
},
},
{
$group: {
_id: {
$dateToString: {
format: dateString,
date: "$createdAt",
},
},
totalChecks: {
$sum: 1,
},
avgResponseTime: {
$avg: "$responseTime",
},
},
},
{
$sort: { _id: 1 },
},
],
},
},
{
$lookup: {
from: "monitors",
let: { monitor_id: { $toObjectId: monitorId } },
pipeline: [
{
$match: {
$expr: { $eq: ["$_id", "$$monitor_id"] },
},
},
{
$project: {
_id: 1,
teamId: 1,
name: 1,
status: 1,
interval: 1,
type: 1,
url: 1,
isActive: 1,
notifications: 1,
},
},
],
as: "monitor",
},
},
{
$project: {
groupedAvgResponseTime: {
$arrayElemAt: ["$groupedAvgResponseTime.avgResponseTime", 0],
},
groupedChecks: "$groupedChecks",
groupedUpChecks: "$groupedUpChecks",
groupedDownChecks: "$groupedDownChecks",
groupedUptimePercentage: { $arrayElemAt: ["$uptimePercentage.percentage", 0] },
monitor: { $arrayElemAt: ["$monitor", 0] },
},
},
];
};
const buildMonitorStatsPipeline = (monitor) => {
return [
{
$match: {
monitorId: monitor._id,
},
},
{
$project: {
avgResponseTime: 1,
uptimePercentage: 1,
totalChecks: 1,
timeSinceLastCheck: {
$subtract: [Date.now(), "$lastCheckTimestamp"],
},
lastCheckTimestamp: 1,
uptBurnt: { $toString: "$uptBurnt" },
},
},
];
};
const buildMonitorSummaryByTeamIdPipeline = ({ matchStage }) => {
return [
{ $match: matchStage },
{
$group: {
_id: null,
totalMonitors: { $sum: 1 },
upMonitors: {
$sum: {
$cond: [{ $eq: ["$status", true] }, 1, 0],
},
},
downMonitors: {
$sum: {
$cond: [{ $eq: ["$status", false] }, 1, 0],
},
},
pausedMonitors: {
$sum: {
$cond: [{ $eq: ["$isActive", false] }, 1, 0],
},
},
},
},
{
$project: {
_id: 0,
},
},
];
};
const buildMonitorsByTeamIdPipeline = ({ matchStage, field, order }) => {
const sort = { [field]: order === "asc" ? 1 : -1 };
return [
{ $match: matchStage },
{ $sort: sort },
{
$project: {
_id: 1,
name: 1,
type: 1,
port: 1,
},
},
];
};
const buildMonitorsAndSummaryByTeamIdPipeline = ({ matchStage }) => {
return [
{ $match: matchStage },
{
$facet: {
summary: [
{
$group: {
_id: null,
totalMonitors: { $sum: 1 },
upMonitors: {
$sum: {
$cond: [{ $eq: ["$status", true] }, 1, 0],
},
},
downMonitors: {
$sum: {
$cond: [{ $eq: ["$status", false] }, 1, 0],
},
},
pausedMonitors: {
$sum: {
$cond: [{ $eq: ["$isActive", false] }, 1, 0],
},
},
},
},
{
$project: {
_id: 0,
},
},
],
monitors: [
{ $sort: { name: 1 } },
{
$project: {
_id: 1,
name: 1,
type: 1,
},
},
],
},
},
{
$project: {
summary: { $arrayElemAt: ["$summary", 0] },
monitors: 1,
},
},
];
};
const buildMonitorsWithChecksByTeamIdPipeline = ({ matchStage, filter, page, rowsPerPage, field, order, limit, type }) => {
const skip = page && rowsPerPage ? page * rowsPerPage : 0;
const sort = { [field]: order === "asc" ? 1 : -1 };
const limitStage = rowsPerPage ? [{ $limit: rowsPerPage }] : [];
// Match name
if (typeof filter !== "undefined" && field === "name") {
matchStage.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }];
}
// Match isActive
if (typeof filter !== "undefined" && field === "isActive") {
matchStage.isActive = filter === "true" ? true : false;
}
if (typeof filter !== "undefined" && field === "status") {
matchStage.status = filter === "true" ? true : false;
}
// Match type
if (typeof filter !== "undefined" && field === "type") {
matchStage.type = filter;
}
const monitorsPipeline = [
{ $sort: sort },
{ $skip: skip },
...limitStage,
{
$project: {
_id: 1,
name: 1,
description: 1,
type: 1,
url: 1,
isActive: 1,
createdAt: 1,
updatedAt: 1,
uptimePercentage: 1,
status: 1,
},
},
];
// Add checks
if (limit) {
const checksCollection = "checks";
monitorsPipeline.push({
$lookup: {
from: checksCollection,
let: { monitorId: "$_id" },
pipeline: [
{
$match: {
$expr: { $eq: ["$monitorId", "$$monitorId"] },
},
},
{ $sort: { updatedAt: -1 } },
{ $limit: limit },
{
$project: {
_id: 1,
status: 1,
responseTime: 1,
statusCode: 1,
createdAt: 1,
updatedAt: 1,
originalResponseTime: 1,
},
},
],
as: "checks",
},
});
}
const pipeline = [
{ $match: matchStage },
{
$facet: {
count: [{ $count: "monitorsCount" }],
monitors: monitorsPipeline,
},
},
{
$project: {
count: { $arrayElemAt: ["$count", 0] },
monitors: 1,
},
},
];
return pipeline;
};
const buildFilteredMonitorsByTeamIdPipeline = ({ matchStage, filter, page, rowsPerPage, field, order, limit, type }) => {
const skip = page && rowsPerPage ? page * rowsPerPage : 0;
const sort = { [field]: order === "asc" ? 1 : -1 };
const limitStage = rowsPerPage ? [{ $limit: rowsPerPage }] : [];
if (typeof filter !== "undefined" && field === "name") {
matchStage.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }];
}
if (typeof filter !== "undefined" && field === "status") {
matchStage.status = filter === "true";
}
const pipeline = [{ $match: matchStage }, { $sort: sort }, { $skip: skip }, ...limitStage];
// Add checks
if (limit) {
const checksCollection = "checks";
pipeline.push({
$lookup: {
from: checksCollection,
let: { monitorId: "$_id" },
pipeline: [
{
$match: {
$expr: { $eq: ["$monitorId", "$$monitorId"] },
},
},
{ $sort: { createdAt: -1 } },
{ $limit: limit },
],
as: "checks",
},
});
}
return pipeline;
};
const buildGetMonitorsByTeamIdPipeline = (req) => {
let { limit, type, page, rowsPerPage, filter, field, order } = req.query;
limit = parseInt(limit);
page = parseInt(page);
rowsPerPage = parseInt(rowsPerPage);
if (field === undefined) {
field = "name";
order = "asc";
}
// Build the match stage
const matchStage = { teamId: new ObjectId(req.params.teamId) };
if (type !== undefined) {
matchStage.type = Array.isArray(type) ? { $in: type } : type;
}
const skip = page && rowsPerPage ? page * rowsPerPage : 0;
const sort = { [field]: order === "asc" ? 1 : -1 };
return [
{ $match: matchStage },
{
$facet: {
summary: [
{
$group: {
_id: null,
totalMonitors: { $sum: 1 },
upMonitors: {
$sum: {
$cond: [{ $eq: ["$status", true] }, 1, 0],
},
},
downMonitors: {
$sum: {
$cond: [{ $eq: ["$status", false] }, 1, 0],
},
},
pausedMonitors: {
$sum: {
$cond: [{ $eq: ["$isActive", false] }, 1, 0],
},
},
},
},
{
$project: {
_id: 0,
},
},
],
monitors: [
{ $sort: sort },
{
$project: {
_id: 1,
name: 1,
},
},
],
filteredMonitors: [
...(filter !== undefined
? [
{
$match: {
$or: [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }],
},
},
]
: []),
{ $sort: sort },
{ $skip: skip },
...(rowsPerPage ? [{ $limit: rowsPerPage }] : []),
...(limit
? [
{
$lookup: {
from: "checks",
let: { monitorId: "$_id" },
pipeline: [
{
$match: {
$expr: { $eq: ["$monitorId", "$$monitorId"] },
},
},
{ $sort: { createdAt: -1 } },
...(limit ? [{ $limit: limit }] : []),
],
as: "standardchecks",
},
},
]
: []),
{
$addFields: {
checks: {
$switch: {
branches: [
{
case: { $in: ["$type", ["http", "ping", "docker", "port", "game"]] },
then: "$standardchecks",
},
],
default: [],
},
},
},
},
{
$project: {
standardchecks: 0,
},
},
],
},
},
{
$project: {
summary: { $arrayElemAt: ["$summary", 0] },
filteredMonitors: 1,
monitors: 1,
},
},
];
};
export {
buildUptimeDetailsPipeline,
buildMonitorStatsPipeline,
buildGetMonitorsByTeamIdPipeline,
buildMonitorSummaryByTeamIdPipeline,
buildMonitorsByTeamIdPipeline,
buildMonitorsAndSummaryByTeamIdPipeline,
buildMonitorsWithChecksByTeamIdPipeline,
buildFilteredMonitorsByTeamIdPipeline,
};
export const getAggregateData = async (monitorId, dates) => {
const result = await CheckModel.aggregate([
{
$match: {
"metadata.monitorId": new ObjectId(monitorId),
"metadata.type": "hardware",
createdAt: { $gte: dates.start, $lte: dates.end },
},
},
{ $sort: { createdAt: -1 } },
{
$group: {
_id: null,
latestCheck: { $first: "$$ROOT" },
totalChecks: { $sum: 1 },
},
},
]);
return result[0] || { totalChecks: 0, latestCheck: null };
};
export const getUpChecks = async (monitorId, dates) => {
const count = await CheckModel.countDocuments({
"metadata.monitorId": new ObjectId(monitorId),
"metadata.type": "hardware",
createdAt: { $gte: dates.start, $lte: dates.end },
status: true,
});
return { totalChecks: count };
};
export const getHardwareStats = async (monitorId, dates, dateString) => {
return await CheckModel.aggregate([
{
$match: {
"metadata.monitorId": new ObjectId(monitorId),
"metadata.type": "hardware",
createdAt: { $gte: dates.start, $lte: dates.end },
},
},
{ $sort: { createdAt: 1 } },
{
$group: {
_id: { $dateToString: { format: dateString, date: "$createdAt" } },
avgCpuUsage: { $avg: "$cpu.usage_percent" },
avgMemoryUsage: { $avg: "$memory.usage_percent" },
avgTemperatures: { $push: { $ifNull: ["$cpu.temperature", [0]] } },
disks: { $push: "$disk" },
net: { $push: "$net" },
updatedAts: { $push: "$updatedAt" },
sampleDoc: { $first: "$$ROOT" },
},
},
{
$project: {
_id: 1,
avgCpuUsage: 1,
avgMemoryUsage: 1,
avgTemperature: {
$map: {
input: { $range: [0, { $size: { $ifNull: [{ $arrayElemAt: ["$avgTemperatures", 0] }, [0]] } }] },
as: "idx",
in: { $avg: { $map: { input: "$avgTemperatures", as: "t", in: { $arrayElemAt: ["$$t", "$$idx"] } } } },
},
},
disks: {
$map: {
input: { $range: [0, { $size: { $ifNull: ["$sampleDoc.disk", []] } }] },
as: "dIdx",
in: {
name: { $concat: ["disk", { $toString: "$$dIdx" }] },
readSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.read_speed_bytes", "$$dIdx"] } } } },
writeSpeed: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.write_speed_bytes", "$$dIdx"] } } } },
totalBytes: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.total_bytes", "$$dIdx"] } } } },
freeBytes: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.free_bytes", "$$dIdx"] } } } },
usagePercent: { $avg: { $map: { input: "$disks", as: "dA", in: { $arrayElemAt: ["$$dA.usage_percent", "$$dIdx"] } } } },
},
},
},
net: {
$map: {
input: { $range: [0, { $size: { $ifNull: ["$sampleDoc.net", []] } }] },
as: "nIdx",
in: {
name: { $arrayElemAt: ["$sampleDoc.net.name", "$$nIdx"] },
bytesSentPerSecond: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.bytes_sent" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.bytes_sent" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
deltaBytesRecv: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.bytes_recv" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.bytes_recv" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
deltaPacketsSent: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.packets_sent" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.packets_sent" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
deltaPacketsRecv: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.packets_recv" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.packets_recv" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
deltaErrIn: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.err_in" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.err_in" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
deltaErrOut: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.err_out" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.err_out" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
deltaDropIn: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.drop_in" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.drop_in" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
deltaDropOut: {
$let: {
vars: {
tDiff: { $divide: [{ $subtract: [{ $last: "$updatedAts" }, { $first: "$updatedAts" }] }, 1000] },
f: { $arrayElemAt: [{ $map: { input: { $first: "$net" }, as: "i", in: "$$i.drop_out" } }, "$$nIdx"] },
l: { $arrayElemAt: [{ $map: { input: { $last: "$net" }, as: "i", in: "$$i.drop_out" } }, "$$nIdx"] },
},
in: { $cond: [{ $gt: ["$$tDiff", 0] }, { $divide: [{ $subtract: ["$$l", "$$f"] }, "$$tDiff"] }, 0] },
},
},
},
},
},
},
},
{ $sort: { _id: 1 } },
]);
};
@@ -153,7 +153,7 @@ class StatusPageModule {
pipeline: [
{
$match: {
$expr: { $eq: ["$monitorId", "$$monitorId"] },
$expr: { $eq: ["$metadata.monitorId", "$$monitorId"] },
},
},
{ $sort: { createdAt: -1 } },
@@ -1,18 +0,0 @@
// import Monitor from "@/db/v1/models/Monitor.js";
async function migrateStatusWindowThreshold() {
try {
// const monitors = await Monitor.find({ statusWindowThreshold: { $lt: 1 } });
// for (const monitor of monitors) {
// monitor.statusWindowThreshold = monitor.statusWindowThreshold * 100;
// await monitor.save();
// console.log(`Migrated monitor ${monitor._id}: statusWindowThreshold set to ${monitor.statusWindowThreshold}`);
// }
// console.log("StatusWindowThreshold migration complete.");
return true;
} catch (err) {
console.error("Migration error:", err);
return false;
}
}
export { migrateStatusWindowThreshold };
-7
View File
@@ -1,7 +0,0 @@
import { migrateStatusWindowThreshold } from "./0001_migrateStatusWindowThreshold.js";
const runMigrations = async () => {
await migrateStatusWindowThreshold();
};
export { runMigrations };
-178
View File
@@ -1,178 +0,0 @@
import mongoose from "mongoose";
const cpuSchema = mongoose.Schema({
physical_core: { type: Number, default: 0 },
logical_core: { type: Number, default: 0 },
frequency: { type: Number, default: 0 },
temperature: { type: [Number], default: [] },
free_percent: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
});
const memorySchema = mongoose.Schema({
total_bytes: { type: Number, default: 0 },
available_bytes: { type: Number, default: 0 },
used_bytes: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
});
const diskSchema = mongoose.Schema({
device: { type: String, default: "" },
mountpoint: { type: String, default: "" },
read_speed_bytes: { type: Number, default: 0 },
write_speed_bytes: { type: Number, default: 0 },
total_bytes: { type: Number, default: 0 },
free_bytes: { type: Number, default: 0 },
usage_percent: { type: Number, default: 0 },
});
const hostSchema = mongoose.Schema({
os: { type: String, default: "" },
platform: { type: String, default: "" },
kernel_version: { type: String, default: "" },
});
const errorSchema = mongoose.Schema({
metric: { type: [String], default: [] },
err: { type: String, default: "" },
});
const captureSchema = mongoose.Schema({
version: { type: String, default: "" },
mode: { type: String, default: "" },
});
const networkInterfaceSchema = mongoose.Schema({
name: { type: String },
bytes_sent: { type: Number, default: 0 },
bytes_recv: { type: Number, default: 0 },
packets_sent: { type: Number, default: 0 },
packets_recv: { type: Number, default: 0 },
err_in: { type: Number, default: 0 },
err_out: { type: Number, default: 0 },
drop_in: { type: Number, default: 0 },
drop_out: { type: Number, default: 0 },
fifo_in: { type: Number, default: 0 },
fifo_out: { type: Number, default: 0 },
});
const CheckSchema = new mongoose.Schema(
{
// Common fields
monitorId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Monitor",
immutable: true,
index: true,
},
teamId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Team",
immutable: true,
index: true,
},
type: {
type: String,
enum: ["http", "ping", "pagespeed", "hardware", "docker", "port", "game"],
required: true,
index: true,
},
status: {
type: Boolean,
index: true,
},
responseTime: {
type: Number,
},
timings: {
type: Object,
default: {},
},
statusCode: {
type: Number,
index: true,
},
message: {
type: String,
},
expiry: {
type: Date,
default: Date.now,
expires: 60 * 60 * 24 * 30, // 30 days
},
ack: {
type: Boolean,
default: false,
},
ackAt: {
type: Date,
},
// Hardware fields
cpu: {
type: cpuSchema,
default: () => ({}),
},
memory: {
type: memorySchema,
default: () => ({}),
},
disk: {
type: [diskSchema],
default: () => [],
},
host: {
type: hostSchema,
default: () => ({}),
},
errors: {
type: [errorSchema],
default: () => [],
},
capture: {
type: captureSchema,
default: () => ({}),
},
net: {
type: [networkInterfaceSchema],
default: () => [],
},
// PageSpeed fields
accessibility: {
type: Number,
},
bestPractices: {
type: Number,
},
seo: {
type: Number,
},
performance: {
type: Number,
},
audits: {
type: Object,
},
},
{ timestamps: true }
);
CheckSchema.index({ updatedAt: 1 });
CheckSchema.index({ monitorId: 1, updatedAt: 1 });
CheckSchema.index({ monitorId: 1, updatedAt: -1 });
CheckSchema.index({ teamId: 1, updatedAt: -1 });
export default mongoose.model("Check", CheckSchema);
-49
View File
@@ -1,49 +0,0 @@
import mongoose from "mongoose";
const MonitorStatsSchema = new mongoose.Schema(
{
monitorId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Monitor",
immutable: true,
index: true,
},
avgResponseTime: {
type: Number,
default: 0,
},
totalChecks: {
type: Number,
default: 0,
},
totalUpChecks: {
type: Number,
default: 0,
},
totalDownChecks: {
type: Number,
default: 0,
},
uptimePercentage: {
type: Number,
default: 0,
},
lastCheckTimestamp: {
type: Number,
default: 0,
},
lastResponseTime: {
type: Number,
default: 0,
},
timeOfLastFailure: {
type: Number,
default: 0,
},
},
{ timestamps: true }
);
const MonitorStats = mongoose.model("MonitorStats", MonitorStatsSchema);
export default MonitorStats;
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -5,10 +5,11 @@ import { initShutdownListener } from "./shutdown.js";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs";
import { runMigrations } from "./db/migration/index.js";
import Logger from "./utils/logger.js";
import SettingsService from "./service/v1/system/settingsService.js";
import AppSettings from "./db/v1/models/AppSettings.js";
import SettingsService from "./service/system/settingsService.js";
import AppSettings from "./db/models/AppSettings.js";
const SERVICE_NAME = "Server";
let logger;
@@ -30,6 +31,8 @@ const startApp = async () => {
// Initialize services
const services = await initializeServices({ logger, envSettings, settingsService });
await runMigrations(logger);
// Initialize controllers
const controllers = initializeControllers(services);
+2 -2
View File
@@ -1,6 +1,6 @@
import { logger } from "../../utils/logger.js";
import ServiceRegistry from "../../service/v1/system/serviceRegistry.js";
import StringService from "../../service/v1/system/stringService.js";
import ServiceRegistry from "../../service/system/serviceRegistry.js";
import StringService from "../../service/system/stringService.js";
const handleErrors = (error, req, res, next) => {
const status = error.status || 500;
+3 -3
View File
@@ -1,9 +1,9 @@
import jwt from "jsonwebtoken";
const TOKEN_PREFIX = "Bearer ";
const SERVICE_NAME = "allowedRoles";
import ServiceRegistry from "../../service/v1/system/serviceRegistry.js";
import StringService from "../../service/v1/system/stringService.js";
import SettingsService from "../../service/v1/system/settingsService.js";
import ServiceRegistry from "../../service/system/serviceRegistry.js";
import StringService from "../../service/system/stringService.js";
import SettingsService from "../../service/system/settingsService.js";
const isAllowed = (allowedRoles) => {
return (req, res, next) => {
+3 -3
View File
@@ -1,7 +1,7 @@
import jwt from "jsonwebtoken";
import ServiceRegistry from "../../service/v1/system/serviceRegistry.js";
import SettingsService from "../../service/v1/system/settingsService.js";
import StringService from "../../service/v1/system/stringService.js";
import ServiceRegistry from "../../service/system/serviceRegistry.js";
import SettingsService from "../../service/system/settingsService.js";
import StringService from "../../service/system/stringService.js";
const SERVICE_NAME = "verifyJWT";
const TOKEN_PREFIX = "Bearer ";
+2 -2
View File
@@ -1,6 +1,6 @@
import { logger } from "../../utils/logger.js";
import ServiceRegistry from "../../service/v1/system/serviceRegistry.js";
import StringService from "../../service/v1/system/stringService.js";
import ServiceRegistry from "../../service/system/serviceRegistry.js";
import StringService from "../../service/system/stringService.js";
import { ObjectId } from "mongodb";
const SERVICE_NAME = "verifyOwnership";
@@ -0,0 +1,65 @@
import type { Check, CheckAudits, MonitorType } from "@/types/index.js";
import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js";
export interface PageSpeedChecksResult {
monitorType: "pagespeed";
checks: Check[];
}
export interface HardwareChecksResult {
monitorType: "hardware";
aggregateData: {
latestCheck: Check | null;
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
checks: Array<{
_id: string;
avgCpuUsage: number;
avgMemoryUsage: number;
avgTemperature: number[];
disks: Array<{
name: string;
readSpeed: number;
writeSpeed: number;
totalBytes: number;
freeBytes: number;
usagePercent: number;
}>;
net: Array<{
name: string;
bytesSentPerSecond: number;
deltaBytesRecv: number;
deltaPacketsSent: number;
deltaPacketsRecv: number;
deltaErrIn: number;
deltaErrOut: number;
deltaDropIn: number;
deltaDropOut: number;
deltaFifoIn: number;
deltaFifoOut: number;
}>;
}>;
}
export interface UptimeChecksResult {
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
groupedChecks: Array<{ _id: string; avgResponseTime: number; totalChecks: number }>;
groupedUpChecks: Array<{ _id: string; totalChecks: number; avgResponseTime: number }>;
groupedDownChecks: Array<{ _id: string; totalChecks: number; avgResponseTime: number }>;
uptimePercentage: number;
avgResponseTime: number;
}
export interface IChecksRepository {
findLatestChecksByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
findDateRangeChecksByMonitor(
monitorId: string,
startDate: Date,
endDate: Date,
dateString: string,
options?: { type?: MonitorType }
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
}
@@ -0,0 +1,395 @@
import { IChecksRepository } from "@/repositories/index.js";
import type {
Check,
CheckAudits,
CheckCaptureInfo,
CheckCpuInfo,
CheckDiskInfo,
CheckErrorInfo,
CheckHostInfo,
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
CheckTimings,
MonitorType,
} from "@/types/index.js";
import { CheckModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import {
getAggregateData as getHardwareAggregateData,
getHardwareStats,
getUpChecks as getHardwareUpChecks,
} from "@/db/modules/monitorModuleQueries.js";
export type LatestChecksMap = Record<string, Check[]>;
class MongoChecksRepistory implements IChecksRepository {
private toEntity = (doc: CheckDocument): Check => {
const toStringId = (value: mongoose.Types.ObjectId | string | undefined | null): string => {
if (!value) {
return "";
}
return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
};
const toDateString = (value?: Date | string | null): string => {
if (!value) {
return new Date(0).toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
const toOptionalDateString = (value?: Date | string | null): string | undefined => {
if (!value) {
return undefined;
}
return toDateString(value);
};
const mapTimings = (timings?: CheckTimings): CheckTimings => {
const phases = timings?.phases ?? {
wait: 0,
dns: 0,
tcp: 0,
tls: 0,
request: 0,
firstByte: 0,
download: 0,
total: 0,
};
return {
start: timings?.start ?? 0,
socket: timings?.socket ?? 0,
lookup: timings?.lookup ?? 0,
connect: timings?.connect ?? 0,
secureConnect: timings?.secureConnect ?? 0,
upload: timings?.upload ?? 0,
response: timings?.response ?? 0,
end: timings?.end ?? 0,
phases,
};
};
const mapCpu = (cpu?: CheckCpuInfo): CheckCpuInfo => ({
physical_core: cpu?.physical_core ?? 0,
logical_core: cpu?.logical_core ?? 0,
frequency: cpu?.frequency ?? 0,
temperature: cpu?.temperature ?? [],
free_percent: cpu?.free_percent ?? 0,
usage_percent: cpu?.usage_percent ?? 0,
});
const mapMemory = (memory?: CheckMemoryInfo): CheckMemoryInfo => ({
total_bytes: memory?.total_bytes ?? 0,
available_bytes: memory?.available_bytes ?? 0,
used_bytes: memory?.used_bytes ?? 0,
usage_percent: memory?.usage_percent ?? 0,
});
const mapHost = (host?: CheckHostInfo): CheckHostInfo => ({
os: host?.os ?? "",
platform: host?.platform ?? "",
kernel_version: host?.kernel_version ?? "",
});
const mapCapture = (capture?: CheckCaptureInfo): CheckCaptureInfo => ({
version: capture?.version ?? "",
mode: capture?.mode ?? "",
});
const mapDisks = (disks?: CheckDiskInfo[]): CheckDiskInfo[] =>
(disks ?? []).map((disk) => ({
device: disk?.device ?? "",
mountpoint: disk?.mountpoint ?? "",
read_speed_bytes: disk?.read_speed_bytes ?? 0,
write_speed_bytes: disk?.write_speed_bytes ?? 0,
total_bytes: disk?.total_bytes ?? 0,
free_bytes: disk?.free_bytes ?? 0,
usage_percent: disk?.usage_percent ?? 0,
}));
const mapErrors = (errors?: CheckErrorInfo[]): CheckErrorInfo[] =>
(errors ?? []).map((error) => ({
metric: error?.metric ?? [],
err: error?.err ?? "",
}));
const mapNet = (net?: CheckNetworkInterfaceInfo[]): CheckNetworkInterfaceInfo[] =>
(net ?? []).map((iface) => ({
name: iface?.name ?? "",
bytes_sent: iface?.bytes_sent ?? 0,
bytes_recv: iface?.bytes_recv ?? 0,
packets_sent: iface?.packets_sent ?? 0,
packets_recv: iface?.packets_recv ?? 0,
err_in: iface?.err_in ?? 0,
err_out: iface?.err_out ?? 0,
drop_in: iface?.drop_in ?? 0,
drop_out: iface?.drop_out ?? 0,
fifo_in: iface?.fifo_in ?? 0,
fifo_out: iface?.fifo_out ?? 0,
}));
const mapAudits = (audits?: CheckAudits): CheckAudits | undefined => {
if (!audits) {
return undefined;
}
return {
cls: audits.cls ?? 0,
si: audits.si ?? 0,
fcp: audits.fcp ?? 0,
lcp: audits.lcp ?? 0,
tbt: audits.tbt ?? 0,
};
};
const mapMetadata = (metadata: CheckDocument["metadata"]): CheckMetadata => ({
monitorId: toStringId(metadata.monitorId),
teamId: toStringId(metadata.teamId),
type: metadata.type,
});
return {
id: toStringId(doc._id),
metadata: mapMetadata(doc.metadata),
status: doc.status ?? false,
responseTime: doc.responseTime ?? 0,
timings: mapTimings(doc.timings),
statusCode: doc.statusCode ?? 0,
message: doc.message ?? "",
ack: doc.ack ?? false,
ackAt: toOptionalDateString(doc.ackAt),
expiry: toDateString(doc.expiry),
cpu: mapCpu(doc.cpu),
memory: mapMemory(doc.memory),
disk: mapDisks(doc.disk),
host: mapHost(doc.host),
errors: mapErrors(doc.errors),
capture: mapCapture(doc.capture),
net: mapNet(doc.net),
accessibility: doc.accessibility,
bestPractices: doc.bestPractices,
seo: doc.seo,
performance: doc.performance,
audits: mapAudits(doc.audits),
__v: doc.__v ?? 0,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
findLatestChecksByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap> => {
if (monitorIds.length === 0) {
return {};
}
const mongoIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const limitPerMonitor = options?.limitPerMonitor ?? 25;
const maxIntervalMs = Number(10 * 60 * 1000);
const bufferMs = Number(maxIntervalMs);
const lookbackMs = limitPerMonitor * maxIntervalMs + bufferMs;
const cutoffDate = new Date(Date.now() - lookbackMs);
const checkGroups = await CheckModel.aggregate([
{
$match: {
"metadata.monitorId": { $in: mongoIds },
createdAt: { $gte: cutoffDate },
},
},
{
$group: {
_id: "$metadata.monitorId",
latestChecks: {
$topN: {
n: limitPerMonitor,
sortBy: { createdAt: -1 },
output: "$$ROOT",
},
},
},
},
]);
return checkGroups.reduce<LatestChecksMap>((acc, group) => {
const monitorId = group._id.toString();
acc[monitorId] = (group.latestChecks ?? []).map((doc: CheckDocument) => this.toEntity(doc));
return acc;
}, {});
};
findDateRangeChecksByMonitor = async (monitorId: string, startDate: Date, endDate: Date, dateString: string, options?: { type?: MonitorType }) => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
if (options?.type === "hardware") {
return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
}
if (options?.type === "pagespeed") {
return this.findPageSpeedDateRangeChecks(monitorObjectId, startDate, endDate);
}
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, startDate, endDate, dateString);
};
private findUptimeDateRangeChecks = async (
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId,
startDate: Date,
endDate: Date,
dateString: string
) => {
const matchStage = {
"metadata.monitorId": monitorObjectId,
updatedAt: { $gte: startDate, $lte: endDate },
};
const [result] = await CheckModel.aggregate([
{ $match: matchStage },
{ $sort: { updatedAt: 1 } },
{
$facet: {
uptimePercentage: [
{
$group: {
_id: null,
upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
totalChecks: { $sum: 1 },
},
},
{
$project: {
_id: 0,
percentage: {
$cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }],
},
},
},
],
groupedAvgResponseTime: [
{
$group: {
_id: null,
avgResponseTime: { $avg: "$responseTime" },
},
},
],
groupedChecks: [
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
avgResponseTime: { $avg: "$responseTime" },
totalChecks: { $sum: 1 },
},
},
{ $sort: { _id: 1 } },
],
groupedUpChecks: [
{ $match: { status: true } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
],
groupedDownChecks: [
{ $match: { status: false } },
{
$group: {
_id: {
$dateToString: { format: dateString, date: "$createdAt" },
},
totalChecks: { $sum: 1 },
avgResponseTime: { $avg: "$responseTime" },
},
},
{ $sort: { _id: 1 } },
],
},
},
]);
const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0;
const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0;
return {
monitorType,
groupedChecks: result?.groupedChecks ?? [],
groupedUpChecks: result?.groupedUpChecks ?? [],
groupedDownChecks: result?.groupedDownChecks ?? [],
uptimePercentage,
avgResponseTime,
};
};
private findHardwareDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => {
const monitorId = monitorObjectId.toHexString();
const dates = { start: startDate, end: endDate };
const [aggregateDataDoc, upChecksDoc, hardwareMetrics] = await Promise.all([
getHardwareAggregateData(monitorId, dates),
getHardwareUpChecks(monitorId, dates),
getHardwareStats(monitorId, dates, dateString),
]);
const aggregateData = {
latestCheck: aggregateDataDoc?.latestCheck ? this.toEntity(aggregateDataDoc.latestCheck as CheckDocument) : null,
totalChecks: aggregateDataDoc?.totalChecks ?? 0,
};
const upChecks = {
totalChecks: upChecksDoc?.totalChecks ?? 0,
};
const checks = (hardwareMetrics ?? []).map((metric) => ({
_id: metric._id,
avgCpuUsage: metric.avgCpuUsage ?? 0,
avgMemoryUsage: metric.avgMemoryUsage ?? 0,
avgTemperature: metric.avgTemperature ?? [],
disks: (metric.disks ?? []).map((disk: { [key: string]: number | string | undefined }) => ({
name: disk?.name ?? "",
readSpeed: disk?.readSpeed ?? 0,
writeSpeed: disk?.writeSpeed ?? 0,
totalBytes: disk?.totalBytes ?? 0,
freeBytes: disk?.freeBytes ?? 0,
usagePercent: disk?.usagePercent ?? 0,
})),
net: (metric.net ?? []).map((iface: { [key: string]: number | string | undefined }) => ({
name: iface?.name ?? "",
bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0,
deltaBytesRecv: iface?.deltaBytesRecv ?? 0,
deltaPacketsSent: iface?.deltaPacketsSent ?? 0,
deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0,
deltaErrIn: iface?.deltaErrIn ?? 0,
deltaErrOut: iface?.deltaErrOut ?? 0,
deltaDropIn: iface?.deltaDropIn ?? 0,
deltaDropOut: iface?.deltaDropOut ?? 0,
deltaFifoIn: iface?.deltaFifoIn ?? 0,
deltaFifoOut: iface?.deltaFifoOut ?? 0,
})),
}));
return {
monitorType: "hardware" as const,
aggregateData,
upChecks,
checks,
};
};
private findPageSpeedDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date) => {
const matchStage = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
};
const checks = await CheckModel.find(matchStage).sort({ createdAt: -1 }).limit(25).lean();
return {
monitorType: "pagespeed" as const,
checks: checks.map((doc) => this.toEntity(doc)),
};
};
}
export default MongoChecksRepistory;
+8
View File
@@ -0,0 +1,8 @@
export * from "@/repositories/monitors/IMonitorsRepository.js";
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
export * from "@/repositories/checks/IChecksRepository.js";
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js";
export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js";
@@ -0,0 +1,9 @@
import type { MonitorStats } from "@/types/index.js";
export interface IMonitorStatsRepository {
// create
// single fetch
findByMonitorId(monitorId: string): Promise<MonitorStats>;
// update
// delete
// other
}
@@ -0,0 +1,44 @@
import { type MonitorStatsDocument, MonitorStatsModel } from "@/db/models/index.js";
import type { MonitorStats } from "@/types/index.js";
import { IMonitorStatsRepository } from "@/repositories/index.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
class MongoMonitorStatsRepository implements IMonitorStatsRepository {
private toEntity = (doc: MonitorStatsDocument): MonitorStats => {
const toStringId = (value: unknown): string => {
if (value instanceof mongoose.Types.ObjectId) {
return value.toString();
}
return value?.toString() ?? "";
};
const toDateString = (value: Date | string): string => {
return value instanceof Date ? value.toISOString() : value;
};
return {
id: toStringId(doc._id),
monitorId: toStringId(doc.monitorId),
avgResponseTime: doc.avgResponseTime,
totalChecks: doc.totalChecks,
totalUpChecks: doc.totalUpChecks,
totalDownChecks: doc.totalDownChecks,
uptimePercentage: doc.uptimePercentage,
lastCheckTimestamp: doc.lastCheckTimestamp,
lastResponseTime: doc.lastResponseTime,
timeOfLastFailure: doc.timeOfLastFailure,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
findByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const monitorStats = await MonitorStatsModel.findOne({ monitorId: new mongoose.Types.ObjectId(monitorId) });
if (!monitorStats) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(monitorStats);
};
}
export default MongoMonitorStatsRepository;
@@ -0,0 +1,30 @@
import { type MonitorType, type Monitor } from "@/types/index.js";
export interface TeamQueryConfig {
limit?: number;
type?: MonitorType | MonitorType[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
}
export interface IMonitorsRepository {
// create
create(monitor: Monitor, teamId: string, userId: string): Promise<Monitor | null>;
createBulkMonitors(monitors: Monitor[]): Promise<Monitor[]>;
// single fetch
findById(monitorId: string, teamId?: string): Promise<Monitor | null>;
// collection fetch
findAll(): Promise<Monitor[] | null>;
findByTeamId(teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null>;
// update
update(monitorId: string, updates: Partial<Monitor>): Promise<Monitor>;
// delete
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
// counts
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
}
@@ -0,0 +1,181 @@
import { MonitorModel } from "@/db/models/index.js";
import type { MonitorDocument } from "@/db/models/Monitor.js";
import type { Monitor, MonitorType } from "@/types/monitor.js";
import mongoose, { type FilterQuery } from "mongoose";
import type { IMonitorsRepository, TeamQueryConfig } from "./IMonitorsRepository.js";
import { AppError } from "@/utils/AppError.js";
class MongoMonitorsRepository implements IMonitorsRepository {
create = async (monitor: Monitor, teamId: string, userId: string) => {
const monitorModel = new MonitorModel({ ...monitor, teamId, userId });
const saved = await monitorModel.save();
return this.toEntity(saved);
};
createBulkMonitors = async (monitors: Monitor[]): Promise<Monitor[]> => {
if (!monitors.length) {
return [];
}
const payload = monitors.map((monitor) => ({ ...monitor, notifications: undefined }));
const inserted = await MonitorModel.insertMany(payload, { ordered: false });
return this.mapDocuments(inserted);
};
findById = async (monitorId: string, teamId?: string): Promise<Monitor> => {
const match: { _id: string; teamId?: string } = { _id: monitorId };
if (teamId) {
match.teamId = teamId;
}
const monitor = await MonitorModel.findOne(match);
if (!monitor) {
if (monitor === null || monitor === undefined) {
throw new AppError({
message: `Monitor with ID ${monitorId} not found.`,
status: 404,
});
}
}
return this.toEntity(monitor);
};
findAll = async (): Promise<Monitor[]> => {
const monitors = await MonitorModel.find();
return this.mapDocuments(monitors);
};
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { page = 0, rowsPerPage = 25, filter, field = "createdAt", order = "desc", type, limit } = config ?? {};
const query: Record<string, unknown> = {
teamId: new mongoose.Types.ObjectId(teamId),
};
if (type !== undefined) {
query.type = Array.isArray(type) ? { $in: type } : type;
}
if (filter !== undefined) {
switch (field) {
case "name":
query.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }];
break;
case "isActive":
query.isActive = filter === "true";
break;
case "status":
query.status = filter === "true";
break;
case "type":
query.type = filter;
break;
default:
break;
}
}
const sort = { [field]: order === "asc" ? 1 : -1 } as const;
const skip = Math.max(page, 0) * rowsPerPage;
const limitValue = limit ?? rowsPerPage;
const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(limitValue).exec();
return this.mapDocuments(documents);
};
findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise<number> => {
const { type } = config ?? {};
const query: FilterQuery<MonitorDocument> = {
teamId: new mongoose.Types.ObjectId(teamId),
};
if (type !== undefined) {
query.type = Array.isArray(type) ? { $in: type } : type;
}
const count = await MonitorModel.countDocuments(query);
return count;
};
update = async (monitorId: string, patch: Partial<Monitor>) => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId },
{
$set: {
...patch,
},
},
{ new: true, runValidators: true }
);
if (!updatedMonitor) {
throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 });
}
return this.toEntity(updatedMonitor);
};
deleteByTeamId = async (teamId: string) => {
const monitors = await MonitorModel.find({ teamId });
const { deletedCount } = await MonitorModel.deleteMany({ teamId });
return { monitors: this.mapDocuments(monitors), deletedCount };
};
private mapDocuments = (documents: MonitorDocument[]): Monitor[] => {
if (!documents?.length) {
return [];
}
return documents.map((doc) => this.toEntity(doc));
};
private toEntity = (doc: MonitorDocument): Monitor => {
const toStringId = (value: unknown): string => {
if (value instanceof mongoose.Types.ObjectId) {
return value.toString();
}
return value?.toString() ?? "";
};
const toDateString = (value: Date | string): string => {
return value instanceof Date ? value.toISOString() : value;
};
const notificationIds = (doc.notifications ?? []).map((notification) => toStringId(notification));
return {
id: toStringId(doc._id),
userId: toStringId(doc.userId),
teamId: toStringId(doc.teamId),
name: doc.name,
description: doc.description ?? undefined,
status: doc.status ?? undefined,
statusWindow: doc.statusWindow ?? [],
statusWindowSize: doc.statusWindowSize,
statusWindowThreshold: doc.statusWindowThreshold,
type: doc.type,
ignoreTlsErrors: doc.ignoreTlsErrors,
jsonPath: doc.jsonPath ?? undefined,
expectedValue: doc.expectedValue ?? undefined,
matchMethod: doc.matchMethod ?? undefined,
url: doc.url,
port: doc.port ?? undefined,
isActive: doc.isActive,
interval: doc.interval,
uptimePercentage: doc.uptimePercentage ?? undefined,
notifications: notificationIds,
secret: doc.secret ?? undefined,
thresholds: doc.thresholds ?? undefined,
alertThreshold: doc.alertThreshold,
cpuAlertThreshold: doc.cpuAlertThreshold,
memoryAlertThreshold: doc.memoryAlertThreshold,
diskAlertThreshold: doc.diskAlertThreshold,
tempAlertThreshold: doc.tempAlertThreshold,
selectedDisks: doc.selectedDisks ?? [],
gameId: doc.gameId ?? undefined,
group: doc.group ?? null,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
}
export default MongoMonitorsRepository;
@@ -1,33 +0,0 @@
import { Router } from "express";
import { verifyJWT } from "../middleware/verifyJWT.js";
import { isAllowed } from "../middleware/isAllowed.js";
class AnnouncementRoutes {
constructor(controller) {
this.router = Router();
this.announcementController = controller;
this.initRoutes();
}
initRoutes() {
/**
* @route POST /
* @desc Create a new announcement
* @access Private (Requires JWT verification)
*/
this.router.post("/", verifyJWT, isAllowed(["admin", "superadmin"]), this.announcementController.createAnnouncement);
/**
* @route GET /
* @desc Get announcements
* @access Public
*/
this.router.get("/", this.announcementController.getAnnouncement);
}
getRouter() {
return this.router;
}
}
export default AnnouncementRoutes;
@@ -1,13 +1,15 @@
import { Router } from "express";
import { isAllowed } from "../../middleware/v1/isAllowed.js";
import multer from "multer";
import { fetchMonitorCertificate } from "../../controllers/v1/controllerUtils.js";
import { fetchMonitorCertificate } from "../../controllers/controllerUtils.js";
const upload = multer({
storage: multer.memoryStorage(), // Store file in memory as Buffer
});
class MonitorRoutes {
constructor(monitorController) {
private router: Router;
private monitorController: any;
constructor(monitorController: any) {
this.router = Router();
this.monitorController = monitorController;
this.initRoutes();
@@ -25,10 +27,11 @@ class MonitorRoutes {
// Hardware routes
this.router.get("/hardware/details/:monitorId", this.monitorController.getHardwareDetailsById);
// PageSpeed routes
this.router.get("/pagespeed/details/:monitorId", this.monitorController.getPageSpeedDetailsById);
// General monitor routes
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById);
// Util routes
this.router.get("/certificate/:monitorId", (req, res, next) => {
@@ -36,7 +39,6 @@ class MonitorRoutes {
});
// General monitor CRUD routes
this.router.get("/", this.monitorController.getAllMonitors);
this.router.post("/", isAllowed(["admin", "superadmin"]), this.monitorController.createMonitor);
this.router.delete("/", isAllowed(["superadmin"]), this.monitorController.deleteAllMonitors);
@@ -0,0 +1,566 @@
import { createMonitorsBodyValidation } from "@/validation/joi.js";
import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js";
import { type Monitor } from "@/types/index.js";
import type { MonitorType } from "@/types/monitor.js";
import type { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository } from "@/repositories/index.js";
import fs from "fs";
import { fileURLToPath } from "url";
import path from "path";
import { AppError } from "../infrastructure/errorService.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
export interface IMonitorService {
readonly serviceName: string;
verifyTeamAccess(args: { teamId: string; monitorId: string }): Promise<void>;
// create
createMonitor(teamId: string, userId: string, body: Monitor): Promise<void>;
createBulkMonitors(fileData: string, userId: string, teamId: string): Promise<any>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<any[]>;
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise<any>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<any>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<any>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
getMonitorsByTeamId(args: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
}): Promise<any>;
getMonitorsAndSummaryByTeamId(args: { teamId: string; type?: string | string[]; explain?: boolean }): Promise<any>;
getMonitorsWithChecksByTeamId(args: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
explain?: boolean;
}): Promise<{ count: number; monitors: any[] }>;
getAllGames(): any;
getGroupsByTeamId(args: { teamId: string }): Promise<any[]>;
// update
editMonitor(args: { teamId: string; monitorId: string; body: any }): Promise<void>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<any>;
// delete
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<any>;
deleteAllMonitors(args: { teamId: string }): Promise<number>;
// other
sendTestEmail(args: { to: string }): Promise<string>;
exportMonitorsToCSV(args: { teamId: string }): Promise<string>;
exportMonitorsToJSON(args: { teamId: string }): Promise<any[]>;
}
export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private db: any;
private jobQueue: any;
private stringService: any;
private emailService: any;
private papaparse: any;
private logger: any;
private errorService: any;
private games: any;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private monitorStatsRepository: IMonitorStatsRepository;
constructor({
db,
jobQueue,
stringService,
emailService,
papaparse,
logger,
errorService,
games,
monitorsRepository,
checksRepository,
monitorStatsRepository,
}: {
db: any;
jobQueue: any;
stringService: any;
emailService: any;
papaparse: any;
logger: any;
errorService: any;
games: any;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
}) {
this.db = db;
this.jobQueue = jobQueue;
this.stringService = stringService;
this.emailService = emailService;
this.papaparse = papaparse;
this.logger = logger;
this.errorService = errorService;
this.games = games;
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
this.monitorStatsRepository = monitorStatsRepository;
}
get serviceName(): string {
return MonitorService.SERVICE_NAME;
}
private getDateRange = (dateRange: DateRangeKey) => {
const startDates = {
recent: new Date(new Date().setHours(new Date().getHours() - 2)),
day: new Date(new Date().setDate(new Date().getDate() - 1)),
week: new Date(new Date().setDate(new Date().getDate() - 7)),
month: new Date(new Date().setMonth(new Date().getMonth() - 1)),
all: new Date(0),
};
return {
start: startDates[dateRange],
end: new Date(),
};
};
private getDateFormat = (dateRange: DateRangeKey): string => {
const formatLookup = {
recent: "%Y-%m-%dT%H:%M:00Z",
day: "%Y-%m-%dT%H:00:00Z",
week: "%Y-%m-%dT00:00:00Z",
month: "%Y-%m-%dT00:00:00Z",
all: "%Y-%m-%dT00:00:00Z",
};
return formatLookup[dateRange];
};
verifyTeamAccess = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<void> => {
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
if (!monitor?.teamId?.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
};
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError("Failed to create monitor", 500);
}
this.jobQueue.addJob(monitor.id, monitor);
};
createBulkMonitors = async (fileData: string, userId: string, teamId: string): Promise<any> => {
const { parse } = this.papaparse;
return new Promise<any>((resolve, reject) => {
parse(fileData, {
header: true,
skipEmptyLines: true,
transform: (value: string, header: string): string | number | undefined => {
if (value === "") return undefined; // Empty fields become undefined
// Handle 'port' and 'interval' fields, check if they're valid numbers
if (["port", "interval"].includes(header)) {
const num = parseInt(value, 10);
if (isNaN(num)) {
throw this.errorService.createBadRequestError(`${header} should be a valid number, got: ${value}`);
}
return num;
}
return value;
},
complete: async ({ data, errors }: { data: any[]; errors: Error[] }): Promise<void> => {
try {
if (errors.length > 0) {
throw this.errorService.createServerError("Error parsing CSV");
}
if (!data || data.length === 0) {
throw this.errorService.createServerError("CSV file contains no data rows");
}
const enrichedData = data.map((monitor: Monitor) => ({
...monitor,
userId,
teamId,
description: monitor.description || monitor.name || monitor.url,
name: monitor.name || monitor.url,
type: monitor.type || "http",
}));
await createMonitorsBodyValidation.validateAsync(enrichedData);
const monitors = await this.monitorsRepository.createBulkMonitors(enrichedData);
await Promise.all(
monitors.map(async (monitor: Monitor) => {
this.jobQueue.addJob(monitor.id, monitor);
})
);
resolve(monitors);
} catch (error) {
reject(error);
}
},
});
});
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<any[]> => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const demoMonitorsPath = path.resolve(__dirname, "../../utils/demoMonitors.json");
const demoData = JSON.parse(fs.readFileSync(demoMonitorsPath, "utf8"));
const monitors: Monitor[] = demoData.map((monitor: Monitor) => {
return {
userId,
teamId,
name: monitor.name,
description: monitor.name,
type: "http",
url: monitor.url,
interval: 60000,
};
});
const demoMonitors = await this.monitorsRepository.createBulkMonitors(monitors);
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
return demoMonitors;
};
getUptimeDetailsById = async ({
teamId,
monitorId,
dateRange,
normalize,
}: {
teamId: string;
monitorId: string;
dateRange: string;
normalize?: boolean;
}): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findDateRangeChecksByMonitor(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
if (
checksData.monitorType !== "http" &&
checksData.monitorType !== "ping" &&
checksData.monitorType !== "docker" &&
checksData.monitorType !== "port" &&
checksData.monitorType !== "game"
) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
return {
monitorData: {
monitor,
groupedChecks: NormalizeDataUptimeDetails(checksData.groupedChecks, 10, 100),
groupedUpChecks: NormalizeDataUptimeDetails(checksData.groupedUpChecks, 10, 100),
groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100),
groupedAvgResponseTime: checksData.avgResponseTime,
groupedUptimePercentage: checksData.uptimePercentage,
},
monitorStats,
};
};
getHardwareDetailsById = async ({ teamId, monitorId, dateRange }: { teamId: string; monitorId: string; dateRange: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (monitor.type !== "hardware") {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findDateRangeChecksByMonitor(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
if (checksData.monitorType !== "hardware") {
throw new AppError({ message: "Unable to load hardware stats for this monitor", status: 500 });
}
const stats = {
aggregateData: checksData.aggregateData,
upChecks: checksData.upChecks,
checks: checksData.checks,
};
return {
...monitor,
stats,
};
};
getPageSpeedDetailsById = async ({ teamId, monitorId, dateRange }: { teamId: string; monitorId: string; dateRange: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
if (monitor.type !== "pagespeed") {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findDateRangeChecksByMonitor(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
if (checksData.monitorType !== "pagespeed") {
throw new AppError({ message: "Unable to load pagespeed stats for this monitor", status: 500 });
}
return {
...monitor,
checks: checksData.checks,
};
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
};
getMonitorsByTeamId = async ({ teamId, type, filter }: { teamId: string; type?: MonitorType | MonitorType[]; filter?: string }): Promise<any> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
type,
filter,
});
return monitors;
};
getMonitorsAndSummaryByTeamId = async ({
teamId,
type,
explain,
}: {
teamId: string;
type?: string | string[];
explain?: boolean;
}): Promise<any> => {
const result = await this.db.monitorModule.getMonitorsAndSummaryByTeamId({
type,
explain,
teamId,
});
return result;
};
getMonitorsWithChecksByTeamId = async ({
teamId,
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
explain,
}: {
teamId: string;
limit?: number;
type?: MonitorType | MonitorType[];
page?: number;
rowsPerPage?: number;
filter?: string;
field?: string;
order?: "asc" | "desc";
explain?: boolean;
}): Promise<{ count: number; monitors: any[] }> => {
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter });
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
});
const monitorsList = (monitors ?? []) as Monitor[];
const snapshotTypes: MonitorType[] = ["hardware", "pagespeed"];
const requestedTypes = Array.isArray(type) ? type : type ? [type] : [];
const snapshotOnlyRequest =
requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType));
const limitPerMonitor = snapshotOnlyRequest ? 1 : 25;
const checksMap = await this.checksRepository.findLatestChecksByMonitorIds(
monitorsList.map((monitor) => monitor.id),
{ limitPerMonitor }
);
const monitorsWithChecks = monitorsList.map((monitor: Monitor) => {
const rawChecks = checksMap[monitor.id] ?? [];
const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type);
const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100);
return {
...monitor,
checks,
};
});
return { count, monitors: monitorsWithChecks };
};
getAllGames = (): any => {
return this.games;
};
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<any[]> => {
const groups = await this.db.monitorModule.getGroupsByTeamId({ teamId });
return groups;
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: any }): Promise<void> => {
await this.verifyTeamAccess({ teamId, monitorId });
const editedMonitor = await this.db.monitorModule.editMonitor({ monitorId, body });
await this.jobQueue.updateJob(editedMonitor);
};
pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.pauseMonitor({ monitorId });
monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor);
return monitor;
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.deleteMonitor({ teamId, monitorId });
await this.jobQueue.deleteJob(monitor);
await this.db.statusPageModule.deleteStatusPagesByMonitorId(monitor._id);
return monitor;
};
deleteAllMonitors = async ({ teamId }: { teamId: string }): Promise<number> => {
const { monitors, deletedCount } = await this.monitorsRepository.deleteByTeamId(teamId);
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.jobQueue.deleteJob(monitor);
await this.db.checkModule.deleteChecks(monitor.id);
await this.db.pageSpeedCheckModule.deletePageSpeedChecksByMonitorId(monitor.id);
await this.db.notificationsModule.deleteNotificationsByMonitorId(monitor.id);
} catch (error: any) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
stack: error.stack,
});
}
})
);
return deletedCount;
};
sendTestEmail = async ({ to }: { to: string }): Promise<string> => {
const subject = this.stringService.testEmailSubject;
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
const messageId = await this.emailService.sendEmail(to, subject, html);
if (!messageId) {
throw this.errorService.createServerError("Failed to send test email.");
}
return messageId;
};
exportMonitorsToCSV = async ({ teamId }: { teamId: string }): Promise<string> => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const csvData = monitors?.filteredMonitors?.map((monitor: any) => ({
name: monitor.name,
description: monitor.description,
type: monitor.type,
url: monitor.url,
interval: monitor.interval,
port: monitor.port,
ignoreTlsErrors: monitor.ignoreTlsErrors,
isActive: monitor.isActive,
}));
const csv = this.papaparse.unparse(csvData);
return csv;
};
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<any[]> => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const json = monitors?.filteredMonitors
?.map((monitor: any) => {
const initialType = monitor.type;
let parsedType;
if (initialType === "hardware") {
parsedType = "infrastructure";
} else if (initialType === "http") {
if (monitor.url.startsWith("https://")) {
parsedType = "https";
} else {
parsedType = "http";
}
} else if (initialType === "pagespeed") {
parsedType = initialType;
} else {
// Skip unsupported types
return;
}
return {
name: monitor.name,
url: monitor.url,
type: parsedType,
interval: monitor.interval,
n: monitor.statusWindowSize,
secret: monitor.secret,
};
})
.filter(Boolean);
return json;
};
}
+1
View File
@@ -0,0 +1 @@
export * from "@/service/business/monitorService.js";
@@ -4,19 +4,20 @@ const SERVICE_NAME = "JobQueue";
class SuperSimpleQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor({ envSettings, db, logger, helper }) {
constructor({ envSettings, db, logger, helper, monitorsRepository }) {
this.envSettings = envSettings;
this.db = db;
this.logger = logger;
this.helper = helper;
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return SuperSimpleQueue.SERVICE_NAME;
}
static async create({ envSettings, db, logger, helper }) {
const instance = new SuperSimpleQueue({ envSettings, db, logger, helper });
static async create({ envSettings, db, logger, helper, monitorsRepository }) {
const instance = new SuperSimpleQueue({ envSettings, db, logger, helper, monitorsRepository });
await instance.init();
return instance;
}
@@ -33,11 +34,11 @@ class SuperSimpleQueue {
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
const monitors = await this.db.monitorModule.getAllMonitors();
const monitors = await this.monitorsRepository.findAll();
for (const monitor of monitors) {
const randomOffset = Math.floor(Math.random() * 100);
setTimeout(() => {
this.addJob(monitor._id, monitor);
this.addJob(monitor.id, monitor);
}, randomOffset);
}
@@ -55,44 +56,44 @@ class SuperSimpleQueue {
addJob = async (monitorId, monitor) => {
this.scheduler.addJob({
id: monitorId.toString(),
id: monitorId,
template: "monitor-job",
repeat: monitor.interval,
active: monitor.isActive,
data: monitor.toObject(),
data: monitor,
});
};
deleteJob = async (monitor) => {
this.scheduler.removeJob(monitor._id.toString());
this.scheduler.removeJob(monitor.id);
};
pauseJob = async (monitor) => {
const result = this.scheduler.pauseJob(monitor._id.toString());
const result = this.scheduler.pauseJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.logger.debug({
message: `Paused monitor ${monitor._id}`,
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
};
resumeJob = async (monitor) => {
const result = this.scheduler.resumeJob(monitor._id.toString());
const result = this.scheduler.resumeJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.logger.debug({
message: `Resumed monitor ${monitor._id}`,
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
};
updateJob = async (monitor) => {
this.scheduler.updateJob(monitor._id.toString(), { repeat: monitor.interval, data: monitor.toObject() });
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
};
shutdown = async () => {
@@ -27,7 +27,7 @@ class SuperSimpleQueueHelper {
getMonitorJob = () => {
return async (monitor) => {
try {
const monitorId = monitor._id;
const monitorId = monitor.id;
const teamId = monitor.teamId;
if (!monitorId) {
throw new Error("No monitor id");
@@ -62,7 +62,7 @@ class SuperSimpleQueueHelper {
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error sending notifications for job ${monitor._id}: ${error.message}`,
details: `Error sending notifications for job ${monitor.id}: ${error.message}`,
stack: error.stack,
});
});
@@ -95,7 +95,7 @@ class NetworkService {
}
const pingResponse = {
monitorId: monitor._id,
monitorId: monitor.id,
type: "ping",
status: response.alive,
code: 200,
@@ -120,9 +120,9 @@ class NetworkService {
}
async requestHttp(monitor) {
const { url, secret, _id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor;
const { url, secret, id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor;
const httpResponse = {
monitorId: _id,
monitorId: id,
teamId: teamId,
type,
};
@@ -298,7 +298,7 @@ class NetworkService {
});
const dockerResponse = {
monitorId: monitor._id,
monitorId: monitor.id,
type: monitor.type,
};
@@ -416,7 +416,7 @@ class NetworkService {
code: 200,
status: response.success,
message: this.stringService.portSuccess,
monitorId: monitor._id,
monitorId: monitor.id,
type: monitor.type,
responseTime: responseTime,
};
@@ -444,7 +444,7 @@ class NetworkService {
code: 200,
status: true,
message: "Success",
monitorId: monitor._id,
monitorId: monitor.id,
type: "game",
};
@@ -1,5 +1,5 @@
import MonitorStats from "../../../db/v1/models/MonitorStats.js";
import Check from "../../../db/v1/models/Check.js";
import MonitorStats from "../../db/models/MonitorStats.js";
import { CheckModel } from "@/db/models/index.js";
const SERVICE_NAME = "StatusService";
class StatusService {
@@ -7,14 +7,19 @@ class StatusService {
/**
* @param {{
* db: any
* logger: any
* buffer: import("./bufferService.js").BufferService
* incidentService: import("../business/incidentService.js").IncidentService
* monitorsRepository: any
* }}
*/ constructor({ db, logger, buffer, incidentService }) {
*/
constructor({ db, logger, buffer, incidentService, monitorsRepository }) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
this.incidentService = incidentService;
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
@@ -23,7 +28,7 @@ class StatusService {
async updateRunningStats({ monitor, networkResponse }) {
try {
const monitorId = monitor._id;
const monitorId = monitor.id;
const { responseTime, status } = networkResponse;
// Get stats
let stats = await MonitorStats.findOne({ monitorId });
@@ -115,7 +120,7 @@ class StatusService {
if (!check._id) {
try {
const checkModel = new Check(check);
const checkModel = new CheckModel(check);
savedCheck = await checkModel.save();
this.buffer.removeCheckFromBuffer(check);
@@ -124,7 +129,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to save check immediately for ${errorContext}: ${checkError.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
stack: checkError.stack,
});
savedCheck = null;
@@ -139,7 +144,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to add incident to buffer for ${errorContext}: ${incidentError.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
action,
stack: incidentError.stack,
});
@@ -150,7 +155,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Error in ${errorContext}: ${error.message}`,
monitorId: monitor?._id,
monitorId: monitor?.id,
stack: error.stack,
});
}
@@ -172,7 +177,8 @@ class StatusService {
await this.insertCheck(check);
try {
const { monitorId, status, code } = networkResponse;
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
const monitor = await this.monitorsRepository.findById(monitorId);
// Update running stats
this.updateRunningStats({ monitor, networkResponse });
@@ -198,9 +204,9 @@ class StatusService {
// Return early if not enough data points
if (monitor.statusWindow.length < monitor.statusWindowSize) {
await monitor.save();
const updated = await this.monitorsRepository.update(monitor.id, monitor);
return {
monitor,
monitor: updated,
statusChanged: false,
prevStatus,
code,
@@ -240,14 +246,14 @@ class StatusService {
if (monitor.status === false && !statusChanged) {
try {
const lastManuallyResolvedIncident = await this.db.incidentModule.getLastManuallyResolvedIncident(monitor._id);
const lastManuallyResolvedIncident = await this.db.incidentModule.getLastManuallyResolvedIncident(monitor.id);
let calculatedFailureRate = failureRate;
if (lastManuallyResolvedIncident && lastManuallyResolvedIncident.endTime) {
try {
const checksAfterResolution = await Check.find({
monitorId: monitor._id,
monitorId: monitor.id,
createdAt: { $gt: lastManuallyResolvedIncident.endTime },
})
.sort({ createdAt: 1 })
@@ -267,7 +273,7 @@ class StatusService {
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Failed to query checks after manual resolution: ${checkQueryError.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
stack: checkQueryError.stack,
});
}
@@ -281,17 +287,17 @@ class StatusService {
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Error handling threshold check without status change: ${error.message}`,
monitorId: monitor._id,
monitorId: monitor.id,
stack: error.stack,
});
}
}
monitor.status = newStatus;
await monitor.save();
const updated = await this.monitorsRepository.update(monitor.id, monitor);
return {
monitor,
monitor: updated,
statusChanged,
prevStatus,
code,
@@ -337,9 +343,11 @@ class StatusService {
} = networkResponse;
const check = {
monitorId,
teamId,
type,
metadata: {
monitorId,
teamId,
type,
},
status,
statusCode: code,
responseTime,
@@ -365,18 +373,30 @@ class StatusService {
}
const categories = payload?.lighthouseResult?.categories ?? {};
const audits = payload?.lighthouseResult?.audits ?? {};
const {
"cumulative-layout-shift": cls = 0,
"speed-index": si = 0,
"first-contentful-paint": fcp = 0,
"largest-contentful-paint": lcp = 0,
"total-blocking-time": tbt = 0,
} = audits;
const mapAudit = (audit) => {
if (!audit || typeof audit !== "object") {
return undefined;
}
return {
id: audit.id,
title: audit.title,
score: typeof audit.score === "number" ? audit.score : (audit.score ?? null),
displayValue: audit.displayValue,
numericValue: typeof audit.numericValue === "number" ? audit.numericValue : undefined,
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.audits = { cls, si, fcp, lcp, tbt };
check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
};
}
if (type === "hardware") {
@@ -1,317 +0,0 @@
import { createMonitorsBodyValidation } from "../../../validation/joi.js";
const SERVICE_NAME = "MonitorService";
class MonitorService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, settingsService, jobQueue, stringService, emailService, papaparse, logger, errorService, games }) {
this.db = db;
this.settingsService = settingsService;
this.jobQueue = jobQueue;
this.stringService = stringService;
this.emailService = emailService;
this.papaparse = papaparse;
this.logger = logger;
this.errorService = errorService;
this.games = games;
}
get serviceName() {
return MonitorService.SERVICE_NAME;
}
verifyTeamAccess = async ({ teamId, monitorId }) => {
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
if (!monitor?.teamId?.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
};
getAllMonitors = async () => {
const monitors = await this.db.monitorModule.getAllMonitors();
return monitors;
};
getUptimeDetailsById = async ({ teamId, monitorId, dateRange, normalize }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const data = await this.db.monitorModule.getUptimeDetailsById({
monitorId,
dateRange,
normalize,
});
return data;
};
getMonitorStatsById = async ({ teamId, monitorId, limit, sortOrder, dateRange, numToDisplay, normalize }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitorStats = await this.db.monitorModule.getMonitorStatsById({
monitorId,
limit,
sortOrder,
dateRange,
numToDisplay,
normalize,
});
return monitorStats;
};
getHardwareDetailsById = async ({ teamId, monitorId, dateRange }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.getHardwareDetailsById({ monitorId, dateRange });
return monitor;
};
getMonitorById = async ({ teamId, monitorId }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
return monitor;
};
createMonitor = async ({ teamId, userId, body }) => {
const monitor = await this.db.monitorModule.createMonitor({
body,
teamId,
userId,
});
this.jobQueue.addJob(monitor._id, monitor);
};
createBulkMonitors = async ({ fileData, userId, teamId }) => {
const { parse } = this.papaparse;
return new Promise((resolve, reject) => {
parse(fileData, {
header: true,
skipEmptyLines: true,
transform: (value, header) => {
if (value === "") return undefined; // Empty fields become undefined
// Handle 'port' and 'interval' fields, check if they're valid numbers
if (["port", "interval"].includes(header)) {
const num = parseInt(value, 10);
if (isNaN(num)) {
throw this.errorService.createBadRequestError(`${header} should be a valid number, got: ${value}`);
}
return num;
}
return value;
},
complete: async ({ data, errors }) => {
try {
if (errors.length > 0) {
throw this.errorService.createServerError("Error parsing CSV");
}
if (!data || data.length === 0) {
throw this.errorService.createServerError("CSV file contains no data rows");
}
const enrichedData = data.map((monitor) => ({
userId,
teamId,
...monitor,
description: monitor.description || monitor.name || monitor.url,
name: monitor.name || monitor.url,
type: monitor.type || "http",
}));
await createMonitorsBodyValidation.validateAsync(enrichedData);
const monitors = await this.db.monitorModule.createBulkMonitors(enrichedData);
await Promise.all(
monitors.map(async (monitor) => {
this.jobQueue.addJob(monitor._id, monitor);
})
);
resolve(monitors);
} catch (error) {
reject(error);
}
},
});
});
};
deleteMonitor = async ({ teamId, monitorId }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.deleteMonitor({ teamId, monitorId });
await this.jobQueue.deleteJob(monitor);
await this.db.statusPageModule.deleteStatusPagesByMonitorId(monitor._id);
return monitor;
};
deleteAllMonitors = async ({ teamId }) => {
const { monitors, deletedCount } = await this.db.monitorModule.deleteAllMonitors(teamId);
await Promise.all(
monitors.map(async (monitor) => {
try {
await this.jobQueue.deleteJob(monitor);
await this.db.checkModule.deleteChecks(monitor._id);
await this.db.pageSpeedCheckModule.deletePageSpeedChecksByMonitorId(monitor._id);
await this.db.notificationsModule.deleteNotificationsByMonitorId(monitor._id);
} catch (error) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
stack: error.stack,
});
}
})
);
return deletedCount;
};
editMonitor = async ({ teamId, monitorId, body }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const editedMonitor = await this.db.monitorModule.editMonitor({ monitorId, body });
await this.jobQueue.updateJob(editedMonitor);
};
pauseMonitor = async ({ teamId, monitorId }) => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.pauseMonitor({ monitorId });
monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor);
return monitor;
};
addDemoMonitors = async ({ userId, teamId }) => {
const demoMonitors = await this.db.monitorModule.addDemoMonitors(userId, teamId);
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor)));
return demoMonitors;
};
sendTestEmail = async ({ to }) => {
const subject = this.stringService.testEmailSubject;
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
const messageId = await this.emailService.sendEmail(to, subject, html);
if (!messageId) {
throw this.errorService.createServerError("Failed to send test email.");
}
return messageId;
};
getMonitorsByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order }) => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
teamId,
});
return monitors;
};
getMonitorsAndSummaryByTeamId = async ({ teamId, type, explain }) => {
const result = await this.db.monitorModule.getMonitorsAndSummaryByTeamId({
type,
explain,
teamId,
});
return result;
};
getMonitorsWithChecksByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order, explain }) => {
const result = await this.db.monitorModule.getMonitorsWithChecksByTeamId({
limit,
type,
page,
rowsPerPage,
filter,
field,
order,
teamId,
explain,
});
return result;
};
exportMonitorsToCSV = async ({ teamId }) => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const csvData = monitors?.filteredMonitors?.map((monitor) => ({
name: monitor.name,
description: monitor.description,
type: monitor.type,
url: monitor.url,
interval: monitor.interval,
port: monitor.port,
ignoreTlsErrors: monitor.ignoreTlsErrors,
isActive: monitor.isActive,
}));
const csv = this.papaparse.unparse(csvData);
return csv;
};
exportMonitorsToJSON = async ({ teamId }) => {
const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
throw this.errorService.createNotFoundError("No monitors to export");
}
const json = monitors?.filteredMonitors
?.map((monitor) => {
const initialType = monitor.type;
let parsedType;
if (initialType === "hardware") {
parsedType = "infrastructure";
} else if (initialType === "http") {
if (monitor.url.startsWith("https://")) {
parsedType = "https";
} else {
parsedType = "http";
}
} else if (initialType === "pagespeed") {
parsedType = initialType;
} else {
// Skip unsupported types
return;
}
return {
name: monitor.name,
url: monitor.url,
type: parsedType,
interval: monitor.interval,
n: monitor.statusWindowSize,
secret: monitor.secret,
};
})
.filter(Boolean);
return json;
};
getAllGames = () => {
return this.games;
};
getGroupsByTeamId = async ({ teamId }) => {
const groups = await this.db.monitorModule.getGroupsByTeamId({ teamId });
return groups;
};
}
export default MonitorService;
+131
View File
@@ -0,0 +1,131 @@
import type { MonitorType } from "@/types/index.js";
export interface CheckMetadata {
monitorId: string;
teamId: string;
type: MonitorType;
}
export interface CheckTimingPhases {
wait: number;
dns: number;
tcp: number;
tls: number;
request: number;
firstByte: number;
download: number;
total: number;
}
export interface CheckTimings {
start: number;
socket: number;
lookup: number;
connect: number;
secureConnect: number;
upload: number;
response: number;
end: number;
phases: CheckTimingPhases;
}
export interface CheckCpuInfo {
physical_core: number;
logical_core: number;
frequency: number;
temperature: number[];
free_percent: number;
usage_percent: number;
}
export interface CheckMemoryInfo {
total_bytes: number;
available_bytes: number;
used_bytes: number;
usage_percent: number;
}
export interface CheckHostInfo {
os: string;
platform: string;
kernel_version: string;
}
export interface CheckCaptureInfo {
version: string;
mode: string;
}
export interface CheckDiskInfo {
device: string;
mountpoint: string;
read_speed_bytes: number;
write_speed_bytes: number;
total_bytes: number;
free_bytes: number;
usage_percent: number;
}
export interface CheckErrorInfo {
metric: string[];
err: string;
}
export interface CheckNetworkInterfaceInfo {
name: string;
bytes_sent: number;
bytes_recv: number;
packets_sent: number;
packets_recv: number;
err_in: number;
err_out: number;
drop_in: number;
drop_out: number;
fifo_in: number;
fifo_out: number;
}
export interface CheckAudits {
cls: ILighthouseAudit;
si: ILighthouseAudit;
fcp: ILighthouseAudit;
lcp: ILighthouseAudit;
tbt: ILighthouseAudit;
}
export interface ILighthouseAudit {
id?: string;
title?: string;
score?: number | null;
displayValue?: string;
numericValue?: number;
numericUnit?: string;
}
export interface Check {
id: string;
metadata: CheckMetadata;
status: boolean;
responseTime: number;
timings: CheckTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu: CheckCpuInfo;
memory: CheckMemoryInfo;
disk: CheckDiskInfo[];
host: CheckHostInfo;
errors: CheckErrorInfo[];
capture: CheckCaptureInfo;
net: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
}

Some files were not shown because too many files have changed in this diff Show More