From dfcb3b4b2138690650451428e965fff6c164ee6f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 4 Aug 2026 11:27:32 -0700 Subject: [PATCH 1/4] slim check snapshot model/types --- client/src/Types/Check.ts | 39 +++++- .../src/db/migration/0010_slimRecentChecks.ts | 128 ++++++++++++++++++ server/src/db/migration/index.ts | 2 + .../checks/check.repository.interface.ts | 2 - .../domain/checks/check.repository.mongo.ts | 28 ---- server/src/domain/checks/check.snapshot.ts | 85 ++++++++++++ server/src/domain/checks/check.type.ts | 30 +++- server/src/domain/monitors/monitor.model.ts | 99 ++------------ .../monitors/monitor.repository.interface.ts | 1 - .../monitors/monitor.repository.mongo.ts | 91 +------------ server/src/service/statusService.ts | 29 +--- .../helpers/InMemoryMonitorsRepository.ts | 4 - 12 files changed, 295 insertions(+), 243 deletions(-) create mode 100644 server/src/db/migration/0010_slimRecentChecks.ts create mode 100644 server/src/domain/checks/check.snapshot.ts diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index c930eacad..e32762d95 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -250,7 +250,44 @@ export interface ChecksSummary { downChecks: number; } -export type CheckSnapshot = Omit; +export type SnapshotCpuInfo = Pick< + CheckCpuInfo, + | "physical_core" + | "logical_core" + | "frequency" + | "current_frequency" + | "temperature" + | "usage_percent" +>; +export type SnapshotMemoryInfo = Pick< + CheckMemoryInfo, + "total_bytes" | "used_bytes" | "usage_percent" +>; +export type SnapshotDiskInfo = Pick< + CheckDiskInfo, + "device" | "total_bytes" | "used_bytes" | "usage_percent" +>; +export type SnapshotHostInfo = Pick; + +export type CheckSnapshot = Pick< + Check, + | "id" + | "status" + | "responseTime" + | "statusCode" + | "message" + | "createdAt" + | "accessibility" + | "bestPractices" + | "seo" + | "performance" + | "audits" +> & { + cpu?: SnapshotCpuInfo; + memory?: SnapshotMemoryInfo; + disk?: SnapshotDiskInfo[]; + host?: SnapshotHostInfo; +}; export interface HasResponseTime { responseTime: number; } diff --git a/server/src/db/migration/0010_slimRecentChecks.ts b/server/src/db/migration/0010_slimRecentChecks.ts new file mode 100644 index 000000000..2b8f8e661 --- /dev/null +++ b/server/src/db/migration/0010_slimRecentChecks.ts @@ -0,0 +1,128 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +const KEEP_KEYS = [ + "id", + "status", + "responseTime", + "statusCode", + "message", + "createdAt", + "cpu", + "memory", + "disk", + "host", + "accessibility", + "bestPractices", + "seo", + "performance", + "audits", +]; + +/** + * Slim stored recentChecks snapshots down to the fields the slim CheckSnapshot type keeps. + * The schema change only affects new writes; paused monitors never rotate their snapshots out. + * Idempotent: re-running on already-slim snapshots is a no-op re-pick. + */ +export async function slimRecentChecks(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:SlimRecentChecks"; + try { + logger.info({ service: SERVICE_NAME, message: "Slimming recentChecks snapshots" }); + + const result = await MonitorModel.updateMany({ "recentChecks.0": { $exists: true } }, [ + { + $set: { + recentChecks: { + $map: { + input: "$recentChecks", + as: "snap", + in: { + $mergeObjects: [ + // 1) keep whitelisted top-level keys only (drops timings/errors/capture/net) + { + $arrayToObject: { + $filter: { + input: { $objectToArray: "$$snap" }, + as: "kv", + cond: { $in: ["$$kv.k", KEEP_KEYS] }, + }, + }, + }, + // 2) where a nested group exists, overwrite it with its slim pick + { + $cond: [ + { $eq: [{ $type: "$$snap.cpu" }, "object"] }, + { + cpu: { + physical_core: "$$snap.cpu.physical_core", + logical_core: "$$snap.cpu.logical_core", + frequency: "$$snap.cpu.frequency", + current_frequency: "$$snap.cpu.current_frequency", + temperature: "$$snap.cpu.temperature", + usage_percent: "$$snap.cpu.usage_percent", + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.memory" }, "object"] }, + { + memory: { + total_bytes: "$$snap.memory.total_bytes", + used_bytes: "$$snap.memory.used_bytes", + usage_percent: "$$snap.memory.usage_percent", + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.disk" }, "array"] }, + { + disk: { + $map: { + input: "$$snap.disk", + as: "d", + in: { + device: "$$d.device", + total_bytes: "$$d.total_bytes", + used_bytes: "$$d.used_bytes", + usage_percent: "$$d.usage_percent", + }, + }, + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.host" }, "object"] }, + { + host: { + os: "$$snap.host.os", + platform: "$$snap.host.platform", + pretty_name: "$$snap.host.pretty_name", + }, + }, + {}, + ], + }, + ], + }, + }, + }, + }, + }, + ]); + + logger.info({ service: SERVICE_NAME, message: `Slimmed recentChecks on ${result.modifiedCount} monitors` }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error slimming recentChecks: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index f59af32b7..24c330ef5 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -8,6 +8,7 @@ import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplic import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; +import { slimRecentChecks } from "./0010_slimRecentChecks.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -25,6 +26,7 @@ const migrations: MigrationEntry[] = [ { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, + { name: "0010_slimRecentChecks", execute: slimRecentChecks }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index e6b7c6368..6889d8d15 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -7,7 +7,6 @@ import type { UptimeChecksResult, } from "@/domain/checks/check.type.js"; import type { MonitorType } from "@/domain/monitors/monitor.type.js"; -import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js"; import { CheckFilter, DateRange } from "@/types/query.js"; export interface IChecksRepository { @@ -34,7 +33,6 @@ export interface IChecksRepository { teamId: string, filter?: CheckFilter ): Promise; - findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise; findByDateRangeAndMonitorId( monitorId: string, dateRange: DateRange, diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index fcc967828..78f0b9344 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -27,8 +27,6 @@ import { NETWORK_ERROR } from "@/types/network.js"; const SERVICE_NAME = "StatusService"; -export type LatestChecksMap = Record; - class MongoChecksRepository implements IChecksRepository { static SERVICE_NAME = SERVICE_NAME; @@ -288,32 +286,6 @@ class MongoChecksRepository implements IChecksRepository { return { checksCount, checks: this.mapDocuments(checks) }; }; - findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { - if (monitorIds.length === 0) { - return {}; - } - const limitPerMonitor = options?.limitPerMonitor ?? 25; - const dateFilter = new Date(Date.now() - 24 * 60 * 60 * 1000); - const results = await Promise.all( - monitorIds.map(async (monitorId) => { - const docs = await CheckModel.find({ - "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), - createdAt: { $gte: dateFilter }, - }) - .sort({ createdAt: -1 }) - .limit(limitPerMonitor) - .lean(); - return { monitorId, docs }; - }) - ); - - const mapped = results.reduce((acc, { monitorId, docs }) => { - acc[monitorId] = docs.map((doc: CheckDocument) => this.toEntity(doc)); - return acc; - }, {}); - return mapped; - }; - findByDateRangeAndMonitorId = async (monitorId: string, dateRange: DateRange, options?: { type?: MonitorType }) => { const monitorObjectId = new mongoose.Types.ObjectId(monitorId); const start = getDateForRange(dateRange); diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts new file mode 100644 index 000000000..3ee208d04 --- /dev/null +++ b/server/src/domain/checks/check.snapshot.ts @@ -0,0 +1,85 @@ +import type { + Check, + CheckAudits, + CheckCpuInfo, + CheckDiskInfo, + CheckHostInfo, + CheckMemoryInfo, + CheckSnapshot, + SnapshotCpuInfo, + SnapshotDiskInfo, + SnapshotHostInfo, + SnapshotMemoryInfo, +} from "@/domain/checks/check.type.js"; +import { toDateString } from "@/utils/mongoMappers.js"; + +export type CheckSnapshotSource = Pick< + Check, + "id" | "status" | "responseTime" | "statusCode" | "message" | "accessibility" | "bestPractices" | "seo" | "performance" +> & { + createdAt: string | Date; + cpu?: CheckCpuInfo; + memory?: CheckMemoryInfo; + disk?: CheckDiskInfo[]; + host?: CheckHostInfo; + audits?: CheckAudits; +}; + +const mapCpu = (cpu?: CheckCpuInfo): SnapshotCpuInfo | undefined => + cpu && { + physical_core: cpu.physical_core, + logical_core: cpu.logical_core, + frequency: cpu.frequency, + current_frequency: cpu.current_frequency, + temperature: cpu.temperature, + usage_percent: cpu.usage_percent, + }; + +const mapMemory = (memory?: CheckMemoryInfo): SnapshotMemoryInfo | undefined => + memory && { + total_bytes: memory.total_bytes, + used_bytes: memory.used_bytes, + usage_percent: memory.usage_percent, + }; + +const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined => + disk?.map((entry) => ({ + device: entry.device, + total_bytes: entry.total_bytes, + used_bytes: entry.used_bytes, + usage_percent: entry.usage_percent, + })); + +const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined => + host && { + os: host.os, + platform: host.platform, + pretty_name: host.pretty_name, + }; + +const mapAudits = (audits?: CheckAudits): CheckAudits | undefined => + audits && { + cls: audits.cls, + si: audits.si, + fcp: audits.fcp, + lcp: audits.lcp, + tbt: audits.tbt, + }; + +export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ({ + id: source.id, + status: source.status, + responseTime: source.responseTime, + statusCode: source.statusCode, + message: source.message, + createdAt: toDateString(source.createdAt), + cpu: mapCpu(source.cpu), + memory: mapMemory(source.memory), + disk: mapDisk(source.disk), + host: mapHost(source.host), + accessibility: source.accessibility, + bestPractices: source.bestPractices, + seo: source.seo, + performance: source.performance, + audits: mapAudits(source.audits), +}); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index d6e60c3e3..4e69a5241 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -168,8 +168,36 @@ export interface HasResponseTime { responseTime: number; } -export type CheckSnapshot = Omit; +export type SnapshotCpuInfo = Pick< + CheckCpuInfo, + "physical_core" | "logical_core" | "frequency" | "current_frequency" | "temperature" | "usage_percent" +>; +export type SnapshotMemoryInfo = Pick; +export type SnapshotDiskInfo = Pick; +export type SnapshotHostInfo = Pick; +export type CheckSnapshot = Pick< + Check, + // uptime charts and down check tooltips + | "id" + | "status" + | "responseTime" + | "statusCode" + | "message" + | "createdAt" + // pagespeed + | "accessibility" + | "bestPractices" + | "seo" + | "performance" + | "audits" +> & { + // hardware monitors only + cpu?: SnapshotCpuInfo; + memory?: SnapshotMemoryInfo; + disk?: SnapshotDiskInfo[]; + host?: SnapshotHostInfo; +}; export interface HardwareDiskStats { name: string; readSpeed: number; diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 446631f45..ffd4238d0 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -3,15 +3,11 @@ import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monito import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js"; import type { CheckAudits, - CheckCaptureInfo, - CheckCpuInfo, - CheckDiskInfo, - CheckErrorInfo, - CheckHostInfo, - CheckMemoryInfo, - CheckNetworkInterfaceInfo, - GotTimings, ILighthouseAudit, + SnapshotCpuInfo, + SnapshotDiskInfo, + SnapshotHostInfo, + SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -37,121 +33,46 @@ interface MonitorDocument extends MonitorDocumentBase { lastEvaluatedAt: number; // epoch ms } -const snapshotTimingPhasesSchema = new Schema( - { - wait: { type: Number }, - dns: { type: Number }, - tcp: { type: Number }, - tls: { type: Number }, - request: { type: Number }, - firstByte: { type: Number }, - download: { type: Number }, - total: { type: Number }, - }, - { _id: false } -); - -const snapshotTimingsSchema = new Schema( - { - start: { type: Number }, - socket: { type: Number }, - lookup: { type: Number }, - connect: { type: Number }, - secureConnect: { type: Number }, - upload: { type: Number }, - response: { type: Number }, - end: { type: Number }, - phases: { type: snapshotTimingPhasesSchema }, - }, - { _id: false } -); - -const snapshotCpuSchema = new Schema( +const snapshotCpuSchema = new Schema( { physical_core: { type: Number }, logical_core: { type: Number }, frequency: { type: Number }, current_frequency: { type: Number }, temperature: { type: [Number] }, - free_percent: { type: Number }, usage_percent: { type: Number }, }, { _id: false } ); -const snapshotMemorySchema = new Schema( +const snapshotMemorySchema = new Schema( { total_bytes: { type: Number }, - available_bytes: { type: Number }, used_bytes: { type: Number }, usage_percent: { type: Number }, }, { _id: false } ); -const snapshotDiskSchema = new Schema( +const snapshotDiskSchema = new Schema( { device: { type: String }, - mountpoint: { type: String }, total_bytes: { type: Number }, - free_bytes: { type: Number }, used_bytes: { type: Number }, usage_percent: { type: Number }, - total_inodes: { type: Number }, - free_inodes: { type: Number }, - used_inodes: { type: Number }, - inodes_usage_percent: { type: Number }, - read_bytes: { type: Number }, - write_bytes: { type: Number }, - read_time: { type: Number }, - write_time: { type: Number }, }, { _id: false } ); -const snapshotHostSchema = new Schema( +const snapshotHostSchema = new Schema( { os: { type: String }, platform: { type: String }, - kernel_version: { type: String }, pretty_name: { type: String }, }, { _id: false } ); -const snapshotErrorSchema = new Schema( - { - metric: { type: [String] }, - err: { type: String }, - }, - { _id: false } -); - -const snapshotCaptureSchema = new Schema( - { - version: { type: String }, - mode: { type: String }, - }, - { _id: false } -); - -const snapshotNetworkInterfaceSchema = new Schema( - { - name: { type: String }, - bytes_sent: { type: Number }, - bytes_recv: { type: Number }, - packets_sent: { type: Number }, - packets_recv: { type: Number }, - err_in: { type: Number }, - err_out: { type: Number }, - drop_in: { type: Number }, - drop_out: { type: Number }, - fifo_in: { type: Number }, - fifo_out: { type: Number }, - }, - { _id: false } -); - const snapshotLighthouseAuditSchema = new Schema( { id: { type: String }, @@ -180,16 +101,12 @@ const checkSnapshotSchema = new Schema( id: { type: String, required: true }, status: { type: Boolean, required: true }, responseTime: { type: Number }, - timings: { type: snapshotTimingsSchema }, statusCode: { type: Number }, message: { type: String }, cpu: { type: snapshotCpuSchema }, memory: { type: snapshotMemorySchema }, disk: { type: [snapshotDiskSchema] }, host: { type: snapshotHostSchema }, - errors: { type: [snapshotErrorSchema] }, - capture: { type: snapshotCaptureSchema }, - net: { type: [snapshotNetworkInterfaceSchema] }, accessibility: { type: Number }, bestPractices: { type: Number }, seo: { type: Number }, diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index d3dc0597e..e743e423f 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -30,7 +30,6 @@ export interface IMonitorsRepository { findByTeamId(teamId: string, config: TeamQueryConfig): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; findByIds(monitorIds: string[]): Promise; - findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index b96ec3907..68065f332 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -7,6 +7,7 @@ import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; import { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "@/domain/monitors/monitor.repository.interface.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js"; class MongoMonitorsRepository implements IMonitorsRepository { create = async (monitor: Monitor, teamId: string, userId: string) => { const monitorModel = new MonitorModel({ ...monitor, teamId, userId }); @@ -131,70 +132,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { return documents.map((doc) => this.toEntity(doc)); }; - findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { - if (!monitorIds.length) { - return []; - } - - const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - - const pipeline: PipelineStage[] = [ - { $match: { _id: { $in: objectIds } } }, - { - $lookup: { - from: "checks", - let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $eq: ["$metadata.monitorId", "$$monitorId"] } } }, { $sort: { createdAt: -1 } }, { $limit: checksCount }], - as: "checks", - }, - }, - { - $lookup: { - from: "maintenancewindows", - let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $in: ["$$monitorId", { $ifNull: ["$monitorIds", []] }] } } }], - as: "maintenanceWindows", - }, - }, - { - $lookup: { - from: "monitorstats", - localField: "_id", - foreignField: "monitorId", - as: "stats", - }, - }, - { - $addFields: { - isMaintenance: { - $reduce: { - input: "$maintenanceWindows", - initialValue: false, - in: { - $or: [ - "$$value", - { - $and: [{ $eq: ["$$this.active", true] }, { $lte: ["$$this.start", "$$NOW"] }, { $gte: ["$$this.end", "$$NOW"] }], - }, - ], - }, - }, - }, - uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] }, - }, - }, - { - $project: { - maintenanceWindows: 0, - stats: 0, - }, - }, - ]; - - const documents = await MonitorModel.aggregate(pipeline); - return documents.map((doc) => this.toEntity(doc)); - }; - findMonitorCountByTeamIdAndType = async (teamId: string, config: TeamQueryConfig): Promise => { const query = this.queryBuilder(config, teamId); const count = await MonitorModel.countDocuments(query); @@ -473,7 +410,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { grpcServiceName: doc.grpcServiceName ?? undefined, strategy: doc.strategy ?? undefined, group: doc.group ?? null, - recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), + recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, @@ -485,30 +422,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; }; - private toCheckSnapshot = (doc: CheckSnapshotDocument): CheckSnapshot => { - return { - id: doc.id, - status: doc.status, - responseTime: doc.responseTime, - timings: doc.timings, - statusCode: doc.statusCode, - message: doc.message, - cpu: doc.cpu, - memory: doc.memory, - disk: doc.disk, - host: doc.host, - errors: doc.errors, - capture: doc.capture, - net: doc.net, - accessibility: doc.accessibility, - bestPractices: doc.bestPractices, - seo: doc.seo, - performance: doc.performance, - audits: doc.audits, - createdAt: toDateString(doc.createdAt), - }; - }; - deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { const objectIds = teamIds.map((id) => new mongoose.Types.ObjectId(id)); const result = await MonitorModel.deleteMany({ teamId: { $nin: objectIds } }); diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index 9be31e5f1..07ddee2a0 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -1,6 +1,6 @@ import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; -import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js"; +import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js"; import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js"; import type { DockerStatusPayload, @@ -18,6 +18,7 @@ import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import type { HardwareStatusMetrics } from "@/types/network.js"; import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js"; +import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js"; const SERVICE_NAME = "StatusService"; const HARDWARE_ALERT_COUNTER_START = 5; @@ -87,30 +88,6 @@ export class StatusService implements IStatusService { } }; - private toCheckSnapshot = (check: Check): CheckSnapshot => { - return { - id: check.id, - status: check.status, - responseTime: check.responseTime, - timings: check.timings, - statusCode: check.statusCode, - message: check.message, - cpu: check.cpu, - memory: check.memory, - disk: check.disk, - host: check.host, - errors: check.errors, - capture: check.capture, - net: check.net, - accessibility: check.accessibility, - bestPractices: check.bestPractices, - seo: check.seo, - performance: check.performance, - audits: check.audits, - createdAt: check.createdAt, - }; - }; - private computeReachability = ( currentStatus: MonitorStatus, window: Array, @@ -207,7 +184,7 @@ export class StatusService implements IStatusService { await this.tryUpdateRunningStats(monitor, statusResponse); const prevStatus = monitor.status; - const checkSnapshot = this.toCheckSnapshot(check); + const checkSnapshot = toCheckSnapshot(check); // Project the window as it will look after updating DB // This is done because we need the updated status window to compute new status, but we don't diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts index 1ff0b0383..fa811ad62 100644 --- a/server/test/helpers/InMemoryMonitorsRepository.ts +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -45,10 +45,6 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return monitor ? { ...monitor } : null; } - async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise { - return this.findByIds(monitorIds); - } - async updateById(monitorId: string, teamId: string, updates: Partial): Promise { const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId); if (index === -1) { From 4c8342f3791ace3e18ab830d9e82e6ed1c502fc3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 4 Aug 2026 14:46:52 -0700 Subject: [PATCH 2/4] fix pretty name bug, ordering bug --- .../Infrastructure/Details/Components/StatusBoxes.tsx | 11 +++-------- .../Infrastructure/Details/Components/TabOverview.tsx | 3 ++- .../Monitors/Components/MonitorsTable.tsx | 9 ++++++--- client/src/Pages/PageSpeed/Details/index.tsx | 6 ++++-- .../Status/themes/shared/ThemedInfrastructure.tsx | 3 ++- server/src/domain/checks/check.repository.mongo.ts | 1 + server/src/domain/monitors/monitor.service.ts | 2 +- server/test/unit/services/monitorService.test.ts | 4 +++- 8 files changed, 22 insertions(+), 17 deletions(-) diff --git a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx index ebfdad569..70f4325c6 100644 --- a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx @@ -11,7 +11,8 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => { const { t } = useTranslation(); const theme = useTheme(); - const latestCheck = monitor?.recentChecks?.[0]; + const recentChecks = monitor?.recentChecks ?? []; + const latestCheck = recentChecks[recentChecks.length - 1]; // Get data from latest check const physicalCores = getCores(latestCheck?.cpu?.physical_core); const logicalCores = getCores(latestCheck?.cpu?.logical_core); @@ -21,13 +22,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => { const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0; const diskTotalBytes = latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0; - const os = getOsAndPlatform(latestCheck?.host); - - const platform = latestCheck?.host?.platform ?? undefined; - const osPlatform = - typeof os === "undefined" && typeof platform === "undefined" - ? undefined - : `${os} ${platform}`; + const osPlatform = getOsAndPlatform(latestCheck?.host); return ( - + { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const cpuUsage = (check?.cpu?.usage_percent || 0) * 100; return ; }, @@ -257,7 +258,8 @@ export const InfraMonitorsTable = ({ id: "memory", content: t("pages.infrastructure.table.headers.memory"), render: (row) => { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const memoryUsage = (check?.memory?.usage_percent || 0) * 100; return ; }, @@ -266,7 +268,8 @@ export const InfraMonitorsTable = ({ id: "disk", content: t("pages.infrastructure.table.headers.disk"), render: (row) => { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const totalDiskUsage = check?.disk?.reduce( (acc, disk) => acc + (disk?.usage_percent || 0), diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx index 48a9ba99c..ea9da4a7f 100644 --- a/client/src/Pages/PageSpeed/Details/index.tsx +++ b/client/src/Pages/PageSpeed/Details/index.tsx @@ -45,6 +45,8 @@ const PageSpeedDetails = () => { const monitor = monitorData?.monitorData?.monitor; const groupedChecks = monitorData?.monitorData?.groupedChecks || []; const monitorStats = monitorData?.monitorStats || null; + const recentChecks = monitor?.recentChecks ?? []; + const latestCheck = recentChecks[recentChecks.length - 1]; return ( { direction={{ xs: "column", md: "row" }} gap={theme.spacing(10)} > - - + + ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx index ab7d0dc35..3c92e6b0a 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx @@ -36,7 +36,8 @@ const heatLevel = (value: number): GaugeFillLevel => export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { const { t } = useTranslation(); - const latest = monitor.recentChecks?.[0]; + const recentChecks = monitor.recentChecks ?? []; + const latest = recentChecks[recentChecks.length - 1]; const renderEmpty = () => ( {t("pages.statusPages.monitorsList.noData")} diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 78f0b9344..cb1c5bfba 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -81,6 +81,7 @@ class MongoChecksRepository implements IChecksRepository { os: host?.os ?? "", platform: host?.platform ?? "", kernel_version: host?.kernel_version ?? "", + pretty_name: host?.pretty_name ?? "", }); const mapCapture = (capture?: CheckCaptureInfo): CheckCaptureInfo => ({ diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 7a8478c5a..78a452044 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -363,7 +363,7 @@ export class MonitorService implements IMonitorService { const monitorsWithChecks = monitors.map((monitor: Monitor) => { const rawChecks = monitor.recentChecks ?? []; const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type); - const checks = isSnapshotType ? rawChecks.slice(0, 1) : rawChecks; + const checks = isSnapshotType ? rawChecks.slice(-1) : rawChecks; monitor.recentChecks = checks; return monitor; }); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 69edbba9c..0f56e5d2b 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -583,7 +583,7 @@ describe("MonitorService", () => { expect(result.monitors).toEqual([]); }); - it("uses snapshot (first check only) for hardware type monitors", async () => { + it("uses snapshot (latest check only) for hardware type monitors", async () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); @@ -602,6 +602,7 @@ describe("MonitorService", () => { const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: "hardware" }); expect(result.monitors[0].recentChecks).toHaveLength(1); + expect(result.monitors[0].recentChecks[0].responseTime).toBe(30); }); it("uses snapshot for hardware type when type is array with hardware", async () => { @@ -622,6 +623,7 @@ describe("MonitorService", () => { const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: ["hardware"] }); expect(result.monitors[0].recentChecks).toHaveLength(1); + expect(result.monitors[0].recentChecks[0].responseTime).toBe(20); }); it("normalizes checks for non-snapshot types when type is an array", async () => { From 5fa74bd88312e1f0a2f7b2234bede4901cd7a7f9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 4 Aug 2026 15:16:03 -0700 Subject: [PATCH 3/4] replace findAll with findAllForScheduling --- .../monitors/monitor.repository.interface.ts | 4 ++-- .../monitors/monitor.repository.mongo.ts | 19 +++++++++++++------ server/src/domain/monitors/monitor.type.ts | 2 ++ server/src/worker/worker.job-scheduler.ts | 8 ++++---- .../helpers/InMemoryMonitorsRepository.ts | 13 ++++++++++--- .../test/unit/services/dbQueueWorker.test.ts | 14 +++++++++++--- .../test/unit/services/jobScheduler.test.ts | 10 +++++----- 7 files changed, 47 insertions(+), 23 deletions(-) diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index e743e423f..e6e68863b 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -1,4 +1,4 @@ -import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; +import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot, MonitorScheduleFields } from "@/domain/monitors/monitor.type.js"; export interface TeamQueryConfig { limit?: number; @@ -26,7 +26,7 @@ export interface IMonitorsRepository { findByIdLean(monitorId: string): Promise; // collection fetch - findAll(): Promise; + findAllForScheduling(): Promise; findByTeamId(teamId: string, config: TeamQueryConfig): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; findByIds(monitorIds: string[]): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 68065f332..cdfbf4ca9 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -1,7 +1,7 @@ import { MonitorModel } from "@/domain/monitors/monitor.model.js"; import type { MonitorDocument, CheckSnapshotDocument } from "@/domain/monitors/monitor.model.js"; import type { CheckSnapshot } from "@/domain/checks/check.type.js"; -import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js"; +import type { Monitor, MonitorScheduleFields, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; @@ -41,13 +41,20 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findByIdLean = async (monitorId: string): Promise => { - const monitor = await MonitorModel.findOne({ _id: monitorId }); + const monitor = await MonitorModel.findOne({ _id: monitorId }).select({ recentChecks: 0 }); return monitor ? this.toEntity(monitor) : null; }; - findAll = async (): Promise => { - const monitors = await MonitorModel.find(); - return this.mapDocuments(monitors); + findAllForScheduling = async (): Promise => { + const docs = await MonitorModel.find({}, { type: 1, isActive: 1, interval: 1, geoCheckEnabled: 1, geoCheckInterval: 1 }).lean(); + return docs.map((doc) => ({ + id: doc._id.toString(), + type: doc.type, + isActive: doc.isActive, + interval: doc.interval, + geoCheckEnabled: doc.geoCheckEnabled ?? false, + geoCheckInterval: doc.geoCheckInterval ?? 300000, + })); }; private queryBuilder = (config: TeamQueryConfig, teamId: string): FilterQuery => { @@ -182,7 +189,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }, ...(statusPatch && { $set: statusPatch }), }, - { returnDocument: "after" } + { returnDocument: "after", projection: { recentChecks: 0 } } ); if (!updatedMonitor) { diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 865bafd76..0fb1ed397 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -178,3 +178,5 @@ export interface Game { } export type GamesMap = Record; + +export type MonitorScheduleFields = Pick; diff --git a/server/src/worker/worker.job-scheduler.ts b/server/src/worker/worker.job-scheduler.ts index 8d103e87f..aae8a489b 100644 --- a/server/src/worker/worker.job-scheduler.ts +++ b/server/src/worker/worker.job-scheduler.ts @@ -1,7 +1,7 @@ import { IJobScheduler } from "@/worker/worker.interface.js"; import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; -import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.type.js"; +import { Monitor, MonitorScheduleFields, supportsGeoCheck } from "@/domain/monitors/monitor.type.js"; import { type Job, type JobSeed, type JobType, jobId } from "@/domain/jobs/job.type.js"; import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js"; import { WorkerJobsPagination, WorkerJobSummary, WorkerMetrics } from "@/worker/worker.interface.js"; @@ -43,7 +43,7 @@ export class JobScheduler implements IJobScheduler { "cleanup-retention": POLL_MS, }; // Helpers, builds job seeds. immediate=true runs on the next tick; otherwise jitter spreads the herd. - protected toCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + protected toCheckJob = (monitor: MonitorScheduleFields, now: number, immediate = false): JobSeed => ({ id: jobId("check", monitor.id), type: "check", refId: monitor.id, @@ -52,7 +52,7 @@ export class JobScheduler implements IJobScheduler { intervalMs: monitor.interval, }); - protected toGeoCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + protected toGeoCheckJob = (monitor: MonitorScheduleFields, now: number, immediate = false): JobSeed => ({ ...this.toCheckJob(monitor, now, immediate), id: jobId("geo-check", monitor.id), type: "geo-check", @@ -187,7 +187,7 @@ export class JobScheduler implements IJobScheduler { // Seed queue protected reconcile = async () => { const now = Date.now(); - const monitors = await this.monitorsRepository.findAll(); + const monitors = await this.monitorsRepository.findAllForScheduling(); for (const monitor of monitors) { await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now)); if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts index fa811ad62..2ec36e8e2 100644 --- a/server/test/helpers/InMemoryMonitorsRepository.ts +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -1,6 +1,6 @@ import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/domain/monitors/monitor.repository.interface.ts"; import type { CheckSnapshot } from "../../src/domain/checks/check.type.ts"; -import type { Monitor, MonitorsSummary } from "../../src/domain/monitors/monitor.type.ts"; +import type { Monitor, MonitorScheduleFields, MonitorsSummary } from "../../src/domain/monitors/monitor.type.ts"; export class InMemoryMonitorsRepository implements IMonitorsRepository { private monitors: Monitor[] = []; @@ -24,8 +24,15 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return { ...monitor }; } - async findAll(): Promise { - return this.monitors.map((m) => ({ ...m })); + async findAllForScheduling(): Promise { + return this.monitors.map((m) => ({ + id: m.id, + type: m.type, + isActive: m.isActive, + interval: m.interval, + geoCheckEnabled: m.geoCheckEnabled ?? false, + geoCheckInterval: m.geoCheckInterval ?? 300000, + })); } async findByTeamId(_teamId: string, _config: TeamQueryConfig): Promise { diff --git a/server/test/unit/services/dbQueueWorker.test.ts b/server/test/unit/services/dbQueueWorker.test.ts index d903ae887..8b13287d4 100644 --- a/server/test/unit/services/dbQueueWorker.test.ts +++ b/server/test/unit/services/dbQueueWorker.test.ts @@ -84,7 +84,7 @@ const createWorker = (overrides?: { queueMode?: QueueMode; queuePrimaryProcesses const monitorsRepository = { findByIds: jest.fn().mockResolvedValue([makeMonitor()]), findByIdLean: jest.fn().mockResolvedValue(makeMonitor()), - findAll: jest.fn().mockResolvedValue([]), + findAllForScheduling: jest.fn().mockResolvedValue([]), updateById: jest.fn().mockResolvedValue({}), }; const checksRepository = { @@ -183,7 +183,11 @@ describe("DBQueueWorker", () => { queueMode: "primary", queuePrimaryProcesses: false, // isolate reconcile from the polling loops mocks: { - monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([makeMonitor()]), updateById: jest.fn() }, + monitorsRepository: { + findByIds: jest.fn(), + findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]), + updateById: jest.fn(), + }, }, }); @@ -200,7 +204,11 @@ describe("DBQueueWorker", () => { queueMode: "primary", queuePrimaryProcesses: false, mocks: { - monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([geoMonitor]), updateById: jest.fn() }, + monitorsRepository: { + findByIds: jest.fn(), + findAllForScheduling: jest.fn().mockResolvedValue([geoMonitor]), + updateById: jest.fn(), + }, }, }); diff --git a/server/test/unit/services/jobScheduler.test.ts b/server/test/unit/services/jobScheduler.test.ts index 098cef245..90c8c2850 100644 --- a/server/test/unit/services/jobScheduler.test.ts +++ b/server/test/unit/services/jobScheduler.test.ts @@ -38,7 +38,7 @@ const createScheduler = (overrides?: { queueMode?: QueueMode; mocks?: Record().mockResolvedValue(undefined), }; const monitorsRepository = { - findAll: jest.fn().mockResolvedValue([]), + findAllForScheduling: jest.fn().mockResolvedValue([]), }; const logger = createMockLogger(); const mocks = { jobsRepository, queueWorkersRepository, monitorsRepository, logger, ...overrides?.mocks }; @@ -73,7 +73,7 @@ describe("JobScheduler", () => { it("primary mode reconciles: seeds a check row per monitor plus the two cleanup rows", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "primary", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]) } }, }); await scheduler.init(); @@ -87,7 +87,7 @@ describe("JobScheduler", () => { it("primary mode also seeds a geo-check row for geo-enabled monitors", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "primary", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor({ geoCheckEnabled: true })]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor({ geoCheckEnabled: true })]) } }, }); await scheduler.init(); @@ -98,12 +98,12 @@ describe("JobScheduler", () => { it("worker mode heartbeats but does not reconcile (no monitor reads, no seeds)", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "worker", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]) } }, }); await scheduler.init(); expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker", false); - expect(mocks.monitorsRepository.findAll).not.toHaveBeenCalled(); + expect(mocks.monitorsRepository.findAllForScheduling).not.toHaveBeenCalled(); expect(mocks.jobsRepository.upsertJob).not.toHaveBeenCalled(); expect(mocks.jobsRepository.upsertCleanupJob).not.toHaveBeenCalled(); }); From 303f47a11733635ef56f9bebfb9e149109ec782b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 4 Aug 2026 15:30:34 -0700 Subject: [PATCH 4/4] add opt out flag for recent checks --- .../maintenance-window.service.ts | 4 ++-- .../monitors/monitor.repository.interface.ts | 4 ++-- .../monitors/monitor.repository.mongo.ts | 18 +++++++++++++----- server/src/domain/monitors/monitor.service.ts | 2 +- server/src/domain/users/user.service.ts | 2 +- .../services/maintenanceWindowService.test.ts | 4 ++-- server/test/unit/services/userService.test.ts | 2 +- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/server/src/domain/maintenance-windows/maintenance-window.service.ts b/server/src/domain/maintenance-windows/maintenance-window.service.ts index 350e755aa..dd27ba0b3 100644 --- a/server/src/domain/maintenance-windows/maintenance-window.service.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.service.ts @@ -106,7 +106,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { start: string; end: string; }) => { - const monitors = await this.monitorsRepository.findByIds(monitorIDs); + const monitors = await this.monitorsRepository.findByIds(monitorIDs, { includeRecentChecks: false }); const unauthorizedMonitors = monitors.filter((monitor) => monitor.teamId !== teamId); @@ -193,7 +193,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { const update: Partial = rest; if (monitors !== undefined) { - const monitorDocs = await this.monitorsRepository.findByIds(monitors); + const monitorDocs = await this.monitorsRepository.findByIds(monitors, { includeRecentChecks: false }); const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId); if (unauthorizedMonitors.length > 0) { throw new AppError({ diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index e6e68863b..53c178a1d 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -27,9 +27,9 @@ export interface IMonitorsRepository { // collection fetch findAllForScheduling(): Promise; - findByTeamId(teamId: string, config: TeamQueryConfig): Promise; + findByTeamId(teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; - findByIds(monitorIds: string[]): Promise; + findByIds(monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index cdfbf4ca9..2411f3840 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -104,10 +104,15 @@ class MongoMonitorsRepository implements IMonitorsRepository { return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage }; }; - findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { + findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise => { const query = this.queryBuilder(config, teamId); const { sort, skip, limit } = this.pageOptions(config); - const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(limit); + + const cursor = MonitorModel.find(query).sort(sort).skip(skip).limit(limit); + if (options?.includeRecentChecks === false) { + cursor.select({ recentChecks: 0 }); + } + const documents = await cursor; return this.mapDocuments(documents); }; @@ -126,15 +131,18 @@ class MongoMonitorsRepository implements IMonitorsRepository { return documents.map((doc) => this.toEntity(doc)); }; - findByIds = async (monitorIds: string[]): Promise => { + findByIds = async (monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise => { if (!monitorIds.length) { return []; } const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - const pipeline: PipelineStage[] = [{ $match: { _id: { $in: objectIds } } }, ...this.uptimeStatsLookupStages]; - + const pipeline: PipelineStage[] = [ + { $match: { _id: { $in: objectIds } } }, + ...(options?.includeRecentChecks === false ? [{ $project: { recentChecks: 0 } } as PipelineStage] : []), + ...this.uptimeStatsLookupStages, + ]; const documents = await MonitorModel.aggregate(pipeline); return documents.map((doc) => this.toEntity(doc)); }; diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 78a452044..31488c378 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -519,7 +519,7 @@ export class MonitorService implements IMonitorService { }; exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise => { - const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); + const monitors = await this.monitorsRepository.findByTeamId(teamId, {}, { includeRecentChecks: false }); if (monitors.length === 0) { throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 }); diff --git a/server/src/domain/users/user.service.ts b/server/src/domain/users/user.service.ts index f3db2f3b5..f042863aa 100644 --- a/server/src/domain/users/user.service.ts +++ b/server/src/domain/users/user.service.ts @@ -324,7 +324,7 @@ export class UserService implements IUserService { } if (roles.includes("superadmin")) { - const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); + const monitors = await this.monitorsRepository.findByTeamId(teamId, {}, { includeRecentChecks: false }); if (monitors) { await Promise.all(monitors.map((monitor) => this.scheduler.deleteJob(monitor))); } diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index e15f20d46..481dc5983 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -123,7 +123,7 @@ describe("MaintenanceWindowService", () => { await service.createMaintenanceWindow(defaultCreateParams); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { includeRecentChecks: false }); }); it("throws 403 when a monitor belongs to a different team", async () => { @@ -404,7 +404,7 @@ describe("MaintenanceWindowService", () => { body: { monitors: ["mon-1", "mon-2"] }, }); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { includeRecentChecks: false }); }); it("throws 403 when a new monitor belongs to a different team", async () => { diff --git a/server/test/unit/services/userService.test.ts b/server/test/unit/services/userService.test.ts index bce8769cf..5187a3c60 100644 --- a/server/test/unit/services/userService.test.ts +++ b/server/test/unit/services/userService.test.ts @@ -472,7 +472,7 @@ describe("UserService", () => { await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); - expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}); + expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}, { includeRecentChecks: false }); expect(scheduler.deleteJob).toHaveBeenCalledTimes(2); expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); });