diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 6889d8d15..e851c3398 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -1,5 +1,6 @@ import type { Check, + CheckSnapshot, ChecksQueryResult, ChecksSummary, HardwareChecksResult, @@ -38,6 +39,7 @@ export interface IChecksRepository { dateRange: DateRange, options?: { type?: MonitorType } ): Promise; + findDailyPageSpeedSnapshotsByMonitorIdsAndDateRange(monitorIds: string[], dateRange: DateRange): Promise>; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; // update diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index cb1c5bfba..2d02d2b4f 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -10,6 +10,7 @@ import type { CheckMemoryInfo, CheckMetadata, CheckNetworkInterfaceInfo, + CheckSnapshot, GotTimings, HardwareCheckStats, } from "@/domain/checks/check.type.js"; @@ -302,6 +303,75 @@ class MongoChecksRepository implements IChecksRepository { return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString); }; + findDailyPageSpeedSnapshotsByMonitorIdsAndDateRange = async ( + monitorIds: string[], + dateRange: DateRange + ): Promise> => { + if (monitorIds.length === 0) { + return {}; + } + + const dateString = getDateFormat(dateRange); + const rows = await CheckModel.aggregate<{ + monitorId: mongoose.Types.ObjectId; + bucketDate: string; + performance: number; + accessibility: number; + bestPractices: number; + seo: number; + }>([ + { + $match: { + "metadata.monitorId": { $in: monitorIds.map((monitorId) => new mongoose.Types.ObjectId(monitorId)) }, + "metadata.type": "pagespeed", + createdAt: { $gte: getDateForRange(dateRange) }, + }, + }, + { + $group: { + _id: { + monitorId: "$metadata.monitorId", + bucketDate: { $dateToString: { format: dateString, date: "$createdAt" } }, + }, + performance: { $avg: "$performance" }, + accessibility: { $avg: "$accessibility" }, + bestPractices: { $avg: "$bestPractices" }, + seo: { $avg: "$seo" }, + }, + }, + { $sort: { "_id.monitorId": 1, "_id.bucketDate": 1 } }, + { + $project: { + _id: 0, + monitorId: "$_id.monitorId", + bucketDate: "$_id.bucketDate", + performance: 1, + accessibility: 1, + bestPractices: 1, + seo: 1, + }, + }, + ]); + + return rows.reduce>((snapshotsByMonitorId, row) => { + const monitorId = toStringId(row.monitorId); + const snapshot: CheckSnapshot = { + id: row.bucketDate, + status: true, + responseTime: 0, + statusCode: 200, + message: "", + createdAt: row.bucketDate, + performance: row.performance, + accessibility: row.accessibility, + bestPractices: row.bestPractices, + seo: row.seo, + }; + (snapshotsByMonitorId[monitorId] ??= []).push(snapshot); + return snapshotsByMonitorId; + }, {}); + }; + findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => { const baseMatch = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 31488c378..7c2a0bf4a 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -359,11 +359,23 @@ export class MonitorService implements IMonitorService { const requestedTypes = Array.isArray(type) ? type : type ? [type] : []; const snapshotOnlyRequest = requestedTypes.length > 0 && requestedTypes.every((requestedType) => snapshotTypes.includes(requestedType as MonitorType)); + const isPageSpeedOverview = requestedTypes.length > 0 && requestedTypes.every((requestedType) => requestedType === "pagespeed"); + const pageSpeedChecksByMonitorId = isPageSpeedOverview + ? await this.checksRepository.findDailyPageSpeedSnapshotsByMonitorIdsAndDateRange( + monitors.map((monitor) => monitor.id), + "month" + ) + : {}; const monitorsWithChecks = monitors.map((monitor: Monitor) => { const rawChecks = monitor.recentChecks ?? []; const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type); - const checks = isSnapshotType ? rawChecks.slice(-1) : rawChecks; + const checks = + isPageSpeedOverview && monitor.type === "pagespeed" + ? (pageSpeedChecksByMonitorId[monitor.id] ?? []) + : isSnapshotType + ? rawChecks.slice(-1) + : rawChecks; monitor.recentChecks = checks; return monitor; }); diff --git a/server/test/integration/checksRepositoryGroupedChecks.test.ts b/server/test/integration/checksRepositoryGroupedChecks.test.ts index cf5384d0c..d9bd23f94 100644 --- a/server/test/integration/checksRepositoryGroupedChecks.test.ts +++ b/server/test/integration/checksRepositoryGroupedChecks.test.ts @@ -100,4 +100,81 @@ describe("MongoChecksRepository groupedChecks facet", () => { avgDownload: 0, }); }); + + it("returns ordered daily Pagespeed snapshots for the requested monitors", async () => { + const pageSpeedMonitorId = new mongoose.Types.ObjectId(); + const otherMonitorId = new mongoose.Types.ObjectId(); + const earlyDay = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + earlyDay.setUTCHours(10, 0, 0, 0); + const laterDay = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + laterDay.setUTCHours(10, 0, 0, 0); + + await CheckModel.create([ + { + metadata: { monitorId: pageSpeedMonitorId, teamId: TEAM_ID, type: "pagespeed" }, + status: true, + responseTime: 100, + createdAt: earlyDay, + performance: 40, + accessibility: 60, + bestPractices: 80, + seo: 100, + }, + { + metadata: { monitorId: pageSpeedMonitorId, teamId: TEAM_ID, type: "pagespeed" }, + status: true, + responseTime: 100, + createdAt: new Date(earlyDay.getTime() + 60 * 60 * 1000), + performance: 60, + accessibility: 80, + bestPractices: 100, + seo: 80, + }, + { + metadata: { monitorId: pageSpeedMonitorId, teamId: TEAM_ID, type: "pagespeed" }, + status: true, + responseTime: 100, + createdAt: new Date(earlyDay.getTime() + 2 * 60 * 60 * 1000), + }, + { + metadata: { monitorId: pageSpeedMonitorId, teamId: TEAM_ID, type: "pagespeed" }, + status: true, + responseTime: 100, + createdAt: laterDay, + performance: 90, + accessibility: 90, + bestPractices: 90, + seo: 90, + }, + { + metadata: { monitorId: otherMonitorId, teamId: TEAM_ID, type: "pagespeed" }, + status: true, + responseTime: 100, + createdAt: laterDay, + performance: 10, + accessibility: 10, + bestPractices: 10, + seo: 10, + }, + ]); + + const snapshots = await repo.findDailyPageSpeedSnapshotsByMonitorIdsAndDateRange([pageSpeedMonitorId.toString()], "month"); + const toBucketDate = (date: Date) => `${date.toISOString().slice(0, 10)}T00:00:00Z`; + + expect(snapshots).toEqual({ + [pageSpeedMonitorId.toString()]: [ + expect.objectContaining({ + createdAt: toBucketDate(earlyDay), + performance: 50, + accessibility: 70, + bestPractices: 90, + seo: 90, + }), + expect.objectContaining({ + createdAt: toBucketDate(laterDay), + performance: 90, + }), + ], + }); + }); }); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 0f56e5d2b..60a4ddbe9 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -29,6 +29,7 @@ const createMonitorsRepositoryMock = () => const createChecksRepositoryMock = () => ({ findByDateRangeAndMonitorId: jest.fn(), + findDailyPageSpeedSnapshotsByMonitorIdsAndDateRange: jest.fn(), deleteByMonitorId: jest.fn(), }) as unknown as IChecksRepository; @@ -570,6 +571,43 @@ describe("MonitorService", () => { expect(result.monitors).toHaveLength(1); }); + it("loads a month of history for pagespeed overview monitors", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monthHistory = [ + { + id: "check-from-last-month", + status: true, + responseTime: 100, + statusCode: 200, + message: "OK", + createdAt: "2026-07-10T12:00:00.000Z", + accessibility: 90, + bestPractices: 80, + performance: 70, + seo: 95, + }, + ]; + + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); + (monitorsRepository.findByTeamIdWithStats as jest.Mock).mockResolvedValue([ + makeMonitor({ + type: "pagespeed", + recentChecks: [], + }), + ]); + (checksRepository.findDailyPageSpeedSnapshotsByMonitorIdsAndDateRange as jest.Mock).mockResolvedValue({ + [MONITOR_ID]: monthHistory, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: "pagespeed" }); + + expect(checksRepository.findDailyPageSpeedSnapshotsByMonitorIdsAndDateRange).toHaveBeenCalledWith([MONITOR_ID], "month"); + expect(result.monitors[0].recentChecks).toEqual(monthHistory); + }); + it("returns null summary when repository returns null", async () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue(null);