diff --git a/server/src/domain/maintenance-windows/maintenance-window.service.ts b/server/src/domain/maintenance-windows/maintenance-window.service.ts index dd27ba0b3..767e17699 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, { includeRecentChecks: false }); + const monitors = await this.monitorsRepository.findByIds(monitorIDs, { recentChecks: "none" }); 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, { includeRecentChecks: false }); + const monitorDocs = await this.monitorsRepository.findByIds(monitors, { recentChecks: "none" }); 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 53c178a1d..5528c97cf 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -16,6 +16,8 @@ export interface SummaryConfig { tags?: string | string[]; } +export type RecentChecksMode = "all" | "latestHardware" | "none"; + export interface IMonitorsRepository { // create create(monitor: Monitor, teamId: string, userId: string): Promise; @@ -29,7 +31,7 @@ export interface IMonitorsRepository { findAllForScheduling(): Promise; findByTeamId(teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; - findByIds(monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise; + findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): 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 2411f3840..33c90dd59 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -5,7 +5,7 @@ import type { Monitor, MonitorScheduleFields, MonitorStatus, MonitorsSummary } f import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; -import { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "@/domain/monitors/monitor.repository.interface.js"; +import { IMonitorsRepository, TeamQueryConfig, SummaryConfig, RecentChecksMode } 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 { @@ -131,7 +131,26 @@ class MongoMonitorsRepository implements IMonitorsRepository { return documents.map((doc) => this.toEntity(doc)); }; - findByIds = async (monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise => { + private recentChecksStages = (mode: RecentChecksMode = "all"): PipelineStage[] => { + switch (mode) { + case "none": + return [{ $project: { recentChecks: 0 } }]; + case "latestHardware": + return [ + { + $set: { + recentChecks: { + $cond: [{ $eq: ["$type", "hardware"] }, { $slice: [{ $ifNull: ["$recentChecks", []] }, -1] }, []], + }, + }, + }, + ]; + case "all": + return []; + } + }; + + findByIds = async (monitorIds: string[], options?: { recentChecks?: "all" | "latestHardware" | "none" }): Promise => { if (!monitorIds.length) { return []; } @@ -140,7 +159,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { const pipeline: PipelineStage[] = [ { $match: { _id: { $in: objectIds } } }, - ...(options?.includeRecentChecks === false ? [{ $project: { recentChecks: 0 } } as PipelineStage] : []), + ...this.recentChecksStages(options?.recentChecks), ...this.uptimeStatsLookupStages, ]; const documents = await MonitorModel.aggregate(pipeline); diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 25e6d3e08..5f700857f 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -134,7 +134,7 @@ export class StatusPageService implements IStatusPageService { const dbSettings = await this.settingsService.getDBSettings(); const showURL = dbSettings.showURL; - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors, { includeRecentChecks: range === "latest" }); + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors, { recentChecks: range === "latest" ? "all" : "latestHardware" }); const order = new Map(statusPage.monitors.map((id, i) => [id, i])); const sorted = [...monitors].sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER)); @@ -162,7 +162,6 @@ export class StatusPageService implements IStatusPageService { checkTTLDays: dbSettings.checkTTL, monitors: sorted.map((monitor) => ({ ...this.toPublicMonitor(monitor, showURL), - recentChecks: [], dailyChecks: bucketsByMonitor.get(monitor.id) ?? [], })), }; diff --git a/server/test/integration/monitorRepository.test.ts b/server/test/integration/monitorRepository.test.ts index 7144f7f3e..ed7a7a7a6 100644 --- a/server/test/integration/monitorRepository.test.ts +++ b/server/test/integration/monitorRepository.test.ts @@ -31,6 +31,23 @@ const seedMonitor = (teamId: mongoose.Types.ObjectId, name: string, tags: mongoo tags, }); +const makeSnapshot = (id: string, dayOfMonth: number) => ({ + id, + status: true, + responseTime: 100, + createdAt: new Date(2026, 7, dayOfMonth), +}); + +const seedMonitorWithChecks = (teamId: mongoose.Types.ObjectId, name: string, type: string, snapshotIds: string[]) => + MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId, + name, + type, + url: "https://example.com", + recentChecks: snapshotIds.map((id, i) => makeSnapshot(id, i + 1)), + }); + describe("MongoMonitorsRepository", () => { describe("tag filtering", () => { it("matches ObjectId tag references when filters arrive as query strings", async () => { @@ -51,4 +68,59 @@ describe("MongoMonitorsRepository", () => { expect(summary.totalMonitors).toBe(1); }); }); + + describe("findByIds recentChecks modes", () => { + const seedFixtures = async () => { + const teamId = new mongoose.Types.ObjectId(); + const hardware = await seedMonitorWithChecks(teamId, "hw", "hardware", ["hw-1", "hw-2", "hw-3"]); + const http = await seedMonitorWithChecks(teamId, "web", "http", ["web-1", "web-2"]); + return { ids: [hardware._id.toString(), http._id.toString()] }; + }; + + const checksByName = (monitors: Awaited>) => + Object.fromEntries(monitors.map((monitor) => [monitor.name, monitor.recentChecks.map((check) => check.id)])); + + it("returns full recentChecks by default and for mode 'all'", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const byDefault = await repo.findByIds(ids); + const byAll = await repo.findByIds(ids, { recentChecks: "all" }); + + for (const monitors of [byDefault, byAll]) { + expect(checksByName(monitors)).toEqual({ hw: ["hw-1", "hw-2", "hw-3"], web: ["web-1", "web-2"] }); + } + }); + + it("strips recentChecks for mode 'none'", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const monitors = await repo.findByIds(ids, { recentChecks: "none" }); + + expect(checksByName(monitors)).toEqual({ hw: [], web: [] }); + }); + + it("keeps only the newest snapshot for hardware monitors in 'latestHardware' mode", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const monitors = await repo.findByIds(ids, { recentChecks: "latestHardware" }); + + expect(checksByName(monitors)).toEqual({ hw: ["hw-3"], web: [] }); + }); + + it("returns an empty array in 'latestHardware' mode for a hardware monitor with no recentChecks field at all", async () => { + const repo = new MongoMonitorsRepository(); + const teamId = new mongoose.Types.ObjectId(); + const hardware = await seedMonitorWithChecks(teamId, "hw-fresh", "hardware", []); + // $slice on a missing operand is a hard aggregation error; the $ifNull guard must cover it + await MonitorModel.updateOne({ _id: hardware._id }, { $unset: { recentChecks: 1 } }); + + const monitors = await repo.findByIds([hardware._id.toString()], { recentChecks: "latestHardware" }); + + expect(monitors).toHaveLength(1); + expect(monitors[0].recentChecks).toEqual([]); + }); + }); }); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index 481dc5983..691291ebe 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"], { includeRecentChecks: false }); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "none" }); }); 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"], { includeRecentChecks: false }); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "none" }); }); it("throws 403 when a new monitor belongs to a different team", async () => { diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 4a6fb8c1c..ba3ac298e 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -376,23 +376,29 @@ describe("StatusPageService", () => { expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); } expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); - expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: true }); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { recentChecks: "all" }); }); - it("attaches dailyChecks, empties recentChecks, and echoes range metadata for a day range", async () => { + it("attaches dailyChecks, passes the repo-trimmed recentChecks through, and echoes range metadata for a day range", async () => { const { service, checksRepo, monitorsRepo } = createService(); const bucket = { monitorId: "mon-1", date: "2026-08-01", totalChecks: 10, upChecks: 9, downChecks: 1, avgResponseTime: 120 }; + const hardwareSnapshot = { id: "chk-1" } as CheckSnapshot; (checksRepo.getDailyStatusBuckets as jest.Mock).mockResolvedValue([bucket]); - (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [{ id: "chk-1" } as CheckSnapshot] })]); - const page = makeStatusPage({ isPublished: true, timezone: "America/Toronto" }); + (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([ + makeMonitor({ id: "mon-1", type: "hardware", recentChecks: [hardwareSnapshot] }), + makeMonitor({ id: "mon-2", recentChecks: [] }), + ]); + const page = makeStatusPage({ isPublished: true, timezone: "America/Toronto", monitors: ["mon-1", "mon-2"] }); const payload = await service.getPublicStatusPagePayload(page, undefined, "30d"); - expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 30, "America/Toronto"); - expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: false }); + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1", "mon-2"], 30, "America/Toronto"); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "latestHardware" }); expect(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); - expect(payload.monitors[0].recentChecks).toEqual([]); + expect(payload.monitors[0].recentChecks).toEqual([hardwareSnapshot]); expect(payload.monitors[0].dailyChecks).toEqual([bucket]); + expect(payload.monitors[1].recentChecks).toEqual([]); + expect(payload.monitors[1].dailyChecks).toEqual([]); for (const field of SENSITIVE_FIELDS) { expect(payload.monitors[0]).not.toHaveProperty(field); }