Merge pull request #3141 from bluewave-labs/feat/job-queue-ts

feat/job queue ts
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-01-16 10:25:40 -08:00
commit 0d7a35367f
21 files changed
+1052 -559

No files matched your search

+2 -2
View File
@@ -87,8 +87,8 @@ const Incidents2 = () => {
useEffect(() => {
const lookup = monitors?.reduce((acc, monitor) => {
acc[monitor._id] = {
_id: monitor._id,
acc[monitor.id] = {
_id: monitor.id,
name: monitor.name,
type: monitor.type,
};
+5
View File
@@ -8,6 +8,11 @@ const config: Config = {
"^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }],
},
moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
},
testMatch: ["<rootDir>/test/**/*.test.ts"],
+15 -11
View File
@@ -155,7 +155,7 @@ export const initializeServices = async ({
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository();
const checksRepository = new MongoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
@@ -183,7 +183,17 @@ export const initializeServices = async ({
stringService,
});
const bufferService = new BufferService({ db, logger, envSettings, incidentService });
const checkService = new CheckService({
db,
settingsService,
stringService,
errorService,
monitorsRepository,
logger,
checksRepository,
});
const bufferService = new BufferService({ db, logger, envSettings, incidentService, checkService });
const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService, monitorsRepository });
@@ -208,11 +218,11 @@ export const initializeServices = async ({
networkService,
statusService,
notificationService,
checkService,
buffer: bufferService,
});
const superSimpleQueue = await SuperSimpleQueue.create({
envSettings,
db,
logger,
helper: superSimpleQueueHelper,
monitorsRepository,
@@ -231,13 +241,7 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
monitorsRepository,
});
const checkService = new CheckService({
db,
settingsService,
stringService,
errorService,
monitorsRepository,
});
const diagnosticService = new DiagnosticService();
const inviteService = new InviteService({
db,
+3 -4
View File
@@ -11,8 +11,7 @@ import type {
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
CheckTimings,
CheckTimingPhases,
GotTimings,
ILighthouseAudit,
} from "@/types/check.js";
@@ -35,7 +34,7 @@ interface CheckDocument extends CheckDocumentBase {
_id: Types.ObjectId;
}
const timingPhasesSchema = new Schema<CheckTimingPhases>(
const timingPhasesSchema = new Schema<GotTimings["phases"]>(
{
wait: { type: Number, default: 0 },
dns: { type: Number, default: 0 },
@@ -49,7 +48,7 @@ const timingPhasesSchema = new Schema<CheckTimingPhases>(
{ _id: false }
);
const timingsSchema = new Schema<CheckTimings>(
const timingsSchema = new Schema<GotTimings>(
{
start: { type: Number, default: 0 },
socket: { type: Number, default: 0 },
@@ -1,4 +1,4 @@
import type { Check, CheckAudits, MonitorType } from "@/types/index.js";
import type { Check, MonitorType } from "@/types/index.js";
import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js";
export interface PageSpeedChecksResult {
@@ -55,6 +55,8 @@ export interface UptimeChecksResult {
export interface IChecksRepository {
// create
createChecks(checks: Check[]): Promise<Check[]>;
// single fetch
// collection fetch
findLatestChecksByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap>;
@@ -10,18 +10,27 @@ import type {
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
CheckTimings,
GotTimings,
MonitorType,
} from "@/types/index.js";
import { CheckModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
const SERVICE_NAME = "StatusService";
export type LatestChecksMap = Record<string, Check[]>;
type DateRange = { start: Date; end: Date };
type HardwareAggregateData = { latestCheck: CheckDocument | null; totalChecks: number };
type HardwareUpChecks = { totalChecks: number };
class MongoChecksRepository implements IChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
constructor(logger: any) {
this.logger = logger;
}
private toEntity = (doc: CheckDocument): Check => {
const toStringId = (value: mongoose.Types.ObjectId | string | undefined | null): string => {
if (!value) {
@@ -44,7 +53,7 @@ class MongoChecksRepository implements IChecksRepository {
return toDateString(value);
};
const mapTimings = (timings?: CheckTimings): CheckTimings => {
const mapTimings = (timings?: GotTimings): GotTimings => {
const phases = timings?.phases ?? {
wait: 0,
dns: 0,
@@ -133,11 +142,11 @@ class MongoChecksRepository implements IChecksRepository {
return undefined;
}
return {
cls: audits.cls ?? 0,
si: audits.si ?? 0,
fcp: audits.fcp ?? 0,
lcp: audits.lcp ?? 0,
tbt: audits.tbt ?? 0,
cls: audits.cls,
si: audits.si,
fcp: audits.fcp,
lcp: audits.lcp,
tbt: audits.tbt,
};
};
@@ -176,6 +185,10 @@ class MongoChecksRepository implements IChecksRepository {
};
};
createChecks = async (checks: Check[]) => {
return await CheckModel.insertMany(checks);
};
findLatestChecksByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise<LatestChecksMap> => {
if (monitorIds.length === 0) {
return {};
+87 -2
View File
@@ -1,4 +1,6 @@
import { IMonitorsRepository } from "@/repositories/index.js";
import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js";
import type { MonitorType, MonitorStatusResponse, CheckErrorInfo, Check } from "@/types/index.js";
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
const SERVICE_NAME = "checkService";
@@ -10,31 +12,114 @@ class CheckService {
private stringService: any;
private errorService: any;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private logger: any;
constructor({
db,
settingsService,
stringService,
errorService,
monitorsRepository,
logger,
checksRepository,
}: {
db: any;
settingsService: any;
stringService: any;
errorService: any;
monitorsRepository: IMonitorsRepository;
logger: any;
checksRepository: IChecksRepository;
}) {
this.db = db;
this.settingsService = settingsService;
this.stringService = stringService;
this.errorService = errorService;
this.monitorsRepository = monitorsRepository;
this.logger = logger;
this.checksRepository = checksRepository;
}
get serviceName() {
return CheckService.SERVICE_NAME;
}
createChecks = async (checks: Check[]) => {
return this.checksRepository.createChecks(checks);
};
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
const check: Partial<Check> = {
metadata: {
monitorId,
teamId,
type,
},
status,
statusCode: code,
responseTime: responseTime || 0,
timings: timings,
message,
};
if (type === "pagespeed") {
const pageSpeedPayload = payload as PageSpeedStatusPayload | undefined;
if (!pageSpeedPayload) {
this.logger.warn({
message: "Failed to build check",
service: SERVICE_NAME,
method: "buildCheck",
details: "empty payload",
});
return undefined;
}
const categories = pageSpeedPayload.lighthouseResult?.categories ?? {};
const audits = pageSpeedPayload.lighthouseResult?.audits ?? {};
const mapAudit = (audit: any) => {
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: 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") {
const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload?.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu;
check.memory = memory;
check.disk = disk;
check.host = host;
check.errors = errorsSource;
check.capture = hardwarePayload?.capture;
check.net = net;
}
return check;
};
getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => {
if (!monitorId) {
throw this.errorService.createBadRequestError("No monitor ID in request");
+1
View File
@@ -1 +1,2 @@
export * from "@/service/business/monitorService.js";
export * from "@/service/infrastructure/networkService.js";
@@ -1,177 +0,0 @@
import Scheduler from "super-simple-scheduler";
const SERVICE_NAME = "JobQueue";
class SuperSimpleQueue {
static SERVICE_NAME = SERVICE_NAME;
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, monitorsRepository }) {
const instance = new SuperSimpleQueue({ envSettings, db, logger, helper, monitorsRepository });
await instance.init();
return instance;
}
init = async () => {
try {
this.scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
debug: true,
// dbUri: this.envSettings.dbConnectionString,
});
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
const monitors = await this.monitorsRepository.findAll();
for (const monitor of monitors) {
const randomOffset = Math.floor(Math.random() * 100);
setTimeout(() => {
this.addJob(monitor.id, monitor);
}, randomOffset);
}
return true;
} catch (error) {
this.logger.error({
message: "Failed to initialize SuperSimpleQueue",
service: SERVICE_NAME,
method: "init",
details: error,
});
return false;
}
};
addJob = async (monitorId, monitor) => {
this.scheduler.addJob({
id: monitorId,
template: "monitor-job",
repeat: monitor.interval,
active: monitor.isActive,
data: monitor,
});
};
deleteJob = async (monitor) => {
this.scheduler.removeJob(monitor.id);
};
pauseJob = async (monitor) => {
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}`,
service: SERVICE_NAME,
method: "pauseJob",
});
};
resumeJob = async (monitor) => {
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}`,
service: SERVICE_NAME,
method: "resumeJob",
});
};
updateJob = async (monitor) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
};
shutdown = async () => {
this.scheduler.stop();
};
getMetrics = async () => {
const jobs = await this.scheduler.getJobs();
const metrics = jobs.reduce(
(acc, job) => {
acc.totalRuns += job.runCount || 0;
acc.totalFailures += job.failCount || 0;
acc.jobs++;
if (job.failCount > 0 && job.lastFailedAt >= job.lsatRunAt) {
acc.failingJobs++;
}
if (job.lockedAt) {
acc.activeJobs++;
}
if (job.failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
failedAt: job.lastFailedAt,
failCount: job.failCount,
failReason: job.lastFailReason,
});
}
return acc;
},
{
jobs: 0,
activeJobs: 0,
failingJobs: 0,
jobsWithFailures: [],
totalRuns: 0,
totalFailures: 0,
}
);
return metrics;
};
getJobs = async () => {
const jobs = await this.scheduler.getJobs();
return jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorInterval: job?.data?.interval || null,
active: job.active,
lockedAt: job.lockedAt,
runCount: job.runCount || 0,
failCount: job.failCount || 0,
failReason: job.lastFailReason,
lastRunAt: job.lastRunAt,
lastFinishedAt: job.lastFinishedAt,
lastRunTook: job.lockedAt ? null : job.lastFinishedAt - job.lastRunAt,
lastFailedAt: job.lastFailedAt,
};
});
};
flushQueues = async () => {
const stopRes = this.scheduler.stop();
const flushRes = this.scheduler.flushJobs();
const initRes = await this.init();
return {
success: stopRes && flushRes && initRes,
};
};
obliterate = async () => {
console.log("obliterate not implemented");
};
}
export default SuperSimpleQueue;
@@ -0,0 +1,254 @@
import { IMonitorsRepository } from "@/repositories/index.js";
import Scheduler from "super-simple-scheduler";
const SERVICE_NAME = "JobQueue";
type QueueJobFailure = {
monitorId: string | number;
monitorUrl: string | null;
monitorType: string | null;
failedAt: number | null;
failCount: number;
failReason: string | null;
};
type QueueMetrics = {
jobs: number;
activeJobs: number;
failingJobs: number;
jobsWithFailures: QueueJobFailure[];
totalRuns: number;
totalFailures: number;
};
type QueueJobSummary = {
monitorId: string | number;
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
active: boolean;
lockedAt: number | null;
runCount: number;
failCount: number;
failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
};
export interface ISuperSimpleQueue {
readonly serviceName: string;
init(): Promise<boolean>;
addJob(monitorId: string, monitor: any): Promise<void>;
deleteJob(monitor: any): Promise<void>;
pauseJob(monitor: any): Promise<void>;
resumeJob(monitor: any): Promise<void>;
updateJob(monitor: any): Promise<void>;
shutdown(): Promise<void>;
getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>;
flushQueues(): Promise<{ success: boolean }>;
obliterate(): Promise<void>;
}
class SuperSimpleQueue implements ISuperSimpleQueue {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
private helper: any;
private monitorsRepository: IMonitorsRepository;
private readonly scheduler: Scheduler;
constructor({
logger,
helper,
monitorsRepository,
scheduler,
}: {
logger: any;
helper: any;
monitorsRepository: IMonitorsRepository;
scheduler: Scheduler;
}) {
this.logger = logger;
this.helper = helper;
this.monitorsRepository = monitorsRepository;
this.scheduler = scheduler;
}
get serviceName() {
return SuperSimpleQueue.SERVICE_NAME;
}
static async create({ logger, helper, monitorsRepository }: { logger: any; helper: any; monitorsRepository: IMonitorsRepository }) {
const scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
// dbUri: envSettings.dbConnectionString,
});
const instance = new SuperSimpleQueue({ logger, helper, monitorsRepository, scheduler });
await instance.init();
return instance;
}
init = async () => {
try {
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
const monitors = await this.monitorsRepository.findAll();
if (!monitors) {
return true;
}
for (const monitor of monitors) {
const randomOffset = Math.floor(Math.random() * 100);
setTimeout(() => {
this.addJob(monitor.id, monitor);
}, randomOffset);
}
return true;
} catch (error) {
this.logger.error({
message: "Failed to initialize SuperSimpleQueue",
service: SERVICE_NAME,
method: "init",
details: error,
});
return false;
}
};
addJob = async (monitorId: string, monitor: any) => {
this.scheduler.addJob({
id: monitorId,
template: "monitor-job",
repeat: monitor.interval,
active: monitor.isActive,
data: monitor,
});
};
deleteJob = async (monitor: any) => {
this.scheduler.removeJob(monitor.id);
};
pauseJob = async (monitor: any) => {
const result = await this.scheduler.pauseJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
};
resumeJob = async (monitor: any) => {
const result = await this.scheduler.resumeJob(monitor.id);
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
};
updateJob = async (monitor: any) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
};
shutdown = async () => {
this.scheduler.stop();
};
getMetrics = async () => {
const jobs = await this.scheduler.getJobs();
const metrics = jobs.reduce(
(acc, job) => {
const runCount = job.runCount ?? 0;
const failCount = job.failCount ?? 0;
const lastFailedAt = job.lastFailedAt ?? 0;
const lastRunAt = job.lastRunAt ?? 0;
acc.totalRuns += runCount;
acc.totalFailures += failCount;
acc.jobs++;
if (failCount > 0 && lastFailedAt >= lastRunAt) {
acc.failingJobs++;
}
if (job.lockedAt) {
acc.activeJobs++;
}
if (failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
failedAt: job.lastFailedAt ?? null,
failCount,
failReason: job.lastFailReason ?? null,
});
}
return acc;
},
{
jobs: 0,
activeJobs: 0,
failingJobs: 0,
jobsWithFailures: [] as Array<{
monitorId: string | number;
monitorUrl: any;
monitorType: any;
failedAt: number | null;
failCount: number;
failReason: string | null;
}>,
totalRuns: 0,
totalFailures: 0,
}
);
return metrics;
};
getJobs = async () => {
const jobs = await this.scheduler.getJobs();
return jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorInterval: job?.data?.interval || null,
active: job.active,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
failCount: job.failCount ?? 0,
failReason: job.lastFailReason ?? null,
lastRunAt: job.lastRunAt ?? null,
lastFinishedAt: job.lastFinishedAt ?? null,
lastRunTook: job.lockedAt ? null : (job.lastFinishedAt ?? 0) - (job.lastRunAt ?? 0),
lastFailedAt: job.lastFailedAt ?? null,
};
});
};
flushQueues = async () => {
const stopRes = await this.scheduler.stop();
const flushRes = await this.scheduler.flushJobs();
const initRes = await this.init();
return {
success: Boolean(stopRes && flushRes && initRes),
};
};
obliterate = async () => {
console.log("obliterate not implemented");
};
}
export default SuperSimpleQueue;
@@ -1,23 +1,42 @@
const SERVICE_NAME = "JobQueueHelper";
import type { Monitor } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { INetworkService } from "@/service/index.js";
class SuperSimpleQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
/**
* @param {{
* db: import("../database.js").Database,
* logger: import("../logger.js").Logger,
* networkService: import("../networkService.js").NetworkService,
* statusService: import("../statusService.js").StatusService,
* notificationService: import("../notificationService.js").NotificationService
* }}
*/
constructor({ db, logger, networkService, statusService, notificationService }) {
private db: any;
private logger: any;
private networkService: INetworkService;
private statusService: any;
private notificationService: any;
private checkService: any;
private buffer: any;
constructor({
db,
logger,
networkService,
statusService,
notificationService,
checkService,
buffer,
}: {
db: any;
logger: any;
networkService: INetworkService;
statusService: any;
notificationService: any;
checkService: any;
buffer: any;
}) {
this.db = db;
this.logger = logger;
this.networkService = networkService;
this.statusService = statusService;
this.notificationService = notificationService;
this.checkService = checkService;
this.buffer = buffer;
}
get serviceName() {
@@ -25,39 +44,48 @@ class SuperSimpleQueueHelper {
}
getMonitorJob = () => {
return async (monitor) => {
return async (monitor: Monitor) => {
try {
const monitorId = monitor.id;
const teamId = monitor.teamId;
if (!monitorId) {
throw new Error("No monitor id");
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" });
}
// Step 1. Check for maintenacne window, if found, skip the check
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId);
if (maintenanceWindowActive) {
this.logger.info({
this.logger.debug({
message: `Monitor ${monitorId} is in maintenance window`,
service: SERVICE_NAME,
method: "getMonitorJob",
});
return;
}
const networkResponse = await this.networkService.requestStatus(monitor);
if (!networkResponse) {
// Step 2. Request monitor status
const status = await this.networkService.requestStatus(monitor);
if (!status) {
throw new Error("No network response");
}
const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse);
// Step 3. Build check
const check = await this.checkService.buildCheck(status);
// Step 4 Add check to buffer
this.buffer.addToBuffer({ check });
// Step 4. Update monitor status
const statusChangeResult = await this.statusService.updateMonitorStatus(status, check);
this.notificationService
.handleNotifications({
...networkResponse,
monitor: updatedMonitor,
prevStatus,
statusChanged,
...status,
monitor: statusChangeResult.monitor,
prevStatus: statusChangeResult.prevStatus,
statusChanged: statusChangeResult.statusChanged,
})
.catch((error) => {
.catch((error: any) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
@@ -66,7 +94,7 @@ class SuperSimpleQueueHelper {
stack: error.stack,
});
});
} catch (error) {
} catch (error: any) {
this.logger.warn({
message: error.message,
service: error.service || SERVICE_NAME,
@@ -78,13 +106,13 @@ class SuperSimpleQueueHelper {
};
};
async isInMaintenanceWindow(monitorId, teamId) {
async isInMaintenanceWindow(monitorId: string, teamId: string) {
const maintenanceWindows = await this.db.maintenanceWindowModule.getMaintenanceWindowsByMonitorId({
monitorId: monitorId.toString(),
teamId: teamId.toString(),
monitorId: monitorId,
teamId: teamId,
});
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => {
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: any, window: any) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
@@ -1,15 +1,38 @@
import { config } from "@/config/index.js";
import type { Check } from "@/types/index.js";
const SERVICE_NAME = "BufferService";
class BufferService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, logger, envSettings, incidentService }) {
console.log(envSettings);
private BUFFER_TIMEOUT: number;
private db: any;
private logger: any;
private incidentService: any;
private SERVICE_NAME: string;
private buffer: any[];
private incidentBuffer: any[];
private bufferTimer: NodeJS.Timeout | null = null;
private checksService: any;
constructor({
db,
logger,
envSettings,
incidentService,
checkService,
}: {
db: any;
logger: any;
envSettings: any;
incidentService: any;
checkService: any;
}) {
this.BUFFER_TIMEOUT = config.NODE_ENV === "development" ? 10 : 1000 * 60 * 1; // 1 minute
this.db = db;
this.logger = logger;
this.incidentService = incidentService;
this.checksService = checkService;
this.SERVICE_NAME = SERVICE_NAME;
this.buffer = [];
this.incidentBuffer = [];
@@ -25,10 +48,10 @@ class BufferService {
return BufferService.SERVICE_NAME;
}
addToBuffer({ check }) {
addToBuffer({ check }: { check: Check }) {
try {
this.buffer.push(check);
} catch (error) {
} catch (error: any) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
@@ -38,7 +61,7 @@ class BufferService {
}
}
addIncidentToBuffer({ monitor, check, action = "create" }) {
addIncidentToBuffer({ monitor, check, action = "create" }: { monitor: any; check: Check; action?: string }) {
try {
if (!monitor || !check) {
this.logger.warn({
@@ -50,7 +73,7 @@ class BufferService {
}
this.incidentBuffer.push({ monitor, check, action });
} catch (error) {
} catch (error: any) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
@@ -60,20 +83,20 @@ class BufferService {
}
}
removeCheckFromBuffer(checkToRemove) {
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
return false;
}
const index = this.buffer.findIndex((check) => {
if (checkToRemove._id && check._id) {
return check._id.toString() === checkToRemove._id.toString();
if (checkToRemove.id && check.id) {
return check.id.toString() === checkToRemove.id.toString();
}
return (
check.monitorId?.toString() === checkToRemove.monitorId?.toString() &&
check.teamId?.toString() === checkToRemove.teamId?.toString() &&
check.type === checkToRemove.type &&
check.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.teamId?.toString() === checkToRemove.metadata.teamId &&
check.type === checkToRemove.metadata.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
@@ -87,7 +110,7 @@ class BufferService {
}
return false;
} catch (error) {
} catch (error: any) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
@@ -99,10 +122,13 @@ class BufferService {
}
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
}
this.bufferTimer = setTimeout(async () => {
try {
await this.flushBuffer();
} catch (error) {
} catch (error: any) {
this.logger.error({
message: `Error in flush cycle: ${error.message}`,
service: this.SERVICE_NAME,
@@ -118,9 +144,9 @@ class BufferService {
async flushBuffer() {
try {
if (this.buffer.length > 0) {
await this.db.checkModule.createChecks(this.buffer);
await this.checksService.createChecks(this.buffer);
}
} catch (error) {
} catch (error: any) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
@@ -133,7 +159,7 @@ class BufferService {
if (this.incidentBuffer.length > 0 && this.incidentService) {
await this.flushIncidentBuffer();
}
} catch (error) {
} catch (error: any) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
@@ -154,7 +180,7 @@ class BufferService {
try {
const itemsToProcess = [...this.incidentBuffer];
await this.incidentService.processIncidentsFromBuffer(itemsToProcess);
} catch (error) {
} catch (error: any) {
this.logger.error({
message: `Error flushing incident buffer: ${error.message}`,
service: this.SERVICE_NAME,
@@ -1,10 +1,145 @@
import { HTTPError, RequestError } from "got";
import type { Got, Response } from "got";
import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
import CacheableLookup from "cacheable-lookup";
const SERVICE_NAME = "NetworkService";
class NetworkService {
type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
interface BuildStatusResponseArgs<T> {
monitor: Monitor;
response?: Response<T> | null;
error?: Error | RequestError | HTTPError | null;
payload?: T | null;
jsonPath?: string;
matchMethod?: MonitorStatusResponse["matchMethod"];
expectedValue?: string;
extracted?: unknown;
overrides?: MonitorStatusResponseOverrides<T>;
}
export interface INetworkService {
readonly serviceName: string;
requestStatus(monitor: Monitor): Promise<MonitorStatusResponse>;
requestWebhook(type: string, url: string, body: any): Promise<{ type: string; status: boolean; code: number; message: string; payload?: unknown }>;
requestPagerDuty(args: { message: string; routingKey: string; monitorUrl: string }): Promise<boolean>;
requestMatrix(args: { homeserverUrl: string; accessToken: string; roomId: string; message: string }): Promise<{
status: boolean;
code: number;
message: string;
payload?: unknown;
}>;
}
class NetworkService implements INetworkService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ axios, got, https, jmespath, GameDig, ping, logger, http, Docker, net, stringService, settingsService }) {
private TYPE_PING: string;
private TYPE_HTTP: string;
private TYPE_PAGESPEED: string;
private TYPE_HARDWARE: string;
private TYPE_DOCKER: string;
private TYPE_PORT: string;
private TYPE_GAME: string;
private SERVICE_NAME: string;
private NETWORK_ERROR: number;
private PING_ERROR: number;
private axios: any;
private got: Got;
private https: any;
private jmespath: any;
private GameDig: any;
private ping: any;
private logger: any;
private http: any;
private Docker: any;
private net: any;
private stringService: any;
private settingsService: any;
private buildStatusResponse = <T>({
monitor,
response,
error,
payload,
jsonPath,
matchMethod,
expectedValue,
extracted,
overrides,
}: BuildStatusResponseArgs<T>): MonitorStatusResponse<T> => {
if (error) {
const statusResponse: MonitorStatusResponse<T> = {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: this.NETWORK_ERROR,
message: error.message ?? "Network error",
responseTime: 0,
timings: undefined,
jsonPath,
matchMethod,
expectedValue,
extracted,
payload,
};
if (error instanceof HTTPError || error instanceof RequestError) {
statusResponse.code = error?.response?.statusCode ?? this.NETWORK_ERROR;
statusResponse.message = error.message;
statusResponse.responseTime = error.timings?.phases?.total ?? 0;
statusResponse.timings = error.timings;
}
return { ...statusResponse, ...(overrides ?? {}) };
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response?.ok ?? false,
code: response?.statusCode ?? this.NETWORK_ERROR,
message: response?.statusMessage ?? "",
responseTime: response?.timings?.phases?.total ?? 0,
timings: response?.timings,
payload: payload ?? response?.body,
jsonPath,
matchMethod,
expectedValue,
extracted,
...(overrides ?? {}),
};
};
constructor({
axios,
got,
https,
jmespath,
GameDig,
ping,
logger,
http,
Docker,
net,
stringService,
settingsService,
}: {
axios: any;
got: Got;
https: any;
jmespath: any;
GameDig: any;
ping: any;
logger: any;
http: any;
Docker: any;
net: any;
stringService: any;
settingsService: any;
}) {
this.TYPE_PING = "ping";
this.TYPE_HTTP = "http";
this.TYPE_PAGESPEED = "pagespeed";
@@ -17,11 +152,11 @@ class NetworkService {
this.PING_ERROR = 5001;
this.axios = axios;
this.https = https;
this.http = http;
this.jmespath = jmespath;
this.GameDig = GameDig;
this.ping = ping;
this.logger = logger;
this.http = http;
this.Docker = Docker;
this.net = net;
this.stringService = stringService;
@@ -38,13 +173,17 @@ class NetworkService {
});
}
get serviceName(): string {
return NetworkService.SERVICE_NAME;
}
// Helper functions
async timeRequest(operation) {
private async timeRequest<T>(operation: () => Promise<T>): Promise<{ response: T | null; responseTime: number; error: unknown }> {
const start = process.hrtime.bigint();
try {
const response = await operation();
const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response, responseTime: elapsedMs };
return { response, responseTime: elapsedMs, error: null };
} catch (error) {
const elapsedMs = Math.round(Number(process.hrtime.bigint() - start) / 1_000_000);
return { response: null, responseTime: elapsedMs, error };
@@ -52,7 +191,7 @@ class NetworkService {
}
// Main entry point
async requestStatus(monitor) {
async requestStatus(monitor: Monitor): Promise<MonitorStatusResponse> {
const type = monitor?.type || "unknown";
switch (type) {
case this.TYPE_PING:
@@ -70,11 +209,11 @@ class NetworkService {
case this.TYPE_GAME:
return await this.requestGame(monitor);
default:
return await this.handleUnsupportedType(type);
return this.handleUnsupportedType(type);
}
}
async requestPing(monitor) {
private async requestPing(monitor: Monitor): Promise<MonitorStatusResponse> {
try {
if (!monitor?.url) {
throw new Error("Monitor URL is required");
@@ -94,44 +233,49 @@ class NetworkService {
throw new Error("Ping failed - no result returned");
}
const pingResponse = {
monitorId: monitor.id,
type: "ping",
status: response.alive,
code: 200,
responseTime: response.time,
message: "Success",
const pingResponse = this.buildStatusResponse({
monitor,
payload: response,
};
overrides: {
status: (response as { alive?: boolean })?.alive ?? false,
code: 200,
message: "Success",
responseTime: (response as { time?: number })?.time ?? 0,
payload: response,
},
});
if (error) {
pingResponse.status = false;
pingResponse.code = 200;
pingResponse.code = this.PING_ERROR;
pingResponse.message = "Ping failed";
return pingResponse;
}
return pingResponse;
} catch (err) {
} catch (err: any) {
err.service = this.SERVICE_NAME;
err.method = "requestPing";
throw err;
}
}
async requestHttp(monitor) {
private async requestHttp(monitor: Monitor): Promise<MonitorStatusResponse> {
const { url, secret, id, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor;
const httpResponse = {
monitorId: id,
teamId: teamId,
type,
};
const httpResponse = this.buildStatusResponse({
monitor,
overrides: {
status: false,
code: this.NETWORK_ERROR,
message: "Request not executed",
},
});
try {
if (!url) {
throw new Error("Monitor URL is required");
}
const config = {
const config: Record<string, any> = {
headers: secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
@@ -145,7 +289,7 @@ class NetworkService {
const response = await this.got(url, config);
let payload;
let payload: any;
const contentType = response.headers["content-type"];
if (contentType && contentType.includes("application/json")) {
@@ -158,12 +302,14 @@ class NetworkService {
payload = response.body;
}
httpResponse.code = response.statusCode;
httpResponse.status = response.ok;
httpResponse.message = response.statusMessage;
httpResponse.responseTime = response.timings.phases.total || 0;
httpResponse.payload = payload;
httpResponse.timings = response.timings || {};
Object.assign(httpResponse, {
code: response.statusCode,
status: response.ok,
message: response.statusMessage ?? "",
responseTime: response.timings.phases.total || 0,
payload,
timings: response.timings,
});
if (!expectedValue && !jsonPath) {
return httpResponse;
@@ -231,7 +377,7 @@ class NetworkService {
}
}
return httpResponse;
} catch (err) {
} catch (err: any) {
if (err.name === "HTTPError" || err.name === "RequestError") {
httpResponse.code = err?.response?.statusCode || this.NETWORK_ERROR;
httpResponse.status = false;
@@ -247,7 +393,7 @@ class NetworkService {
}
}
async requestPageSpeed(monitor) {
private async requestPageSpeed(monitor: Monitor): Promise<MonitorStatusResponse> {
try {
const url = monitor.url;
if (!url) {
@@ -269,24 +415,24 @@ class NetworkService {
...monitor,
url: pageSpeedUrl,
});
} catch (err) {
} catch (err: any) {
err.service = this.SERVICE_NAME;
err.method = "requestPageSpeed";
throw err;
}
}
async requestHardware(monitor) {
private async requestHardware(monitor: Monitor): Promise<MonitorStatusResponse> {
try {
return await this.requestHttp(monitor);
} catch (err) {
} catch (err: any) {
err.service = this.SERVICE_NAME;
err.method = "requestHardware";
throw err;
}
}
async requestDocker(monitor) {
private async requestDocker(monitor: Monitor): Promise<MonitorStatusResponse> {
try {
if (!monitor.url) {
throw new Error("Monitor URL is required");
@@ -297,10 +443,14 @@ class NetworkService {
handleError: true, // Enable error handling
});
const dockerResponse = {
monitorId: monitor.id,
type: monitor.type,
};
const dockerResponse = this.buildStatusResponse({
monitor,
overrides: {
status: false,
code: this.NETWORK_ERROR,
message: "No response",
},
});
const containers = await docker.listContainers({ all: true });
@@ -309,18 +459,18 @@ class NetworkService {
// Priority-based matching to avoid ambiguity:
// 1. Exact full ID match (64-char)
let exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput);
let exactIdMatch = containers.find((c: any) => c.Id.toLowerCase() === normalizedInput);
// 2. Exact container name match (case-insensitive)
let exactNameMatch = containers.find((c) =>
c.Names.some((name) => {
let exactNameMatch = containers.find((c: any) =>
c.Names.some((name: string) => {
const cleanName = name.replace(/^\/+/, "").toLowerCase();
return cleanName === normalizedInput;
})
);
// 3. Partial ID match (fallback for backwards compatibility)
let partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput));
let partialIdMatch = containers.find((c: any) => c.Id.toLowerCase().startsWith(normalizedInput));
// Select container based on priority
let targetContainer = exactIdMatch || exactNameMatch || partialIdMatch;
@@ -353,14 +503,15 @@ class NetworkService {
method: "requestDocker",
details: { url: monitor.url },
});
dockerResponse.status = 404;
dockerResponse.status = false;
dockerResponse.message = `Ambiguous container match for "${monitor.url}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
return dockerResponse;
}
const container = docker.getContainer(targetContainer.Id);
const { response, responseTime, error } = await this.timeRequest(() => container.inspect());
const { response, responseTime, error }: { response?: any; responseTime?: number; error?: any } = await this.timeRequest(() =>
container.inspect()
);
dockerResponse.responseTime = responseTime;
dockerResponse.status = response?.State?.Status === "running" ? true : false;
@@ -375,14 +526,14 @@ class NetworkService {
}
return dockerResponse;
} catch (err) {
} catch (err: any) {
err.service = this.SERVICE_NAME;
err.method = "requestDocker";
throw err;
}
}
async requestPort(monitor) {
private async requestPort(monitor: Monitor): Promise<MonitorStatusResponse> {
try {
const { url, port } = monitor;
const { response, responseTime, error } = await this.timeRequest(async () => {
@@ -405,21 +556,22 @@ class NetworkService {
reject(new Error("Connection timeout"));
});
socket.on("error", (err) => {
socket.on("error", (err: any) => {
socket.destroy();
reject(err);
});
});
});
const portResponse = {
code: 200,
status: response.success,
message: this.stringService.portSuccess,
monitorId: monitor.id,
type: monitor.type,
responseTime: responseTime,
};
const portResponse = this.buildStatusResponse({
monitor,
overrides: {
code: 200,
status: (response as { success?: boolean })?.success ?? false,
message: this.stringService.portSuccess,
responseTime,
},
});
if (error) {
portResponse.code = this.NETWORK_ERROR;
@@ -429,30 +581,31 @@ class NetworkService {
}
return portResponse;
} catch (error) {
} catch (error: any) {
error.service = this.SERVICE_NAME;
error.method = "requestTCP";
throw error;
}
}
async requestGame(monitor) {
private async requestGame(monitor: Monitor): Promise<MonitorStatusResponse> {
try {
const { url, port, gameId } = monitor;
const gameResponse = {
code: 200,
status: true,
message: "Success",
monitorId: monitor.id,
type: "game",
};
const gameResponse = this.buildStatusResponse({
monitor,
overrides: {
code: 200,
status: true,
message: "Success",
},
});
const state = await this.GameDig.query({
type: gameId,
host: url,
port: port,
}).catch((error) => {
}).catch((error: any) => {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
@@ -471,21 +624,25 @@ class NetworkService {
gameResponse.responseTime = state.ping;
gameResponse.payload = state;
return gameResponse;
} catch (error) {
} catch (error: any) {
error.service = this.SERVICE_NAME;
error.method = "requestPing";
throw error;
}
}
async handleUnsupportedType(type) {
const err = new Error(`Unsupported type: ${type}`);
err.service = this.SERVICE_NAME;
err.method = "getStatus";
throw err;
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
return {
monitorId: "unknown",
teamId: "unknown",
type: "unknown",
status: false,
code: this.NETWORK_ERROR,
message: `Unsupported type: ${type}`,
};
}
// Other network requests unrelated to monitoring:
async requestWebhook(type, url, body) {
async requestWebhook(type: string, url: string, body: any) {
try {
const response = await this.axios.post(url, body, {
headers: {
@@ -500,7 +657,7 @@ class NetworkService {
message: `Successfully sent ${type} notification`,
payload: response.data,
};
} catch (error) {
} catch (error: any) {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
@@ -517,7 +674,7 @@ class NetworkService {
}
}
async requestPagerDuty({ message, routingKey, monitorUrl }) {
async requestPagerDuty({ message, routingKey, monitorUrl }: { message: string; routingKey: string; monitorUrl: string }) {
try {
const response = await this.axios.post(`https://events.pagerduty.com/v2/enqueue`, {
routing_key: routingKey,
@@ -532,7 +689,7 @@ class NetworkService {
if (response?.data?.status !== "success") return false;
return true;
} catch (error) {
} catch (error: any) {
error.details = error.response?.data;
error.service = this.SERVICE_NAME;
error.method = "requestPagerDuty";
@@ -540,7 +697,17 @@ class NetworkService {
}
}
async requestMatrix({ homeserverUrl, accessToken, roomId, message }) {
async requestMatrix({
homeserverUrl,
accessToken,
roomId,
message,
}: {
homeserverUrl: string;
accessToken: string;
roomId: string;
message: string;
}) {
try {
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
@@ -560,7 +727,7 @@ class NetworkService {
code: response.status,
message: "Successfully sent Matrix notification",
};
} catch (error) {
} catch (error: any) {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
@@ -1,11 +1,38 @@
import { IMonitorsRepository } from "@/repositories/index.js";
import MonitorStats from "../../db/models/MonitorStats.js";
import { CheckModel } from "@/db/models/index.js";
import type {
CheckErrorInfo,
Monitor,
MonitorStatusResponse,
StatusChangeResult,
Check,
HardwareStatusPayload,
PageSpeedStatusPayload,
} from "@/types/index.js";
const SERVICE_NAME = "StatusService";
class StatusService {
static SERVICE_NAME = SERVICE_NAME;
private db: any;
private logger: any;
private buffer: any;
private incidentService: any;
private monitorsRepository: IMonitorsRepository;
constructor({ db, logger, buffer, incidentService, monitorsRepository }) {
constructor({
db,
logger,
buffer,
incidentService,
monitorsRepository,
}: {
db: any;
logger: any;
buffer: any;
incidentService: any;
monitorsRepository: IMonitorsRepository;
}) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
@@ -17,7 +44,7 @@ class StatusService {
return StatusService.SERVICE_NAME;
}
async updateRunningStats({ monitor, networkResponse }) {
async updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: any }) {
try {
const monitorId = monitor.id;
const { responseTime, status } = networkResponse;
@@ -78,9 +105,9 @@ class StatusService {
await stats.save();
return true;
} catch (error) {
} catch (error: any) {
this.logger.error({
service: this.SERVICE_NAME,
service: SERVICE_NAME,
message: error.message,
method: "updateRunningStats",
stack: error.stack,
@@ -89,23 +116,13 @@ class StatusService {
}
}
getStatusString = (status) => {
getStatusString = (status: boolean | undefined) => {
if (status === true) return "up";
if (status === false) return "down";
return "unknown";
};
/**
* Saves check if needed and adds to incident buffer
* Removes check from checks buffer if it was saved immediately
*
* @param {Object} check - The check object
* @param {Object} monitor - The monitor object
* @param {string} action - The incident action ("create" or "resolve")
* @param {string} errorContext - Context for error messages
* @returns {Promise<void>}
*/
handleIncidentForCheck = async (check, monitor, action, errorContext = "incident handling") => {
handleIncidentForCheck = async (check: any, monitor: Monitor, action: any, errorContext = "incident handling") => {
try {
let savedCheck = check;
@@ -115,9 +132,9 @@ class StatusService {
savedCheck = await checkModel.save();
this.buffer.removeCheckFromBuffer(check);
} catch (checkError) {
} catch (checkError: any) {
this.logger.error({
service: this.SERVICE_NAME,
service: SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to save check immediately for ${errorContext}: ${checkError.message}`,
monitorId: monitor.id,
@@ -130,9 +147,9 @@ class StatusService {
if (savedCheck && savedCheck._id) {
try {
this.buffer.addIncidentToBuffer({ monitor, check: savedCheck, action });
} catch (incidentError) {
} catch (incidentError: any) {
this.logger.error({
service: this.SERVICE_NAME,
service: SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Failed to add incident to buffer for ${errorContext}: ${incidentError.message}`,
monitorId: monitor.id,
@@ -141,9 +158,9 @@ class StatusService {
});
}
}
} catch (error) {
} catch (error: any) {
this.logger.error({
service: this.SERVICE_NAME,
service: SERVICE_NAME,
method: "handleIncidentForCheck",
message: `Error in ${errorContext}: ${error.message}`,
monitorId: monitor?.id,
@@ -151,27 +168,17 @@ class StatusService {
});
}
};
/**
* Updates the status of a monitor based on the network response.
*
* @param {Object} networkResponse - The network response containing monitorId and status.
* @param {string} networkResponse.monitorId - The ID of the monitor.
* @param {string} networkResponse.status - The new status of the monitor.
* @returns {Promise<Object>} - A promise that resolves to an object containinfg the monitor, statusChanged flag, and previous status if the status changed, or false if an error occurred.
* @returns {Promise<Object>} returnObject - The object returned by the function.
* @returns {Object} returnObject.monitor - The monitor object.
* @returns {boolean} returnObject.statusChanged - Flag indicating if the status has changed.
* @returns {boolean} returnObject.prevStatus - The previous status of the monitor
*/
updateStatus = async (networkResponse) => {
const check = this.buildCheck(networkResponse);
await this.insertCheck(check);
updateMonitorStatus = async (
statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>,
check: Check
): Promise<StatusChangeResult> => {
try {
const { monitorId, teamId, status, code } = networkResponse;
const { monitorId, teamId, status, code } = statusResponse;
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
// Update running stats
this.updateRunningStats({ monitor, networkResponse });
this.updateRunningStats({ monitor, networkResponse: statusResponse });
// If the status window size has changed, empty
while (monitor.statusWindow.length > monitor.statusWindowSize) {
@@ -221,7 +228,7 @@ class StatusService {
if (statusChanged) {
this.logger.info({
service: this.SERVICE_NAME,
service: SERVICE_NAME,
message: `${monitor.name} went from ${this.getStatusString(prevStatus)} to ${this.getStatusString(newStatus)}`,
prevStatus,
newStatus,
@@ -242,7 +249,7 @@ class StatusService {
if (lastManuallyResolvedIncident && lastManuallyResolvedIncident.endTime) {
try {
const checksAfterResolution = await Check.find({
const checksAfterResolution = await CheckModel.find({
monitorId: monitor.id,
createdAt: { $gt: lastManuallyResolvedIncident.endTime },
})
@@ -258,9 +265,9 @@ class StatusService {
} else {
calculatedFailureRate = 0;
}
} catch (checkQueryError) {
} catch (checkQueryError: any) {
this.logger.error({
service: this.SERVICE_NAME,
service: SERVICE_NAME,
method: "updateStatus",
message: `Failed to query checks after manual resolution: ${checkQueryError.message}`,
monitorId: monitor.id,
@@ -272,9 +279,9 @@ class StatusService {
if (calculatedFailureRate >= monitor.statusWindowThreshold) {
await this.handleIncidentForCheck(check, monitor, "create", "threshold check without status change");
}
} catch (error) {
} catch (error: any) {
this.logger.error({
service: this.SERVICE_NAME,
service: SERVICE_NAME,
method: "updateStatus",
message: `Error handling threshold check without status change: ${error.message}`,
monitorId: monitor.id,
@@ -293,147 +300,31 @@ class StatusService {
code,
timestamp: new Date().getTime(),
};
} catch (error) {
error.service = this.SERVICE_NAME;
} catch (error: any) {
error.service = SERVICE_NAME;
error.method = "updateStatus";
throw error;
}
};
/**
* Builds a check object from the network response.
*
* @param {Object} networkResponse - The network response object.
* @param {string} networkResponse.monitorId - The monitor ID.
* @param {string} networkResponse.type - The type of the response.
* @param {string} networkResponse.status - The status of the response.
* @param {number} networkResponse.responseTime - The response time.
* @param {number} networkResponse.code - The status code.
* @param {string} networkResponse.message - The message.
* @param {Object} networkResponse.payload - The payload of the response.
* @returns {Object} The check object.
*/
buildCheck = (networkResponse) => {
const {
monitorId,
teamId,
type,
status,
responseTime,
code,
message,
payload,
first_byte_took,
body_read_took,
dns_took,
conn_took,
connect_took,
tls_took,
timings,
} = networkResponse;
const check = {
metadata: {
monitorId,
teamId,
type,
},
status,
statusCode: code,
responseTime,
timings: timings || {},
message,
first_byte_took,
body_read_took,
dns_took,
conn_took,
connect_took,
tls_took,
};
if (type === "pagespeed") {
if (typeof payload === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: this.SERVICE_NAME,
method: "buildCheck",
details: "empty payload",
});
return undefined;
}
const categories = payload?.lighthouseResult?.categories ?? {};
const audits = payload?.lighthouseResult?.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: 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") {
const { cpu, memory, disk, host, net } = payload?.data ?? {};
const { errors } = payload?.errors ?? [];
check.cpu = cpu ?? {};
check.memory = memory ?? {};
check.disk = disk ?? {};
check.host = host ?? {};
check.errors = errors ?? [];
check.capture = payload?.capture ?? {};
check.net = net ?? {};
}
return check;
};
/**
* Inserts a check into the database based on the network response.
*
* @param {Object} networkResponse - The network response object.
* @param {string} networkResponse.monitorId - The monitor ID.
* @param {string} networkResponse.type - The type of the response.
* @param {string} networkResponse.status - The status of the response.
* @param {number} networkResponse.responseTime - The response time.
* @param {number} networkResponse.code - The status code.
* @param {string} networkResponse.message - The message.
* @param {Object} networkResponse.payload - The payload of the response.
* @returns {Promise<void>} A promise that resolves when the check is inserted.
*/
insertCheck = async (check) => {
insertCheck = async (check: Check) => {
try {
if (typeof check === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: this.SERVICE_NAME,
service: SERVICE_NAME,
method: "insertCheck",
});
return false;
}
this.buffer.addToBuffer({ check });
return true;
} catch (error) {
} catch (error: any) {
this.logger.error({
message: error.message,
service: error.service || this.SERVICE_NAME,
service: error.service || SERVICE_NAME,
method: error.method || "insertCheck",
details: error.details || `Error inserting check for monitor: ${check?.monitorId}`,
details: error.details || `Error inserting check for monitor: ${check?.metadata.monitorId}`,
stack: error.stack,
});
}
+37 -58
View File
@@ -1,4 +1,6 @@
import type { MonitorType } from "@/types/index.js";
import type { Response } from "got";
export type GotTimings = Response["timings"];
export interface CheckMetadata {
monitorId: string;
@@ -6,64 +8,41 @@ export interface CheckMetadata {
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;
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;
total_bytes?: number;
available_bytes?: number;
used_bytes?: number;
usage_percent?: number;
}
export interface CheckHostInfo {
os: string;
platform: string;
kernel_version: string;
os?: string;
platform?: string;
kernel_version?: string;
}
export interface CheckCaptureInfo {
version: string;
mode: string;
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;
device?: string;
mountpoint?: string;
read_speed_bytes?: number;
write_speed_bytes?: number;
total_bytes?: number;
free_bytes?: number;
usage_percent?: number;
}
export interface CheckErrorInfo {
@@ -86,11 +65,11 @@ export interface CheckNetworkInterfaceInfo {
}
export interface CheckAudits {
cls: ILighthouseAudit;
si: ILighthouseAudit;
fcp: ILighthouseAudit;
lcp: ILighthouseAudit;
tbt: ILighthouseAudit;
cls?: ILighthouseAudit;
si?: ILighthouseAudit;
fcp?: ILighthouseAudit;
lcp?: ILighthouseAudit;
tbt?: ILighthouseAudit;
}
export interface ILighthouseAudit {
@@ -107,19 +86,19 @@ export interface Check {
metadata: CheckMetadata;
status: boolean;
responseTime: number;
timings: CheckTimings;
timings?: GotTimings;
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[];
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
host?: CheckHostInfo;
errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo;
net?: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
seo?: number;
+1
View File
@@ -2,3 +2,4 @@ export * from "@/types/check.js";
export * from "@/types/monitor.js";
export * from "@/types/monitorStats.js";
export * from "@/types/statusPage.js";
export * from "@/types/network.js";
+1 -1
View File
@@ -1,4 +1,4 @@
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game"] as const;
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export interface MonitorThresholds {
+112
View File
@@ -0,0 +1,112 @@
import type {
CheckCaptureInfo,
CheckCpuInfo,
CheckDiskInfo,
CheckErrorInfo,
CheckHostInfo,
CheckMemoryInfo,
CheckNetworkInterfaceInfo,
GotTimings,
ILighthouseAudit,
Monitor,
MonitorMatchMethod,
MonitorType,
} from "@/types/index.js";
export interface MonitorStatusResponse<T = unknown> {
monitorId: string;
teamId: string;
type: MonitorType;
status: boolean;
code: number;
message: string;
responseTime?: number;
payload?: T | null;
timings?: GotTimings;
first_byte_took?: number;
body_read_took?: number;
dns_took?: number;
conn_took?: number;
connect_took?: number;
tls_took?: number;
jsonPath?: string;
matchMethod?: MonitorMatchMethod;
expectedValue?: string;
extracted?: unknown;
}
export interface PingStatusPayload {
host: string;
numeric_host?: string;
alive: boolean;
time: number;
times?: number[];
output?: string;
min?: string;
max?: string;
avg?: string;
stddev?: string;
packetLoss?: string;
}
export type HttpStatusPayload = unknown;
export interface PageSpeedCategoryScore {
score?: number | null;
}
export interface PageSpeedStatusPayload {
lighthouseResult?: {
categories?: {
accessibility?: PageSpeedCategoryScore;
"best-practices"?: PageSpeedCategoryScore;
performance?: PageSpeedCategoryScore;
seo?: PageSpeedCategoryScore;
[key: string]: PageSpeedCategoryScore | undefined;
};
audits?: Record<string, ILighthouseAudit | undefined>;
};
[key: string]: unknown;
}
export interface HardwareStatusMetrics {
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
host?: CheckHostInfo;
net?: CheckNetworkInterfaceInfo[];
}
export interface HardwareStatusPayload {
data?: HardwareStatusMetrics;
errors?: CheckErrorInfo[] | { errors?: CheckErrorInfo[] };
capture?: CheckCaptureInfo;
[key: string]: unknown;
}
export type DockerStatusPayload = Record<string, unknown>;
export interface PortStatusPayload {
success: boolean;
}
export type GameStatusPayload = Record<string, unknown>;
export interface MonitorPayloadMap {
ping: PingStatusPayload;
http: HttpStatusPayload;
pagespeed: PageSpeedStatusPayload;
hardware: HardwareStatusPayload;
docker: DockerStatusPayload;
port: PortStatusPayload;
game: GameStatusPayload;
default: unknown;
}
export type StatusChangeResult = {
monitor: Monitor;
statusChanged: boolean;
prevStatus: boolean | undefined;
code: number;
timestamp: number;
};
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it, jest } from "@jest/globals";
import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
import type { Monitor } from "../src/types/monitor.ts";
const createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() });
const createHelper = (overrides?: Partial<ConstructorParameters<typeof SuperSimpleQueueHelper>[0]>) => {
const maintenanceWindowModule = {
getMaintenanceWindowsByMonitorId: jest.fn().mockResolvedValue([]),
};
const statusServiceMock = {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }),
};
const helper = new SuperSimpleQueueHelper({
db: { maintenanceWindowModule },
logger: createLogger(),
networkService: { requestStatus: jest.fn() },
statusService: statusServiceMock,
notificationService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
...overrides,
});
return { helper, maintenanceWindowModule };
};
describe("SuperSimpleQueueHelper", () => {
describe("getMonitorJob", () => {
it("skips execution when monitor is in maintenance window", async () => {
const { helper } = createHelper();
const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
const job = helper.getMonitorJob();
await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor);
expect(helper["networkService"].requestStatus).not.toHaveBeenCalled();
expect(helper["logger"].debug).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") })
);
spy.mockRestore();
});
it("processes monitor status and notifications when active", async () => {
const networkResponse = { monitor: { id: "m1" }, status: true };
const updatedMonitor = { id: "m1", status: true };
const { helper } = createHelper({
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
statusService: {
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false }),
},
notificationService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
});
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
const job = helper.getMonitorJob();
const monitor = { id: "m1", teamId: "team" } as Monitor;
await job(monitor);
expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor);
expect(helper["statusService"].updateMonitorStatus).toHaveBeenCalledWith(networkResponse);
expect(helper["notificationService"].handleNotifications).toHaveBeenCalledWith(
expect.objectContaining({ monitor: updatedMonitor, statusChanged: true, prevStatus: false })
);
});
it("throws when monitor id is missing", async () => {
const { helper } = createHelper();
const job = helper.getMonitorJob();
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
expect(helper["logger"].warn).toHaveBeenCalled();
});
});
describe("isInMaintenanceWindow", () => {
it("returns true when an active window spans now", async () => {
const now = new Date();
const { helper, maintenanceWindowModule } = createHelper();
maintenanceWindowModule.getMaintenanceWindowsByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now.getTime() - 1000).toISOString(),
end: new Date(now.getTime() + 1000).toISOString(),
repeat: 0,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns true when repeat interval advances window into current time", async () => {
const now = Date.now();
const { helper, maintenanceWindowModule } = createHelper();
maintenanceWindowModule.getMaintenanceWindowsByMonitorId.mockResolvedValue([
{
active: true,
start: new Date(now - 7200000).toISOString(),
end: new Date(now - 6600000).toISOString(),
repeat: 3600000,
},
]);
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
});
it("returns false when no active windows exist", async () => {
const { helper } = createHelper();
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
});
});
});
+2 -1
View File
@@ -3,7 +3,8 @@
"compilerOptions": {
"rootDir": ".",
"types": ["jest"],
"noEmit": true
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src", "test"]
}
+1 -1
View File
@@ -4,7 +4,7 @@
"allowJs": true,
"checkJs": false,
"paths": {
"@/*": ["./src/*"] // allows "@/db" -> "./src/db"
"@/*": ["./src/*"]
},
"outDir": "./dist",
"strict": true,