From a058c0518c9208e58c665322be1b3ed515b06d14 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 19 Jun 2026 09:37:24 -0700 Subject: [PATCH 1/2] change status on exit maintenance window --- server/src/service/statusService.ts | 4 ++-- server/src/worker/worker.check-producer.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index 47aa8d89a..f9bbbb61c 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -232,10 +232,10 @@ export class StatusService implements IStatusService { // Build the status patch — computed against the projected window const patch: Partial = {}; - // Resolve "initializing" up-front, incidents should be created on initialization && down + // Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down let newStatus: MonitorStatus = monitor.status; let statusChanged = false; - if (monitor.status === "initializing") { + if (monitor.status === "initializing" || monitor.status === "maintenance") { newStatus = status === true ? "up" : "down"; patch.status = newStatus; statusChanged = newStatus === "down"; diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index ad3975909..3365ea93b 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -49,7 +49,8 @@ export class CheckProducer implements ICheckProducer { method: "produce", }); if (monitor.status !== "maintenance") { - await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance" }); + // Clear the status window to avoid incidents being created on next check + await this.monitorsRepository.updateById(monitor.id, monitor.teamId, { status: "maintenance", statusWindow: [] }); } return null; } From 7fe7b79103374138f97ceb27aabadf485a38165a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 19 Jun 2026 09:51:25 -0700 Subject: [PATCH 2/2] update server tests --- .../test/unit/services/checkPipeline.test.ts | 2 +- .../test/unit/services/statusService.test.ts | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/server/test/unit/services/checkPipeline.test.ts b/server/test/unit/services/checkPipeline.test.ts index 9b1fffd8b..3cbe71360 100644 --- a/server/test/unit/services/checkPipeline.test.ts +++ b/server/test/unit/services/checkPipeline.test.ts @@ -67,7 +67,7 @@ describe("CheckPipeline", () => { expect(result).toBeNull(); expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); - expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" }); + expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance", statusWindow: [] }); }); it("does not re-write status when already in maintenance", async () => { diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index 2bd5fa74c..d4d25364a 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -557,6 +557,55 @@ describe("StatusService", () => { expect(result.prevStatus).toBe("initializing"); }); + it("leaves 'maintenance' for 'up' on a passing check after the window closes (empty statusWindow)", async () => { + // On entry to a maintenance window the producer clears statusWindow, so the first + // check after the window closes sees an empty window and lands in the warmup + // early-return branch. A passing check must move the monitor out of 'maintenance' + // to 'up' without flagging a status change (no incident). + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + expect(result.prevStatus).toBe("maintenance"); + }); + + it("leaves 'maintenance' for 'down' on a failing check after the window closes (empty statusWindow)", async () => { + // Mirror of the recovery case: a failing first check after the window closes must + // flip the monitor straight to 'down' and surface statusChanged so an incident opens, + // rather than leave it stuck in 'maintenance'. + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "maintenance" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + expect(result.prevStatus).toBe("maintenance"); + }); + + it("unsticks from 'maintenance' to 'up' on a passing check even with a full window (regression: stuck in maintenance)", async () => { + // Guards the up-front override directly: even if a full window survives (e.g. the + // entry-clear is bypassed), computeReachability only transitions out to 'up' from + // 'down', so without the override the monitor would stay stuck in 'maintenance'. + const monitor = makeMonitor({ + statusWindow: [true, true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "maintenance", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.monitor.status).toBe("up"); + }); + it("does not flip status on subsequent checks during warmup once 'initializing' has been left (regression: down→up incident resolves once window fills)", async () => { // After a 'initializing'→'down' transition on check #1, monitor.status is now 'down' // and statusWindow has one false. Subsequent up checks during warmup must leave