hardware details

This commit is contained in:
Alex Holliday
2026-01-14 19:28:17 +00:00
parent d1faeab042
commit 665eb9a67f
25 changed files with 4050 additions and 590 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;
+3304 -359
View File
File diff suppressed because it is too large Load Diff
+10 -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,21 +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);
});
@@ -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,7 +46,6 @@ import { GenerateAvatarImage } from "../utils/imageProcessing.js";
import { ParseBoolean } from "../utils/utils.js";
// Models
import { CheckModel } from "@/db/models/index.js";
import Monitor from "../db/models/Monitor.js";
import User from "../db/models/User.js";
import InviteToken from "../db/models/InviteToken.js";
@@ -72,11 +70,11 @@ import SettingsModule from "../db/modules/settingsModule.js";
import IncidentModule from "../db/modules/incidentModule.js";
// repositories
import { MongoMonitorsRepository, MongoChecksRepository } from "@/repositories/index.js";
import { MongoMonitorsRepository, MongoChecksRepository, MongoMonitorStatsRepository } from "@/repositories/index.js";
export const initializeServices = async ({ logger, envSettings, settingsService }) => {
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();
@@ -84,7 +82,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
const stringService = new StringService(translationService);
// Create DB
const checkModule = new CheckModule({ logger, CheckModel, Monitor, User });
const checkModule = new CheckModule({ logger, Monitor, User });
const inviteModule = new InviteModule({ InviteToken, crypto, stringService });
const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService });
const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService });
@@ -125,6 +123,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository();
const monitorStatsRepository = new MongoMonitorStatsRepository();
const networkService = new NetworkService({
axios,
@@ -228,6 +227,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService
games,
monitorsRepository,
checksRepository,
monitorStatsRepository,
});
const services = {
@@ -8,8 +8,6 @@ import {
createMonitorBodyValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
@@ -90,39 +88,6 @@ 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);
-6
View File
@@ -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;
}
-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;
+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;
+3
View File
@@ -3,3 +3,6 @@ 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";
-42
View File
@@ -253,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);
@@ -1,3 +1,4 @@
import type { Check, MonitorType } from "@/types/index.js";
import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js";
export interface IChecksRepository {
@@ -6,12 +7,53 @@ export interface IChecksRepository {
monitorId: string,
startDate: Date,
endDate: Date,
dateString: string
): Promise<{
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;
}>;
dateString: string,
options?: { type?: MonitorType }
): Promise<
| {
monitorType: "uptime";
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;
}
| {
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;
}>;
}>;
}
>;
}
@@ -14,6 +14,11 @@ import type {
} 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[]>;
@@ -210,20 +215,17 @@ class MongoChecksRepistory implements IChecksRepository {
}, {});
};
findDateRangeChecksByMonitor = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateString: string
): Promise<{
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;
}> => {
findDateRangeChecksByMonitor = async (monitorId: string, startDate: Date, endDate: Date, dateString: string, options?: { type?: string }) => {
const monitorObjectId = new mongoose.Types.ObjectId(monitorId);
if (options?.type === "hardware") {
return this.findHardwareDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
}
return this.findUptimeDateRangeChecks(monitorObjectId, startDate, endDate, dateString);
};
private findUptimeDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => {
const matchStage = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.monitorId": monitorObjectId,
updatedAt: { $gte: startDate, $lte: endDate },
};
const [result] = await CheckModel.aggregate([
@@ -296,12 +298,13 @@ class MongoChecksRepistory implements IChecksRepository {
],
},
},
]).exec();
]);
const uptimePercentage = result?.uptimePercentage?.[0]?.percentage ?? 0;
const avgResponseTime = result?.groupedAvgResponseTime?.[0]?.avgResponseTime ?? 0;
return {
monitorType: "uptime" as const,
groupedChecks: result?.groupedChecks ?? [],
groupedUpChecks: result?.groupedUpChecks ?? [],
groupedDownChecks: result?.groupedDownChecks ?? [],
@@ -309,6 +312,60 @@ class MongoChecksRepistory implements IChecksRepository {
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,
};
};
}
export default MongoChecksRepistory;
+3
View File
@@ -3,3 +3,6 @@ export { default as MongoMonitorsRepository } from "@/repositories/monitors/Mong
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;
-1
View File
@@ -30,7 +30,6 @@ class MonitorRoutes {
// 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) => {
+41 -49
View File
@@ -2,7 +2,7 @@ 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 } from "@/repositories/index.js";
import type { IChecksRepository, IMonitorsRepository, IMonitorStatsRepository } from "@/repositories/index.js";
import fs from "fs";
import { fileURLToPath } from "url";
import path from "path";
@@ -23,17 +23,8 @@ export interface IMonitorService {
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise<any>;
getMonitorStatsById(args: {
teamId: string;
monitorId: string;
limit?: number;
sortOrder?: 1 | -1;
dateRange?: string;
numToDisplay?: number;
normalize?: boolean;
}): Promise<any>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<any>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<any>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
getMonitorsByTeamId(args: {
teamId: string;
limit?: number;
@@ -86,7 +77,7 @@ export class MonitorService implements IMonitorService {
private games: any;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private fs: any;
private monitorStatsRepository: IMonitorStatsRepository;
constructor({
db,
@@ -99,6 +90,7 @@ export class MonitorService implements IMonitorService {
games,
monitorsRepository,
checksRepository,
monitorStatsRepository,
}: {
db: any;
jobQueue: any;
@@ -110,6 +102,7 @@ export class MonitorService implements IMonitorService {
games: any;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
}) {
this.db = db;
this.jobQueue = jobQueue;
@@ -121,6 +114,7 @@ export class MonitorService implements IMonitorService {
this.games = games;
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
this.monitorStatsRepository = monitorStatsRepository;
}
get serviceName(): string {
@@ -269,10 +263,14 @@ export class MonitorService implements IMonitorService {
}
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));
const monitorStats = await this.db.monitorModule.getMonitorStatsById({
monitorId,
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 !== "uptime") {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
return {
monitorData: {
@@ -287,47 +285,41 @@ export class MonitorService implements IMonitorService {
};
};
getMonitorStatsById = async ({
teamId,
monitorId,
limit,
sortOrder,
dateRange,
numToDisplay,
normalize,
}: {
teamId: string;
monitorId: string;
limit?: number;
sortOrder?: 1 | -1;
dateRange?: string;
numToDisplay?: number;
normalize?: boolean;
}): Promise<any> => {
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 }: { teamId: string; monitorId: string; dateRange: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.getHardwareDetailsById({ monitorId, dateRange });
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 });
}
return monitor;
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,
};
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<any> => {
await this.verifyTeamAccess({ teamId, monitorId });
const monitor = await this.db.monitorModule.getMonitorById(monitorId);
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
};
@@ -7,10 +7,14 @@ 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, monitorsRepository }) {
*/
constructor({ db, logger, buffer, incidentService, monitorsRepository }) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
+1
View File
@@ -1,2 +1,3 @@
export * from "@/types/check.js";
export * from "@/types/monitor.js";
export * from "@/types/monitorStats.js";
+14
View File
@@ -0,0 +1,14 @@
export interface MonitorStats {
id: string;
monitorId: string;
avgResponseTime: number;
totalChecks: number;
totalUpChecks: number;
totalDownChecks: number;
uptimePercentage: number;
lastCheckTimestamp: number;
lastResponseTime: number;
timeOfLastFailure?: number;
createdAt: string;
updatedAt: string;
}
-14
View File
@@ -134,18 +134,6 @@ const getMonitorsByTeamIdQueryValidation = joi.object({
order: joi.string().valid("asc", "desc"),
});
const getMonitorStatsByIdParamValidation = joi.object({
monitorId: joi.string().required(),
});
const getMonitorStatsByIdQueryValidation = joi.object({
status: joi.string(),
limit: joi.number(),
sortOrder: joi.string().valid("asc", "desc"),
dateRange: joi.string().valid("hour", "day", "week", "month", "all"),
numToDisplay: joi.number(),
normalize: joi.boolean(),
});
const getCertificateParamValidation = joi.object({
monitorId: joi.string().required(),
});
@@ -725,8 +713,6 @@ export {
getMonitorByIdQueryValidation,
getMonitorsByTeamIdParamValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorStatsByIdParamValidation,
getMonitorStatsByIdQueryValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
getCertificateParamValidation,
+218
View File
@@ -0,0 +1,218 @@
import { jest } from "@jest/globals";
import { MonitorService } from "../src/service/business/monitorService.ts";
import type { IMonitorsRepository, IChecksRepository } from "../src/repositories/index.ts";
const createMonitorsRepositoryMock = () =>
({
findMonitorCountByTeamIdAndType: jest.fn(),
findByTeamId: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
createBulkMonitors: jest.fn(),
deleteByTeamId: jest.fn(),
}) as unknown as IMonitorsRepository;
const createChecksRepositoryMock = () =>
({
findLatestChecksByMonitorIds: jest.fn(),
findDateRangeChecksByMonitor: jest.fn(),
}) as unknown as IChecksRepository;
const createService = ({
monitorsRepository = createMonitorsRepositoryMock(),
checksRepository = createChecksRepositoryMock(),
monitorStatsRepository = { findByMonitorId: jest.fn() },
monitorModuleOverrides = {},
}: {
monitorsRepository?: IMonitorsRepository;
checksRepository?: IChecksRepository;
monitorStatsRepository?: { findByMonitorId: jest.Mock };
monitorModuleOverrides?: Record<string, unknown>;
} = {}) => {
const monitorModule = {
getMonitorById: jest.fn().mockResolvedValue({ teamId: { equals: () => true } }),
getMonitorStatsById: jest.fn().mockResolvedValue({ latest: {} }),
getMonitorsByTeamId: jest.fn().mockResolvedValue([]),
getMonitorsAndSummaryByTeamId: jest.fn().mockResolvedValue({ monitors: [], summary: {} }),
...monitorModuleOverrides,
};
return new MonitorService({
db: {
monitorModule,
statusPageModule: { deleteStatusPagesByMonitorId: jest.fn() },
checkModule: { deleteChecks: jest.fn() },
pageSpeedCheckModule: { deletePageSpeedChecksByMonitorId: jest.fn() },
notificationsModule: { deleteNotificationsByMonitorId: jest.fn() },
},
jobQueue: {
addJob: jest.fn(),
updateJob: jest.fn(),
resumeJob: jest.fn(),
pauseJob: jest.fn(),
deleteJob: jest.fn(),
},
stringService: {},
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() },
papaparse: { parse: jest.fn(), unparse: jest.fn() },
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
errorService: {
createAuthorizationError: jest.fn(() => new Error("unauthorized")),
createServerError: jest.fn(() => new Error("server")),
createBadRequestError: jest.fn(() => new Error("bad request")),
createNotFoundError: jest.fn(() => new Error("not found")),
},
games: [],
monitorsRepository,
checksRepository,
monitorStatsRepository,
});
};
describe("MonitorService", () => {
describe("getMonitorsWithChecksByTeamId", () => {
it("returns monitors enriched with normalized checks", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
{ id: "m1", name: "Monitor 1", interval: 60000 },
{ id: "m2", name: "Monitor 2", interval: 60000 },
]);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({
m1: [
{ responseTime: 10, status: true, message: "OK" },
{ responseTime: 20, status: true, message: "OK" },
],
m2: [{ responseTime: 50, status: true, message: "OK" }],
});
const service = createService({ monitorsRepository, checksRepository });
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
expect(result).toMatchObject({ count: 2 });
expect(result.monitors).toHaveLength(2);
expect(result.monitors[0]).toHaveProperty("checks");
expect(result.monitors[0].checks.length).toBeGreaterThan(0);
expect(result.monitors[0].checks[0]).toEqual(
expect.objectContaining({
responseTime: expect.any(Number),
status: expect.any(Boolean),
message: expect.any(String),
})
);
});
});
describe("getMonitorsByTeamId", () => {
it("returns monitors array from db module", async () => {
const monitorsPayload = [
{ id: "m1", name: "Monitor 1" },
{ id: "m2", name: "Monitor 2" },
];
const monitorModuleOverrides = {
getMonitorsByTeamId: jest.fn().mockResolvedValue(monitorsPayload),
};
const service = createService({ monitorModuleOverrides });
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("id", "m1");
});
});
describe("getMonitorsAndSummaryByTeamId", () => {
it("returns monitors with summary block", async () => {
const monitorModuleOverrides = {
getMonitorsAndSummaryByTeamId: jest.fn().mockResolvedValue({ monitors: [{ id: "m1" }], summary: { total: 1, uptime: 0.99 } }),
};
const service = createService({ monitorModuleOverrides });
const result = await service.getMonitorsAndSummaryByTeamId({ teamId: "team" });
expect(result).toEqual({ monitors: [{ id: "m1" }], summary: { total: 1, uptime: 0.99 } });
});
});
describe("getUptimeDetailsById", () => {
it("returns monitorData and monitorStats with expected shape", async () => {
const TEAM_ID = "team";
const monitor = {
id: "monitor-1",
teamId: TEAM_ID,
name: "Hardware monitor",
interval: 60000,
statusWindow: [],
statusWindowSize: 5,
statusWindowThreshold: 60,
type: "http",
ignoreTlsErrors: false,
url: "https://example.com",
isActive: true,
alertThreshold: 5,
cpuAlertThreshold: 5,
memoryAlertThreshold: 5,
diskAlertThreshold: 5,
tempAlertThreshold: 5,
selectedDisks: [],
notifications: [],
group: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const checksRepository = createChecksRepositoryMock();
(checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({
monitorType: "uptime",
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
groupedDownChecks: [{ _id: "2024-01-01", totalChecks: 0, avgResponseTime: 0 }],
uptimePercentage: 0.99,
avgResponseTime: 95,
});
const monitorStatsRepository = {
findByMonitorId: jest.fn().mockResolvedValue({
id: "stats-1",
monitorId: monitor.id,
avgResponseTime: 90,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
uptimePercentage: 0.9,
lastCheckTimestamp: 123456789,
lastResponseTime: 80,
timeOfLastFailure: 123456700,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
} as any;
const monitorModuleOverrides = {
getMonitorById: jest.fn().mockResolvedValue({ teamId: { equals: (value: string) => value === TEAM_ID } }),
};
const service = createService({ monitorsRepository, checksRepository, monitorModuleOverrides, monitorStatsRepository });
const result = await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: "monitor-1", dateRange: "recent" });
expect(result).toHaveProperty("monitorData");
expect(result.monitorData.monitor).toMatchObject({ id: monitor.id, name: monitor.name });
expect(result.monitorData.groupedChecks[0]).toEqual(
expect.objectContaining({
_id: expect.any(String),
avgResponseTime: expect.any(Number),
totalChecks: expect.any(Number),
})
);
expect(result.monitorStats).toEqual(
expect.objectContaining({
monitorId: monitor.id,
avgResponseTime: 90,
totalChecks: 10,
totalUpChecks: 9,
totalDownChecks: 1,
})
);
});
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"types": ["jest"],
"noEmit": true
},
"include": ["src", "test"]
}
+2 -1
View File
@@ -12,7 +12,8 @@
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"noUncheckedIndexedAccess": true
"noUncheckedIndexedAccess": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["tests", "dist", "node_modules"]