From c12ce2fbcffb904ccac57338db0e545114093e6f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:26:46 -0700 Subject: [PATCH 01/15] add queue types --- server/src/types/settings.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index 3d0d0d931..7bbf57401 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -1,6 +1,9 @@ export const DbTypes = ["mongodb"] as const; export type DbType = (typeof DbTypes)[number]; +export const QueueTypes = ["lessSimpleQueue", "superSimpleQueue"] as const; +export type QueueType = (typeof QueueTypes)[number]; + export interface SettingsThresholds { cpu?: number; memory?: number; From 201ad987ce80faabffde354c473625b668cba4db Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:27:03 -0700 Subject: [PATCH 02/15] Add validation for Queue type --- server/src/validation/envValidation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/validation/envValidation.ts b/server/src/validation/envValidation.ts index 3adf4c90c..ea9ec02c2 100644 --- a/server/src/validation/envValidation.ts +++ b/server/src/validation/envValidation.ts @@ -11,6 +11,7 @@ const envSchema = z.object({ // Database DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"), DB_TYPE: z.enum(DbTypes).default("mongodb"), + QUEUE_TYPE: z.enum(["lessSimpleQueue", "superSimpleQueue"]).default("superSimpleQueue"), // JWT Authentication JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), From f1e16b65ee5f95b5aa5583a13e553f9cbd2751f7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:27:38 -0700 Subject: [PATCH 03/15] Extract job queue interfaces, add less simple queue --- .../infrastructure/JobQueues/IJobQueue.ts | 52 +++ .../JobQueues/LessSimpleQueue.ts | 355 ++++++++++++++ .../infrastructure/JobQueues/QueueHelper.ts | 437 ++++++++++++++++++ .../JobQueues/SuperSimpleQueue.ts | 395 ++++++++++++++++ 4 files changed, 1239 insertions(+) create mode 100644 server/src/service/infrastructure/JobQueues/IJobQueue.ts create mode 100644 server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts create mode 100644 server/src/service/infrastructure/JobQueues/QueueHelper.ts create mode 100644 server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts diff --git a/server/src/service/infrastructure/JobQueues/IJobQueue.ts b/server/src/service/infrastructure/JobQueues/IJobQueue.ts new file mode 100644 index 000000000..a97c8eb6a --- /dev/null +++ b/server/src/service/infrastructure/JobQueues/IJobQueue.ts @@ -0,0 +1,52 @@ +import { Monitor } from "@/types/monitor.js"; + +export type QueueJobFailure = { + monitorId: string | number; + monitorUrl: string | null; + monitorType: string | null; + failedAt: number | null; + failCount: number; + failReason: string | null; +}; + +export type QueueMetrics = { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: QueueJobFailure[]; + totalRuns: number; + totalFailures: number; +}; + +export type QueueJobSummary = { + monitorId: string | number; + monitorUrl: string | null; + monitorType: string | null; + monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | 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; + repeat: number | null; +}; + +export interface IJobQueue { + readonly serviceName: string; + init(): Promise; + addJob(monitorId: string, monitor: Monitor): Promise; + deleteJob(monitor: Monitor): Promise; + pauseJob(monitor: Monitor): Promise; + resumeJob(monitor: Monitor): Promise; + updateJob(monitor: Monitor): Promise; + shutdown(): Promise; + getMetrics(): Promise; + getJobs(): Promise; + flushQueues(): Promise<{ success: boolean }>; +} diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts new file mode 100644 index 000000000..4ef57047f --- /dev/null +++ b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts @@ -0,0 +1,355 @@ +import { IMonitorsRepository } from "@/repositories/index.js"; +import { ILogger } from "@/utils/logger.js"; +import { MongoStore, Scheduler, IJob } from "less-simple-scheduler"; +import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; +import { Monitor, supportsGeoCheck } from "@/types/monitor.js"; +import { type QueueMetrics, IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; +import { EnvConfig } from "@/service/system/settingsService.js"; +const SERVICE_NAME = "JobQueue"; + +// For discriminating job types +type MonitorJob = Omit & { data: Monitor }; +const MONITOR_TEMPLATES = new Set(["monitor-job", "geo-check-job"]); +const isMonitorJob = (job: IJob): job is MonitorJob => MONITOR_TEMPLATES.has(job.template); + +export class LessSimpleQueue implements IJobQueue { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private helper: IQueueHelper; + private monitorsRepository: IMonitorsRepository; + private readonly scheduler: Scheduler; + + constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { + this.logger = logger; + this.helper = helper; + this.monitorsRepository = monitorsRepository; + this.scheduler = scheduler; + } + + get serviceName() { + return LessSimpleQueue.SERVICE_NAME; + } + + private registerListeners = () => { + this.scheduler.on("scheduler:start", () => { + this.logger.info({ + message: "Scheduler started", + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("scheduler:stop", () => { + this.logger.info({ + message: "Scheduler stopped", + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("scheduler:error", (workerId, error) => { + this.logger.error({ + message: `w${workerId} error: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:locked", (workerId, job) => { + this.logger.debug({ + message: `w${workerId} ${job.id} locked`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:abort", (workerId, job, reason) => { + this.logger.warn({ + message: `w${workerId} ${job.id} aborted: ${reason}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:attempt", (workerId, job, attempt) => { + this.logger.debug({ + message: `w${workerId} ${job.id} attempt ${attempt}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:complete", (workerId, job) => { + this.logger.debug({ + message: `w${workerId} ${job.id} completed successfully`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:exhausted", (workerId, job, error) => { + this.logger.error({ + message: `w${workerId} ${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:fail", (workerId, job, error, attempt) => { + this.logger.warn({ + message: `w${workerId} ${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:start", (workerId, job) => { + this.logger.debug({ + message: `w${workerId} ${job.id} started`, + service: SERVICE_NAME, + }); + }); + }; + + static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, envSettings: EnvConfig) { + const store = new MongoStore({ + url: envSettings.dbConnectionString ?? "mongodb://localhost:27017/uptime_db", + }); + const scheduler = new Scheduler(store, { + concurrency: 50, + }); + const instance = new LessSimpleQueue(logger, helper, monitorsRepository, scheduler); + await instance.init(); + + return instance; + } + + init = async () => { + try { + this.registerListeners(); + await this.scheduler.start(); + this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); + this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); + this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); + this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); + 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); + } + + this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, upsert: true }); + this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000, upsert: true }); + + return true; + } catch (error: unknown) { + this.logger.error({ + message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "init", + }); + return false; + } + }; + + addJob = async (monitorId: string, monitor: Monitor) => { + this.scheduler.addJob({ + id: monitorId, + template: "monitor-job", + repeat: monitor.interval, + active: monitor.isActive, + data: monitor, + upsert: true, + }); + + // Return early if we don't need geo checks + if (!supportsGeoCheck(monitor.type)) { + return; + } + + // Add geo check job if enabled for HTTP monitors + if (monitor.geoCheckEnabled) { + this.scheduler.addJob({ + id: `${monitorId}-geo`, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + upsert: true, + }); + } + }; + + deleteJob = async (monitor: Monitor) => { + this.scheduler.removeJob(monitor.id); + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); + }; + + pauseJob = async (monitor: Monitor) => { + const result = await this.scheduler.pauseJob(monitor.id); + if (result === false) { + throw new Error("Failed to pause monitor"); + } + this.scheduler.updateJob(monitor.id, { data: monitor }); + + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to pause geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } + + this.logger.debug({ + message: `Paused monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + }; + + resumeJob = async (monitor: Monitor) => { + const result = await this.scheduler.resumeJob(monitor.id); + if (result === false) { + throw new Error("Failed to resume monitor"); + } + this.scheduler.updateJob(monitor.id, { data: monitor }); + + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to resume geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } + this.logger.debug({ + message: `Resumed monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + }; + + private syncGeoJob = async (monitor: Monitor) => { + const geoJobId = `${monitor.id}-geo`; + const existingGeoJob = await this.scheduler.getJob(geoJobId); + + // If geoChecks have been disabled, or the monitor type doesn't support them, remove + if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) { + if (existingGeoJob) this.scheduler.removeJob(geoJobId); + return; + } + + // If the job exists, update it + if (existingGeoJob) { + this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor }); + return; + } + + // Otherwise, create it + this.scheduler.addJob({ + id: geoJobId, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + upsert: true, + }); + }; + + updateJob = async (monitor: Monitor) => { + this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); + await this.syncGeoJob(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) { + const monitor = isMonitorJob(job) ? job.data : undefined; + acc.jobsWithFailures.push({ + monitorId: job.id, + monitorUrl: monitor?.url || null, + monitorType: monitor?.type || null, + failedAt: job.lastFailedAt ?? null, + failCount, + failReason: job.lastError ?? null, + }); + } + 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) => { + const monitor = isMonitorJob(job) ? job.data : undefined; + return { + monitorId: job.id, + monitorUrl: monitor?.url || null, + monitorType: monitor?.type || null, + monitorInterval: monitor?.interval || null, + monitorGeoInterval: monitor?.geoCheckInterval || null, + monitorActive: monitor?.isActive ?? null, + active: job.active, + repeat: job.repeat ?? null, + lockedAt: job.lockedAt ?? null, + runCount: job.runCount ?? 0, + failCount: job.failCount ?? 0, + failReason: job.lastError ?? null, + lastRunAt: job.lastRunAt ?? null, + lastFinishedAt: job.lastRunAt ?? null, + lastRunTook: job.lockedAt ? null : (job.lastRunAt ?? 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), + }; + }; +} diff --git a/server/src/service/infrastructure/JobQueues/QueueHelper.ts b/server/src/service/infrastructure/JobQueues/QueueHelper.ts new file mode 100644 index 000000000..e162e8d94 --- /dev/null +++ b/server/src/service/infrastructure/JobQueues/QueueHelper.ts @@ -0,0 +1,437 @@ +const SERVICE_NAME = "JobQueueHelper"; +import type { Monitor } from "@/types/monitor.js"; +import { supportsGeoCheck } from "@/types/monitor.js"; +import { AppError } from "@/utils/AppError.js"; +import { + ICheckService, + INetworkService, + INotificationsService, + ISettingsService, + IStatusService, + IncidentService, + type IGeoChecksService, +} from "@/service/index.js"; +import { CHECK_TTL_SENTINEL, type StatusChangeResult } from "@/types/index.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; +import { + IMaintenanceWindowsRepository, + IMonitorsRepository, + ITeamsRepository, + IMonitorStatsRepository, + IChecksRepository, + IIncidentsRepository, + IGeoChecksRepository, +} from "@/repositories/index.js"; +import { ILogger } from "@/utils/logger.js"; +import { IBufferService } from "@/service/index.js"; + +export interface IQueueHelper { + readonly serviceName: string; + getHeartbeatJob(): (monitor: Monitor) => Promise; + getHeartbeatGeoJob(): (monitor: Monitor) => Promise; + getCleanupOrphanedJob(): () => Promise; + getCleanupRetentionJob(): () => Promise; + isInMaintenanceWindow(monitorId: string, teamId: string): Promise; +} + +export interface MonitorActionDecision { + shouldCreateIncident: boolean; + shouldResolveIncident: boolean; + shouldSendNotification: boolean; + incidentReason: "status_down" | "threshold_breach" | null; + notificationReason: "status_change" | "threshold_breach" | null; + thresholdBreaches?: { + cpu?: boolean; + memory?: boolean; + disk?: boolean; + temp?: boolean; + }; +} + +export class QueueHelper implements IQueueHelper { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private networkService: INetworkService; + private statusService: IStatusService; + private notificationsService: INotificationsService; + private checkService: ICheckService; + private settingsService: ISettingsService; + private buffer: IBufferService; + private incidentService: IncidentService; + private maintenanceWindowsRepository: IMaintenanceWindowsRepository; + private monitorsRepository: IMonitorsRepository; + private teamsRepository: ITeamsRepository; + private monitorStatsRepository: IMonitorStatsRepository; + private checksRepository: IChecksRepository; + private incidentsRepository: IIncidentsRepository; + private geoChecksService: IGeoChecksService; + private geoChecksRepository: IGeoChecksRepository; + + constructor( + logger: ILogger, + networkService: INetworkService, + statusService: IStatusService, + notificationsService: INotificationsService, + checkService: ICheckService, + settingsService: ISettingsService, + buffer: IBufferService, + incidentService: IncidentService, + maintenanceWindowsRepository: IMaintenanceWindowsRepository, + monitorsRepository: IMonitorsRepository, + teamsRepository: ITeamsRepository, + monitorStatsRepository: IMonitorStatsRepository, + checksRepository: IChecksRepository, + incidentsRepository: IIncidentsRepository, + geoChecksService: IGeoChecksService, + geoChecksRepository: IGeoChecksRepository + ) { + this.logger = logger; + this.networkService = networkService; + this.statusService = statusService; + this.checkService = checkService; + this.settingsService = settingsService; + this.buffer = buffer; + this.notificationsService = notificationsService; + this.incidentService = incidentService; + this.maintenanceWindowsRepository = maintenanceWindowsRepository; + this.monitorsRepository = monitorsRepository; + this.teamsRepository = teamsRepository; + this.monitorStatsRepository = monitorStatsRepository; + this.checksRepository = checksRepository; + this.incidentsRepository = incidentsRepository; + this.geoChecksService = geoChecksService; + this.geoChecksRepository = geoChecksRepository; + } + + get serviceName() { + return SuperSimpleQueueHelper.SERVICE_NAME; + } + + getHeartbeatJob = () => { + return async (monitor: Monitor) => { + try { + const monitorId = monitor.id; + const teamId = monitor.teamId; + if (!monitorId) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" }); + } + + // Step 1. Check for maintenance window, if found, skip the check + + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Monitor ${monitorId} is in maintenance window`, + service: SERVICE_NAME, + method: "getMonitorJob", + }); + if (monitor.status !== "maintenance") { + await this.monitorsRepository.updateById(monitorId, teamId, { status: "maintenance" }); + } + return; + } + + // Step 2. Request monitor status + const status = await this.networkService.requestStatus(monitor); + if (!status) { + throw new Error("No network response"); + } + + // Step 3. Build check + const check = this.checkService.buildCheck(status); + if (!check) { + this.logger.warn({ + message: `No check could be built for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getMonitorJob", + details: { code: status.code, message: status.message }, + }); + return; + } + // Step 4 Add check to buffer + this.buffer.addToBuffer(check); + // Step 4. Update monitor status + const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); + + // Step 5. Get decisions + const decision = this.evaluateMonitorAction(statusChangeResult); + + // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) + if (decision.shouldSendNotification) { + this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => { + this.logger.error({ + message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, + service: SERVICE_NAME, + method: "getMonitorJob", + stack: error instanceof Error ? error.stack : undefined, + }); + }); + } + + // Step 7. Handle incidents + try { + await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status); + } catch (error: unknown) { + this.logger.warn({ + message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, + service: SERVICE_NAME, + method: "getMonitorJob", + stack: error instanceof Error ? error.stack : undefined, + }); + } + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getMonitorJob", + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } + }; + }; + + getCleanupOrphanedJob = () => { + return async () => { + try { + this.logger.info({ + message: "Starting cleanup of orphaned data", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Get all valid team IDs + const validTeamIds = await this.teamsRepository.findAllTeamIds(); + this.logger.debug({ + message: `Found ${validTeamIds.length} valid teams`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + // Remove orphaned monitors (monitors without a valid team) + const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds); + if (deletedMonitorCount > 0) { + this.logger.info({ + message: `Deleted ${deletedMonitorCount} orphaned monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned monitorStats (stats without a valid monitor) + const allMonitorIds = await this.monitorsRepository.findAllMonitorIds(); + this.logger.debug({ + message: `Found ${allMonitorIds.length} valid monitors`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + + const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedStatsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedStatsCount} orphaned monitor stats`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned checks + const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedChecksCount} orphaned checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned incidents + const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedIncidentsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedIncidentsCount} orphaned incidents`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + // Remove orphaned geo checks + const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedGeoChecksCount > 0) { + this.logger.info({ + message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + + this.logger.info({ + message: "Cleanup of orphaned data completed", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } + }; + }; + + getHeartbeatGeoJob = () => { + return async (monitor: Monitor) => { + try { + const monitorId = monitor.id; + const teamId = monitor.teamId; + + // Step 1: Validate monitor eligibility + if (!monitorId) { + throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" }); + } + + if (!monitor.geoCheckEnabled) { + return; + } + if (!supportsGeoCheck(monitor.type)) { + this.logger.debug({ + message: `Monitor ${monitorId} type does not support geo checks, skipping`, + service: SERVICE_NAME, + method: "getHeartbeatGeoJob", + }); + return; + } + + if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { + this.logger.warn({ + message: `No geo check locations configured for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getHeartbeatGeoJob", + }); + return; + } + + // Step 2: Check for maintenance window + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); + if (maintenanceWindowActive) { + this.logger.debug({ + message: `Monitor ${monitorId} is in maintenance window, skipping geo check`, + service: SERVICE_NAME, + method: "getHeartbeatGeoJob", + }); + return; + } + + // Step 3: Build geo check (handles API calls and polling) + const geoCheck = await this.geoChecksService.buildGeoCheck(monitor); + if (!geoCheck) { + this.logger.warn({ + message: `No geo check could be built for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getHeartbeatGeoJob", + }); + return; + } + + // Step 4: Add geo check to buffer + this.buffer.addGeoCheckToBuffer(geoCheck); + + this.logger.debug({ + message: `Geo check job executed for monitor ${monitorId}`, + service: SERVICE_NAME, + method: "getHeartbeatGeoJob", + }); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getHeartbeatGeoJob", + stack: error instanceof Error ? error.stack : undefined, + }); + // Don't throw - geo check failures shouldn't crash the job scheduler + } + }; + }; + + async isInMaintenanceWindow(monitorId: string, teamId: string) { + const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); + const now = new Date(); + return maintenanceWindows.some((window) => isWindowActive(window, now)); + } + + getCleanupRetentionJob = () => { + return async () => { + try { + const settings = await this.settingsService.getDBSettings(); + + const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS + + if (checkTTL === CHECK_TTL_SENTINEL) { + this.logger.info({ + message: `Check TTL is set to unlimited, skipping cleanup`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + return; + } + const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000; + const cutoffDate = new Date(Date.now() - checkTTLInMs); + const deleteCount = await this.checkService.deleteOlderThan(cutoffDate); + this.logger.info({ + message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`, + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + }); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getCleanupRetentionJob", + stack: error instanceof Error ? error.stack : undefined, + }); + } + }; + }; + + private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { + const { monitor, statusChanged, prevStatus } = statusChangeResult; + + // Initialize result + const decision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + }; + + if (!statusChanged) { + return decision; + } + + if (monitor.status === "down") { + // Monitor went down (unreachable) + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (monitor.status === "breached") { + // Hardware monitor exceeded thresholds + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "threshold_breach"; + decision.notificationReason = "threshold_breach"; + } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { + // Monitor recovered from down or breached state + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } + + return decision; + } +} diff --git a/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts new file mode 100644 index 000000000..b98ecb2db --- /dev/null +++ b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts @@ -0,0 +1,395 @@ +import { IMonitorsRepository } from "@/repositories/index.js"; +import { ILogger } from "@/utils/logger.js"; +import Scheduler from "super-simple-scheduler"; +import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; +import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js"; +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; + monitorGeoInterval: number | null; + monitorActive: boolean | 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; + repeat: number | null; +}; + +export interface ISuperSimpleQueue { + readonly serviceName: string; + init(): Promise; + addJob(monitorId: string, monitor: Monitor): Promise; + deleteJob(monitor: Monitor): Promise; + pauseJob(monitor: Monitor): Promise; + resumeJob(monitor: Monitor): Promise; + updateJob(monitor: Monitor): Promise; + shutdown(): Promise; + getMetrics(): Promise; + getJobs(): Promise; + flushQueues(): Promise<{ success: boolean }>; +} + +export class SuperSimpleQueue implements ISuperSimpleQueue { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private helper: IQueueHelper; + private monitorsRepository: IMonitorsRepository; + private readonly scheduler: Scheduler; + + constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { + this.logger = logger; + this.helper = helper; + this.monitorsRepository = monitorsRepository; + this.scheduler = scheduler; + } + + get serviceName() { + return SuperSimpleQueue.SERVICE_NAME; + } + + private registerListeners = () => { + this.scheduler.on("scheduler:start", () => { + this.logger.info({ + message: "Scheduler started", + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("scheduler:stop", () => { + this.logger.info({ + message: "Scheduler stopped", + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("scheduler:error", (error) => { + this.logger.error({ + message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:abort", (job, reason) => { + this.logger.warn({ + message: `${job.id} aborted: ${reason}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:attempt", (job, attempt) => { + this.logger.debug({ + message: `${job.id} attempt ${attempt}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:complete", (job) => { + this.logger.debug({ + message: `${job.id} completed successfully`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:exhausted", (job, error) => { + this.logger.error({ + message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:fail", (job, error, attempt) => { + this.logger.warn({ + message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:start", (job) => { + this.logger.debug({ + message: `${job.id} started`, + service: SERVICE_NAME, + }); + }); + }; + + static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository) { + const scheduler = new Scheduler({ + concurrency: 50, + // storeType: "mongo", + // storeType: "redis", + // dbUri: envSettings.dbConnectionString, + }); + const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler); + await instance.init(); + + return instance; + } + + init = async () => { + try { + this.registerListeners(); + + this.scheduler.start(); + this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); + this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); + this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); + this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); + 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); + } + + this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true }); + this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 }); + + return true; + } catch (error: unknown) { + this.logger.error({ + message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "init", + }); + return false; + } + }; + + addJob = async (monitorId: string, monitor: Monitor) => { + this.scheduler.addJob({ + id: monitorId, + template: "monitor-job", + repeat: monitor.interval, + active: monitor.isActive, + data: monitor, + }); + + // Return early if we don't need geo checks + if (!supportsGeoCheck(monitor.type)) { + return; + } + + // Add geo check job if enabled for HTTP monitors + if (monitor.geoCheckEnabled) { + this.scheduler.addJob({ + id: `${monitorId}-geo`, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + }); + } + }; + + deleteJob = async (monitor: Monitor) => { + this.scheduler.removeJob(monitor.id); + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); + }; + + pauseJob = async (monitor: Monitor) => { + const result = await this.scheduler.pauseJob(monitor.id); + if (result === false) { + throw new Error("Failed to pause monitor"); + } + this.scheduler.updateJob(monitor.id, { data: monitor }); + + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to pause geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } + + this.logger.debug({ + message: `Paused monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + }; + + resumeJob = async (monitor: Monitor) => { + const result = await this.scheduler.resumeJob(monitor.id); + if (result === false) { + throw new Error("Failed to resume monitor"); + } + this.scheduler.updateJob(monitor.id, { data: monitor }); + + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to resume geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } + this.logger.debug({ + message: `Resumed monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + }; + + private syncGeoJob = async (monitor: Monitor) => { + const geoJobId = `${monitor.id}-geo`; + const existingGeoJob = await this.scheduler.getJob(geoJobId); + + // If geoChecks have been disabled, or the monitor type doesn't support them, remove + if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) { + if (existingGeoJob) this.scheduler.removeJob(geoJobId); + return; + } + + // If the job exists, update it + if (existingGeoJob) { + this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor }); + return; + } + + // Otherwise, create it + this.scheduler.addJob({ + id: geoJobId, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + }); + }; + + updateJob = async (monitor: Monitor) => { + this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); + await this.syncGeoJob(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: string; + monitorType: MonitorType; + 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, + monitorGeoInterval: job.data?.geoCheckInterval || null, + monitorActive: job.data?.isActive ?? null, + active: job.active, + repeat: job.repeat ?? null, + 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), + }; + }; +} From bf25a65d8ede230f1f4d415d25ba668652ee5495 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:28:01 -0700 Subject: [PATCH 04/15] add configurable queue selection to service config, default to in memory --- server/src/config/services.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 4eb6c1081..04966d6e2 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -7,8 +7,9 @@ import { EmailService, BufferService, GlobalPingService, + LessSimpleQueue, SuperSimpleQueue, - SuperSimpleQueueHelper, + QueueHelper, NotificationsService, TagsService, StatusService, @@ -37,7 +38,7 @@ import { INetworkService, IEmailService, IBufferService, - ISuperSimpleQueue, + IJobQueue, INotificationsService, ITagsService, IStatusService, @@ -131,7 +132,7 @@ export type InitializedServices = { emailService: IEmailService; bufferService: IBufferService; statusService: IStatusService; - jobQueue: ISuperSimpleQueue; + jobQueue: IJobQueue; userService: IUserService; checkService: ICheckService; geoChecksService: IGeoChecksService; @@ -325,7 +326,7 @@ export const initializeServices = async ({ const tagsService = new TagsService(tagsRepository, monitorsRepository); - const superSimpleQueueHelper = new SuperSimpleQueueHelper( + const queueHelper = new QueueHelper( logger, networkService, statusService, @@ -344,7 +345,14 @@ export const initializeServices = async ({ geoChecksRepository ); - const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository); + let jobQueue: IJobQueue; + switch (envSettings.queueType) { + case "lessSimpleQueue": + jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, envSettings); + break; + default: + jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository); + } // Business services const userService = new UserService({ @@ -353,7 +361,7 @@ export const initializeServices = async ({ settingsService, logger, jwt, - jobQueue: superSimpleQueue, + jobQueue, monitorsRepository, usersRepository, invitesRepository, @@ -373,7 +381,7 @@ export const initializeServices = async ({ maintenanceWindowsRepository, }); const monitorService = new MonitorService({ - jobQueue: superSimpleQueue, + jobQueue, logger, games, monitorsRepository, @@ -393,7 +401,7 @@ export const initializeServices = async ({ emailService, bufferService, statusService, - jobQueue: superSimpleQueue, + jobQueue, userService, checkService, geoChecksService, From 0d255c896287aed49d2a37896ae85c3a5ba2ea32 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:28:18 -0700 Subject: [PATCH 05/15] update imports --- server/src/service/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 9bac381f6..f94f82a19 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -10,8 +10,10 @@ export * from "@/service/business/statusPageService.js"; export * from "@/service/business/userService.js"; // Infrastructure services -export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; -export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +export * from "@/service/infrastructure/JobQueues/IJobQueue.js"; +export * from "@/service/infrastructure/JobQueues/SuperSimpleQueue.js"; +export * from "@/service/infrastructure/JobQueues/LessSimpleQueue.js"; +export * from "@/service/infrastructure/JobQueues/QueueHelper.js"; export * from "@/service/infrastructure/notificationMessageBuilder.js"; export * from "@/service/infrastructure/bufferService.js"; export * from "@/service/infrastructure/emailService.js"; From f476710c2995c3ee20732f219d8f4363afe91181 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:28:49 -0700 Subject: [PATCH 06/15] add queue type to settings service --- server/src/service/system/settingsService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index 32b95d261..97960df3a 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -1,5 +1,5 @@ import { ISettingsRepository } from "@/repositories/index.js"; -import { type DbType, Settings, SettingsUpdate } from "@/types/index.js"; +import { type DbType, type QueueType, Settings, SettingsUpdate } from "@/types/index.js"; import { AppError } from "@/utils/AppError.js"; import { ValidatedEnv } from "@/validation/envValidation.js"; import type { StringValue } from "ms"; @@ -13,6 +13,7 @@ export type EnvConfig = { clientHost: string; dbConnectionString: string; dbType: DbType; + queueType: QueueType; statusPageThemesEnabled: boolean; }; @@ -39,6 +40,7 @@ export class SettingsService implements ISettingsService { clientHost: env.CLIENT_HOST, dbConnectionString: env.DB_CONNECTION_STRING, dbType: env.DB_TYPE, + queueType: env.QUEUE_TYPE, statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED, }; } From cd91e838171f517094538c79417e8ea6cb664daf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:28:59 -0700 Subject: [PATCH 07/15] Remove old queues --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 395 ---------------- .../SuperSimpleQueueHelper.ts | 437 ------------------ 2 files changed, 832 deletions(-) delete mode 100644 server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts delete mode 100644 server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts deleted file mode 100644 index 8ecfecc65..000000000 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { IMonitorsRepository } from "@/repositories/index.js"; -import { ILogger } from "@/utils/logger.js"; -import Scheduler from "super-simple-scheduler"; -import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; -import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js"; -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; - monitorGeoInterval: number | null; - monitorActive: boolean | 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; - repeat: number | null; -}; - -export interface ISuperSimpleQueue { - readonly serviceName: string; - init(): Promise; - addJob(monitorId: string, monitor: Monitor): Promise; - deleteJob(monitor: Monitor): Promise; - pauseJob(monitor: Monitor): Promise; - resumeJob(monitor: Monitor): Promise; - updateJob(monitor: Monitor): Promise; - shutdown(): Promise; - getMetrics(): Promise; - getJobs(): Promise; - flushQueues(): Promise<{ success: boolean }>; -} - -export class SuperSimpleQueue implements ISuperSimpleQueue { - static SERVICE_NAME = SERVICE_NAME; - - private logger: ILogger; - private helper: ISuperSimpleQueueHelper; - private monitorsRepository: IMonitorsRepository; - private readonly scheduler: Scheduler; - - constructor(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { - this.logger = logger; - this.helper = helper; - this.monitorsRepository = monitorsRepository; - this.scheduler = scheduler; - } - - get serviceName() { - return SuperSimpleQueue.SERVICE_NAME; - } - - private registerListeners = () => { - this.scheduler.on("scheduler:start", () => { - this.logger.info({ - message: "Scheduler started", - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("scheduler:stop", () => { - this.logger.info({ - message: "Scheduler stopped", - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("scheduler:error", (error) => { - this.logger.error({ - message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:abort", (job, reason) => { - this.logger.warn({ - message: `${job.id} aborted: ${reason}`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:attempt", (job, attempt) => { - this.logger.debug({ - message: `${job.id} attempt ${attempt}`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:complete", (job) => { - this.logger.debug({ - message: `${job.id} completed successfully`, - service: SERVICE_NAME, - }); - }); - - this.scheduler.on("job:exhausted", (job, error) => { - this.logger.error({ - message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:fail", (job, error, attempt) => { - this.logger.warn({ - message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - stack: error instanceof Error ? error.stack : undefined, - }); - }); - - this.scheduler.on("job:start", (job) => { - this.logger.debug({ - message: `${job.id} started`, - service: SERVICE_NAME, - }); - }); - }; - - static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) { - const scheduler = new Scheduler({ - concurrency: 50, - // storeType: "mongo", - // storeType: "redis", - // dbUri: envSettings.dbConnectionString, - }); - const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler); - await instance.init(); - - return instance; - } - - init = async () => { - try { - this.registerListeners(); - - this.scheduler.start(); - this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); - this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); - this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); - this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); - 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); - } - - this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true }); - this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 }); - - return true; - } catch (error: unknown) { - this.logger.error({ - message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, - service: SERVICE_NAME, - method: "init", - }); - return false; - } - }; - - addJob = async (monitorId: string, monitor: Monitor) => { - this.scheduler.addJob({ - id: monitorId, - template: "monitor-job", - repeat: monitor.interval, - active: monitor.isActive, - data: monitor, - }); - - // Return early if we don't need geo checks - if (!supportsGeoCheck(monitor.type)) { - return; - } - - // Add geo check job if enabled for HTTP monitors - if (monitor.geoCheckEnabled) { - this.scheduler.addJob({ - id: `${monitorId}-geo`, - template: "geo-check-job", - repeat: monitor.geoCheckInterval, - active: monitor.isActive, - data: monitor, - }); - } - }; - - deleteJob = async (monitor: Monitor) => { - this.scheduler.removeJob(monitor.id); - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); - }; - - pauseJob = async (monitor: Monitor) => { - const result = await this.scheduler.pauseJob(monitor.id); - if (result === false) { - throw new Error("Failed to pause monitor"); - } - this.scheduler.updateJob(monitor.id, { data: monitor }); - - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) { - const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); - if (geoRes === false) { - this.logger.error({ - message: `Failed to pause geo check job for monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - } - this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); - } - - this.logger.debug({ - message: `Paused monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - }; - - resumeJob = async (monitor: Monitor) => { - const result = await this.scheduler.resumeJob(monitor.id); - if (result === false) { - throw new Error("Failed to resume monitor"); - } - this.scheduler.updateJob(monitor.id, { data: monitor }); - - const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) { - const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); - if (geoRes === false) { - this.logger.error({ - message: `Failed to resume geo check job for monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - } - this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); - } - this.logger.debug({ - message: `Resumed monitor ${monitor.id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - }; - - private syncGeoJob = async (monitor: Monitor) => { - const geoJobId = `${monitor.id}-geo`; - const existingGeoJob = await this.scheduler.getJob(geoJobId); - - // If geoChecks have been disabled, or the monitor type doesn't support them, remove - if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) { - if (existingGeoJob) this.scheduler.removeJob(geoJobId); - return; - } - - // If the job exists, update it - if (existingGeoJob) { - this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor }); - return; - } - - // Otherwise, create it - this.scheduler.addJob({ - id: geoJobId, - template: "geo-check-job", - repeat: monitor.geoCheckInterval, - active: monitor.isActive, - data: monitor, - }); - }; - - updateJob = async (monitor: Monitor) => { - this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); - await this.syncGeoJob(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: string; - monitorType: MonitorType; - 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, - monitorGeoInterval: job.data?.geoCheckInterval || null, - monitorActive: job.data?.isActive ?? null, - active: job.active, - repeat: job.repeat ?? null, - 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), - }; - }; -} diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts deleted file mode 100644 index 603379190..000000000 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ /dev/null @@ -1,437 +0,0 @@ -const SERVICE_NAME = "JobQueueHelper"; -import type { Monitor } from "@/types/monitor.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; -import { AppError } from "@/utils/AppError.js"; -import { - ICheckService, - INetworkService, - INotificationsService, - ISettingsService, - IStatusService, - IncidentService, - type IGeoChecksService, -} from "@/service/index.js"; -import { CHECK_TTL_SENTINEL, type StatusChangeResult } from "@/types/index.js"; -import { isWindowActive } from "@/utils/maintenanceWindow.js"; -import { - IMaintenanceWindowsRepository, - IMonitorsRepository, - ITeamsRepository, - IMonitorStatsRepository, - IChecksRepository, - IIncidentsRepository, - IGeoChecksRepository, -} from "@/repositories/index.js"; -import { ILogger } from "@/utils/logger.js"; -import { IBufferService } from "@/service/index.js"; - -export interface ISuperSimpleQueueHelper { - readonly serviceName: string; - getHeartbeatJob(): (monitor: Monitor) => Promise; - getHeartbeatGeoJob(): (monitor: Monitor) => Promise; - getCleanupOrphanedJob(): () => Promise; - getCleanupRetentionJob(): () => Promise; - isInMaintenanceWindow(monitorId: string, teamId: string): Promise; -} - -export interface MonitorActionDecision { - shouldCreateIncident: boolean; - shouldResolveIncident: boolean; - shouldSendNotification: boolean; - incidentReason: "status_down" | "threshold_breach" | null; - notificationReason: "status_change" | "threshold_breach" | null; - thresholdBreaches?: { - cpu?: boolean; - memory?: boolean; - disk?: boolean; - temp?: boolean; - }; -} - -export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { - static SERVICE_NAME = SERVICE_NAME; - - private logger: ILogger; - private networkService: INetworkService; - private statusService: IStatusService; - private notificationsService: INotificationsService; - private checkService: ICheckService; - private settingsService: ISettingsService; - private buffer: IBufferService; - private incidentService: IncidentService; - private maintenanceWindowsRepository: IMaintenanceWindowsRepository; - private monitorsRepository: IMonitorsRepository; - private teamsRepository: ITeamsRepository; - private monitorStatsRepository: IMonitorStatsRepository; - private checksRepository: IChecksRepository; - private incidentsRepository: IIncidentsRepository; - private geoChecksService: IGeoChecksService; - private geoChecksRepository: IGeoChecksRepository; - - constructor( - logger: ILogger, - networkService: INetworkService, - statusService: IStatusService, - notificationsService: INotificationsService, - checkService: ICheckService, - settingsService: ISettingsService, - buffer: IBufferService, - incidentService: IncidentService, - maintenanceWindowsRepository: IMaintenanceWindowsRepository, - monitorsRepository: IMonitorsRepository, - teamsRepository: ITeamsRepository, - monitorStatsRepository: IMonitorStatsRepository, - checksRepository: IChecksRepository, - incidentsRepository: IIncidentsRepository, - geoChecksService: IGeoChecksService, - geoChecksRepository: IGeoChecksRepository - ) { - this.logger = logger; - this.networkService = networkService; - this.statusService = statusService; - this.checkService = checkService; - this.settingsService = settingsService; - this.buffer = buffer; - this.notificationsService = notificationsService; - this.incidentService = incidentService; - this.maintenanceWindowsRepository = maintenanceWindowsRepository; - this.monitorsRepository = monitorsRepository; - this.teamsRepository = teamsRepository; - this.monitorStatsRepository = monitorStatsRepository; - this.checksRepository = checksRepository; - this.incidentsRepository = incidentsRepository; - this.geoChecksService = geoChecksService; - this.geoChecksRepository = geoChecksRepository; - } - - get serviceName() { - return SuperSimpleQueueHelper.SERVICE_NAME; - } - - getHeartbeatJob = () => { - return async (monitor: Monitor) => { - try { - const monitorId = monitor.id; - const teamId = monitor.teamId; - if (!monitorId) { - throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getMonitorJob" }); - } - - // Step 1. Check for maintenance window, if found, skip the check - - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); - if (maintenanceWindowActive) { - this.logger.debug({ - message: `Monitor ${monitorId} is in maintenance window`, - service: SERVICE_NAME, - method: "getMonitorJob", - }); - if (monitor.status !== "maintenance") { - await this.monitorsRepository.updateById(monitorId, teamId, { status: "maintenance" }); - } - return; - } - - // Step 2. Request monitor status - const status = await this.networkService.requestStatus(monitor); - if (!status) { - throw new Error("No network response"); - } - - // Step 3. Build check - const check = this.checkService.buildCheck(status); - if (!check) { - this.logger.warn({ - message: `No check could be built for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getMonitorJob", - details: { code: status.code, message: status.message }, - }); - return; - } - // Step 4 Add check to buffer - this.buffer.addToBuffer(check); - // Step 4. Update monitor status - const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); - - // Step 5. Get decisions - const decision = this.evaluateMonitorAction(statusChangeResult); - - // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) - if (decision.shouldSendNotification) { - this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: unknown) => { - this.logger.error({ - message: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - }); - } - - // Step 7. Handle incidents - try { - await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status); - } catch (error: unknown) { - this.logger.warn({ - message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - } - } catch (error: unknown) { - this.logger.warn({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getMonitorJob", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - }; - - getCleanupOrphanedJob = () => { - return async () => { - try { - this.logger.info({ - message: "Starting cleanup of orphaned data", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - // Get all valid team IDs - const validTeamIds = await this.teamsRepository.findAllTeamIds(); - this.logger.debug({ - message: `Found ${validTeamIds.length} valid teams`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - // Remove orphaned monitors (monitors without a valid team) - const deletedMonitorCount = await this.monitorsRepository.deleteByTeamIdsNotIn(validTeamIds); - if (deletedMonitorCount > 0) { - this.logger.info({ - message: `Deleted ${deletedMonitorCount} orphaned monitors`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned monitorStats (stats without a valid monitor) - const allMonitorIds = await this.monitorsRepository.findAllMonitorIds(); - this.logger.debug({ - message: `Found ${allMonitorIds.length} valid monitors`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - - const deletedStatsCount = await this.monitorStatsRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedStatsCount > 0) { - this.logger.info({ - message: `Deleted ${deletedStatsCount} orphaned monitor stats`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned checks - const deletedChecksCount = await this.checksRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedChecksCount > 0) { - this.logger.info({ - message: `Deleted ${deletedChecksCount} orphaned checks`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned incidents - const deletedIncidentsCount = await this.incidentsRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedIncidentsCount > 0) { - this.logger.info({ - message: `Deleted ${deletedIncidentsCount} orphaned incidents`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - // Remove orphaned geo checks - const deletedGeoChecksCount = await this.geoChecksRepository.deleteByMonitorIdsNotIn(allMonitorIds); - if (deletedGeoChecksCount > 0) { - this.logger.info({ - message: `Deleted ${deletedGeoChecksCount} orphaned geo checks`, - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } - - this.logger.info({ - message: "Cleanup of orphaned data completed", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - }); - } catch (error: unknown) { - this.logger.warn({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getCleanupOrphanedJob", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - }; - - getHeartbeatGeoJob = () => { - return async (monitor: Monitor) => { - try { - const monitorId = monitor.id; - const teamId = monitor.teamId; - - // Step 1: Validate monitor eligibility - if (!monitorId) { - throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "getHeartbeatGeoJob" }); - } - - if (!monitor.geoCheckEnabled) { - return; - } - if (!supportsGeoCheck(monitor.type)) { - this.logger.debug({ - message: `Monitor ${monitorId} type does not support geo checks, skipping`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - if (!monitor.geoCheckLocations || monitor.geoCheckLocations.length === 0) { - this.logger.warn({ - message: `No geo check locations configured for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 2: Check for maintenance window - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId, teamId); - if (maintenanceWindowActive) { - this.logger.debug({ - message: `Monitor ${monitorId} is in maintenance window, skipping geo check`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 3: Build geo check (handles API calls and polling) - const geoCheck = await this.geoChecksService.buildGeoCheck(monitor); - if (!geoCheck) { - this.logger.warn({ - message: `No geo check could be built for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - return; - } - - // Step 4: Add geo check to buffer - this.buffer.addGeoCheckToBuffer(geoCheck); - - this.logger.debug({ - message: `Geo check job executed for monitor ${monitorId}`, - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getHeartbeatGeoJob", - stack: error instanceof Error ? error.stack : undefined, - }); - // Don't throw - geo check failures shouldn't crash the job scheduler - } - }; - }; - - async isInMaintenanceWindow(monitorId: string, teamId: string) { - const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); - const now = new Date(); - return maintenanceWindows.some((window) => isWindowActive(window, now)); - } - - getCleanupRetentionJob = () => { - return async () => { - try { - const settings = await this.settingsService.getDBSettings(); - - const checkTTL = settings.checkTTL; // Check TTL is in DAYS, not MS - - if (checkTTL === CHECK_TTL_SENTINEL) { - this.logger.info({ - message: `Check TTL is set to unlimited, skipping cleanup`, - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - }); - return; - } - const checkTTLInMs = checkTTL * 24 * 60 * 60 * 1000; - const cutoffDate = new Date(Date.now() - checkTTLInMs); - const deleteCount = await this.checkService.deleteOlderThan(cutoffDate); - this.logger.info({ - message: `Deleted ${deleteCount} checks older than ${cutoffDate.toISOString()}`, - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "getCleanupRetentionJob", - stack: error instanceof Error ? error.stack : undefined, - }); - } - }; - }; - - private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { - const { monitor, statusChanged, prevStatus } = statusChangeResult; - - // Initialize result - const decision: MonitorActionDecision = { - shouldCreateIncident: false, - shouldResolveIncident: false, - shouldSendNotification: false, - incidentReason: null, - notificationReason: null, - }; - - if (!statusChanged) { - return decision; - } - - if (monitor.status === "down") { - // Monitor went down (unreachable) - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "status_down"; - decision.notificationReason = "status_change"; - } else if (monitor.status === "breached") { - // Hardware monitor exceeded thresholds - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "threshold_breach"; - decision.notificationReason = "threshold_breach"; - } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { - // Monitor recovered from down or breached state - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } - - return decision; - } -} From 4dc3a756a81f27d33bad91e5f54dc52e06328fe6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:32:58 -0700 Subject: [PATCH 08/15] add less-simple-scheduler, bump dependencies --- server/.env.example | 1 + server/package-lock.json | 135 +++++++++++++++++++++++++++++++++++++-- server/package.json | 2 + 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/server/.env.example b/server/.env.example index 20c579f5c..f6dee2347 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,6 +11,7 @@ LOG_LEVEL=debug # Database # -------- DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db +QUEUE_TYPE=lessSimpleQueue # JWT Authentication # ------------------ diff --git a/server/package-lock.json b/server/package-lock.json index 540efdacc..78d7f6ffa 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -31,6 +31,7 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", + "less-simple-scheduler": "2.1.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", @@ -75,6 +76,7 @@ "lint-staged": "^16.2.7", "nodemon": "^3.1.11", "prettier": "^3.3.3", + "prettier-plugin-ini": "1.3.0", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "tsc-alias": "1.8.16", @@ -5468,16 +5470,42 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -10388,6 +10416,95 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/less-simple-scheduler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.1.0.tgz", + "integrity": "sha512-DA43B3lAkcE2LEknKRZK3hFwoRYM1htOfeJ+JImXsFrwIqqxKb4PyWiv0lH3xUBqeAn1Pfody9fgC77lV3y4yQ==", + "license": "ISC", + "dependencies": { + "mongodb": "7.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/less-simple-scheduler/node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/less-simple-scheduler/node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/less-simple-scheduler/node_modules/mongodb": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz", + "integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/less-simple-scheduler/node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -12991,6 +13108,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-ini": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-ini/-/prettier-plugin-ini-1.3.0.tgz", + "integrity": "sha512-kX1KnPwITCTtYV0MTBZQKuFLxcNcbHOS9r7Ta1jHLLvRvZRTzHHqX0MEZMrCkedjLuxpQSgzr4aC5IRd+R3FPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier": ">=3.0.0-alpha.3" + } + }, "node_modules/pretty-format": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", diff --git a/server/package.json b/server/package.json index 2382c5cc9..9ee5e3aad 100755 --- a/server/package.json +++ b/server/package.json @@ -51,6 +51,7 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", + "less-simple-scheduler": "2.1.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", @@ -95,6 +96,7 @@ "lint-staged": "^16.2.7", "nodemon": "^3.1.11", "prettier": "^3.3.3", + "prettier-plugin-ini": "1.3.0", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "tsc-alias": "1.8.16", From 0a3232419a9c22ff9e79753ed2b62debd530be1b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:33:15 -0700 Subject: [PATCH 09/15] update prettierrc --- server/.prettierrc | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/.prettierrc b/server/.prettierrc index 681669aa7..932d6330b 100755 --- a/server/.prettierrc +++ b/server/.prettierrc @@ -12,5 +12,14 @@ "quoteProps": "as-needed", "arrowParens": "always", "trailingComma": "es5", - "htmlWhitespaceSensitivity": "css" + "htmlWhitespaceSensitivity": "css", + "plugins": ["prettier-plugin-ini"], + "overrides": [ + { + "files": [".env", ".env.*"], + "options": { + "parser": "ini" + } + } + ] } From 4280a6c2c384a25b925fc1b40d62172f487cf0da Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 10:34:42 -0700 Subject: [PATCH 10/15] update env vars --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17ad26903..c2e536da5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,7 @@ LOG_LEVEL="debug" - `ORIGIN`: Origin for CORS purposes - `TOKEN_TTL`: Token time to live (in vercel/ms format) - `LOG_LEVEL`: Debug level (debug, info, warn, error) +- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`) Start the backend server: From 0342870bbd68ec2cbd449adb998aaf422a3871b5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 11:08:32 -0700 Subject: [PATCH 11/15] lastRunAt -> lastFinishedAT --- server/package-lock.json | 8 ++++---- server/package.json | 2 +- .../src/service/infrastructure/JobQueues/IJobQueue.ts | 1 - .../infrastructure/JobQueues/LessSimpleQueue.ts | 10 +++++----- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 78d7f6ffa..131589613 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -31,7 +31,7 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "less-simple-scheduler": "2.1.0", + "less-simple-scheduler": "2.2.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", @@ -10417,9 +10417,9 @@ "license": "MIT" }, "node_modules/less-simple-scheduler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.1.0.tgz", - "integrity": "sha512-DA43B3lAkcE2LEknKRZK3hFwoRYM1htOfeJ+JImXsFrwIqqxKb4PyWiv0lH3xUBqeAn1Pfody9fgC77lV3y4yQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.2.0.tgz", + "integrity": "sha512-0bbFohlkcWZY2wfkGfDiYEbDxkshj0RuskkL0RLUDMn1Y3WjoPqy29PAnKNtnHG+DQqn0hMjZ0e3hVFJ4k76oA==", "license": "ISC", "dependencies": { "mongodb": "7.2.0" diff --git a/server/package.json b/server/package.json index 9ee5e3aad..ea518a298 100755 --- a/server/package.json +++ b/server/package.json @@ -51,7 +51,7 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "less-simple-scheduler": "2.1.0", + "less-simple-scheduler": "2.2.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", diff --git a/server/src/service/infrastructure/JobQueues/IJobQueue.ts b/server/src/service/infrastructure/JobQueues/IJobQueue.ts index a97c8eb6a..dba8e3f1a 100644 --- a/server/src/service/infrastructure/JobQueues/IJobQueue.ts +++ b/server/src/service/infrastructure/JobQueues/IJobQueue.ts @@ -30,7 +30,6 @@ export type QueueJobSummary = { runCount: number; failCount: number; failReason: string | null; - lastRunAt: number | null; lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts index 4ef57047f..b50151406 100644 --- a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts @@ -282,11 +282,11 @@ export class LessSimpleQueue implements IJobQueue { const runCount = job.runCount ?? 0; const failCount = job.failCount ?? 0; const lastFailedAt = job.lastFailedAt ?? 0; - const lastRunAt = job.lastRunAt ?? 0; + const lastFinishedAt = job.lastFinishedAt ?? 0; acc.totalRuns += runCount; acc.totalFailures += failCount; acc.jobs++; - if (failCount > 0 && lastFailedAt >= lastRunAt) { + if (failCount > 0 && lastFailedAt >= lastFinishedAt) { acc.failingJobs++; } @@ -323,6 +323,7 @@ export class LessSimpleQueue implements IJobQueue { const jobs = await this.scheduler.getJobs(); return jobs.map((job) => { const monitor = isMonitorJob(job) ? job.data : undefined; + return { monitorId: job.id, monitorUrl: monitor?.url || null, @@ -336,9 +337,8 @@ export class LessSimpleQueue implements IJobQueue { runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, failReason: job.lastError ?? null, - lastRunAt: job.lastRunAt ?? null, - lastFinishedAt: job.lastRunAt ?? null, - lastRunTook: job.lockedAt ? null : (job.lastRunAt ?? 0) - (job.lastRunAt ?? 0), + lastFinishedAt: job.lastFinishedAt ?? null, + lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastStartedAt ? null : job.lastFinishedAt - job.lastStartedAt, lastFailedAt: job.lastFailedAt ?? null, }; }); From 2db88ac192672633a718643efd182d02b66d23ee Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 11:20:19 -0700 Subject: [PATCH 12/15] add lastRunAt back --- server/src/service/infrastructure/JobQueues/IJobQueue.ts | 1 + server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/src/service/infrastructure/JobQueues/IJobQueue.ts b/server/src/service/infrastructure/JobQueues/IJobQueue.ts index dba8e3f1a..a97c8eb6a 100644 --- a/server/src/service/infrastructure/JobQueues/IJobQueue.ts +++ b/server/src/service/infrastructure/JobQueues/IJobQueue.ts @@ -30,6 +30,7 @@ export type QueueJobSummary = { runCount: number; failCount: number; failReason: string | null; + lastRunAt: number | null; lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts index b50151406..dec796b29 100644 --- a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts @@ -337,6 +337,7 @@ export class LessSimpleQueue implements IJobQueue { runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, failReason: job.lastError ?? null, + lastRunAt: job.lastStartedAt ?? null, lastFinishedAt: job.lastFinishedAt ?? null, lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastStartedAt ? null : job.lastFinishedAt - job.lastStartedAt, lastFailedAt: job.lastFailedAt ?? null, From 6ddee6e85bed913595e1c6fc8cbcfea2b42b2f7c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 11:23:31 -0700 Subject: [PATCH 13/15] fix imports --- server/openapi.json | 75 ++++++++++++++++++- .../src/service/business/incidentService.ts | 2 +- server/src/service/business/monitorService.ts | 6 +- server/src/service/business/userService.ts | 6 +- .../infrastructure/JobQueues/QueueHelper.ts | 2 +- .../notificationMessageBuilder.ts | 2 +- .../infrastructure/notificationsService.ts | 2 +- 7 files changed, 84 insertions(+), 11 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 26c58fa29..233aad746 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -392,6 +392,12 @@ "type": "string" } }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, "secret": { "type": "string" }, @@ -469,6 +475,7 @@ "ignoreTlsErrors", "useAdvancedMatching", "notifications", + "tags", "cpuAlertThreshold", "memoryAlertThreshold", "diskAlertThreshold", @@ -2362,6 +2369,16 @@ "profilePicture": { "type": "string" }, + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "newPassword": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, "profileImage": { "type": "string", "format": "binary" @@ -5207,7 +5224,8 @@ "type": "array", "items": { "type": "string" - } + }, + "minItems": 1 }, "duration": { "type": "number" @@ -5478,6 +5496,24 @@ "required": false, "name": "filter", "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "required": false, + "name": "tags", + "in": "query" } ], "responses": { @@ -5672,6 +5708,24 @@ "name": "type", "in": "query" }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "required": false, + "name": "tags", + "in": "query" + }, { "schema": { "type": "boolean" @@ -6924,6 +6978,12 @@ "type": "string" } }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, "secret": { "type": "string" }, @@ -7586,6 +7646,13 @@ }, "default": [] }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, "secret": { "type": "string" }, @@ -8104,6 +8171,12 @@ "type": "string" } }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, "secret": { "type": "string" }, diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 2070a8937..1606cc95d 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -5,7 +5,7 @@ import { AppError } from "@/utils/AppError.js"; import { getDateForRange } from "@/utils/dataUtils.js"; import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; import type { Incident, IncidentSummary, User } from "@/types/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; import type { ILogger } from "@/utils/logger.js"; diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 2023a1d7a..46b81a2ca 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -23,7 +23,7 @@ import type { import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" }; import { AppError } from "@/utils/AppError.js"; import type { ImportedMonitor } from "@/validation/monitorValidation.js"; -import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; +import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "MonitorService"; @@ -96,7 +96,7 @@ export interface IMonitorService { export class MonitorService implements IMonitorService { static SERVICE_NAME = SERVICE_NAME; - private jobQueue: ISuperSimpleQueue; + private jobQueue: IJobQueue; private logger: ILogger; private games: GamesMap; private monitorsRepository: IMonitorsRepository; @@ -117,7 +117,7 @@ export class MonitorService implements IMonitorService { statusPagesRepository, incidentsRepository, }: { - jobQueue: ISuperSimpleQueue; + jobQueue: IJobQueue; logger: ILogger; games: GamesMap; monitorsRepository: IMonitorsRepository; diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 7acca5742..8b2c52159 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -10,7 +10,7 @@ import type { User } from "@/types/index.js"; import { canManageRole, type UserRole } from "@/types/user.js"; import bcrypt from "bcryptjs"; import { AppError } from "@/utils/AppError.js"; -import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; +import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; import { IEmailService } from "@/service/infrastructure/emailService.js"; import { EnvConfig, ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; @@ -55,7 +55,7 @@ export class UserService implements IUserService { private settingsService: ISettingsService; private logger: ILogger; private jwt: JwtType; - private jobQueue: ISuperSimpleQueue; + private jobQueue: IJobQueue; private crypto: CryptoType; private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; @@ -83,7 +83,7 @@ export class UserService implements IUserService { settingsService: ISettingsService; logger: ILogger; jwt: JwtType; - jobQueue: ISuperSimpleQueue; + jobQueue: IJobQueue; monitorsRepository: IMonitorsRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; diff --git a/server/src/service/infrastructure/JobQueues/QueueHelper.ts b/server/src/service/infrastructure/JobQueues/QueueHelper.ts index e162e8d94..ea86feae0 100644 --- a/server/src/service/infrastructure/JobQueues/QueueHelper.ts +++ b/server/src/service/infrastructure/JobQueues/QueueHelper.ts @@ -105,7 +105,7 @@ export class QueueHelper implements IQueueHelper { } get serviceName() { - return SuperSimpleQueueHelper.SERVICE_NAME; + return QueueHelper.SERVICE_NAME; } getHeartbeatJob = () => { diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index 934163b2a..bc1d7ac82 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -1,5 +1,5 @@ import type { HardwareStatusPayload, Monitor, MonitorStatusResponse } from "@/types/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import type { NotificationMessage, NotificationType, diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 90e9d637a..d1956a724 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -2,7 +2,7 @@ import type { Monitor, MonitorStatusResponse, Notification } from "@/types/index import type { NotificationMessage } from "@/types/notificationMessage.js"; import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import type { ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; From 458442f364a1e4c9ede42e0ec0d37795a368ceaa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 11:31:11 -0700 Subject: [PATCH 14/15] fix env sample, fix log queue, use dbType for enum --- server/.env.example | 2 +- .../src/service/infrastructure/JobQueues/LessSimpleQueue.ts | 2 +- server/src/validation/envValidation.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/.env.example b/server/.env.example index f6dee2347..92798b56b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,7 +11,7 @@ LOG_LEVEL=debug # Database # -------- DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db -QUEUE_TYPE=lessSimpleQueue +QUEUE_TYPE=superSimpleQueue # JWT Authentication # ------------------ diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts index dec796b29..954f6ca91 100644 --- a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts @@ -144,7 +144,7 @@ export class LessSimpleQueue implements IJobQueue { return true; } catch (error: unknown) { this.logger.error({ - message: `Failed to initialize SuperSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, + message: `Failed to initialize LessSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, service: SERVICE_NAME, method: "init", }); diff --git a/server/src/validation/envValidation.ts b/server/src/validation/envValidation.ts index ea9ec02c2..7eea70fc5 100644 --- a/server/src/validation/envValidation.ts +++ b/server/src/validation/envValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { DbTypes } from "@/types/settings.js"; +import { DbTypes, QueueTypes } from "@/types/settings.js"; import { booleanCoercion } from "./shared.js"; const envSchema = z.object({ @@ -11,7 +11,7 @@ const envSchema = z.object({ // Database DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"), DB_TYPE: z.enum(DbTypes).default("mongodb"), - QUEUE_TYPE: z.enum(["lessSimpleQueue", "superSimpleQueue"]).default("superSimpleQueue"), + QUEUE_TYPE: z.enum(QueueTypes).default("superSimpleQueue"), // JWT Authentication JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), From da92b9d067cf8e1746ed8ae8b75a4f5f5ff18e37 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 29 May 2026 11:33:31 -0700 Subject: [PATCH 15/15] update tests --- server/test/helpers/heartbeatTestHarness.ts | 4 ++-- server/test/integration/incidentLifecycle.test.ts | 2 +- server/test/unit/services/incidentService.test.ts | 2 +- .../test/unit/services/notificationMessageBuilder.test.ts | 2 +- server/test/unit/services/notificationsService.test.ts | 2 +- server/test/unit/services/superSimpleQueue.test.ts | 2 +- server/test/unit/services/superSimpleQueueHelper.test.ts | 6 +++--- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index ec0c3714e..9b0592afc 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -1,5 +1,5 @@ import { jest } from "@jest/globals"; -import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import { QueueHelper } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts"; import { StatusService } from "../../src/service/infrastructure/statusService.ts"; import { IncidentService } from "../../src/service/business/incidentService.ts"; import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts"; @@ -143,7 +143,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; - const helper = new SuperSimpleQueueHelper( + const helper = new QueueHelper( logger, networkService as any, statusService as any, diff --git a/server/test/integration/incidentLifecycle.test.ts b/server/test/integration/incidentLifecycle.test.ts index d2429a812..0fe1ddcf8 100644 --- a/server/test/integration/incidentLifecycle.test.ts +++ b/server/test/integration/incidentLifecycle.test.ts @@ -5,7 +5,7 @@ import { createMockLogger } from "../helpers/createMockLogger.ts"; import type { IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts"; import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts"; import type { Monitor } from "../../src/types/monitor.ts"; -import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/incidentService.test.ts b/server/test/unit/services/incidentService.test.ts index 0a17cbd79..4274f67d8 100644 --- a/server/test/unit/services/incidentService.test.ts +++ b/server/test/unit/services/incidentService.test.ts @@ -5,7 +5,7 @@ import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; import type { Monitor } from "../../../src/types/monitor.ts"; import type { Incident } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/notificationMessageBuilder.test.ts b/server/test/unit/services/notificationMessageBuilder.test.ts index d10bd1604..4403a8bb3 100644 --- a/server/test/unit/services/notificationMessageBuilder.test.ts +++ b/server/test/unit/services/notificationMessageBuilder.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach } from "@jest/globals"; import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index cdc1ad320..e8c3a0d97 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 685dd71c7..660d0b8f7 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -35,7 +35,7 @@ jest.unstable_mockModule("super-simple-scheduler", () => ({ default: MockScheduler, })); -const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts"); +const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/JobQueues/SuperSimpleQueue.ts"); const createQueueHelper = () => ({ getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), diff --git a/server/test/unit/services/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts index 4d8ecfbc0..7075051eb 100644 --- a/server/test/unit/services/superSimpleQueueHelper.test.ts +++ b/server/test/unit/services/superSimpleQueueHelper.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import { QueueHelper } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; import type { Monitor } from "../../../src/types/monitor.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; @@ -59,7 +59,7 @@ const createHelper = (overrides?: Record) => { ...overrides, }; - const helper = new SuperSimpleQueueHelper( + const helper = new QueueHelper( defaults.logger as any, defaults.networkService as any, defaults.statusService as any, @@ -94,7 +94,7 @@ const makeMonitor = (overrides?: Partial): Monitor => // ── Tests ──────────────────────────────────────────────────────────────────── -describe("SuperSimpleQueueHelper", () => { +describe("QueueHelper", () => { describe("serviceName", () => { it("returns JobQueueHelper", () => { const { helper } = createHelper();