Merge pull request #3845 from bluewave-labs/feat/status-page-ranges-server

feat: status page ranges server
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-08-17 13:14:40 -07:00
commit 40e06a2f2b
14 files changed
+788 -43

No files matched your search

+299 -18
View File
@@ -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": ["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"]
}
}
}
+48 -3
View File
@@ -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({
@@ -69,14 +69,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,
@@ -86,8 +86,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 +97,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,
+15
View File
@@ -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
@@ -1,6 +1,13 @@
import { z } from "zod";
import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.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";
import { ImageMimeTypes } from "@/types/upload.js";
@@ -15,7 +22,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({
@@ -40,7 +47,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(),
@@ -80,3 +87,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(StatusPageDayRanges).optional(),
bucketTimezone: z.string().optional(),
checkTTLDays: z.number().optional(),
});
+1 -1
View File
@@ -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);
@@ -2,6 +2,7 @@ import type {
Check,
ChecksQueryResult,
ChecksSummary,
DailyCheckBucket,
HardwareChecksResult,
PageSpeedChecksResult,
UptimeChecksResult,
@@ -40,6 +41,8 @@ export interface IChecksRepository {
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
// update
//delete
deleteByMonitorId(monitorId: string): Promise<number>;
@@ -329,6 +329,44 @@ class MongoChecksRepository implements IChecksRepository {
return docs.map(this.toEntity);
};
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);
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<number> => {
const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) });
return result.deletedCount;
+9
View File
@@ -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 | null;
};
@@ -5,18 +5,22 @@ 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";
import type { DailyCheckBucket } from "@/domain/checks/check.type.js";
export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
getStatusPageByUrl(url: string): Promise<StatusPage>;
getStatusPageByCustomDomain(customDomain: string): Promise<StatusPage>;
getStatusPagesByTeamId(teamId: string): Promise<StatusPage[]>;
getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined): Promise<PublicStatusPagePayload>;
getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined, range: StatusPageRange): Promise<PublicStatusPagePayload>;
updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
deleteStatusPage(statusPageId: string, teamId: string): Promise<StatusPage>;
@@ -26,7 +30,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,19 +121,51 @@ export class StatusPageService implements IStatusPageService {
return statusPages.map((sp) => this.normalizeTheme(sp));
};
getPublicStatusPagePayload = async (statusPage: StatusPage, requesterTeamId: string | undefined): Promise<PublicStatusPagePayload> => {
getPublicStatusPagePayload = async (
statusPage: StatusPage,
requesterTeamId: string | undefined,
range: StatusPageRange = "latest"
): Promise<PublicStatusPagePayload> => {
if (!statusPage.isPublished) {
if (!requesterTeamId || statusPage.teamId !== requesterTeamId) {
throw new AppError({ message: "Forbidden", status: 403 });
}
}
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));
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 = 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<string, DailyCheckBucket[]>());
return {
statusPage,
range,
bucketTimezone,
checkTTLDays: dbSettings.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<StatusPage>): Promise<StatusPage> => {
@@ -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,15 @@ 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 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<StatusPageDayRange, number> = {
"30d": 30,
"60d": 60,
"90d": 90,
};
export interface StatusPageLogo {
data: string;
contentType: string;
@@ -46,10 +56,16 @@ export interface StatusPage {
updatedAt: string;
}
export type PublicStatusPageMonitor = Pick<Monitor, "id" | "name" | "type" | "status" | "uptimePercentage" | "recentChecks"> &
Partial<Pick<Monitor, "url" | "port">>;
export type PublicStatusPageMonitor = Pick<Monitor, "id" | "name" | "type" | "status" | "uptimePercentage" | "recentChecks"> & {
url?: string;
port?: number;
dailyChecks?: DailyCheckBucket[]; // Only present when range !== "latest"
};
export interface PublicStatusPagePayload {
statusPage: StatusPage;
monitors: PublicStatusPageMonitor[];
range?: StatusPageDayRange;
bucketTimezone?: string;
checkTTLDays?: number;
}
@@ -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<string, unknown> = {}) =>
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 });
});
});
@@ -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<ISettingsService>;
const createMonitorsRepo = () =>
@@ -77,12 +79,18 @@ const createMonitorsRepo = () =>
findByIds: jest.fn().mockResolvedValue([makeMonitor()]),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createChecksRepo = () =>
({
getDailyStatusBuckets: jest.fn().mockResolvedValue([]),
}) as unknown as jest.Mocked<IChecksRepository>;
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,61 @@ 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, monitorsRepo } = 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();
expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: true });
});
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(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]);
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", () => {
@@ -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<string, unknown> = {}) => ({
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"] })]));
}
});
});