move to src

This commit is contained in:
Alex Holliday committed 2025-07-25 15:33:48 -07:00
1 parent acc910cbc0
commit 1c47c8ce2c
127 files changed
+71 -19

No files matched your search

@@ -0,0 +1,321 @@
const QUEUE_NAMES = ["uptime", "pagespeed", "hardware"];
const SERVICE_NAME = "JobQueue";
const HEALTH_CHECK_INTERVAL = 10 * 60 * 1000; // 10 minutes
const QUEUE_LOOKUP = {
hardware: "hardware",
http: "uptime",
ping: "uptime",
port: "uptime",
docker: "uptime",
pagespeed: "pagespeed",
};
const getSchedulerId = (monitor) => `scheduler:${monitor.type}:${monitor._id}`;
class JobQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, jobQueueHelper, logger, stringService }) {
this.db = db;
this.jobQueueHelper = jobQueueHelper;
this.stringService = stringService;
this.logger = logger;
this.queues = {};
this.workers = [];
}
get serviceName() {
return JobQueue.SERVICE_NAME;
}
static async create({ db, jobQueueHelper, logger, stringService }) {
const instance = new JobQueue({ db, jobQueueHelper, logger, stringService });
await instance.init();
return instance;
}
async init() {
try {
await this.initQueues();
await this.initWorkers();
const monitors = await this.db.getAllMonitors();
await Promise.all(
monitors
.filter((monitor) => monitor.isActive)
.map(async (monitor) => {
try {
await this.addJob(monitor._id, monitor);
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "initJobQueue",
stack: error.stack,
});
}
})
);
this.healthCheckInterval = setInterval(async () => {
try {
const queueHealthChecks = await this.checkQueueHealth();
const queueIsStuck = queueHealthChecks.some((healthCheck) => healthCheck.stuck);
if (queueIsStuck) {
this.logger.warn({
message: "Queue is stuck",
service: SERVICE_NAME,
method: "periodicHealthCheck",
details: queueHealthChecks,
});
await this.flushQueues();
}
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "periodicHealthCheck",
stack: error.stack,
});
}
}, HEALTH_CHECK_INTERVAL);
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "initJobQueue",
stack: error.stack,
});
}
}
async initQueues() {
const readyPromises = [];
for (const queueName of QUEUE_NAMES) {
const q = this.jobQueueHelper.createQueue(queueName);
this.queues[queueName] = q;
readyPromises.push(q.waitUntilReady());
}
await Promise.all(readyPromises);
this.logger.info({
message: "Queues ready",
service: SERVICE_NAME,
method: "initQueues",
});
}
async initWorkers() {
const workerReadyPromises = [];
for (const queueName of QUEUE_NAMES) {
const worker = this.jobQueueHelper.createWorker(queueName, this.queues[queueName]);
this.workers.push(worker);
workerReadyPromises.push(worker.waitUntilReady());
}
await Promise.all(workerReadyPromises);
this.logger.info({
message: "Workers ready",
service: SERVICE_NAME,
method: "initWorkers",
});
}
pauseJob = async (monitor) => {
this.deleteJob(monitor);
};
resumeJob = async (monitor) => {
this.addJob(monitor._id, monitor);
};
async addJob(jobName, monitor) {
this.logger.info({
message: `Adding job ${monitor?.url ?? "No URL"}`,
service: SERVICE_NAME,
method: "addJob",
});
const queueName = QUEUE_LOOKUP[monitor.type];
const queue = this.queues[queueName];
if (typeof queue === "undefined") {
throw new Error(`Queue for ${monitor.type} not found`);
}
const jobTemplate = {
name: jobName,
data: monitor,
opts: {
attempts: 1,
backoff: {
type: "exponential",
delay: 1000,
},
removeOnComplete: true,
removeOnFail: false,
timeout: 1 * 60 * 1000,
},
};
const schedulerId = getSchedulerId(monitor);
await queue.upsertJobScheduler(schedulerId, { every: monitor?.interval ?? 60000 }, jobTemplate);
}
async deleteJob(monitor) {
try {
const queue = this.queues[QUEUE_LOOKUP[monitor.type]];
const schedulerId = getSchedulerId(monitor);
const wasDeleted = await queue.removeJobScheduler(schedulerId);
if (wasDeleted === true) {
this.logger.info({
message: this.stringService.jobQueueDeleteJob,
service: SERVICE_NAME,
method: "deleteJob",
details: `Deleted job ${monitor._id}`,
});
return true;
} else {
this.logger.error({
message: this.stringService.jobQueueDeleteJob,
service: SERVICE_NAME,
method: "deleteJob",
details: `Failed to delete job ${monitor._id}`,
});
return false;
}
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "deleteJob") : null;
throw error;
}
}
async updateJob(monitor) {
await this.deleteJob(monitor);
await this.addJob(monitor._id, monitor);
}
async getJobs() {
try {
let stats = {};
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const queue = this.queues[name];
const jobs = await queue.getJobs();
const ret = await Promise.all(
jobs.map(async (job) => {
const state = await job.getState();
return { url: job.data.url, state, progress: job.progress };
})
);
stats[name] = { jobs: ret };
})
);
return stats;
} catch (error) {
error.service === undefined ? (error.service = SERVICE_NAME) : null;
error.method === undefined ? (error.method = "getJobStats") : null;
throw error;
}
}
async getMetrics() {
try {
let metrics = {};
await Promise.all(
QUEUE_NAMES.map(async (name) => {
const queue = this.queues[name];
const [waiting, active, failed, delayed, repeatableJobs] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
queue.getRepeatableJobs(),
]);
metrics[name] = {
waiting,
active,
failed,
delayed,
repeatableJobs: repeatableJobs.length,
};
})
);
return metrics;
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "getMetrics",
stack: error.stack,
});
}
}
async checkQueueHealth() {
const res = [];
for (const queueName of QUEUE_NAMES) {
const q = this.queues[queueName];
await q.waitUntilReady();
const lastJobProcessedTime = q.lastJobProcessedTime;
const currentTime = Date.now();
const timeDiff = currentTime - lastJobProcessedTime;
// Check for jobs
const jobCounts = await q.getJobCounts();
const hasJobs = Object.values(jobCounts).some((count) => count > 0);
res.push({
queueName,
timeSinceLastJob: timeDiff,
stuck: hasJobs && timeDiff > 10000,
jobCounts,
});
}
return res;
}
async flushQueues() {
try {
this.logger.warn({
message: "Flushing queues",
method: "flushQueues",
service: SERVICE_NAME,
});
for (const worker of this.workers) {
await worker.close();
}
this.workers = [];
for (const queue of Object.values(this.queues)) {
await queue.obliterate();
}
this.queue = {};
await this.init();
return true;
} catch (error) {
this.logger.warn({
message: `${error.message} - Flushing redis manually`,
service: SERVICE_NAME,
method: "flushQueues",
});
return await this.jobQueueHelper.flushRedis();
}
}
async shutdown() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
for (const worker of this.workers) {
await worker.close();
}
for (const queue of Object.values(this.queues)) {
await queue.obliterate();
}
}
}
export default JobQueue;
@@ -0,0 +1,308 @@
const SERVICE_NAME = "JobQueueHelper";
class JobQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
constructor({ redisService, Queue, Worker, logger, db, networkService, statusService, notificationService }) {
this.db = db;
this.redisService = redisService;
this.Queue = Queue;
this.Worker = Worker;
this.logger = logger;
this.networkService = networkService;
this.statusService = statusService;
this.notificationService = notificationService;
}
get serviceName() {
return JobQueueHelper.SERVICE_NAME;
}
createQueue(queueName) {
const connection = this.redisService.getNewConnection();
const q = new this.Queue(queueName, {
connection,
});
q.lastJobProcessedTime = Date.now();
q.on("cleaned", (jobs, type) => {
this.logger.debug({
message: `Queue ${queueName} is cleaned with jobs: ${jobs} and type: ${type}`,
service: SERVICE_NAME,
method: "createQueue:cleaned",
});
});
q.on("error", (err) => {
this.logger.error({
message: `Queue ${queueName} is error with msg: ${err}`,
service: SERVICE_NAME,
method: "createQueue:error",
});
});
q.on("ioredis:close", () => {
this.logger.debug({
message: `Queue ${queueName} is ioredis:close`,
service: SERVICE_NAME,
method: "createQueue:ioredis:close",
});
});
q.on("paused", () => {
this.logger.debug({
message: `Queue ${queueName} is paused`,
service: SERVICE_NAME,
method: "createQueue:paused",
});
});
q.on("progress", (job, progress) => {
this.logger.debug({
message: `Queue ${queueName} is progress with msg: ${progress}`,
service: SERVICE_NAME,
method: "createQueue:progress",
});
});
q.on("removed", (job) => {
this.logger.debug({
message: `Queue ${queueName} is removed with msg: ${job}`,
service: SERVICE_NAME,
method: "createQueue:removed",
});
});
q.on("resumed", () => {
this.logger.debug({
message: `Queue ${queueName} is resumed`,
service: SERVICE_NAME,
method: "createQueue:resumed",
});
});
q.on("waiting", () => {
this.logger.debug({
message: `Queue ${queueName} is waiting`,
service: SERVICE_NAME,
method: "createQueue:waiting",
});
});
return q;
}
createWorker(queueName, queue) {
const connection = this.redisService.getNewConnection({
maxRetriesPerRequest: null,
});
const worker = new this.Worker(queueName, this.createJobHandler(queue), {
connection,
concurrency: 50,
});
worker.on("active", (job) => {
this.logger.debug({
message: `Worker ${queueName} is active`,
service: SERVICE_NAME,
method: "createWorker:active",
});
});
worker.on("closed", () => {
this.logger.debug({
message: `Worker ${queueName} is closed`,
service: SERVICE_NAME,
method: "createWorker:closed",
});
});
worker.on("closing", (msg) => {
this.logger.debug({
message: `Worker ${queueName} is closing with msg: ${msg}`,
service: SERVICE_NAME,
method: "createWorker:closing",
});
});
worker.on("completed", (job) => {
this.logger.debug({
message: `Worker ${queueName} is completed`,
service: SERVICE_NAME,
method: "createWorker:completed",
});
});
worker.on("drained", () => {
this.logger.debug({
message: `Worker ${queueName} is drained`,
service: SERVICE_NAME,
method: "createWorker:drained",
});
});
worker.on("error", (failedReason) => {
this.logger.error({
message: `Worker ${queueName} is error with msg: ${failedReason}`,
service: SERVICE_NAME,
method: "createWorker:error",
});
});
worker.on("failed", (job, error, prev) => {
this.logger.error({
message: `Worker ${queueName} is failed with msg: ${error.message}`,
service: error?.service ?? SERVICE_NAME,
method: error?.method ?? "createWorker:failed",
stack: error?.stack,
});
});
worker.on("ioredis:close", () => {
this.logger.debug({
message: `Worker ${queueName} is ioredis:close`,
service: SERVICE_NAME,
method: "createWorker:ioredis:close",
});
});
worker.on("paused", () => {
this.logger.debug({
message: `Worker ${queueName} is paused`,
service: SERVICE_NAME,
method: "createWorker:paused",
});
});
worker.on("progress", (job, progress) => {
this.logger.debug({
message: `Worker ${queueName} is progress with msg: ${progress}`,
service: SERVICE_NAME,
method: "createWorker:progress",
});
});
worker.on("ready", () => {
this.logger.debug({
message: `Worker ${queueName} is ready`,
service: SERVICE_NAME,
method: "createWorker:ready",
});
});
worker.on("resumed", () => {
this.logger.debug({
message: `Worker ${queueName} is resumed`,
service: SERVICE_NAME,
method: "createWorker:resumed",
});
});
worker.on("stalled", () => {
this.logger.warn({
message: `Worker ${queueName} is stalled`,
service: SERVICE_NAME,
method: "createWorker:stalled",
});
});
return worker;
}
async isInMaintenanceWindow(monitorId) {
const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId);
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
const now = new Date();
const repeatInterval = window.repeat || 0;
// If start is < now and end > now, we're in maintenance
if (start <= now && end >= now) return true;
// If maintenance window was set in the past with a repeat,
// we need to advance start and end to see if we are in range
while (start < now && repeatInterval !== 0) {
start.setTime(start.getTime() + repeatInterval);
end.setTime(end.getTime() + repeatInterval);
if (start <= now && end >= now) {
return true;
}
}
return false;
}
return acc;
}, false);
return maintenanceWindowIsActive;
}
createJobHandler(q) {
return async (job) => {
try {
// Update the last job processed time for this queue
q.lastJobProcessedTime = Date.now();
// Get all maintenance windows for this monitor
await job.updateProgress(0);
const monitorId = job.data._id;
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId);
// If a maintenance window is active, we're done
if (maintenanceWindowActive) {
await job.updateProgress(100);
this.logger.info({
message: `Monitor ${monitorId} is in maintenance window`,
service: SERVICE_NAME,
method: "createWorker",
});
return false;
}
// Get the current status
await job.updateProgress(30);
const monitor = job.data;
const networkResponse = await this.networkService.getStatus(monitor);
// If the network response is not found, we're done
if (!networkResponse) {
await job.updateProgress(100);
return false;
}
// Handle status change
await job.updateProgress(60);
const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse);
// Handle notifications
await job.updateProgress(80);
this.notificationService
.handleNotifications({
...networkResponse,
monitor: updatedMonitor,
prevStatus,
statusChanged,
})
.catch((error) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "createJobHandler",
details: `Error sending notifications for job ${job.id}: ${error.message}`,
stack: error.stack,
});
});
await job.updateProgress(100);
return true;
} catch (error) {
await job.updateProgress(100);
throw error;
}
};
}
async flushRedis() {
try {
const connection = this.redisService.getNewConnection();
const flushResult = await connection.flushall();
return flushResult;
} catch (error) {
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "flushRedis",
});
return false;
}
}
}
export default JobQueueHelper;
@@ -0,0 +1,213 @@
import { Pulse } from "@pulsecron/pulse";
const SERVICE_NAME = "JobQueue";
class PulseQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor({ appSettings, db, pulseQueueHelper, logger }) {
this.db = db;
this.appSettings = appSettings;
this.pulseQueueHelper = pulseQueueHelper;
this.logger = logger;
}
get serviceName() {
return PulseQueue.SERVICE_NAME;
}
static async create({ appSettings, db, pulseQueueHelper, logger }) {
const instance = new PulseQueue({ appSettings, db, pulseQueueHelper, logger });
await instance.init();
return instance;
}
// ****************************************
// Core methods
// ****************************************
init = async () => {
try {
const mongoConnectionString = this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db";
this.pulse = new Pulse({ db: { address: mongoConnectionString } });
await this.pulse.start();
this.pulse.define("monitor-job", this.pulseQueueHelper.getMonitorJob(), {});
const monitors = await this.db.getAllMonitors();
for (const monitor of monitors) {
await this.addJob(monitor._id, monitor);
}
return true;
} catch (error) {
this.logger.error({
message: "Failed to initialize PulseQueue",
service: SERVICE_NAME,
method: "init",
details: error,
});
return false;
}
};
addJob = async (monitorId, monitor) => {
this.logger.debug({
message: `Adding job ${monitor?.url ?? "No URL"}`,
service: SERVICE_NAME,
method: "addJob",
});
const intervalInSeconds = monitor.interval / 1000;
const job = this.pulse.create("monitor-job", {
monitor,
});
job.unique({ "data.monitor._id": monitor._id });
job.attrs.jobId = monitorId.toString();
job.repeatEvery(`${intervalInSeconds} seconds`);
if (monitor.isActive === false) {
job.disable();
}
await job.save();
};
deleteJob = async (monitor) => {
this.logger.debug({
message: `Deleting job ${monitor?.url ?? "No URL"}`,
service: SERVICE_NAME,
method: "deleteJob",
});
await this.pulse.cancel({
"data.monitor._id": monitor._id,
});
};
pauseJob = async (monitor) => {
const result = await this.pulse.disable({
"data.monitor._id": monitor._id,
});
if (result.length < 1) {
throw new Error("Failed to pause monitor");
}
this.logger.debug({
message: `Paused monitor ${monitor._id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
};
resumeJob = async (monitor) => {
const result = await this.pulse.enable({
"data.monitor._id": monitor._id,
});
if (result.length < 1) {
throw new Error("Failed to resume monitor");
}
this.logger.debug({
message: `Resumed monitor ${monitor._id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
};
updateJob = async (monitor) => {
const jobs = await this.pulse.jobs({
"data.monitor._id": monitor._id,
});
const job = jobs[0];
if (!job) {
throw new Error("Job not found");
}
const intervalInSeconds = monitor.interval / 1000;
job.repeatEvery(`${intervalInSeconds} seconds`);
job.attrs.data.monitor = monitor;
await job.save();
};
shutdown = async () => {
this.logger.info({
message: "Shutting down JobQueue",
service: SERVICE_NAME,
method: "shutdown",
});
await this.pulse.stop();
};
// ****************************************
// Diagnostic methods
// ****************************************
getMetrics = async () => {
const jobs = await this.pulse.jobs();
const metrics = jobs.reduce(
(acc, job) => {
acc.totalRuns += job.attrs.runCount || 0;
acc.totalFailures += job.attrs.failCount || 0;
acc.jobs++;
if (job.attrs.failCount > 0 && job.attrs.failedAt >= job.attrs.lastFinishedAt) {
acc.failingJobs++;
}
if (job.attrs.lockedAt) {
acc.activeJobs++;
}
if (job.attrs.failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.attrs.data.monitor._id,
monitorUrl: job.attrs.data.monitor.url,
monitorType: job.attrs.data.monitor.type,
failedAt: job.attrs.failedAt,
failCount: job.attrs.failCount,
failReason: job.attrs.failReason,
});
}
return acc;
},
{
jobs: 0,
activeJobs: 0,
failingJobs: 0,
jobsWithFailures: [],
totalRuns: 0,
totalFailures: 0,
}
);
return metrics;
};
getJobs = async () => {
const jobs = await this.pulse.jobs();
return jobs.map((job) => {
return {
monitorId: job.attrs.data.monitor._id,
monitorUrl: job.attrs.data.monitor.url,
monitorType: job.attrs.data.monitor.type,
active: !job.attrs.disabled,
lockedAt: job.attrs.lockedAt,
runCount: job.attrs.runCount || 0,
failCount: job.attrs.failCount || 0,
failReason: job.attrs.failReason,
lastRunAt: job.attrs.lastRunAt,
lastFinishedAt: job.attrs.lastFinishedAt,
lastRunTook: job.attrs.lockedAt ? null : job.attrs.lastFinishedAt - job.attrs.lastRunAt,
lastFailedAt: job.attrs.failedAt,
};
});
};
flushQueues = async () => {
const cancelRes = await this.pulse.cancel();
await this.pulse.stop();
const initRes = await this.init();
return {
flushedJobs: cancelRes,
initSuccess: initRes,
};
};
obliterate = async () => {
await this.flushQueues();
};
}
export default PulseQueue;
@@ -0,0 +1,104 @@
const SERVICE_NAME = "PulseQueueHelper";
class PulseQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, logger, networkService, statusService, notificationService }) {
this.db = db;
this.logger = logger;
this.networkService = networkService;
this.statusService = statusService;
this.notificationService = notificationService;
}
get serviceName() {
return PulseQueueHelper.SERVICE_NAME;
}
getMonitorJob = () => {
return async (job) => {
try {
const monitor = job.attrs.data.monitor;
const monitorId = job.attrs.data.monitor._id;
if (!monitorId) {
throw new Error("No monitor id");
}
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId);
if (maintenanceWindowActive) {
this.logger.info({
message: `Monitor ${monitorId} is in maintenance window`,
service: SERVICE_NAME,
method: "getMonitorJob",
});
return;
}
const networkResponse = await this.networkService.getStatus(monitor);
if (!networkResponse) {
throw new Error("No network response");
}
const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse);
this.notificationService
.handleNotifications({
...networkResponse,
monitor: updatedMonitor,
prevStatus,
statusChanged,
})
.catch((error) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error sending notifications for job ${job.id}: ${error.message}`,
stack: error.stack,
});
});
} catch (error) {
this.logger.warn({
message: error.message,
service: error.service || SERVICE_NAME,
method: error.method || "getMonitorJob",
stack: error.stack,
});
throw error;
}
};
};
async isInMaintenanceWindow(monitorId) {
const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId);
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
const now = new Date();
const repeatInterval = window.repeat || 0;
// If start is < now and end > now, we're in maintenance
if (start <= now && end >= now) return true;
// If maintenance window was set in the past with a repeat,
// we need to advance start and end to see if we are in range
while (start < now && repeatInterval !== 0) {
start.setTime(start.getTime() + repeatInterval);
end.setTime(end.getTime() + repeatInterval);
if (start <= now && end >= now) {
return true;
}
}
return false;
}
return acc;
}, false);
return maintenanceWindowIsActive;
}
}
export default PulseQueueHelper;
@@ -0,0 +1,170 @@
import Scheduler from "super-simple-scheduler";
const SERVICE_NAME = "JobQueue";
class SuperSimpleQueue {
static SERVICE_NAME = SERVICE_NAME;
constructor({ appSettings, db, logger, helper }) {
this.appSettings = appSettings;
this.db = db;
this.logger = logger;
this.helper = helper;
}
get serviceName() {
return SuperSimpleQueue.SERVICE_NAME;
}
static async create({ appSettings, db, logger, helper }) {
const instance = new SuperSimpleQueue({ appSettings, db, logger, helper });
await instance.init();
return instance;
}
init = async () => {
try {
this.scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
debug: true,
dbUri: this.appSettings.dbConnectionString,
});
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob());
const monitors = await this.db.getAllMonitors();
for (const monitor of monitors) {
await this.addJob(monitor._id, monitor);
}
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.toString(),
template: "monitor-job",
repeat: monitor.interval,
data: monitor.toObject(),
});
};
deleteJob = async (monitor) => {
this.scheduler.removeJob(monitor._id.toString());
};
pauseJob = async (monitor) => {
const result = this.scheduler.pauseJob(monitor._id.toString());
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.toString());
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.toString(), monitor.interval);
};
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,
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,100 @@
const SERVICE_NAME = "JobQueueHelper";
class SuperSimpleQueueHelper {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, logger, networkService, statusService, notificationService }) {
this.db = db;
this.logger = logger;
this.networkService = networkService;
this.statusService = statusService;
this.notificationService = notificationService;
}
get serviceName() {
return SuperSimpleQueueHelper.SERVICE_NAME;
}
getMonitorJob = () => {
return async (monitor) => {
try {
const monitorId = monitor._id;
if (!monitorId) {
throw new Error("No monitor id");
}
const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId);
if (maintenanceWindowActive) {
this.logger.info({
message: `Monitor ${monitorId} is in maintenance window`,
service: SERVICE_NAME,
method: "getMonitorJob",
});
return;
}
const networkResponse = await this.networkService.getStatus(monitor);
if (!networkResponse) {
throw new Error("No network response");
}
const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse);
this.notificationService
.handleNotifications({
...networkResponse,
monitor: updatedMonitor,
prevStatus,
statusChanged,
})
.catch((error) => {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "getMonitorJob",
details: `Error sending notifications for job ${monitor._id}: ${error.message}`,
stack: error.stack,
});
});
} catch (error) {
this.logger.warn({
message: error.message,
service: error.service || SERVICE_NAME,
method: error.method || "getMonitorJob",
stack: error.stack,
});
throw error;
}
};
};
async isInMaintenanceWindow(monitorId) {
const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId);
// Check for active maintenance window:
const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => {
if (window.active) {
const start = new Date(window.start);
const end = new Date(window.end);
const now = new Date();
const repeatInterval = window.repeat || 0;
// If start is < now and end > now, we're in maintenance
if (start <= now && end >= now) return true;
// If maintenance window was set in the past with a repeat,
// we need to advance start and end to see if we are in range
while (start < now && repeatInterval !== 0) {
start.setTime(start.getTime() + repeatInterval);
end.setTime(end.getTime() + repeatInterval);
if (start <= now && end >= now) {
return true;
}
}
return false;
}
return acc;
}, false);
return maintenanceWindowIsActive;
}
}
export default SuperSimpleQueueHelper;
+105
View File
@@ -0,0 +1,105 @@
const SERVICE_NAME = "BufferService";
const BUFFER_TIMEOUT = process.env.NODE_ENV === "development" ? 5000 : 1000 * 60 * 1; // 1 minute
const TYPE_MAP = {
http: "checks",
ping: "checks",
port: "checks",
docker: "checks",
pagespeed: "pagespeedChecks",
hardware: "hardwareChecks",
};
class BufferService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, logger }) {
this.db = db;
this.logger = logger;
this.SERVICE_NAME = SERVICE_NAME;
this.buffers = {
checks: [],
pagespeedChecks: [],
hardwareChecks: [],
};
this.OPERATION_MAP = {
checks: this.db.createChecks,
pagespeedChecks: this.db.createPageSpeedChecks,
hardwareChecks: this.db.createHardwareChecks,
};
this.scheduleNextFlush();
this.logger.info({
message: `Buffer service initialized, flushing every ${BUFFER_TIMEOUT / 1000}s`,
service: this.SERVICE_NAME,
method: "constructor",
});
}
get serviceName() {
return BufferService.SERVICE_NAME;
}
addToBuffer({ check, type }) {
try {
this.buffers[TYPE_MAP[type]].push(check);
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "addToBuffer",
stack: error.stack,
});
}
}
scheduleNextFlush() {
this.bufferTimer = setTimeout(async () => {
try {
await this.flushBuffers();
} catch (error) {
this.logger.error({
message: `Error in flush cycle: ${error.message}`,
service: this.SERVICE_NAME,
method: "scheduleNextFlush",
stack: error.stack,
});
} finally {
// Schedule the next flush only after the current one completes
this.scheduleNextFlush();
}
}, BUFFER_TIMEOUT);
}
async flushBuffers() {
let items = 0;
for (const [bufferName, buffer] of Object.entries(this.buffers)) {
items += buffer.length;
const operation = this.OPERATION_MAP[bufferName];
if (!operation) {
this.logger.error({
message: `No operation found for ${bufferName}`,
service: this.SERVICE_NAME,
method: "flushBuffers",
});
continue;
}
try {
await operation(buffer);
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "flushBuffers",
stack: error.stack,
});
}
this.buffers[bufferName] = [];
}
this.logger.debug({
message: `Flushed ${items} items`,
service: this.SERVICE_NAME,
method: "flushBuffers",
});
items = 0;
}
}
export default BufferService;
+171
View File
@@ -0,0 +1,171 @@
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SERVICE_NAME = "EmailService";
/**
* Represents an email service that can load templates, build, and send emails.
*/
class EmailService {
static SERVICE_NAME = SERVICE_NAME;
/**
* Constructs an instance of the EmailService, initializing template loaders and the email transporter.
* @param {Object} settingsService - The settings service to get email configuration.
* @param {Object} fs - The file system module.
* @param {Object} path - The path module.
* @param {Function} compile - The Handlebars compile function.
* @param {Function} mjml2html - The MJML to HTML conversion function.
* @param {Object} nodemailer - The nodemailer module.
* @param {Object} logger - The logger module.
*/
constructor(settingsService, fs, path, compile, mjml2html, nodemailer, logger) {
this.settingsService = settingsService;
this.fs = fs;
this.path = path;
this.compile = compile;
this.mjml2html = mjml2html;
this.nodemailer = nodemailer;
this.logger = logger;
this.init();
}
get serviceName() {
return EmailService.SERVICE_NAME;
}
init = async () => {
/**
* Loads an email template from the filesystem.
*
* @param {string} templateName - The name of the template to load.
* @returns {Function} A compiled template function that can be used to generate HTML email content.
*/
this.loadTemplate = (templateName) => {
try {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
return this.compile(templateContent);
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "loadTemplate",
stack: error.stack,
});
}
};
/**
* A lookup object to access preloaded email templates.
* @type {Object.<string, Function>}
* TODO Load less used templates in their respective functions
*/
this.templateLookup = {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
serverIsDownTemplate: this.loadTemplate("serverIsDown"),
serverIsUpTemplate: this.loadTemplate("serverIsUp"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
};
/**
* The email transporter used to send emails.
* @type {Object}
*/
};
buildEmail = async (template, context) => {
try {
const mjml = this.templateLookup[template](context);
const html = await this.mjml2html(mjml);
return html.html;
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "buildEmail",
stack: error.stack,
});
}
};
sendEmail = async (to, subject, html, transportConfig) => {
let config;
if (typeof transportConfig !== "undefined") {
config = transportConfig;
} else {
config = await this.settingsService.getDBSettings();
}
const {
systemEmailHost,
systemEmailPort,
systemEmailSecure,
systemEmailPool,
systemEmailUser,
systemEmailAddress,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailTLSServername,
systemEmailIgnoreTLS,
systemEmailRequireTLS,
systemEmailRejectUnauthorized,
} = config;
const emailConfig = {
host: systemEmailHost,
port: Number(systemEmailPort),
secure: systemEmailSecure,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: systemEmailPassword,
},
name: systemEmailConnectionHost || "localhost",
connectionTimeout: 5000,
pool: systemEmailPool,
tls: {
rejectUnauthorized: systemEmailRejectUnauthorized,
ignoreTLS: systemEmailIgnoreTLS,
requireTLS: systemEmailRequireTLS,
servername: systemEmailTLSServername,
},
};
this.transporter = this.nodemailer.createTransport(emailConfig);
try {
await this.transporter.verify();
} catch (error) {
this.logger.warn({
message: "Email transporter verification failed",
service: SERVICE_NAME,
method: "verifyTransporter",
});
return false;
}
try {
const info = await this.transporter.sendMail({
to: to,
from: systemEmailAddress,
subject: subject,
html: html,
});
return info?.messageId;
} catch (error) {
this.logger.error({
message: error.message,
service: SERVICE_NAME,
method: "sendEmail",
stack: error.stack,
});
}
};
}
export default EmailService;
@@ -0,0 +1,101 @@
export class AppError extends Error {
constructor(message, status = 500, service = null, method = null, details = null) {
super(message);
this.status = status;
this.service = service;
this.method = method;
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message, details = null, service = null, method = null) {
super(message, 422, service, method, details);
}
}
class AuthenticationError extends AppError {
constructor(message, details = null, service = null, method = null) {
super(message, 401, service, method, details);
}
}
class AuthorizationError extends AppError {
constructor(message, details = null, service = null, method = null) {
super(message, 403, service, method, details);
}
}
class NotFoundError extends AppError {
constructor(message, details = null, service = null, method = null) {
super(message, 404, service, method, details);
}
}
class ConflictError extends AppError {
constructor(message, details = null, service = null, method = null) {
super(message, 409, service, method, details);
}
}
class DatabaseError extends AppError {
constructor(message, details = null, service = null, method = null) {
super(message, 500, service, method, details);
}
}
class BadRequestError extends AppError {
constructor(message, details = null, service = null, method = null) {
super(message, 400, service, method, details);
}
}
const SERVICE_NAME = "ErrorService";
class ErrorService {
static SERVICE_NAME = SERVICE_NAME;
constructor() {}
get serviceName() {
return ErrorService.SERVICE_NAME;
}
createError = (message, status = 500, service = null, method = null, details = null) => {
return new AppError(message, status, service, method, details);
};
createValidationError = (message, details = null, service = null, method = null) => {
return new ValidationError(message, details, service, method);
};
createAuthenticationError = (message = "Unauthorized", details = null, service = null, method = null) => {
return new AuthenticationError(message, details, service, method);
};
createAuthorizationError = (message, details = null, service = null, method = null) => {
return new AuthorizationError(message, details, service, method);
};
createNotFoundError = (message, details = null, service = null, method = null) => {
return new NotFoundError(message, details, service, method);
};
createConflictError = (message, details = null, service = null, method = null) => {
return new ConflictError(message, details, service, method);
};
createDatabaseError = (message, details = null, service = null, method = null) => {
return new DatabaseError(message, details, service, method);
};
createServerError = (message, details = null, service = null, method = null) => {
return this.createError(message, 500, service, method, details);
};
createBadRequestError = (message = "BadRequest", details = null, service = null, method = null) => {
return new BadRequestError(message, details, service, method);
};
}
export default ErrorService;
+503
View File
@@ -0,0 +1,503 @@
import jmespath from "jmespath";
import https from "https";
const SERVICE_NAME = "NetworkService";
const UPROCK_ENDPOINT = "https://api.uprock.com/checkmate/push";
/**
* Constructs a new NetworkService instance.
*
* @param {Object} axios - The axios instance for HTTP requests.
* @param {Object} ping - The ping utility for network checks.
* @param {Object} logger - The logger instance for logging.
* @param {Object} http - The HTTP utility for network operations.
* @param {Object} net - The net utility for network operations.
*/
class NetworkService {
static SERVICE_NAME = SERVICE_NAME;
constructor(axios, ping, logger, http, Docker, net, stringService, settingsService) {
this.TYPE_PING = "ping";
this.TYPE_HTTP = "http";
this.TYPE_PAGESPEED = "pagespeed";
this.TYPE_HARDWARE = "hardware";
this.TYPE_DOCKER = "docker";
this.TYPE_PORT = "port";
this.SERVICE_NAME = SERVICE_NAME;
this.NETWORK_ERROR = 5000;
this.PING_ERROR = 5001;
this.axios = axios;
this.ping = ping;
this.logger = logger;
this.http = http;
this.Docker = Docker;
this.net = net;
this.stringService = stringService;
this.settingsService = settingsService;
}
get serviceName() {
return NetworkService.SERVICE_NAME;
}
/**
* Times the execution of an asynchronous operation.
*
* @param {Function} operation - The asynchronous operation to be timed.
* @returns {Promise<Object>} An object containing the response, response time, and optionally an error.
* @property {Object|null} response - The response from the operation, or null if an error occurred.
* @property {number} responseTime - The time taken for the operation to complete, in milliseconds.
* @property {Error} [error] - The error object if an error occurred during the operation.
*/
async timeRequest(operation) {
const startTime = Date.now();
try {
const response = await operation();
const endTime = Date.now();
const responseTime = endTime - startTime;
return { response, responseTime };
} catch (error) {
const endTime = Date.now();
const responseTime = endTime - startTime;
return { response: null, responseTime, error };
}
}
/**
* Performs a ping check to a specified host to verify its availability.
* @async
* @param {Object} monitor - The monitor configuration object
* @param {string} monitor.url - The host URL to ping
* @param {string} monitor._id - The unique identifier of the monitor
* @returns {Promise<Object>} A promise that resolves to a ping response object
* @returns {string} pingResponse.monitorId - The ID of the monitor
* @returns {string} pingResponse.type - The type of monitor (always "ping")
* @returns {number} pingResponse.responseTime - The time taken for the ping
* @returns {Object} pingResponse.payload - The raw ping response data
* @returns {boolean} pingResponse.status - Whether the host is alive (true) or not (false)
* @returns {number} pingResponse.code - Status code (200 for success, PING_ERROR for failure)
* @returns {string} pingResponse.message - Success or failure message
* @throws {Error} If there's an error during the ping operation
*/
async requestPing(monitor) {
try {
const url = monitor.url;
const { response, responseTime, error } = await this.timeRequest(() => this.ping.promise.probe(url));
const pingResponse = {
monitorId: monitor._id,
type: "ping",
responseTime,
payload: response,
};
if (error) {
pingResponse.status = false;
pingResponse.code = this.PING_ERROR;
pingResponse.message = "No response";
return pingResponse;
}
pingResponse.code = 200;
pingResponse.status = response.alive;
pingResponse.message = "Success";
return pingResponse;
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestPing";
throw error;
}
}
/**
* Performs an HTTP GET request to a specified URL with optional validation of response data.
* @async
* @param {Object} monitor - The monitor configuration object
* @param {string} monitor.url - The URL to make the HTTP request to
* @param {string} [monitor.secret] - Optional Bearer token for authentication
* @param {string} monitor._id - The unique identifier of the monitor
* @param {string} monitor.name - The name of the monitor
* @param {string} monitor.teamId - The team ID associated with the monitor
* @param {string} monitor.type - The type of monitor
* @param {boolean} [monitor.ignoreTlsErrors] - Whether to ignore TLS certificate errors
* @param {string} [monitor.jsonPath] - Optional JMESPath expression to extract data from JSON response
* @param {string} [monitor.matchMethod] - Method to match response data ('include', 'regex', or exact match)
* @param {string} [monitor.expectedValue] - Expected value to match against response data
* @returns {Promise<Object>} A promise that resolves to an HTTP response object
* @returns {string} httpResponse.monitorId - The ID of the monitor
* @returns {string} httpResponse.teamId - The team ID
* @returns {string} httpResponse.type - The type of monitor
* @returns {number} httpResponse.responseTime - The time taken for the request
* @returns {Object} httpResponse.payload - The response data
* @returns {boolean} httpResponse.status - Whether the request was successful and matched expected value (if specified)
* @returns {number} httpResponse.code - HTTP status code or NETWORK_ERROR
* @returns {string} httpResponse.message - Success or failure message
* @throws {Error} If there's an error during the HTTP request or data validation
*/
async requestHttp(monitor) {
try {
const { url, secret, _id, name, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor;
const config = {};
secret !== undefined && (config.headers = { Authorization: `Bearer ${secret}` });
if (ignoreTlsErrors === true) {
config.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
const { response, responseTime, error } = await this.timeRequest(() => this.axios.get(url, config));
const httpResponse = {
monitorId: _id,
teamId,
type,
responseTime,
payload: response?.data,
};
if (error) {
const code = error.response?.status || this.NETWORK_ERROR;
httpResponse.code = code;
httpResponse.status = false;
httpResponse.message = this.http.STATUS_CODES[code] || this.stringService.httpNetworkError;
return httpResponse;
}
httpResponse.code = response.status;
if (!expectedValue) {
// not configure expected value, return
httpResponse.status = true;
httpResponse.message = this.http.STATUS_CODES[response.status];
return httpResponse;
}
// validate if response data match expected value
let result = response?.data;
this.logger.info({
service: this.SERVICE_NAME,
method: "requestHttp",
message: `Job: [${name}](${_id}) match result with expected value`,
details: { expectedValue, result, jsonPath, matchMethod },
});
if (jsonPath) {
const contentType = response.headers["content-type"];
const isJson = contentType?.includes("application/json");
if (!isJson) {
httpResponse.status = false;
httpResponse.message = this.stringService.httpNotJson;
return httpResponse;
}
try {
result = jmespath.search(result, jsonPath);
} catch (error) {
httpResponse.status = false;
httpResponse.message = this.stringService.httpJsonPathError;
return httpResponse;
}
}
if (result === null || result === undefined) {
httpResponse.status = false;
httpResponse.message = this.stringService.httpEmptyResult;
return httpResponse;
}
let match;
result = typeof result === "object" ? JSON.stringify(result) : result.toString();
if (matchMethod === "include") match = result.includes(expectedValue);
else if (matchMethod === "regex") match = new RegExp(expectedValue).test(result);
else match = result === expectedValue;
httpResponse.status = match;
httpResponse.message = match ? this.stringService.httpMatchSuccess : this.stringService.httpMatchFail;
return httpResponse;
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestHttp";
throw error;
}
}
/**
* Checks the performance of a webpage using Google's PageSpeed Insights API.
* @async
* @param {Object} monitor - The monitor configuration object
* @param {string} monitor.url - The URL of the webpage to analyze
* @returns {Promise<Object|undefined>} A promise that resolves to a pagespeed response object or undefined if API key is missing
* @returns {string} response.monitorId - The ID of the monitor
* @returns {string} response.type - The type of monitor
* @returns {number} response.responseTime - The time taken for the analysis
* @returns {boolean} response.status - Whether the analysis was successful
* @returns {number} response.code - HTTP status code from the PageSpeed API
* @returns {string} response.message - Success or failure message
* @returns {Object} response.payload - The PageSpeed analysis results
* @throws {Error} If there's an error during the PageSpeed analysis
*/
async requestPagespeed(monitor) {
try {
const url = monitor.url;
const updatedMonitor = { ...monitor };
let pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`;
const dbSettings = await this.settingsService.getDBSettings();
if (dbSettings?.pagespeedApiKey) {
pagespeedUrl += `&key=${dbSettings.pagespeedApiKey}`;
} else {
this.logger.warn({
message: "Pagespeed API key not found, job not executed",
service: this.SERVICE_NAME,
method: "requestPagespeed",
details: { url },
});
return;
}
updatedMonitor.url = pagespeedUrl;
return await this.requestHttp(updatedMonitor);
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestPagespeed";
throw error;
}
}
async requestHardware(monitor) {
try {
return await this.requestHttp(monitor);
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestHardware";
throw error;
}
}
/**
* Checks the status of a Docker container by its ID.
* @async
* @param {Object} monitor - The monitor configuration object
* @param {string} monitor.url - The Docker container ID to check
* @param {string} monitor._id - The unique identifier of the monitor
* @param {string} monitor.type - The type of monitor
* @returns {Promise<Object>} A promise that resolves to a docker response object
* @returns {string} dockerResponse.monitorId - The ID of the monitor
* @returns {string} dockerResponse.type - The type of monitor
* @returns {number} dockerResponse.responseTime - The time taken for the container inspection
* @returns {boolean} dockerResponse.status - Whether the container is running (true) or not (false)
* @returns {number} dockerResponse.code - HTTP-like status code (200 for success, NETWORK_ERROR for failure)
* @returns {string} dockerResponse.message - Success or failure message
* @throws {Error} If the container is not found or if there's an error inspecting the container
*/
async requestDocker(monitor) {
try {
const docker = new this.Docker({
socketPath: "/var/run/docker.sock",
handleError: true, // Enable error handling
});
const containers = await docker.listContainers({ all: true });
const containerExists = containers.some((c) => c.Id.startsWith(monitor.url));
if (!containerExists) {
throw new Error(this.stringService.dockerNotFound);
}
const container = docker.getContainer(monitor.url);
const { response, responseTime, error } = await this.timeRequest(() => container.inspect());
const dockerResponse = {
monitorId: monitor._id,
type: monitor.type,
responseTime,
};
if (error) {
dockerResponse.status = false;
dockerResponse.code = error.statusCode || this.NETWORK_ERROR;
dockerResponse.message = error.reason || "Failed to fetch Docker container information";
return dockerResponse;
}
dockerResponse.status = response?.State?.Status === "running" ? true : false;
dockerResponse.code = 200;
dockerResponse.message = "Docker container status fetched successfully";
return dockerResponse;
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestDocker";
throw error;
}
}
/**
* Attempts to establish a TCP connection to a specified host and port.
* @async
* @param {Object} monitor - The monitor configuration object
* @param {string} monitor.url - The host URL to connect to
* @param {number} monitor.port - The port number to connect to
* @param {string} monitor._id - The unique identifier of the monitor
* @param {string} monitor.type - The type of monitor
* @returns {Promise<Object>} A promise that resolves to a port response object
* @returns {string} portResponse.monitorId - The ID of the monitor
* @returns {string} portResponse.type - The type of monitor
* @returns {number} portResponse.responseTime - The time taken for the connection attempt
* @returns {boolean} portResponse.status - Whether the connection was successful
* @returns {number} portResponse.code - HTTP-like status code (200 for success, NETWORK_ERROR for failure)
* @returns {string} portResponse.message - Success or failure message
* @throws {Error} If the connection times out or encounters an error
*/
async requestPort(monitor) {
try {
const { url, port } = monitor;
const { response, responseTime, error } = await this.timeRequest(async () => {
return new Promise((resolve, reject) => {
const socket = this.net.createConnection(
{
host: url,
port,
},
() => {
socket.end();
socket.destroy();
resolve({ success: true });
}
);
socket.setTimeout(5000);
socket.on("timeout", () => {
socket.destroy();
reject(new Error("Connection timeout"));
});
socket.on("error", (err) => {
socket.destroy();
reject(err);
});
});
});
const portResponse = {
monitorId: monitor._id,
type: monitor.type,
responseTime,
};
if (error) {
portResponse.status = false;
portResponse.code = this.NETWORK_ERROR;
portResponse.message = this.stringService.portFail;
return portResponse;
}
portResponse.status = response.success;
portResponse.code = 200;
portResponse.message = this.stringService.portSuccess;
return portResponse;
} catch (error) {
error.service = this.SERVICE_NAME;
error.method = "requestTCP";
throw error;
}
}
/**
* Handles unsupported job types by throwing an error with details.
*
* @param {string} type - The unsupported job type that was provided
* @throws {Error} An error with service name, method name and unsupported type message
*/
handleUnsupportedType(type) {
const err = new Error(`Unsupported type: ${type}`);
err.service = this.SERVICE_NAME;
err.method = "getStatus";
throw err;
}
async requestWebhook(type, url, body) {
try {
const response = await this.axios.post(url, body, {
headers: {
"Content-Type": "application/json",
},
});
return {
type: "webhook",
status: true,
code: response.status,
message: `Successfully sent ${type} notification`,
payload: response.data,
};
} catch (error) {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
method: "requestWebhook",
});
return {
type: "webhook",
status: false,
code: error.response?.status || this.NETWORK_ERROR,
message: `Failed to send ${type} notification`,
payload: error.response?.data,
};
}
}
async requestPagerDuty({ message, routingKey, monitorUrl }) {
try {
const response = await this.axios.post(`https://events.pagerduty.com/v2/enqueue`, {
routing_key: routingKey,
event_action: "trigger",
payload: {
summary: message,
severity: "critical",
source: monitorUrl,
timestamp: new Date().toISOString(),
},
});
if (response?.data?.status !== "success") return false;
return true;
} catch (error) {
error.details = error.response?.data;
error.service = this.SERVICE_NAME;
error.method = "requestPagerDuty";
throw error;
}
}
/**
* Gets the status of a job based on its type and returns the appropriate response.
*
* @param {Object} job - The job object containing the data for the status request.
* @param {Object} job.data - The data object within the job.
* @param {string} job.data.type - The type of the job (e.g., "ping", "http", "pagespeed", "hardware").
* @returns {Promise<Object>} The response object from the appropriate request method.
* @throws {Error} Throws an error if the job type is unsupported.
*/
async getStatus(monitor) {
const type = monitor.type ?? "unknown";
switch (type) {
case this.TYPE_PING:
return await this.requestPing(monitor);
case this.TYPE_HTTP:
return await this.requestHttp(monitor);
case this.TYPE_PAGESPEED:
return await this.requestPagespeed(monitor);
case this.TYPE_HARDWARE:
return await this.requestHardware(monitor);
case this.TYPE_DOCKER:
return await this.requestDocker(monitor);
case this.TYPE_PORT:
return await this.requestPort(monitor);
default:
return this.handleUnsupportedType(type);
}
}
}
export default NetworkService;
@@ -0,0 +1,119 @@
const SERVICE_NAME = "NotificationService";
class NotificationService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ emailService, db, logger, networkService, stringService, notificationUtils }) {
this.emailService = emailService;
this.db = db;
this.logger = logger;
this.networkService = networkService;
this.stringService = stringService;
this.notificationUtils = notificationUtils;
}
get serviceName() {
return NotificationService.SERVICE_NAME;
}
sendNotification = async ({ notification, subject, content, html }) => {
const { type, address } = notification;
if (type === "email") {
const messageId = await this.emailService.sendEmail(address, subject, html);
if (!messageId) return false;
return true;
}
// Create a body for webhooks
let body = { text: content };
if (type === "discord") {
body = { content };
}
if (type === "slack" || type === "discord" || type === "webhook") {
const response = await this.networkService.requestWebhook(type, address, body);
return response.status;
}
if (type === "pager_duty") {
const response = await this.networkService.requestPagerDuty({
message: content,
monitorUrl: subject,
routingKey: address,
});
return response;
}
};
async handleNotifications(networkResponse) {
const { monitor, statusChanged, prevStatus } = networkResponse;
const { type } = monitor;
if (type !== "hardware" && statusChanged === false) return false;
// if prevStatus is undefined, monitor is resuming, we're done
if (type !== "hardware" && prevStatus === undefined) return false;
const notificationIDs = networkResponse.monitor?.notifications ?? [];
if (notificationIDs.length === 0) return false;
if (networkResponse.monitor.type === "hardware") {
const thresholds = networkResponse?.monitor?.thresholds;
if (thresholds === undefined) return false; // No thresholds set, we're done
const metrics = networkResponse?.payload?.data ?? null;
if (metrics === null) return false; // No metrics, we're done
const alerts = await this.notificationUtils.buildHardwareAlerts(networkResponse);
if (alerts.length === 0) return false;
const { subject, html } = await this.notificationUtils.buildHardwareEmail(networkResponse, alerts);
const content = await this.notificationUtils.buildHardwareNotificationMessage(alerts);
const success = await this.notifyAll({ notificationIDs, subject, html, content });
return success;
}
// Status monitors
const { subject, html } = await this.notificationUtils.buildStatusEmail(networkResponse);
const content = await this.notificationUtils.buildWebhookMessage(networkResponse);
const success = this.notifyAll({ notificationIDs, subject, html, content });
return success;
}
async notifyAll({ notificationIDs, subject, html, content }) {
const notifications = await this.db.getNotificationsByIds(notificationIDs);
// Map each notification to a test promise
const promises = notifications.map(async (notification) => {
try {
await this.sendNotification({ notification, subject, content, html });
return true;
} catch (err) {
return false;
}
});
const results = await Promise.all(promises);
return results.every((r) => r === true);
}
async getTestNotification() {
const html = await this.notificationUtils.buildTestEmail();
const content = "This is a test notification";
const subject = "Test Notification";
return { subject, html, content };
}
async testAllNotifications(notificationIDs) {
const { subject, html, content } = await this.getTestNotification();
return this.notifyAll({ notificationIDs, subject, html, content });
}
async sendTestNotification(notification) {
const { subject, html, content } = await this.getTestNotification();
const success = await this.sendNotification({ notification, subject, content, html });
return success;
}
}
export default NotificationService;
@@ -0,0 +1,132 @@
const SERVICE_NAME = "NotificationUtils";
class NotificationUtils {
static SERVICE_NAME = SERVICE_NAME;
constructor({ stringService, emailService }) {
this.stringService = stringService;
this.emailService = emailService;
}
get serviceName() {
return NotificationUtils.SERVICE_NAME;
}
buildTestEmail = async () => {
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
return html;
};
buildStatusEmail = async (networkResponse) => {
const { monitor, status, prevStatus } = networkResponse;
const template = prevStatus === false ? "serverIsUpTemplate" : "serverIsDownTemplate";
const context = { monitor: monitor.name, url: monitor.url };
const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`;
const html = await this.emailService.buildEmail(template, context);
return { subject, html };
};
buildWebhookMessage = (networkResponse) => {
const { monitor, status, code, timestamp } = networkResponse;
// Format timestamp using the local system timezone
const formatTime = (timestamp) => {
const date = new Date(timestamp);
// Get timezone abbreviation and format the date
const timeZoneAbbr = date.toLocaleTimeString("en-US", { timeZoneName: "short" }).split(" ").pop();
// Format the date with readable format
return (
date
.toLocaleString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/(\d+)\/(\d+)\/(\d+),\s/, "$3-$1-$2 ") +
" " +
timeZoneAbbr
);
};
// Get formatted time
const formattedTime = timestamp ? formatTime(timestamp) : formatTime(new Date().getTime());
// Create different messages based on status with extra spacing
let messageText;
if (status === true) {
messageText = this.stringService.monitorUpAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
} else {
messageText = this.stringService.monitorDownAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
}
return messageText;
};
buildHardwareAlerts = async (networkResponse) => {
const monitor = networkResponse?.monitor;
const thresholds = networkResponse?.monitor?.thresholds;
const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds;
const metrics = networkResponse?.payload?.data;
const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics;
const alerts = {
cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false,
memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false,
disk: disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold) ?? false,
};
const alertsToSend = [];
const alertTypes = ["cpu", "memory", "disk"];
for (const type of alertTypes) {
// Iterate over each alert type to see if any need to be decremented
if (alerts[type] === true) {
monitor[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered
if (monitor[`${type}AlertThreshold`] <= 0) {
// If threshold drops below 0, reset and send notification
monitor[`${type}AlertThreshold`] = monitor.alertThreshold;
const formatAlert = {
cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`,
memory: () =>
`Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`,
disk: () =>
`Your current disk usage: ${disk
.map((d, idx) => `(Disk${idx}: ${(d.usage_percent * 100).toFixed(0)}%)`)
.join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`,
};
alertsToSend.push(formatAlert[type]());
}
}
}
await monitor.save();
return alertsToSend;
};
buildHardwareEmail = async (networkResponse, alerts) => {
const { monitor } = networkResponse;
const template = "hardwareIncidentTemplate";
const context = { monitor: monitor.name, url: monitor.url, alerts };
const subject = `Monitor ${monitor.name} infrastructure alerts`;
const html = await this.emailService.buildEmail(template, context);
return { subject, html };
};
buildHardwareNotificationMessage = (alerts) => {
return alerts.map((alert) => alert).join("\n");
};
}
export default NotificationUtils;
+288
View File
@@ -0,0 +1,288 @@
import MonitorStats from "../../db/models/MonitorStats.js";
import { safelyParseFloat } from "../../utils/dataUtils.js";
const SERVICE_NAME = "StatusService";
class StatusService {
static SERVICE_NAME = SERVICE_NAME;
/**
* Creates an instance of StatusService.
*
* @param {Object} db - The database instance.
* @param {Object} logger - The logger instance.
*/
constructor({ db, logger, buffer }) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
}
get serviceName() {
return StatusService.SERVICE_NAME;
}
async updateRunningStats({ monitor, networkResponse }) {
try {
const monitorId = monitor._id;
const { responseTime, status, upt_burnt } = networkResponse;
// Get stats
let stats = await MonitorStats.findOne({ monitorId });
if (!stats) {
stats = new MonitorStats({
monitorId,
avgResponseTime: 0,
totalChecks: 0,
totalUpChecks: 0,
totalDownChecks: 0,
uptimePercentage: 0,
lastCheck: null,
timeSInceLastCheck: 0,
uptBurnt: 0,
});
}
// Update stats
// Last response time
stats.lastResponseTime = responseTime;
// Avg response time:
let avgResponseTime = stats.avgResponseTime;
if (typeof responseTime !== "undefined" && responseTime !== null) {
if (avgResponseTime === 0) {
avgResponseTime = responseTime;
} else {
avgResponseTime = (avgResponseTime * (stats.totalChecks - 1) + responseTime) / stats.totalChecks;
}
}
stats.avgResponseTime = avgResponseTime;
// Total checks
stats.totalChecks++;
if (status === true) {
stats.totalUpChecks++;
// Update the timeSinceLastFailure if needed
if (stats.timeOfLastFailure === 0) {
stats.timeOfLastFailure = new Date().getTime();
}
} else {
stats.totalDownChecks++;
stats.timeOfLastFailure = 0;
}
// Calculate uptime percentage
let uptimePercentage;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
// latest check
stats.lastCheckTimestamp = new Date().getTime();
// UPT burned
if (typeof upt_burnt !== "undefined" && upt_burnt !== null) {
const currentUptBurnt = safelyParseFloat(stats.uptBurnt);
const newUptBurnt = safelyParseFloat(upt_burnt);
stats.uptBurnt = currentUptBurnt + newUptBurnt;
}
await stats.save();
return true;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
message: error.message,
method: "updateRunningStats",
stack: error.stack,
});
return false;
}
}
getStatusString = (status) => {
if (status === true) return "up";
if (status === false) return "down";
return "unknown";
};
/**
* 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) => {
this.insertCheck(networkResponse);
try {
const { monitorId, status, code } = networkResponse;
const monitor = await this.db.getMonitorById(monitorId);
// Update running stats
this.updateRunningStats({ monitor, networkResponse });
// No change in monitor status, return early
if (monitor.status === status)
return {
monitor,
statusChanged: false,
prevStatus: monitor.status,
code,
timestamp: new Date().getTime(),
};
// Monitor status changed, save prev status and update monitor
this.logger.info({
service: this.SERVICE_NAME,
message: `${monitor.name} went from ${this.getStatusString(monitor.status)} to ${this.getStatusString(status)}`,
prevStatus: monitor.status,
newStatus: status,
});
const prevStatus = monitor.status;
monitor.status = status;
await monitor.save();
return {
monitor,
statusChanged: true,
prevStatus,
code,
timestamp: new Date().getTime(),
};
} catch (error) {
error.service = this.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,
} = networkResponse;
const check = {
monitorId,
teamId,
status,
statusCode: code,
responseTime,
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 {
"cumulative-layout-shift": cls = 0,
"speed-index": si = 0,
"first-contentful-paint": fcp = 0,
"largest-contentful-paint": lcp = 0,
"total-blocking-time": tbt = 0,
} = audits;
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.audits = { cls, si, fcp, lcp, tbt };
}
if (type === "hardware") {
const { cpu, memory, disk, host } = 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 ?? {};
}
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 (networkResponse) => {
try {
const check = this.buildCheck(networkResponse);
if (typeof check === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: this.SERVICE_NAME,
method: "insertCheck",
details: networkResponse,
});
return;
}
this.buffer.addToBuffer({ check, type: networkResponse.type });
} catch (error) {
this.logger.error({
message: error.message,
service: error.service || this.SERVICE_NAME,
method: error.method || "insertCheck",
details: error.details || `Error inserting check for monitor: ${networkResponse?.monitorId}`,
stack: error.stack,
});
}
};
}
export default StatusService;