diff --git a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts index 52594603..6eba4ace 100644 --- a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts @@ -5,9 +5,9 @@ import { page, pageComponent, statusReport, + statusReportsToPageComponents, statusReportUpdate, statusReportUpdateToPageComponents, - statusReportsToPageComponents, } from "@openstatus/db/src/schema"; import type { FrozenMonitorUptimeDay } from "@openstatus/db/src/schema"; import { expect } from "@std/expect"; @@ -49,7 +49,10 @@ function key(offset: number): string { const d = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - offset, 1), ); - return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart( + 2, + "0", + )}`; } function monthStart(k: string): Date { @@ -383,7 +386,7 @@ describe("getUptimeHistory", () => { ctx, input: { pageId: testPage.id }, pipes, - now, + now: new Date(monthStart(key(0)).getTime() + MS_PER_DAY), sleep: noSleep, }); @@ -401,6 +404,67 @@ describe("getUptimeHistory", () => { }); }); + test("expired previous-month counts are no-data unless frozen", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...userCtx, db: tx }; + const testMonitor = await insertMonitor(tx); + const testPage = await insertPage(tx); + await insertComponent(tx, { + pageId: testPage.id, + monitorId: testMonitor.id, + }); + const pipes = makePipes([ + { + monitorId: String(testMonitor.id), + day: "2026-08-31", + ok: 100, + degraded: 0, + error: 0, + }, + { + monitorId: String(testMonitor.id), + day: "2026-09-01", + ok: 0, + degraded: 0, + error: 100, + }, + ]); + + for (const [date, previous, rolling] of [ + ["2026-09-14T23:59:59.999Z", 100, 50], + ["2026-09-15T00:00:00.000Z", null, 0], + ["2026-09-30T12:00:00.000Z", null, 0], + ] as const) { + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes, + now: new Date(date), + sleep: noSleep, + }); + expect(res.rows[0].months["2026-08"]).toBe(previous); + expect(res.rows[0].months["2026-09"]).toBe(0); + expect(res.rows[0].rolling["6"]).toBe(rolling); + expect(res.summary["6"].uptime).toBe(rolling); + } + + await insertFrozen(tx, { + monitorId: testMonitor.id, + month: "2026-08-01", + days: [{ day: "2026-08-01", ok: 25, degraded: 0, error: 75 }], + }); + const res = await getUptimeHistory({ + ctx, + input: { pageId: testPage.id }, + pipes, + now: new Date("2026-09-30T12:00:00.000Z"), + sleep: noSleep, + }); + expect(res.rows[0].months["2026-08"]).toBe(25); + expect(res.rows[0].rolling["6"]).toBe(12.5); + }); + }); + test("tinybird failure: live months degrade to null, frozen months still served", async () => { await withTestTransaction(async (tx) => { const ctx = { ...userCtx, db: tx }; diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index 2effc4a0..5d38b027 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -4,19 +4,23 @@ import { pageConfigurationSchema, } from "@openstatus/db/src/schema"; -import { type ServiceContext, defaultTb, getReadDb } from "../context"; +import { defaultTb, getReadDb, type ServiceContext } from "../context"; import { ForbiddenError, NotFoundError } from "../errors"; import { - type Event, dayCoverage, durationDowntimeMs, + type Event, floorPct, getEvents, reportsOnlyDowntimeMs, requestsTally, } from "../status-timeline"; import { type ComputeCountRow, monthRange } from "./compute"; -import { type UptimeFreezePipes, fetchFreezeCounts } from "./run"; +import { + fetchFreezeCounts, + FREEZE_CUTOFF_MS, + type UptimeFreezePipes, +} from "./run"; import { GetUptimeHistoryInput } from "./schemas"; const HISTORY_MONTHS = 24; @@ -276,9 +280,11 @@ export async function getUptimeHistory(args: { function countsFor(monitorId: number, key: string): DayCount[] | null { const frozen = frozenByKey.get(`${monitorId}:${key}`); if (frozen && key !== currentKey) return frozen; - // older unfrozen months are never reconstructed from the partial 45d - // overlap — backfill is the fix, not partial months - const isLive = key === currentKey || key === previousKey; + // Unfrozen months need full retention coverage, just like the freeze job. + const isLive = + key === currentKey || + (key === previousKey && + nowMs - monthRange(`${key}-01`).start < FREEZE_CUTOFF_MS); if (!isLive || liveFailed.has(String(monitorId))) return null; const byDay = liveByMonitorMonth.get(`${monitorId}:${key}`); if (!byDay || byDay.size === 0) return null; diff --git a/packages/services/src/frozen-uptime/run.ts b/packages/services/src/frozen-uptime/run.ts index fb133030..7113ea6c 100644 --- a/packages/services/src/frozen-uptime/run.ts +++ b/packages/services/src/frozen-uptime/run.ts @@ -42,7 +42,7 @@ const TB_THROTTLE_MS = 250; // the status pipes look back a fixed 45 days; past monthStart + 45d the // earliest month days return no rows and would freeze as permanent zeros -const FREEZE_CUTOFF_MS = 45 * 86_400_000; +export const FREEZE_CUTOFF_MS = 45 * 86_400_000; function chunk(items: T[], size: number): T[][] { if (size <= 0) throw new Error(`chunk size must be positive, got ${size}`);