From 3a22d3e819848b6caed530b6e9f70fa737bdec44 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 11:48:44 -0700 Subject: [PATCH 1/9] add range buckets for status pages --- .../api/validation/statusPageValidation.ts | 4 +- server/src/domain/checks/check.type.ts | 9 +++++ .../status-page-repository.interface.ts | 2 + .../status-page-repository.mongo.ts | 38 +++++++++++++++++++ .../domain/status-pages/status-page.type.ts | 15 +++++++- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index ee2b7376c..1b07dc1dc 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; -import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; +import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; import { ImageMimeTypes } from "@/types/upload.js"; @@ -15,7 +15,7 @@ export const getStatusPageParamValidation = z.object({ export const getStatusPageQueryValidation = z.object({ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])), - timeFrame: z.coerce.number().optional(), + range: z.enum(StatusPageRanges).optional().default("latest"), }); export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({ diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 4e69a5241..2f933dd0b 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -243,3 +243,12 @@ export interface HardwareStats { export interface HardwareChecksResult extends HardwareStats { monitorType: "hardware"; } + +export type DailyCheckBucket = { + monitorId: string; + date: string; + totalChecks: number; + upChecks: number; + downChecks: number; + avgResponseTime: number; +}; diff --git a/server/src/domain/status-pages/status-page-repository.interface.ts b/server/src/domain/status-pages/status-page-repository.interface.ts index 4b3eccd0a..ed58b18a2 100644 --- a/server/src/domain/status-pages/status-page-repository.interface.ts +++ b/server/src/domain/status-pages/status-page-repository.interface.ts @@ -1,3 +1,4 @@ +import { DailyCheckBucket } from "@/domain/checks/check.type.js"; import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; export interface IStatusPagesRepository { @@ -8,6 +9,7 @@ export interface IStatusPagesRepository { findByCustomDomain(customDomain: string): Promise; findByTeamId(teamId: string): Promise; // collection fetch + getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; // update updateById(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // delete diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 467ce10f5..931efa9d0 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -5,6 +5,7 @@ import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import CheckModel from "@/domain/checks/check.model.js"; // Type for update data that can include document-level fields (Buffer for logo) type StatusPageUpdateData = Partial> & { logo?: StatusPageLogoDocument | null; @@ -113,6 +114,43 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { return this.mapDocuments(statusPages); }; + getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const windowStart = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000); + const results = await CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": { $in: objectIds }, + createdAt: { $gte: windowStart }, + }, + }, + { + $group: { + _id: { + monitorId: "$metadata.monitorId", + day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone } }, + }, + totalChecks: { $sum: 1 }, + upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + avgResponseTime: { $avg: { $ifNull: ["$responseTime", 0] } }, + }, + }, + { $sort: { "_id.day": 1 } }, + { + $project: { + _id: 0, + monitorId: { $toString: "$_id.monitorId" }, + date: { $dateToString: { date: "$_id.day", format: "%Y-%m-%d", timezone } }, + totalChecks: 1, + upChecks: 1, + downChecks: { $subtract: ["$totalChecks", "$upChecks"] }, + avgResponseTime: { $round: ["$avgResponseTime", 0] }, + }, + }, + ]); + return results; + }; + updateById = async ( id: string, teamId: string, diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index e80087b39..dc5977273 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -1,3 +1,4 @@ +import { DailyCheckBucket } from "@/domain/checks/check.type.js"; import type { Monitor } from "@/domain/monitors/monitor.type.js"; export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; @@ -10,6 +11,13 @@ export const StatusPageThemeModes = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; +export const StatusPageRanges = ["latest", "30d", "60d", "90d"] as const; +export type StatusPageRange = (typeof StatusPageRanges)[number]; +export const STATUS_PAGE_RANGE_DAYS: Record, number> = { + "30d": 30, + "60d": 60, + "90d": 90, +}; export interface StatusPageLogo { data: string; contentType: string; @@ -46,8 +54,11 @@ export interface StatusPage { updatedAt: string; } -export type PublicStatusPageMonitor = Pick & - Partial>; +export type PublicStatusPageMonitor = Pick & { + url?: string; + port?: number; + dailyChecks?: DailyCheckBucket[]; // Only present when range !== "latest" +}; export interface PublicStatusPagePayload { statusPage: StatusPage; From 6f9642c298dc3b2afab6cd6805b9cb2bff73ae94 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 11:48:56 -0700 Subject: [PATCH 2/9] tighten timezone validation --- server/src/api/validation/shared.ts | 15 +++++++++++++++ server/src/api/validation/statusPageValidation.ts | 4 ++-- .../status-pages/status-page-repository.mongo.ts | 5 +++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/server/src/api/validation/shared.ts b/server/src/api/validation/shared.ts index 18b8ba463..ef3ec24ef 100644 --- a/server/src/api/validation/shared.ts +++ b/server/src/api/validation/shared.ts @@ -25,6 +25,21 @@ export const dnsServerValidation = z .min(1, "DNS server is required") .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); +// Canonical casing matters: MongoDB's date operators reject non-canonical zone names (e.g. "america/toronto") that Intl accepts, +// so the resolved canonical form is what gets stored. +export const resolveTimezone = (value: string): string | undefined => { + try { + return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone; + } catch { + return undefined; + } +}; + +export const timezoneValidation = z + .string() + .refine((v) => resolveTimezone(v) !== undefined, "Enter a valid IANA timezone (e.g. America/Toronto)") + .transform((v) => resolveTimezone(v) as string); + // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, // optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). // No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 1b07dc1dc..6b8011044 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; @@ -40,7 +40,7 @@ export const createStatusPageBodyValidation = z message: "URL can only contain letters, numbers, underscores, and hyphens", }), customDomain: customDomainValidation, - timezone: z.string().optional(), + timezone: timezoneValidation.optional(), color: z.string().optional(), monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), subMonitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/)).optional(), diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 931efa9d0..326db1493 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -116,7 +116,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - const windowStart = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000); + // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket + const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); const results = await CheckModel.aggregate([ { $match: { @@ -132,7 +133,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { }, totalChecks: { $sum: 1 }, upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, - avgResponseTime: { $avg: { $ifNull: ["$responseTime", 0] } }, + avgResponseTime: { $avg: "$responseTime" }, }, }, { $sort: { "_id.day": 1 } }, From 3fc7020a903528eb7927f69883e91a58aa3542a3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:26:49 -0700 Subject: [PATCH 3/9] move methods to checks, wire up service --- .../checks/check.repository.interface.ts | 3 ++ .../domain/checks/check.repository.mongo.ts | 38 +++++++++++++++++++ .../status-page-repository.interface.ts | 2 - .../status-page-repository.mongo.ts | 38 ------------------- .../status-pages/status-page.service.ts | 33 ++++++++++++++-- .../domain/status-pages/status-page.type.ts | 3 ++ 6 files changed, 74 insertions(+), 43 deletions(-) diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 6889d8d15..91ea5bb5b 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -2,6 +2,7 @@ import type { Check, ChecksQueryResult, ChecksSummary, + DailyCheckBucket, HardwareChecksResult, PageSpeedChecksResult, UptimeChecksResult, @@ -40,6 +41,8 @@ export interface IChecksRepository { ): Promise; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; + getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; + // update //delete deleteByMonitorId(monitorId: string): Promise; diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index cb1c5bfba..2840907c9 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -329,6 +329,44 @@ class MongoChecksRepository implements IChecksRepository { return docs.map(this.toEntity); }; + getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket + const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const results = await CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": { $in: objectIds }, + createdAt: { $gte: windowStart }, + }, + }, + { + $group: { + _id: { + monitorId: "$metadata.monitorId", + day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone } }, + }, + totalChecks: { $sum: 1 }, + upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + avgResponseTime: { $avg: "$responseTime" }, + }, + }, + { $sort: { "_id.day": 1 } }, + { + $project: { + _id: 0, + monitorId: { $toString: "$_id.monitorId" }, + date: { $dateToString: { date: "$_id.day", format: "%Y-%m-%d", timezone } }, + totalChecks: 1, + upChecks: 1, + downChecks: { $subtract: ["$totalChecks", "$upChecks"] }, + avgResponseTime: { $round: ["$avgResponseTime", 0] }, + }, + }, + ]); + return results; + }; + deleteByMonitorId = async (monitorId: string): Promise => { const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) }); return result.deletedCount; diff --git a/server/src/domain/status-pages/status-page-repository.interface.ts b/server/src/domain/status-pages/status-page-repository.interface.ts index ed58b18a2..4b3eccd0a 100644 --- a/server/src/domain/status-pages/status-page-repository.interface.ts +++ b/server/src/domain/status-pages/status-page-repository.interface.ts @@ -1,4 +1,3 @@ -import { DailyCheckBucket } from "@/domain/checks/check.type.js"; import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; export interface IStatusPagesRepository { @@ -9,7 +8,6 @@ export interface IStatusPagesRepository { findByCustomDomain(customDomain: string): Promise; findByTeamId(teamId: string): Promise; // collection fetch - getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; // update updateById(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // delete diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 326db1493..0b74a9374 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -114,44 +114,6 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { return this.mapDocuments(statusPages); }; - getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { - const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket - const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); - const results = await CheckModel.aggregate([ - { - $match: { - "metadata.monitorId": { $in: objectIds }, - createdAt: { $gte: windowStart }, - }, - }, - { - $group: { - _id: { - monitorId: "$metadata.monitorId", - day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone } }, - }, - totalChecks: { $sum: 1 }, - upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, - avgResponseTime: { $avg: "$responseTime" }, - }, - }, - { $sort: { "_id.day": 1 } }, - { - $project: { - _id: 0, - monitorId: { $toString: "$_id.monitorId" }, - date: { $dateToString: { date: "$_id.day", format: "%Y-%m-%d", timezone } }, - totalChecks: 1, - upChecks: 1, - downChecks: { $subtract: ["$totalChecks", "$upChecks"] }, - avgResponseTime: { $round: ["$avgResponseTime", 0] }, - }, - }, - ]); - return results; - }; - updateById = async ( id: string, teamId: string, diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 7a2a0724b..7d953f8f3 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -5,11 +5,14 @@ import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, PublicStatusPagePayload, + STATUS_PAGE_RANGE_DAYS, StatusPage, + StatusPageRange, } from "@/domain/status-pages/status-page.type.js"; import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { Monitor } from "@/domain/monitors/monitor.type.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -26,7 +29,8 @@ export class StatusPageService implements IStatusPageService { constructor( private statusPagesRepository: IStatusPagesRepository, private settingsService: ISettingsService, - private monitorsRepository: IMonitorsRepository + private monitorsRepository: IMonitorsRepository, + private checksRepository: IChecksRepository ) {} private assertCustomDomainAllowed = (customDomain: string | null | undefined) => { @@ -116,7 +120,11 @@ export class StatusPageService implements IStatusPageService { return statusPages.map((sp) => this.normalizeTheme(sp)); }; - getPublicStatusPagePayload = async (statusPage: StatusPage, requesterTeamId: string | undefined): Promise => { + getPublicStatusPagePayload = async ( + statusPage: StatusPage, + requesterTeamId: string | undefined, + range: StatusPageRange = "latest" + ): Promise => { if (!statusPage.isPublished) { if (!requesterTeamId || statusPage.teamId !== requesterTeamId) { throw new AppError({ message: "Forbidden", status: 403 }); @@ -128,7 +136,26 @@ export class StatusPageService implements IStatusPageService { 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)); - return { statusPage, monitors: sorted.map((monitor) => this.toPublicMonitor(monitor, showURL)) }; + if (range === "latest") { + return { statusPage, monitors: sorted.map((monitor) => this.toPublicMonitor(monitor, showURL)) }; + } + + const days = STATUS_PAGE_RANGE_DAYS[range]; + const bucketTimezone = statusPage.timezone ?? "Etc/UTC"; + const buckets = await this.checksRepository.getDailyStatusBuckets(statusPage.monitors, days, bucketTimezone); + const bucketsByMonitor = Map.groupBy(buckets, (bucket) => bucket.monitorId); + + return { + statusPage, + range, + bucketTimezone, + checkTTLDays: (await this.settingsService.getDBSettings()).checkTTL, + monitors: sorted.map((monitor) => ({ + ...this.toPublicMonitor(monitor, showURL), + recentChecks: [], + dailyChecks: bucketsByMonitor.get(monitor.id) ?? [], + })), + }; }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index dc5977273..a8bb8fbc7 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -63,4 +63,7 @@ export type PublicStatusPageMonitor = Pick Date: Mon, 17 Aug 2026 12:31:01 -0700 Subject: [PATCH 4/9] wire controller --- server/src/api/controllers/statusPageController.ts | 7 ++++--- server/src/domain/status-pages/status-page.service.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 038ddb423..322f74c8d 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -70,13 +70,14 @@ class StatusPageController implements IStatusPageController { getStatusPageByUrl = catchAsync(async (req: Request, res: Response) => { getStatusPageParamValidation.parse(req.params); getStatusPageQueryValidation.parse(req.query); + const { range } = getStatusPageQueryValidation.parse(req.query); if (!req.params.url) { throw new AppError({ message: "Status page URL is required", status: 400 }); } const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); - const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId); + const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId, range); return res.status(200).json({ success: true, @@ -87,7 +88,7 @@ class StatusPageController implements IStatusPageController { resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => { resolveStatusPageQueryValidation.parse(req.query); - + const { range } = resolveStatusPageQueryValidation.parse(req.query); const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); if (!domain) { throw new AppError({ message: "Domain is required", status: 400 }); @@ -98,7 +99,7 @@ class StatusPageController implements IStatusPageController { throw new AppError({ message: "Status page not found", status: 404 }); } - const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId); + const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId, range); return res.status(200).json({ success: true, diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 7d953f8f3..cfacc3c4f 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -19,7 +19,7 @@ export interface IStatusPageService { getStatusPageByUrl(url: string): Promise; getStatusPageByCustomDomain(customDomain: string): Promise; getStatusPagesByTeamId(teamId: string): Promise; - getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined): Promise; + getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined, range: StatusPageRange): Promise; updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; deleteStatusPage(statusPageId: string, teamId: string): Promise; From ab7f2cd8f4edf24b13551843843a85045ab14f50 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:36:39 -0700 Subject: [PATCH 5/9] update openapi docs --- server/openapi.json | 317 +++++++++++++++++- server/openapi/routes/statusPage.ts | 51 ++- .../api/validation/statusPageValidation.ts | 80 +++++ server/src/config/services.api.ts | 2 +- server/src/domain/checks/check.type.ts | 2 +- .../status-page-repository.mongo.ts | 1 - 6 files changed, 429 insertions(+), 24 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index e95617426..3fc6568b6 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1419,6 +1419,293 @@ "address": "https://ntfy.sh", "topic": "checkmate-alerts" } + }, + "PublicStatusPagePayload": { + "type": "object", + "properties": { + "statusPage": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "type": { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + }, + "companyName": { + "type": "string" + }, + "url": { + "type": "string" + }, + "customDomain": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string" + }, + "color": { + "type": "string" + }, + "monitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "subMonitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "originalMonitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "logo": { + "type": "object", + "nullable": true, + "properties": { + "data": { + "type": "string" + }, + "contentType": { + "type": "string" + } + }, + "required": ["data", "contentType"] + }, + "isPublished": { + "type": "boolean" + }, + "showCharts": { + "type": "boolean" + }, + "showUptimePercentage": { + "type": "boolean" + }, + "showAdminLoginLink": { + "type": "boolean" + }, + "showInfrastructure": { + "type": "boolean" + }, + "customCSS": { + "type": "string" + }, + "theme": { + "type": "string", + "enum": ["refined", "modern", "bold", "editorial", "minimal"] + }, + "themeMode": { + "type": "string", + "enum": ["auto", "light", "dark"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "teamId", + "type", + "companyName", + "url", + "color", + "monitors", + "subMonitors", + "isPublished", + "showCharts", + "showUptimePercentage", + "showAdminLoginLink", + "showInfrastructure", + "customCSS", + "theme", + "themeMode", + "createdAt", + "updatedAt" + ] + }, + "monitors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "uptimePercentage": { + "type": "number" + }, + "recentChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "boolean" + }, + "responseTime": { + "type": "number" + }, + "statusCode": { + "type": "number" + }, + "message": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "accessibility": { + "type": "number" + }, + "bestPractices": { + "type": "number" + }, + "seo": { + "type": "number" + }, + "performance": { + "type": "number" + } + }, + "required": ["id", "status", "responseTime", "statusCode", "message", "createdAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "dailyChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "monitorId": { + "type": "string" + }, + "date": { + "type": "string" + }, + "totalChecks": { + "type": "number" + }, + "upChecks": { + "type": "number" + }, + "downChecks": { + "type": "number" + }, + "avgResponseTime": { + "type": "number", + "nullable": true + } + }, + "required": ["monitorId", "date", "totalChecks", "upChecks", "downChecks", "avgResponseTime"] + } + } + }, + "required": ["id", "name", "type", "status", "recentChecks"] + } + }, + "range": { + "type": "string", + "enum": ["latest", "30d", "60d", "90d"] + }, + "bucketTimezone": { + "type": "string" + }, + "checkTTLDays": { + "type": "number" + } + }, + "required": ["statusPage", "monitors"], + "example": { + "statusPage": { + "id": "65f1c2a4d8b9e0123456789c", + "userId": "65f1c2a4d8b9e01234567891", + "teamId": "65f1c2a4d8b9e01234567890", + "type": ["uptime"], + "companyName": "Acme", + "url": "acme-status", + "customDomain": null, + "timezone": "America/Toronto", + "color": "#4169E1", + "monitors": ["65f1c2a4d8b9e0123456789a"], + "subMonitors": [], + "isPublished": true, + "showCharts": true, + "showUptimePercentage": true, + "showAdminLoginLink": false, + "showInfrastructure": false, + "customCSS": "", + "theme": "refined", + "themeMode": "auto", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + }, + "range": "90d", + "bucketTimezone": "America/Toronto", + "checkTTLDays": 30, + "monitors": [ + { + "id": "65f1c2a4d8b9e0123456789a", + "name": "API", + "type": "http", + "status": "up", + "uptimePercentage": 0.9987, + "recentChecks": [], + "dailyChecks": [ + { + "monitorId": "65f1c2a4d8b9e0123456789a", + "date": "2026-07-19", + "totalChecks": 2880, + "upChecks": 2877, + "downChecks": 3, + "avgResponseTime": 142 + } + ] + } + ] + } } }, "parameters": {} @@ -6789,14 +7076,6 @@ "name": "numToDisplay", "in": "query" }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "normalize", - "in": "query" - }, { "schema": { "anyOf": [ @@ -11483,11 +11762,12 @@ }, { "schema": { - "type": "number", - "nullable": true + "type": "string", + "enum": ["latest", "30d", "60d", "90d"], + "default": "latest" }, "required": false, - "name": "timeFrame", + "name": "range", "in": "query" }, { @@ -11515,10 +11795,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/PublicStatusPagePayload" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } @@ -11588,11 +11868,12 @@ }, { "schema": { - "type": "number", - "nullable": true + "type": "string", + "enum": ["latest", "30d", "60d", "90d"], + "default": "latest" }, "required": false, - "name": "timeFrame", + "name": "range", "in": "query" } ], @@ -11612,10 +11893,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/PublicStatusPagePayload" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } diff --git a/server/openapi/routes/statusPage.ts b/server/openapi/routes/statusPage.ts index 7ecc6e6d7..b8bfddc76 100644 --- a/server/openapi/routes/statusPage.ts +++ b/server/openapi/routes/statusPage.ts @@ -1,14 +1,59 @@ import { registry } from "../registry.js"; -import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { bearer, multipart, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, + publicStatusPagePayloadResponseSchema, resolveStatusPageQueryValidation, } from "@/api/validation/statusPageValidation.js"; const tags = ["status-page"]; +const publicStatusPageObject = publicStatusPagePayloadResponseSchema.openapi("PublicStatusPagePayload", { + example: { + statusPage: { + id: "65f1c2a4d8b9e0123456789c", + userId: "65f1c2a4d8b9e01234567891", + teamId: "65f1c2a4d8b9e01234567890", + type: ["uptime"], + companyName: "Acme", + url: "acme-status", + customDomain: null, + timezone: "America/Toronto", + color: "#4169E1", + monitors: ["65f1c2a4d8b9e0123456789a"], + subMonitors: [], + isPublished: true, + showCharts: true, + showUptimePercentage: true, + showAdminLoginLink: false, + showInfrastructure: false, + customCSS: "", + theme: "refined", + themeMode: "auto", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", + }, + range: "90d", + bucketTimezone: "America/Toronto", + checkTTLDays: 30, + monitors: [ + { + id: "65f1c2a4d8b9e0123456789a", + name: "API", + type: "http", + status: "up", + uptimePercentage: 0.9987, + recentChecks: [], + dailyChecks: [ + { monitorId: "65f1c2a4d8b9e0123456789a", date: "2026-07-19", totalChecks: 2880, upChecks: 2877, downChecks: 3, avgResponseTime: 142 }, + ], + }, + ], + }, +}); + registry.registerPath({ method: "get", path: "/status-page/team", @@ -44,7 +89,7 @@ registry.registerPath({ tags, summary: "Resolve a published status page by custom domain", request: { query: resolveStatusPageQueryValidation }, - responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, + responses: { "200": okJson(publicStatusPageObject), "404": { description: "Status page not found" }, "500": standardErrors["500"] }, }); registry.registerPath({ @@ -53,7 +98,7 @@ registry.registerPath({ tags, summary: "Get a public status page by its URL slug", request: { params: getStatusPageParamValidation, query: getStatusPageQueryValidation }, - responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, + responses: { "200": okJson(publicStatusPageObject), "404": { description: "Status page not found" }, "500": standardErrors["500"] }, }); registry.registerPath({ diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 6b8011044..b363efd15 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; +import { MonitorTypes, MonitorStatuses } from "@/domain/monitors/monitor.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; import { ImageMimeTypes } from "@/types/upload.js"; @@ -80,3 +81,82 @@ export const imageValidation = z .refine((data) => data.buffer, { message: "Image file is required", }); + +// Keep aligned with DailyCheckBucket in domain/checks/check.type.ts. avgResponseTime is null +// when no check that day recorded a response time ($avg skips missing, $round passes null through). +export const dailyCheckBucketResponseSchema = z.object({ + monitorId: z.string(), + date: z.string(), + totalChecks: z.number(), + upChecks: z.number(), + downChecks: z.number(), + avgResponseTime: z.number().nullable(), +}); + +// Keep aligned with CheckSnapshot in domain/checks/check.type.ts. +export const checkSnapshotResponseSchema = z + .object({ + id: z.string(), + status: z.boolean(), + responseTime: z.number(), + statusCode: z.number(), + message: z.string(), + createdAt: z.string(), + accessibility: z.number().optional(), + bestPractices: z.number().optional(), + seo: z.number().optional(), + performance: z.number().optional(), + }) + .passthrough(); + +// Status page entity as serialized by the repository. Keep aligned with StatusPage in +// domain/status-pages/status-page.type.ts. +export const statusPageResponseSchema = z.object({ + id: z.string(), + userId: z.string(), + teamId: z.string(), + type: z.array(z.enum(StatusPageTypes)), + companyName: z.string(), + url: z.string(), + customDomain: z.string().nullable().optional(), + timezone: z.string().optional(), + color: z.string(), + monitors: z.array(z.string()), + subMonitors: z.array(z.string()), + originalMonitors: z.array(z.string()).optional(), + logo: z.object({ data: z.string(), contentType: z.string() }).nullable().optional(), + isPublished: z.boolean(), + showCharts: z.boolean(), + showUptimePercentage: z.boolean(), + showAdminLoginLink: z.boolean(), + showInfrastructure: z.boolean(), + customCSS: z.string(), + theme: z.enum(StatusPageThemes), + themeMode: z.enum(StatusPageThemeModes), + createdAt: z.string(), + updatedAt: z.string(), +}); + +// Keep aligned with PublicStatusPageMonitor in domain/status-pages/status-page.type.ts. +// url/port are present only when the showURL setting is enabled; dailyChecks only when range !== "latest". +export const publicStatusPageMonitorResponseSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(MonitorTypes), + status: z.enum(MonitorStatuses), + uptimePercentage: z.number().optional(), + recentChecks: z.array(checkSnapshotResponseSchema), + url: z.string().optional(), + port: z.number().optional(), + dailyChecks: z.array(dailyCheckBucketResponseSchema).optional(), +}); + +// Response of the public status page endpoints. Keep aligned with PublicStatusPagePayload in +// domain/status-pages/status-page.type.ts; range, bucketTimezone, and checkTTLDays are present only when range !== "latest". +export const publicStatusPagePayloadResponseSchema = z.object({ + statusPage: statusPageResponseSchema, + monitors: z.array(publicStatusPageMonitorResponseSchema), + range: z.enum(StatusPageRanges).optional(), + bucketTimezone: z.string().optional(), + checkTTLDays: z.number().optional(), +}); diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index 2768770c3..ae16814fd 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -91,7 +91,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A emailService, }); - const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository); + const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository); const tagsService = new TagsService(tagsRepository, monitorsRepository); const diagnosticService = new DiagnosticService(db); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 2f933dd0b..aa1eb3782 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -250,5 +250,5 @@ export type DailyCheckBucket = { totalChecks: number; upChecks: number; downChecks: number; - avgResponseTime: number; + avgResponseTime: number | null; }; diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 0b74a9374..467ce10f5 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -5,7 +5,6 @@ import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; -import CheckModel from "@/domain/checks/check.model.js"; // Type for update data that can include document-level fields (Buffer for logo) type StatusPageUpdateData = Partial> & { logo?: StatusPageLogoDocument | null; From 9001a8d29494392e11e241ddc52fef0fb7696dc7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:58:56 -0700 Subject: [PATCH 6/9] add tests --- ...checksRepositoryDailyStatusBuckets.test.ts | 124 ++++++++++++++++++ .../unit/services/statusPageService.test.ts | 67 +++++++++- .../validation/statusPageValidation.test.ts | 31 ++++- 3 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 server/test/integration/checksRepositoryDailyStatusBuckets.test.ts diff --git a/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts new file mode 100644 index 000000000..5517c3a98 --- /dev/null +++ b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts"; +import { CheckModel } from "../../src/domain/checks/check.model.ts"; +import type { ILogger } from "../../src/utils/logger.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The guarantees under test — timezone-correct $dateTrunc day boundaries, $avg +// skipping missing responseTime (null bucket, not 0), and zero-check days yielding +// no rows — are all aggregation-engine behavior, so they can only be exercised +// against a live mongod. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await CheckModel.createCollection(); // timeseries collections must exist before insert +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await CheckModel.deleteMany({}); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const MONITOR_A = new mongoose.Types.ObjectId(); +const MONITOR_B = new mongoose.Types.ObjectId(); +const TEAM_ID = new mongoose.Types.ObjectId(); + +const DAY_MS = 24 * 60 * 60 * 1000; + +// One shared instant per seeded "day" keeps every check of that day in the same +// bucket no matter when the test runs; instants exactly 24h apart always land on +// distinct calendar days. +const TODAY = new Date(Date.now() - 10 * 60 * 1000); +const ONE_DAY_AGO = new Date(TODAY.getTime() - DAY_MS); + +const utcDate = (date: Date) => date.toISOString().slice(0, 10); + +// Yesterday at 03:30 UTC: in America/Toronto that is 23:30 (EDT) or 22:30 (EST) +// of the *prior* day, so the local calendar date is always one behind UTC. +const yesterdayAtUtc = (hours: number, minutes: number) => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - 1); + date.setUTCHours(hours, minutes, 0, 0); + return date; +}; + +const seedCheck = (monitorId: mongoose.Types.ObjectId, createdAt: Date, overrides: Record = {}) => + CheckModel.create({ + metadata: { monitorId, teamId: TEAM_ID, type: "http" }, + status: true, + responseTime: 100, + createdAt, + ...overrides, + }); + +describe("MongoChecksRepository getDailyStatusBuckets", () => { + let repo: MongoChecksRepository; + + beforeEach(() => { + repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger); + }); + + it("groups by monitor and calendar day with up/down math; zero-check days produce no rows", async () => { + await seedCheck(MONITOR_A, TODAY, { responseTime: 100 }); + await seedCheck(MONITOR_A, TODAY, { responseTime: 300 }); + await seedCheck(MONITOR_A, TODAY, { status: false, responseTime: 200 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { responseTime: 100 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { status: false, responseTime: undefined }); + await seedCheck(MONITOR_B, TODAY, { status: false, responseTime: undefined }); + // Two days ago is deliberately unseeded: the gap day must be absent, not zero-filled. + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString(), MONITOR_B.toString()], 7, "UTC"); + + const monitorABuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_A.toString()); + const monitorBBuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_B.toString()); + + // Ascending by day; the day with a responseTime-less check averages over the + // checks that have one ($avg skips missing) rather than coercing them to 0. + expect(monitorABuckets).toEqual([ + { monitorId: MONITOR_A.toString(), date: utcDate(ONE_DAY_AGO), totalChecks: 2, upChecks: 1, downChecks: 1, avgResponseTime: 100 }, + { monitorId: MONITOR_A.toString(), date: utcDate(TODAY), totalChecks: 3, upChecks: 2, downChecks: 1, avgResponseTime: 200 }, + ]); + // A day where no check recorded a responseTime yields null, not 0. + expect(monitorBBuckets).toEqual([ + { monitorId: MONITOR_B.toString(), date: utcDate(TODAY), totalChecks: 1, upChecks: 0, downChecks: 1, avgResponseTime: null }, + ]); + }); + + it("cuts day boundaries in the requested timezone", async () => { + const lateEveningLocal = yesterdayAtUtc(3, 30); + await seedCheck(MONITOR_A, lateEveningLocal); + + const utcBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + const torontoBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "America/Toronto"); + + const previousUtcDay = new Date(lateEveningLocal); + previousUtcDay.setUTCDate(previousUtcDay.getUTCDate() - 1); + + expect(utcBuckets).toHaveLength(1); + expect(utcBuckets[0].date).toBe(utcDate(lateEveningLocal)); + expect(torontoBuckets).toHaveLength(1); + expect(torontoBuckets[0].date).toBe(utcDate(previousUtcDay)); + }); + + it("excludes checks older than the requested window", async () => { + await seedCheck(MONITOR_A, new Date(Date.now() - 10 * DAY_MS)); + await seedCheck(MONITOR_A, TODAY); + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + + expect(buckets).toHaveLength(1); + expect(buckets[0]).toMatchObject({ date: utcDate(TODAY), totalChecks: 1 }); + }); +}); diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 149179bd7..9c8a4b27f 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -3,6 +3,8 @@ import { StatusPageService } from "../../../src/domain/status-pages/status-page. import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; +import type { CheckSnapshot } from "../../../src/domain/checks/check.type.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/domain/status-pages/status-page.type.ts"; @@ -69,7 +71,7 @@ const createSettingsService = (themesEnabled: boolean, clientHost = "http://loca ({ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), getSettings: jest.fn().mockReturnValue({ clientHost }), - getDBSettings: jest.fn().mockResolvedValue({ showURL }), + getDBSettings: jest.fn().mockResolvedValue({ showURL, checkTTL: 30 }), }) as unknown as jest.Mocked; const createMonitorsRepo = () => @@ -77,12 +79,18 @@ const createMonitorsRepo = () => findByIds: jest.fn().mockResolvedValue([makeMonitor()]), }) as unknown as jest.Mocked; +const createChecksRepo = () => + ({ + getDailyStatusBuckets: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + const createService = (themesEnabled = true, clientHost = "http://localhost:5173", showURL = false) => { const repo = createRepo(); const settingsService = createSettingsService(themesEnabled, clientHost, showURL); const monitorsRepo = createMonitorsRepo(); - const service = new StatusPageService(repo, settingsService, monitorsRepo); - return { service, repo, settingsService, monitorsRepo }; + const checksRepo = createChecksRepo(); + const service = new StatusPageService(repo, settingsService, monitorsRepo, checksRepo); + return { service, repo, settingsService, monitorsRepo, checksRepo }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -353,6 +361,59 @@ describe("StatusPageService", () => { statusPage: expect.objectContaining({ id: "sp-1" }), }); }); + + describe("range", () => { + it("omits range-mode fields and never queries buckets for the default (latest) range", async () => { + const { service, checksRepo } = createService(); + + const implicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined); + const explicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined, "latest"); + + for (const payload of [implicitLatest, explicitLatest]) { + expect(payload).not.toHaveProperty("range"); + expect(payload).not.toHaveProperty("bucketTimezone"); + expect(payload).not.toHaveProperty("checkTTLDays"); + expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); + } + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + }); + + it("attaches dailyChecks, empties recentChecks, 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 }; + (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" }); + + const payload = await service.getPublicStatusPagePayload(page, undefined, "30d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 30, "America/Toronto"); + expect(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); + expect(payload.monitors[0].recentChecks).toEqual([]); + expect(payload.monitors[0].dailyChecks).toEqual([bucket]); + for (const field of SENSITIVE_FIELDS) { + expect(payload.monitors[0]).not.toHaveProperty(field); + } + }); + + it("falls back to Etc/UTC when the page has no timezone and defaults dailyChecks to empty", async () => { + const { service, checksRepo } = createService(); + + const payload = await service.getPublicStatusPagePayload(publishedPage(), undefined, "60d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 60, "Etc/UTC"); + expect(payload.bucketTimezone).toBe("Etc/UTC"); + expect(payload.monitors[0].dailyChecks).toEqual([]); + }); + + it("does not bypass the unpublished-page 403", async () => { + const { service, checksRepo } = createService(); + const unpublished = makeStatusPage({ isPublished: false, teamId: "team-A" }); + + await expect(service.getPublicStatusPagePayload(unpublished, undefined, "90d")).rejects.toMatchObject({ status: 403 }); + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + }); + }); }); describe("deleteStatusPage", () => { diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts index a2d659c34..ce4e0191b 100644 --- a/server/test/unit/validation/statusPageValidation.test.ts +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@jest/globals"; -import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { createStatusPageBodyValidation, getStatusPageQueryValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { StatusPageRanges } from "../../../src/domain/status-pages/status-page.type.ts"; const baseCreateBody = (overrides: Record = {}) => ({ type: "uptime", @@ -61,3 +62,31 @@ describe("createStatusPageBodyValidation", () => { } }); }); + +describe("getStatusPageQueryValidation", () => { + it("defaults range to latest when absent", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe("latest"); + } + }); + + it("accepts every supported range", () => { + for (const range of StatusPageRanges) { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe(range); + } + } + }); + + it("rejects an unsupported range", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range: "45d" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: ["range"] })])); + } + }); +}); From ff2e1fd648eea840557b377a0e786c9e11d56233 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:59:23 -0700 Subject: [PATCH 7/9] use reduce --- server/src/domain/status-pages/status-page.service.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index cfacc3c4f..6aab311ee 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -13,6 +13,7 @@ import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { Monitor } from "@/domain/monitors/monitor.type.js"; import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import type { DailyCheckBucket } from "@/domain/checks/check.type.js"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -143,7 +144,15 @@ export class StatusPageService implements IStatusPageService { const days = STATUS_PAGE_RANGE_DAYS[range]; const bucketTimezone = statusPage.timezone ?? "Etc/UTC"; const buckets = await this.checksRepository.getDailyStatusBuckets(statusPage.monitors, days, bucketTimezone); - const bucketsByMonitor = Map.groupBy(buckets, (bucket) => bucket.monitorId); + const bucketsByMonitor = buckets.reduce((grouped, bucket) => { + const monitorBuckets = grouped.get(bucket.monitorId); + if (monitorBuckets) { + monitorBuckets.push(bucket); + } else { + grouped.set(bucket.monitorId, [bucket]); + } + return grouped; + }, new Map()); return { statusPage, From 7b1d6c763fb133168628082e738025f68860183b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:05:03 -0700 Subject: [PATCH 8/9] tighten types, remove duplicate calls --- server/openapi.json | 2 +- server/src/api/controllers/statusPageController.ts | 1 - server/src/api/validation/statusPageValidation.ts | 10 ++++++++-- server/src/domain/checks/check.repository.interface.ts | 2 +- server/src/domain/checks/check.repository.mongo.ts | 2 +- server/src/domain/status-pages/status-page.service.ts | 7 ++++--- server/src/domain/status-pages/status-page.type.ts | 8 +++++--- server/test/unit/services/statusPageService.test.ts | 4 +++- 8 files changed, 23 insertions(+), 13 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 3fc6568b6..0ea728a61 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1648,7 +1648,7 @@ }, "range": { "type": "string", - "enum": ["latest", "30d", "60d", "90d"] + "enum": ["30d", "60d", "90d"] }, "bucketTimezone": { "type": "string" diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 322f74c8d..9153c152c 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -69,7 +69,6 @@ class StatusPageController implements IStatusPageController { getStatusPageByUrl = catchAsync(async (req: Request, res: Response) => { getStatusPageParamValidation.parse(req.params); - getStatusPageQueryValidation.parse(req.query); const { range } = getStatusPageQueryValidation.parse(req.query); if (!req.params.url) { diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index b363efd15..ad2277797 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; -import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; +import { + StatusPageTypes, + StatusPageThemes, + StatusPageThemeModes, + StatusPageRanges, + StatusPageDayRanges, +} from "@/domain/status-pages/status-page.type.js"; import { MonitorTypes, MonitorStatuses } from "@/domain/monitors/monitor.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; @@ -156,7 +162,7 @@ export const publicStatusPageMonitorResponseSchema = z.object({ export const publicStatusPagePayloadResponseSchema = z.object({ statusPage: statusPageResponseSchema, monitors: z.array(publicStatusPageMonitorResponseSchema), - range: z.enum(StatusPageRanges).optional(), + range: z.enum(StatusPageDayRanges).optional(), bucketTimezone: z.string().optional(), checkTTLDays: z.number().optional(), }); diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 91ea5bb5b..1767b2ea1 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -41,7 +41,7 @@ export interface IChecksRepository { ): Promise; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; - getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; + getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise; // update //delete diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 2840907c9..de144ea80 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -329,7 +329,7 @@ class MongoChecksRepository implements IChecksRepository { return docs.map(this.toEntity); }; - getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { + getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string) => { const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 6aab311ee..25e6d3e08 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -132,8 +132,9 @@ export class StatusPageService implements IStatusPageService { } } - const showURL = (await this.settingsService.getDBSettings()).showURL; - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); + const dbSettings = await this.settingsService.getDBSettings(); + const showURL = dbSettings.showURL; + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors, { includeRecentChecks: range === "latest" }); 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)); @@ -158,7 +159,7 @@ export class StatusPageService implements IStatusPageService { statusPage, range, bucketTimezone, - checkTTLDays: (await this.settingsService.getDBSettings()).checkTTL, + checkTTLDays: dbSettings.checkTTL, monitors: sorted.map((monitor) => ({ ...this.toPublicMonitor(monitor, showURL), recentChecks: [], diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index a8bb8fbc7..630a22ce2 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -11,9 +11,11 @@ export const StatusPageThemeModes = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; -export const StatusPageRanges = ["latest", "30d", "60d", "90d"] as const; +export const StatusPageDayRanges = ["30d", "60d", "90d"] as const; +export type StatusPageDayRange = (typeof StatusPageDayRanges)[number]; +export const StatusPageRanges = ["latest", ...StatusPageDayRanges] as const; export type StatusPageRange = (typeof StatusPageRanges)[number]; -export const STATUS_PAGE_RANGE_DAYS: Record, number> = { +export const STATUS_PAGE_RANGE_DAYS: Record = { "30d": 30, "60d": 60, "90d": 90, @@ -63,7 +65,7 @@ export type PublicStatusPageMonitor = Pick { describe("range", () => { it("omits range-mode fields and never queries buckets for the default (latest) range", async () => { - const { service, checksRepo } = createService(); + const { service, checksRepo, monitorsRepo } = createService(); const implicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined); const explicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined, "latest"); @@ -376,6 +376,7 @@ describe("StatusPageService", () => { expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); } expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: true }); }); it("attaches dailyChecks, empties recentChecks, and echoes range metadata for a day range", async () => { @@ -388,6 +389,7 @@ describe("StatusPageService", () => { 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(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); expect(payload.monitors[0].recentChecks).toEqual([]); expect(payload.monitors[0].dailyChecks).toEqual([bucket]); From 7478bd7d17b3b7bb93d596d5dc41b470975a105d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:08:43 -0700 Subject: [PATCH 9/9] remove duplicate validation --- server/src/api/controllers/statusPageController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 9153c152c..94783b5a9 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -86,7 +86,6 @@ class StatusPageController implements IStatusPageController { }); resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => { - resolveStatusPageQueryValidation.parse(req.query); const { range } = resolveStatusPageQueryValidation.parse(req.query); const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); if (!domain) {