diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 3c0408628..e6b3e953c 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -149,6 +149,7 @@ const StatusPageView = () => { diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 96beeb173..df99291f2 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -4,9 +4,10 @@ import Stack from "@mui/material/Stack"; import { useTranslation } from "react-i18next"; import type { SxProps, Theme } from "@mui/material/styles"; import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; +import type { StatusPage, ActiveMaintenanceInfo } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; +import { MaintenanceBanner } from "@/Pages/StatusPage/Status/themes/shared/MaintenanceBanner"; import { ThemedHeatmap, type HeatCellKind, @@ -81,10 +82,16 @@ export interface ThemeConfig { interface Props { statusPage: StatusPage; monitors: StatusPageMonitor[]; + activeMaintenances?: ActiveMaintenanceInfo[]; config: ThemeConfig; } -export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { +export const BaseStatusPage = ({ + statusPage, + monitors, + activeMaintenances, + config, +}: Props) => { const { t } = useTranslation(); const { tokens, mode } = useStatusPageTheme(); const styles = useMemo( @@ -125,6 +132,12 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { styles={styles} /> + + {statusPage.showCharts && ( { + const { t } = useTranslation(); + const { tokens, timezone: themeTimezone } = useStatusPageTheme(); + const effectiveTimezone = timezone || themeTimezone; + + if (!activeMaintenances || activeMaintenances.length === 0) { + return null; + } + + const monitorMap = new Map(monitors.map((m) => [m.id, m.name])); + + return ( + + {activeMaintenances.map((mw) => { + const affectedNames = mw.monitorIds + .map((id) => monitorMap.get(id)) + .filter(Boolean); + + const formattedEnd = formatDateWithTz( + mw.end, + "MMM D, YYYY h:mm A", + effectiveTimezone + ); + + const etaText = t("pages.statusPages.maintenanceBanner.eta", { + time: `${formattedEnd} (${effectiveTimezone})`, + }); + + return ( + + + + + + + + {mw.name || t("pages.statusPages.maintenanceBanner.title")} + + + + + + {etaText} + + + + {affectedNames.length > 0 && ( + + + {t("pages.statusPages.maintenanceBanner.affectedServices")} + + {affectedNames.map((name, idx) => ( + + {name} + + ))} + + )} + + ); + })} + + ); +}; diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 56724259a..82c5bc11f 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -9,7 +9,8 @@ import { HeaderGeoTabs, GeoChecksMap, } from "@/Components/monitors"; -import { TrendingUp, AlertTriangle } from "lucide-react"; +import { TrendingUp, AlertTriangle, Wrench } from "lucide-react"; +import Alert from "@mui/material/Alert"; import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable"; import { MonitorStatBoxes } from "@/Components/monitors"; @@ -189,6 +190,18 @@ const UptimeDetailsPage = () => { isAdmin={isAdmin} refetch={refetchMonitor} /> + {monitor.status === "maintenance" && ( + } + sx={{ + borderRadius: "8px", + fontWeight: 500, + }} + > + {t("pages.uptime.details.maintenanceAlert")} + + )} { @@ -128,7 +132,35 @@ 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)) }; + let activeMaintenances: ActiveMaintenanceInfo[] | undefined; + if (this.maintenanceWindowsRepository && statusPage.monitors.length > 0) { + const windows = await this.maintenanceWindowsRepository.findByMonitorIds(statusPage.monitors, statusPage.teamId); + const now = new Date(); + const activeList = windows + .filter((win) => isWindowActive(win, now)) + .map((win) => { + const activeEnd = getActiveWindowEnd(win, now) ?? new Date(win.end); + const affectedMonitorIds = win.monitorIds.filter((id) => statusPage.monitors.includes(id)); + return { + id: win.id, + name: win.name, + start: win.start, + end: activeEnd.toISOString(), + monitorIds: affectedMonitorIds, + }; + }) + .filter((m) => m.monitorIds.length > 0); + + if (activeList.length > 0) { + activeMaintenances = activeList; + } + } + + return { + statusPage, + monitors: sorted.map((monitor) => this.toPublicMonitor(monitor, showURL)), + ...(activeMaintenances ? { activeMaintenances } : {}), + }; }; 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 e80087b39..6800d447f 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -49,7 +49,16 @@ export interface StatusPage { export type PublicStatusPageMonitor = Pick & Partial>; +export interface ActiveMaintenanceInfo { + id: string; + name: string; + start: string; + end: string; + monitorIds: string[]; +} + export interface PublicStatusPagePayload { statusPage: StatusPage; monitors: PublicStatusPageMonitor[]; + activeMaintenances?: ActiveMaintenanceInfo[]; } diff --git a/server/src/utils/maintenanceWindow.ts b/server/src/utils/maintenanceWindow.ts index 67351639d..d1120c965 100644 --- a/server/src/utils/maintenanceWindow.ts +++ b/server/src/utils/maintenanceWindow.ts @@ -26,3 +26,35 @@ export const isWindowActive = (window: MaintenanceWindow, now: Date = new Date() return false; }; + +export const getActiveWindowEnd = (window: MaintenanceWindow, now: Date = new Date()): Date | null => { + if (!window.active) { + return null; + } + + const start = new Date(window.start); + const end = new Date(window.end); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + + if (start <= now && end >= now) { + return new Date(end); + } + + const repeatInterval = window.repeat ?? 0; + if (repeatInterval <= 0 || now < start) { + return null; + } + + const durationMs = end.getTime() - start.getTime(); + if (durationMs < 0) { + return null; + } + + const occurrencesPassed = Math.floor((now.getTime() - start.getTime()) / repeatInterval); + const occurrenceStart = start.getTime() + occurrencesPassed * repeatInterval; + const occurrenceEnd = occurrenceStart + durationMs; + + return occurrenceStart <= now.getTime() && occurrenceEnd >= now.getTime() ? new Date(occurrenceEnd) : null; +}; diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 149179bd7..05abbbee1 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -1,10 +1,14 @@ +import type { Express } from "express"; +import "multer"; import { describe, expect, it, jest } from "@jest/globals"; import { StatusPageService } from "../../../src/domain/status-pages/status-page.service.ts"; 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 { IMaintenanceWindowsRepository } from "../../../src/domain/maintenance-windows/maintenance-window.repository.interface.ts"; +import type { MaintenanceWindow } from "../../../src/domain/maintenance-windows/maintenance-window.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 type { StatusPage, PublicStatusPageMonitor } 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"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -26,13 +30,13 @@ const makeStatusPage = (overrides?: Partial): StatusPage => const createRepo = () => ({ - create: jest.fn().mockResolvedValue(makeStatusPage()), - findByUrl: jest.fn().mockResolvedValue(makeStatusPage()), - findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()), - findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]), - updateById: jest.fn().mockResolvedValue(makeStatusPage()), - deleteById: jest.fn().mockResolvedValue(makeStatusPage()), - removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1), + create: jest.fn(async () => makeStatusPage()), + findByUrl: jest.fn(async () => makeStatusPage()), + findByCustomDomain: jest.fn(async () => makeStatusPage()), + findByTeamId: jest.fn(async () => [makeStatusPage()]), + updateById: jest.fn(async () => makeStatusPage()), + deleteById: jest.fn(async () => makeStatusPage()), + removeMonitorFromStatusPages: jest.fn(async () => 1), }) as unknown as jest.Mocked; const makeMonitor = (overrides?: Partial): Monitor => @@ -69,20 +73,26 @@ 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(async () => ({ showURL })), }) as unknown as jest.Mocked; +const createMaintenanceWindowsRepo = () => + ({ + findByMonitorIds: jest.fn(async () => []), + }) as unknown as jest.Mocked; + const createMonitorsRepo = () => ({ - findByIds: jest.fn().mockResolvedValue([makeMonitor()]), + findByIds: jest.fn(async () => [makeMonitor()]), }) 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 maintenanceWindowsRepo = createMaintenanceWindowsRepo(); + const service = new StatusPageService(repo, settingsService, monitorsRepo, maintenanceWindowsRepo); + return { service, repo, settingsService, monitorsRepo, maintenanceWindowsRepo }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -196,10 +206,7 @@ describe("StatusPageService", () => { it("applies default theme to every result when themes disabled", async () => { const { service, repo } = createService(false); - (repo.findByTeamId as jest.Mock).mockResolvedValue([ - makeStatusPage({ id: "sp-1", theme: "bold" }), - makeStatusPage({ id: "sp-2", theme: "editorial" }), - ]); + repo.findByTeamId.mockResolvedValue([makeStatusPage({ id: "sp-1", theme: "bold" }), makeStatusPage({ id: "sp-2", theme: "editorial" })]); const result = await service.getStatusPagesByTeamId("team-1"); @@ -215,7 +222,7 @@ describe("StatusPageService", () => { it("delegates to repository with all parameters", async () => { const { service, repo } = createService(true); const updated = makeStatusPage({ companyName: "Updated Co" }); - (repo.updateById as jest.Mock).mockResolvedValue(updated); + repo.updateById.mockResolvedValue(updated); const file = { originalname: "new-logo.png" } as Express.Multer.File; const result = await service.updateStatusPage("sp-1", "team-1", file, { companyName: "Updated Co" }); @@ -321,12 +328,12 @@ describe("StatusPageService", () => { it("orders monitors to match the status page's monitor list", async () => { const { service, monitorsRepo } = createService(); - (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([makeMonitor({ id: "mon-1" }), makeMonitor({ id: "mon-2" })]); + monitorsRepo.findByIds.mockResolvedValue([makeMonitor({ id: "mon-1" }), makeMonitor({ id: "mon-2" })]); const page = makeStatusPage({ isPublished: true, monitors: ["mon-2", "mon-1"] }); const { monitors } = await service.getPublicStatusPagePayload(page, undefined); - expect(monitors.map((monitor) => monitor.id)).toEqual(["mon-2", "mon-1"]); + expect(monitors.map((monitor: PublicStatusPageMonitor) => monitor.id)).toEqual(["mon-2", "mon-1"]); }); it("rejects an unpublished page for a mismatched or absent requester team (403)", async () => { @@ -353,6 +360,60 @@ describe("StatusPageService", () => { statusPage: expect.objectContaining({ id: "sp-1" }), }); }); + + it("includes activeMaintenances when active maintenance windows cover status page monitors", async () => { + const { service, maintenanceWindowsRepo } = createService(); + const now = Date.now(); + const activeWindow: MaintenanceWindow = { + id: "mw-1", + name: "Scheduled DB Migration", + teamId: "team-1", + monitorIds: ["mon-1", "mon-99"], + active: true, + duration: 60, + durationUnit: "minutes", + repeat: 0, + start: new Date(now - 60_000).toISOString(), + end: new Date(now + 60_000).toISOString(), + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; + maintenanceWindowsRepo.findByMonitorIds.mockResolvedValue([activeWindow]); + + const result = await service.getPublicStatusPagePayload(publishedPage(), undefined); + + expect(result.activeMaintenances).toBeDefined(); + expect(result.activeMaintenances).toHaveLength(1); + expect(result.activeMaintenances![0]).toMatchObject({ + id: "mw-1", + name: "Scheduled DB Migration", + monitorIds: ["mon-1"], // only includes mon-1 since mon-99 is not on this status page + }); + }); + + it("does not include activeMaintenances when maintenance windows are inactive", async () => { + const { service, maintenanceWindowsRepo } = createService(); + const now = Date.now(); + const inactiveWindow: MaintenanceWindow = { + id: "mw-1", + name: "Past Maintenance", + teamId: "team-1", + monitorIds: ["mon-1"], + active: true, + duration: 60, + durationUnit: "minutes", + repeat: 0, + start: new Date(now - 120_000).toISOString(), + end: new Date(now - 60_000).toISOString(), + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; + maintenanceWindowsRepo.findByMonitorIds.mockResolvedValue([inactiveWindow]); + + const result = await service.getPublicStatusPagePayload(publishedPage(), undefined); + + expect(result.activeMaintenances).toBeUndefined(); + }); }); describe("deleteStatusPage", () => { diff --git a/server/test/unit/utils/maintenanceWindow.test.ts b/server/test/unit/utils/maintenanceWindow.test.ts index 4c4ed3337..dbda6f244 100644 --- a/server/test/unit/utils/maintenanceWindow.test.ts +++ b/server/test/unit/utils/maintenanceWindow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { isWindowActive } from "../../../src/utils/maintenanceWindow.ts"; +import { isWindowActive, getActiveWindowEnd } from "../../../src/utils/maintenanceWindow.ts"; import type { MaintenanceWindow } from "../../../src/domain/maintenance-windows/maintenance-window.type.ts"; const makeWindow = (overrides?: Partial): MaintenanceWindow => { @@ -93,3 +93,37 @@ describe("isWindowActive", () => { expect(isWindowActive(win, new Date("2026-01-15T10:00:00Z"))).toBe(false); }); }); + +describe("getActiveWindowEnd", () => { + it("returns null when window is not active", () => { + expect(getActiveWindowEnd(makeWindow({ active: false }))).toBeNull(); + }); + + it("returns correct end date for one-time active window", () => { + const win = makeWindow({ + start: "2026-01-15T11:00:00Z", + end: "2026-01-15T13:00:00Z", + }); + const result = getActiveWindowEnd(win, new Date("2026-01-15T12:00:00Z")); + expect(result?.toISOString()).toBe("2026-01-15T13:00:00.000Z"); + }); + + it("returns advanced end date for recurring active window", () => { + const win = makeWindow({ + start: "2026-01-15T10:00:00Z", + end: "2026-01-15T10:30:00Z", + repeat: 3_600_000, // 1 hour repeat + }); + // at 12:15, current occurrence is 12:00 -> 12:30 + const result = getActiveWindowEnd(win, new Date("2026-01-15T12:15:00Z")); + expect(result?.toISOString()).toBe("2026-01-15T12:30:00.000Z"); + }); + + it("returns null when window is outside active time", () => { + const win = makeWindow({ + start: "2026-01-15T11:00:00Z", + end: "2026-01-15T13:00:00Z", + }); + expect(getActiveWindowEnd(win, new Date("2026-01-15T14:00:00Z"))).toBeNull(); + }); +});