From f0ae8f326bcf2cd88565acb0594fd2c58835b993 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:47:13 +0200 Subject: [PATCH] fix: status-page private-location only stats (#2879) * fix: status-page private-location only stats * fix(api): require a private location before counting probe errors as downtime An empty public-region list alone does not make a monitor private-only. Confirm an active private-location assignment so monitors with no locations at all keep incident-based uptime. * Update packages/api/src/router/statusPage.utils.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/api/src/router/statusPage.ts | 23 +++ .../api/src/router/statusPage.utils.test.ts | 145 ++++++++++++++++++ packages/api/src/router/statusPage.utils.ts | 13 +- 3 files changed, 178 insertions(+), 3 deletions(-) diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 2b91ee49b..70fde1fd5 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -858,6 +858,27 @@ export const statusPageRouter = createTRPCRouter({ input.days ?? (parsedConfiguration.success ? parsedConfiguration.data.days : 45); + // No public regions alone doesn't make a monitor private-only: it may + // have no locations at all, so confirm an active private assignment. + const noRegionMonitorIds = monitors + .filter((c) => c.monitor.regions.length === 0) + .map((c) => c.monitor.id); + const privateOnlyMonitorIds = new Set(); + if (noRegionMonitorIds.length > 0) { + const rows = await opts.ctx.db.query.privateLocationToMonitors.findMany( + { + where: and( + inArray(privateLocationToMonitors.monitorId, noRegionMonitorIds), + isNull(privateLocationToMonitors.deletedAt), + ), + columns: { monitorId: true }, + }, + ); + for (const row of rows) { + if (row.monitorId !== null) privateOnlyMonitorIds.add(row.monitorId); + } + } + return pageComponents.map((c) => { const events = getEvents({ maintenances: _page.maintenances, @@ -912,6 +933,8 @@ export const statusPageRouter = createTRPCRouter({ events, barType: effectiveBarType, cardType: effectiveCardType, + privateLocationOnly: + c.monitor != null && privateOnlyMonitorIds.has(c.monitor.id), }); return { diff --git a/packages/api/src/router/statusPage.utils.test.ts b/packages/api/src/router/statusPage.utils.test.ts index 30a7ff249..56542e0a8 100644 --- a/packages/api/src/router/statusPage.utils.test.ts +++ b/packages/api/src/router/statusPage.utils.test.ts @@ -1749,6 +1749,151 @@ describe("componentImpacts", () => { }); }); + describe("getUptime - duration mode probe downtime", () => { + const day2 = dayStartUTC(2); + // day 2: 6 of 24 hourly checks failed; day 1: all ok + const twoDayData = [createStatusData(2, 18, 0, 6), createStatusData(1, 24)]; + const incident = { + id: 1, + name: "Downtime", + from: hoursAfter(day2, 6), + to: hoursAfter(day2, 12), + type: "incident" as const, + status: "error" as const, + }; + + it("public monitor: incident covering the failed checks counts once", () => { + // pre-fix the day-wide probe weight stacked on the incident: 10.5h/48h + expect( + getUptime({ + data: twoDayData, + events: [incident], + barType: "absolute", + cardType: "duration", + }), + ).toBe("87.5%"); + }); + + it("public monitor: failed checks without an incident do not count", () => { + expect( + getUptime({ + data: twoDayData, + events: [], + barType: "absolute", + cardType: "duration", + }), + ).toBe("100%"); + }); + + it("private-only monitor: failed checks count since probes never open incidents", () => { + expect( + getUptime({ + data: twoDayData, + events: [], + barType: "absolute", + cardType: "duration", + privateLocationOnly: true, + }), + ).toBe("87.5%"); + }); + + it("private-only monitor: incident covering the failed checks counts once", () => { + const fullDay = { ...incident, from: day2, to: hoursAfter(day2, 24) }; + expect( + getUptime({ + data: twoDayData, + events: [fullDay], + barType: "absolute", + cardType: "duration", + privateLocationOnly: true, + }), + ).toBe("50%"); + }); + + it("private-only monitor: incident on another day adds to the failed checks", () => { + const day1 = dayStartUTC(1); + const other = { ...incident, from: day1, to: hoursAfter(day1, 12) }; + // 6h probe (day 2) + 12h incident (day 1) = 18h / 48h + expect( + getUptime({ + data: twoDayData, + events: [other], + barType: "absolute", + cardType: "duration", + privateLocationOnly: true, + }), + ).toBe("62.5%"); + }); + + it("private-only monitor: worst weight wins between a report and failed checks", () => { + // partial_outage (0.5) across day 2 outweighs the 0.25 probe error ratio + const events = [ + createImpactEvent(1, day2, hoursAfter(day2, 24), [ + { from: day2, to: hoursAfter(day2, 24), impact: "partial_outage" }, + ]), + ]; + expect( + getUptime({ + data: twoDayData, + events, + barType: "absolute", + cardType: "duration", + privateLocationOnly: true, + }), + ).toBe("75%"); + }); + + it("private-only monitor: degraded checks count as up", () => { + expect( + getUptime({ + data: [createStatusData(2, 18, 6, 0), createStatusData(1, 24)], + events: [], + barType: "absolute", + cardType: "duration", + privateLocationOnly: true, + }), + ).toBe("100%"); + }); + + it("private-only monitor: a day without checks adds no downtime", () => { + expect( + getUptime({ + data: [createStatusData(2, 0, 0, 0), createStatusData(1, 24)], + events: [], + barType: "absolute", + cardType: "duration", + privateLocationOnly: true, + }), + ).toBe("100%"); + }); + + it("flag is ignored in requests mode", () => { + for (const privateLocationOnly of [true, false]) { + expect( + getUptime({ + data: twoDayData, + events: [], + barType: "absolute", + cardType: "requests", + privateLocationOnly, + }), + ).toBe("87.5%"); + } + }); + + it("flag is ignored in manual mode: reports only", () => { + expect( + getUptime({ + data: twoDayData, + events: [incident], + barType: "manual", + cardType: "manual", + privateLocationOnly: true, + }), + ).toBe("100%"); + }); + }); + describe("getEvents - impact projection", () => { const day = dayStartUTC(1); diff --git a/packages/api/src/router/statusPage.utils.ts b/packages/api/src/router/statusPage.utils.ts index 8f1613b5c..b43b144b7 100644 --- a/packages/api/src/router/statusPage.utils.ts +++ b/packages/api/src/router/statusPage.utils.ts @@ -677,11 +677,14 @@ export function getUptime({ events, barType, cardType, + privateLocationOnly = false, }: { data: StatusData[]; events: Event[]; barType: "absolute" | "dominant" | "manual"; cardType: "requests" | "duration" | "dominant" | "manual"; + /** no public regions and ≥1 private location: include probe errors in duration uptime */ + privateLocationOnly?: boolean; }): string { if (barType === "manual" || cardType === "duration") { // Clamp event durations to the data lookback window to avoid @@ -702,10 +705,14 @@ export function getUptime({ // Manual mode: only count manually created reports duration = reportsOnlyDowntimeMs(events, window, coverage); } else { - // Duration mode: merge both event-based and probe-based downtime - // to capture both manual incidents/reports AND automated probe failures + // Duration mode = detected incidents + impact-weighted reports. Private + // probes never open incidents, so private-only monitors fall back to probe + // errors; for public monitors the day-wide probe weight would stack on top + // of the incident covering the same outage and nearly double it (#2481). const eventIntervals = downtimeIntervals(events, window, false); - const probeIntervals = probeDowntimeIntervals(data, window); + const probeIntervals = privateLocationOnly + ? probeDowntimeIntervals(data, window) + : []; const allIntervals = [...eventIntervals, ...probeIntervals]; duration = mergedDowntimeMs( coverage ? clipToCoverage(allIntervals, coverage) : allIntervals, -- 2.51.2