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();
+ });
+});