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;