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;