From b0c75269cd48f0538149429ff8fd780c84c86c47 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Tue, 21 Jul 2026 21:50:56 +0200 Subject: [PATCH] refactor: move functions into services --- apps/dashboard/package.json | 1 - apps/status-page/package.json | 1 - packages/api/src/router/statusPage.ts | 22 +- .../api/src/router/statusPage.utils.test.ts | 2111 ----------------- packages/services/package.json | 4 + .../__tests__/set-data-by-type.test.ts | 841 +++++++ .../__tests__/tinybird-fallback.test.ts | 33 + .../src/status-page/__tests__/uptime.test.ts | 556 +++++ packages/services/src/status-page/index.ts | 3 + .../src/status-page/set-data-by-type.ts} | 86 +- .../src/status-page/tinybird-fallback.ts | 36 + packages/services/src/status-page/uptime.ts | 46 + .../status-timeline/__tests__/events.test.ts | 521 +++- packages/services/test/timeline-fixtures.ts | 190 ++ pnpm-lock.yaml | 6 - 15 files changed, 2244 insertions(+), 2213 deletions(-) delete mode 100644 packages/api/src/router/statusPage.utils.test.ts create mode 100644 packages/services/src/status-page/__tests__/set-data-by-type.test.ts create mode 100644 packages/services/src/status-page/__tests__/tinybird-fallback.test.ts create mode 100644 packages/services/src/status-page/__tests__/uptime.test.ts create mode 100644 packages/services/src/status-page/index.ts rename packages/{api/src/router/statusPage.utils.ts => services/src/status-page/set-data-by-type.ts} (86%) create mode 100644 packages/services/src/status-page/tinybird-fallback.ts create mode 100644 packages/services/src/status-page/uptime.ts create mode 100644 packages/services/test/timeline-fixtures.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 89749cb0..21555864 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -51,7 +51,6 @@ "@openstatus/subscriptions": "workspace:*", "@openstatus/theme-store": "workspace:*", "@openstatus/tinybird": "workspace:*", - "@openstatus/tracker": "workspace:*", "@openstatus/ui": "workspace:*", "@openstatus/upstash": "workspace:*", "@openstatus/utils": "workspace:*", diff --git a/apps/status-page/package.json b/apps/status-page/package.json index 58a35fa7..d8791eeb 100644 --- a/apps/status-page/package.json +++ b/apps/status-page/package.json @@ -33,7 +33,6 @@ "@openstatus/react": "workspace:*", "@openstatus/theme-store": "workspace:*", "@openstatus/tinybird": "workspace:*", - "@openstatus/tracker": "workspace:*", "@openstatus/ui": "workspace:*", "@openstatus/utils": "workspace:*", "@radix-ui/react-dropdown-menu": "catalog:", diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 1659d28e..b2422c97 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -23,23 +23,25 @@ import { upsertSelfSignupSubscriber, verifySelfSignupSubscriber, } from "@openstatus/services/page-subscriber"; -import { TRPCError } from "@trpc/server"; -import { endOfDay, startOfDay, subDays } from "date-fns"; -import { z } from "zod"; - -import { createTRPCRouter, publicProcedure } from "../trpc"; +import { + getUptime, + setDataByType, + withTinybirdFallback, +} from "@openstatus/services/status-page"; import { type StatusData, activeReportStatus, fillStatusDataFor45Days, fillStatusDataFor45DaysNoop, getEvents, - getUptime, getWorstVariant, isMonitorComponent, - setDataByType, - withTinybirdFallback, -} from "./statusPage.utils"; +} from "@openstatus/services/status-timeline"; +import { TRPCError } from "@trpc/server"; +import { endOfDay, startOfDay, subDays } from "date-fns"; +import { z } from "zod"; + +import { createTRPCRouter, publicProcedure } from "../trpc"; import { getMetricsLatencyMultiProcedure, getMetricsLatencyProcedure, @@ -417,7 +419,7 @@ export const statusPageRouter = createTRPCRouter({ // In "manual" mode the page only surfaces user-authored events, so drop // monitor-derived incidents from the components consumers read (e.g. the - // calendar). Mirrors the bar/uptime gating in statusPage.utils.ts. + // calendar). Mirrors the bar/uptime gating in @openstatus/services/status-page. const publicPageComponents = barType === "manual" ? pageComponents.map((c) => diff --git a/packages/api/src/router/statusPage.utils.test.ts b/packages/api/src/router/statusPage.utils.test.ts deleted file mode 100644 index a9ae1c57..00000000 --- a/packages/api/src/router/statusPage.utils.test.ts +++ /dev/null @@ -1,2111 +0,0 @@ -import type { - Incident, - Maintenance, - PageComponent, - StatusReport, - StatusReportUpdate, -} from "@openstatus/db/src/schema"; -import { expect } from "@std/expect"; -import { describe, it } from "@std/testing/bdd"; - -import { - currentImpactByComponent, - fillStatusDataFor45Days, - getEvents, - getUptime, - setDataByType, - withTinybirdFallback, -} from "./statusPage.utils"; - -type StatusData = { - day: string; - count: number; - ok: number; - degraded: number; - error: number; - monitorId: string; -}; - -type Event = { - id: number; - name: string; - from: Date; - to: Date | null; - type: "maintenance" | "incident" | "report"; - status: "success" | "degraded" | "error" | "info"; -}; - -// Helper functions to create test data -function createStatusData( - daysAgo: number, - ok = 0, - degraded = 0, - error = 0, -): StatusData { - const date = new Date(); - date.setDate(date.getDate() - daysAgo); - date.setUTCHours(0, 0, 0, 0); - - return { - day: date.toISOString(), - count: ok + degraded + error, - ok, - degraded, - error, - monitorId: "1", - }; -} - -function createIncident(id: number, daysAgo: number, durationHours = 1): Event { - const from = new Date(); - from.setDate(from.getDate() - daysAgo); - from.setHours(from.getHours() - durationHours); - - const to = new Date(from); - to.setHours(to.getHours() + durationHours); - - return { - id, - name: "Downtime", - from, - to, - type: "incident", - status: "error", - }; -} - -function createReport(id: number, daysAgo: number, durationHours = 2): Event { - const from = new Date(); - from.setDate(from.getDate() - daysAgo); - from.setHours(from.getHours() - durationHours); - - const to = new Date(from); - to.setHours(to.getHours() + durationHours); - - return { - id, - name: "Performance Issues", - from, - to, - type: "report", - status: "degraded", - }; -} - -function createMaintenance( - id: number, - daysAgo: number, - durationHours = 1, -): Event { - const from = new Date(); - from.setDate(from.getDate() - daysAgo); - from.setHours(from.getHours() - durationHours); - - const to = new Date(from); - to.setHours(to.getHours() + durationHours); - - return { - id, - name: "Scheduled Maintenance", - from, - to, - type: "maintenance", - status: "info", - }; -} - -describe("setDataByType", () => { - describe("barType: absolute", () => { - it("should show proportional bar segments with error-only incident", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createIncident(1, 0, 2)]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - expect(result).toHaveLength(1); - expect(result[0].bar).toHaveLength(2); - expect(result[0].bar[0].status).toBe("success"); - expect(result[0].bar[1].status).toBe("error"); - // Should have uptime and downtime segments - expect(result[0].bar[0].height).toBeGreaterThan(0); - expect(result[0].bar[1].height).toBeGreaterThan(0); - }); - - it("should show proportional segments with multiple event types", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [ - createIncident(1, 0, 1), - createReport(2, 0, 2), - createMaintenance(3, 0, 1), - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - expect(result[0].bar.length).toBeGreaterThan(1); - // Should include info, degraded, and error segments - const statuses = result[0].bar.map((b) => b.status); - expect(statuses).toContain("error"); - expect(statuses).toContain("degraded"); - expect(statuses).toContain("info"); - }); - - it("should keep downtime proportional to the day when maintenance is present", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [ - createMaintenance(1, 0, 2), // 2h maintenance - createIncident(2, 0, 1), // 1h downtime - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - const bar = result[0].bar; - const errorHeight = bar.find((b) => b.status === "error")?.height ?? 0; - const infoHeight = bar.find((b) => b.status === "info")?.height ?? 0; - - // 1h downtime is at most 1/24 of the day (~4.17%), never the old ~33% - expect(errorHeight).toBeGreaterThan(0); - expect(errorHeight).toBeLessThan(5); - expect(errorHeight).toBeLessThan(infoHeight); - // maintenance fills the remaining space, no green uptime - expect(bar.some((b) => b.status === "success")).toBe(false); - expect(errorHeight + infoHeight).toBeCloseTo(100, 5); - }); - - it("should fill the whole day for a maintenance-only day", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createMaintenance(1, 0, 2)]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("info"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should fill the whole day for a report-only day", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createReport(1, 0, 2)]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("degraded"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should show empty bar when no data available", () => { - const data = [createStatusData(0, 0, 0, 0)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("empty"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should show operational bar with duration cardType and no events", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "duration", - barType: "absolute", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("success"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should show proportional status segments with mixed data and no events", () => { - const data = [createStatusData(0, 80, 15, 5)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - expect(result[0].bar.length).toBeGreaterThan(1); - const statuses = result[0].bar.map((b) => b.status); - expect(statuses).toContain("success"); - expect(statuses).toContain("degraded"); - expect(statuses).toContain("error"); - }); - }); - - describe("barType: dominant", () => { - it("should show error as dominant status when incident exists", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createIncident(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("error"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should show degraded when only reports exist", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createReport(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("degraded"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should show info when only maintenance exists", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createMaintenance(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("info"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should prioritize error over other statuses", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [ - createIncident(1, 0), - createReport(2, 0), - createMaintenance(3, 0), - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].bar[0].status).toBe("error"); - }); - - it("should show data status when no events", () => { - const data = [createStatusData(0, 0, 100, 0)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].bar[0].status).toBe("degraded"); - }); - }); - - describe("barType: manual", () => { - it("should show degraded when reports exist", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createReport(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "manual", - barType: "manual", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("degraded"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should show info when only maintenance exists", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createMaintenance(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "manual", - barType: "manual", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("info"); - expect(result[0].bar[0].height).toBe(100); - }); - - it("should ignore incidents and show success", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createIncident(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "manual", - barType: "manual", - }); - - expect(result[0].bar).toHaveLength(1); - expect(result[0].bar[0].status).toBe("success"); - }); - - it("should prioritize reports over maintenance", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createReport(1, 0), createMaintenance(2, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "manual", - barType: "manual", - }); - - expect(result[0].bar[0].status).toBe("degraded"); - }); - }); - - describe("cardType: requests", () => { - it("should show request counts for each status", () => { - const data = [createStatusData(0, 100, 50, 10)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].card.length).toBe(3); - expect(result[0].card.some((c) => c.value.includes("100 reqs"))).toBe( - true, - ); - expect(result[0].card.some((c) => c.value.includes("50 reqs"))).toBe( - true, - ); - expect(result[0].card.some((c) => c.value.includes("10 reqs"))).toBe( - true, - ); - }); - - it("should format large numbers correctly", () => { - const data = [createStatusData(0, 5000, 0, 0)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].card[0].value).toBe("5.0k reqs"); - }); - - it("should show empty card when no data", () => { - const data = [createStatusData(0, 0, 0, 0)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].card).toHaveLength(1); - expect(result[0].card[0].value).toBe(""); - expect(result[0].card[0].status).toBe("empty"); - }); - - it("should show event status in empty card when no data but events exist", () => { - const data = [createStatusData(0, 0, 0, 0)]; - const events = [createIncident(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result[0].card).toHaveLength(1); - expect(result[0].card[0].status).toBe("error"); - }); - }); - - describe("cardType: duration", () => { - it("should calculate duration for events", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [ - createIncident(1, 0, 1), // 1 hour - createReport(2, 0, 2), // 2 hours - ]; - - const result = setDataByType({ - events, - data, - cardType: "duration", - barType: "absolute", - }); - - expect(result[0].card.length).toBeGreaterThan(0); - // Should have durations for error, degraded, and success - const hasError = result[0].card.some( - (c) => c.status === "error" && c.value.includes("h"), - ); - const hasDegraded = result[0].card.some( - (c) => c.status === "degraded" && c.value.includes("h"), - ); - expect(hasError || hasDegraded).toBe(true); - }); - - it("should format duration in hours and minutes", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createIncident(1, 0, 1.5)]; // 1.5 hours = 1h 30m - - const result = setDataByType({ - events, - data, - cardType: "duration", - barType: "absolute", - }); - - const errorCard = result[0].card.find((c) => c.status === "error"); - expect(errorCard).toBeDefined(); - // Should contain hour notation and optionally minutes - expect(errorCard?.value).toMatch(/\d+h(\s\d+m)?/); - }); - - it("should show success duration as remaining time", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createIncident(1, 0, 1)]; // 1 hour downtime - - const result = setDataByType({ - events, - data, - cardType: "duration", - barType: "absolute", - }); - - const successCard = result[0].card.find((c) => c.status === "success"); - expect(successCard).toBeDefined(); - // Success duration should be total time minus downtime - expect(successCard?.value).toBeTruthy(); - }); - - it("should exclude maintenance from success calculation", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createMaintenance(1, 0, 2)]; // 2 hours maintenance - - const result = setDataByType({ - events, - data, - cardType: "duration", - barType: "absolute", - }); - - const successCard = result[0].card.find((c) => c.status === "success"); - // Success should account for maintenance being excluded from total time - expect(successCard).toBeDefined(); - }); - - it("should show empty card when no data", () => { - const data = [createStatusData(0, 0, 0, 0)]; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "duration", - barType: "absolute", - }); - - expect(result[0].card).toHaveLength(1); - expect(result[0].card[0].value).toBe(""); - }); - }); - - describe("cardType: dominant", () => { - it("should show dominant status without value", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createIncident(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "dominant", - barType: "dominant", - }); - - expect(result[0].card).toHaveLength(1); - expect(result[0].card[0].status).toBe("error"); - expect(result[0].card[0].value).toBe(""); - }); - }); - - describe("cardType: manual", () => { - it("should show degraded for reports", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createReport(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "manual", - barType: "manual", - }); - - expect(result[0].card).toHaveLength(1); - expect(result[0].card[0].status).toBe("degraded"); - expect(result[0].card[0].value).toBe(""); - }); - - it("should show success when no manual events", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [createIncident(1, 0)]; - - const result = setDataByType({ - events, - data, - cardType: "manual", - barType: "manual", - }); - - expect(result[0].card[0].status).toBe("success"); - }); - }); - - describe("event bundling", () => { - it("should bundle more than 4 incidents into single event", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [ - createIncident(1, 0), - createIncident(2, 0), - createIncident(3, 0), - createIncident(4, 0), - createIncident(5, 0), - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - // Should have bundled incident with special id -1 - const bundledIncident = result[0].events.find((e) => e.id === -1); - expect(bundledIncident).toBeDefined(); - expect(bundledIncident?.name).toContain("5 incidents"); - }); - - it("should not bundle 4 or fewer incidents", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [ - createIncident(1, 0), - createIncident(2, 0), - createIncident(3, 0), - createIncident(4, 0), - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "absolute", - }); - - // Should not have bundled incident - const bundledIncident = result[0].events.find((e) => e.id === -1); - expect(bundledIncident).toBeUndefined(); - }); - - it("should not bundle incidents for non-absolute bar types", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events = [ - createIncident(1, 0), - createIncident(2, 0), - createIncident(3, 0), - createIncident(4, 0), - createIncident(5, 0), - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - // Should not include any incidents in events array - expect(result[0].events.length).toBe(0); - }); - }); - - describe("multiple days", () => { - it("should handle data across multiple days", () => { - const data = [ - createStatusData(0, 100, 0, 0), - createStatusData(1, 80, 20, 0), - createStatusData(2, 60, 30, 10), - ]; - const events = [ - createIncident(1, 0), - createReport(2, 1), - createMaintenance(3, 2), - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result).toHaveLength(3); - expect(result[0].bar[0].status).toBe("error"); // Day 0 has incident - expect(result[1].bar[0].status).toBe("degraded"); // Day 1 has report - expect(result[2].bar[0].status).toBe("info"); // Day 2 has maintenance - }); - }); - - describe("edge cases", () => { - it("should handle empty data array", () => { - const data: StatusData[] = []; - const events: Event[] = []; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - expect(result).toHaveLength(0); - }); - - it("should handle events with null end date", () => { - const data = [createStatusData(0, 100, 0, 0)]; - const events: Event[] = [ - { - id: 1, - name: "Ongoing Incident", - from: new Date(), - to: null, - type: "incident", - status: "error", - }, - ]; - - const result = setDataByType({ - events, - data, - cardType: "duration", - barType: "absolute", - }); - - expect(result[0].bar.some((b) => b.status === "error")).toBe(true); - }); - - it("should handle events spanning multiple days", () => { - const data = [ - createStatusData(0, 100, 0, 0), - createStatusData(1, 100, 0, 0), - ]; - const from = new Date(); - from.setDate(from.getDate() - 1); - from.setHours(12, 0, 0, 0); - const to = new Date(); - to.setHours(12, 0, 0, 0); - - const events: Event[] = [ - { - id: 1, - name: "Multi-day Incident", - from, - to, - type: "incident", - status: "error", - }, - ]; - - const result = setDataByType({ - events, - data, - cardType: "requests", - barType: "dominant", - }); - - // Both days should show error status - expect(result[0].bar[0].status).toBe("error"); - expect(result[1].bar[0].status).toBe("error"); - }); - }); -}); - -describe("fillStatusDataFor45Days", () => { - it("should fill all 45 days", () => { - const data: StatusData[] = []; - const result = fillStatusDataFor45Days(data, "1"); - - expect(result).toHaveLength(45); - }); - - it("should fill exactly 30 days when lookbackPeriod is 30", () => { - const result = fillStatusDataFor45Days([], "1", 30); - - expect(result).toHaveLength(30); - }); - - it("should sort data by day oldest first", () => { - const data: StatusData[] = []; - const result = fillStatusDataFor45Days(data, "1"); - - for (let i = 1; i < result.length; i++) { - const prev = new Date(result[i - 1].day); - const curr = new Date(result[i].day); - expect(curr.getTime()).toBeGreaterThan(prev.getTime()); - } - }); - - it("should preserve existing data", () => { - const existingData = [createStatusData(5, 100, 50, 10)]; - const result = fillStatusDataFor45Days(existingData, "1"); - - expect(result).toHaveLength(45); - const matchingDay = result.find( - (d) => d.ok === 100 && d.degraded === 50 && d.error === 10, - ); - expect(matchingDay).toBeDefined(); - }); - - it("should fill missing days with zeros", () => { - const existingData = [createStatusData(5, 100, 0, 0)]; - const result = fillStatusDataFor45Days(existingData, "1"); - - const emptyDays = result.filter((d) => d.count === 0); - expect(emptyDays.length).toBe(44); - }); -}); - -describe("getUptime", () => { - describe("manual bar type", () => { - it("should calculate uptime based on report durations", () => { - const data = Array.from({ length: 45 }, (_, i) => - createStatusData(i, 100, 0, 0), - ); - const events = [createReport(1, 0, 24)]; // 1 day downtime - - const uptime = getUptime({ - data, - events, - barType: "manual", - cardType: "manual", - }); - - // Should be approximately 97.78% (44/45 days) - expect(Number.parseFloat(uptime)).toBeGreaterThan(97); - expect(Number.parseFloat(uptime)).toBeLessThan(98); - }); - - it("should only consider reports not incidents", () => { - const data = Array.from({ length: 45 }, (_, i) => - createStatusData(i, 100, 0, 0), - ); - const events = [createIncident(1, 0, 24)]; // Should be ignored - - const uptime = getUptime({ - data, - events, - barType: "manual", - cardType: "manual", - }); - - expect(uptime).toBe("100%"); - }); - - it("should clamp report durations to the lookback window and never go negative", () => { - const data = Array.from({ length: 45 }, (_, i) => - createStatusData(i, 100, 0, 0), - ); - // Create reports that started well before the 45-day window - const events: Event[] = Array.from({ length: 15 }, (_, i) => { - const from = new Date(); - from.setDate(from.getDate() - (60 + i * 30)); // 60 to 480 days ago - const to = new Date(from); - to.setDate(to.getDate() + 2); // each 2 days long - return { - id: i + 1, - name: `Old Report ${i}`, - from, - to, - type: "report" as const, - status: "degraded" as const, - }; - }); - - const uptime = getUptime({ - data, - events, - barType: "manual", - cardType: "manual", - }); - - // Reports are entirely outside the window, so uptime should be 100% - expect(Number.parseFloat(uptime)).toBe(100); - }); - - it("should clamp partially overlapping reports to the window boundary", () => { - const data = Array.from({ length: 45 }, (_, i) => - createStatusData(i, 100, 0, 0), - ); - // Report started 50 days ago (before window) and ended 40 days ago (inside window) - const from = new Date(); - from.setDate(from.getDate() - 50); - const to = new Date(); - to.setDate(to.getDate() - 40); - const events: Event[] = [ - { - id: 1, - name: "Overlapping Report", - from, - to, - type: "report", - status: "degraded", - }, - ]; - - const uptime = getUptime({ - data, - events, - barType: "manual", - cardType: "manual", - }); - - // Only ~5 days should count (from window start to report end), not 10 days - const uptimeNum = Number.parseFloat(uptime); - expect(uptimeNum).toBeGreaterThan(85); - expect(uptimeNum).toBeLessThan(100); - }); - }); - - describe("duration card type", () => { - it("should calculate uptime based on incident durations", () => { - const data = Array.from({ length: 45 }, (_, i) => - createStatusData(i, 100, 0, 0), - ); - const events = [createIncident(1, 0, 24)]; // 1 day downtime - - const uptime = getUptime({ - data, - events, - barType: "absolute", - cardType: "duration", - }); - - // Should be approximately 97.78% (44/45 days) - expect(Number.parseFloat(uptime)).toBeGreaterThan(97); - expect(Number.parseFloat(uptime)).toBeLessThan(98); - }); - it("should ignore legacy reports (no impact rows) when calculating duration uptime", () => { - const data = Array.from({ length: 45 }, (_, i) => - createStatusData(i, 100, 0, 0), - ); - const events = [createReport(2, 0, 24)]; // Should be ignored - - const uptime = getUptime({ - data, - events, - barType: "absolute", - cardType: "duration", - }); - - expect(uptime).toBe("100%"); - }); - }); - - describe("request card type", () => { - it("should calculate uptime based on ok vs total requests", () => { - const data = [ - createStatusData(0, 90, 5, 5), // 95 ok, 100 total - createStatusData(1, 100, 0, 0), // 100 ok, 100 total - ]; - const events: Event[] = []; - - const uptime = getUptime({ - data, - events, - barType: "absolute", - cardType: "requests", - }); - - // (90+5+100) / (90+5+5+100) = 195/200 = 97.5% - expect(uptime).toBe("97.5%"); - }); - - it("should count degraded as ok", () => { - const data = [createStatusData(0, 80, 20, 0)]; - const events: Event[] = []; - - const uptime = getUptime({ - data, - events, - barType: "absolute", - cardType: "requests", - }); - - expect(uptime).toBe("100%"); - }); - - it("should return 100% for empty data", () => { - const data: StatusData[] = []; - const events: Event[] = []; - - const uptime = getUptime({ - data, - events, - barType: "absolute", - cardType: "requests", - }); - - expect(uptime).toBe("100%"); - }); - - it("should return 100% when total is zero", () => { - const data = [createStatusData(0, 0, 0, 0)]; - const events: Event[] = []; - - const uptime = getUptime({ - data, - events, - barType: "absolute", - cardType: "requests", - }); - - expect(uptime).toBe("100%"); - }); - }); -}); - -describe("getEvents - pageComponent filtering", () => { - // Helper to create a mock page component - function createMockPageComponent( - id: number, - monitorId?: number, - ): PageComponent { - return { - id, - workspaceId: 1, - pageId: 1, - type: monitorId ? ("monitor" as const) : ("static" as const), - monitorId: monitorId ?? null, - name: `Component ${id}`, - description: null, - order: 0, - groupId: null, - groupOrder: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - } - - // Helper to create a mock maintenance - function createMockMaintenance( - id: number, - pageComponentIds: number[], - ): Maintenance & { - maintenancesToPageComponents: { - pageComponent: PageComponent | null; - }[]; - } { - const now = new Date(); - const from = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago - const to = new Date(now.getTime() + 1000 * 60 * 60); // 1 hour from now - - return { - id, - title: `Maintenance ${id}`, - message: "Test maintenance", - from, - to, - workspaceId: 1, - pageId: 1, - createdAt: new Date(), - updatedAt: new Date(), - maintenancesToPageComponents: pageComponentIds.map((pcId) => ({ - pageComponent: createMockPageComponent(pcId, pcId * 10), - })), - }; - } - - // Helper to create a mock status report - function createMockStatusReport( - id: number, - pageComponentIds: number[], - status: "investigating" | "resolved" = "investigating", - ): StatusReport & { - statusReportsToPageComponents: { - pageComponent: PageComponent | null; - }[]; - statusReportUpdates: StatusReportUpdate[]; - } { - const now = new Date(); - const updateDate = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago - - return { - id, - title: `Status Report ${id}`, - status, - workspaceId: 1, - pageId: 1, - createdAt: new Date(), - updatedAt: new Date(), - statusReportsToPageComponents: pageComponentIds.map((pcId) => ({ - pageComponent: createMockPageComponent(pcId, pcId * 10), - })), - statusReportUpdates: [ - { - id: id * 100, - statusReportId: id, - date: updateDate, - status: "investigating", - message: "Investigating the issue", - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - }; - } - - // Helper to create a mock incident - function createMockIncident(id: number, monitorId: number): Incident { - const now = new Date(); - const startedAt = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago - - return { - id, - title: `Incident ${id}`, - summary: "Test incident", - status: "investigating", - monitorId, - workspaceId: 1, - startedAt, - acknowledgedAt: null, - acknowledgedBy: null, - resolvedAt: null, - resolvedBy: null, - incidentScreenshotUrl: null, - recoveryScreenshotUrl: null, - autoResolved: false, - createdAt: startedAt, - updatedAt: new Date(), - }; - } - - it("should filter maintenances by pageComponentId", () => { - const maintenances = [ - createMockMaintenance(1, [1, 2]), - createMockMaintenance(2, [3, 4]), - createMockMaintenance(3, [1, 5]), - ]; - - const events = getEvents({ - maintenances, - incidents: [], - reports: [], - pageComponentId: 1, - pastDays: 365, - }); - - const maintenanceEvents = events.filter((e) => e.type === "maintenance"); - expect(maintenanceEvents).toHaveLength(2); - expect(maintenanceEvents.map((e) => e.id).sort()).toEqual([1, 3]); - }); - - it("should filter status reports by pageComponentId", () => { - const reports = [ - createMockStatusReport(1, [1, 2]), - createMockStatusReport(2, [3, 4]), - createMockStatusReport(3, [1, 5]), - ]; - - const events = getEvents({ - maintenances: [], - incidents: [], - reports, - pageComponentId: 1, - pastDays: 365, - }); - - const reportEvents = events.filter((e) => e.type === "report"); - expect(reportEvents).toHaveLength(2); - expect(reportEvents.map((e) => e.id).sort()).toEqual([1, 3]); - }); - - it("should exclude incidents for static components", () => { - const incidents = [createMockIncident(1, 10), createMockIncident(2, 20)]; - - const events = getEvents({ - maintenances: [], - incidents, - reports: [], - componentType: "static", - pastDays: 365, - }); - - const incidentEvents = events.filter((e) => e.type === "incident"); - expect(incidentEvents).toHaveLength(0); - }); - - it("should include incidents for monitor components", () => { - const incidents = [createMockIncident(1, 10), createMockIncident(2, 20)]; - - const events = getEvents({ - maintenances: [], - incidents, - reports: [], - monitorId: 10, - componentType: "monitor", - pastDays: 365, - }); - - const incidentEvents = events.filter((e) => e.type === "incident"); - expect(incidentEvents).toHaveLength(1); - expect(incidentEvents[0].id).toBe(1); - }); - - it("should maintain backward compatibility with monitorId filtering", () => { - const maintenances = [ - createMockMaintenance(1, [1]), - createMockMaintenance(2, [2]), - ]; - - const events = getEvents({ - maintenances, - incidents: [], - reports: [], - monitorId: 10, - pastDays: 365, - }); - - const maintenanceEvents = events.filter((e) => e.type === "maintenance"); - expect(maintenanceEvents).toHaveLength(1); - expect(maintenanceEvents[0].id).toBe(1); - }); - - it("should prioritize pageComponentId over monitorId", () => { - const maintenances = [ - createMockMaintenance(1, [1]), - createMockMaintenance(2, [2]), - ]; - - const events = getEvents({ - maintenances, - incidents: [], - reports: [], - pageComponentId: 1, - monitorId: 20, - pastDays: 365, - }); - - const maintenanceEvents = events.filter((e) => e.type === "maintenance"); - expect(maintenanceEvents).toHaveLength(1); - expect(maintenanceEvents[0].id).toBe(1); - }); - - it("should return success status for resolved reports", () => { - const reports = [createMockStatusReport(1, [1], "resolved")]; - - const events = getEvents({ - maintenances: [], - incidents: [], - reports, - pageComponentId: 1, - pastDays: 365, - }); - - const reportEvents = events.filter((e) => e.type === "report"); - expect(reportEvents).toHaveLength(1); - expect(reportEvents[0].status).toBe("success"); - }); - - it("should return degraded status for unresolved reports", () => { - const reports = [createMockStatusReport(1, [1], "investigating")]; - - const events = getEvents({ - maintenances: [], - incidents: [], - reports, - pageComponentId: 1, - pastDays: 365, - }); - - const reportEvents = events.filter((e) => e.type === "report"); - expect(reportEvents).toHaveLength(1); - expect(reportEvents[0].status).toBe("degraded"); - }); -}); - -describe("componentImpacts", () => { - type Impact = - | "operational" - | "degraded_performance" - | "partial_outage" - | "major_outage"; - - function dayStartUTC(daysAgo: number): Date { - const d = new Date(); - d.setUTCDate(d.getUTCDate() - daysAgo); - d.setUTCHours(0, 0, 0, 0); - return d; - } - - function hoursAfter(date: Date, hours: number): Date { - return new Date(date.getTime() + hours * 60 * 60 * 1000); - } - - function createImpactEvent( - id: number, - from: Date, - to: Date | null, - impactIntervals: { from: Date; to: Date | null; impact: Impact }[], - ) { - return { - id, - name: `Impact Report ${id}`, - from, - to, - type: "report" as const, - status: "degraded" as const, - impactIntervals, - }; - } - - function createLegacyEvent(id: number, from: Date, to: Date | null) { - return { - id, - name: `Legacy Report ${id}`, - from, - to, - type: "report" as const, - status: "degraded" as const, - }; - } - - function impactComponent(id: number, monitorId?: number): PageComponent { - return { - id, - workspaceId: 1, - pageId: 1, - type: monitorId ? ("monitor" as const) : ("static" as const), - monitorId: monitorId ?? null, - name: `Component ${id}`, - description: null, - order: 0, - groupId: null, - groupOrder: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - } - - function createImpactReport(args: { - id: number; - status?: "investigating" | "resolved"; - components: number[]; - updates: Array<{ - id: number; - date: Date; - status: "investigating" | "identified" | "monitoring" | "resolved"; - impacts?: Array<{ pageComponentId: number; impact: Impact }>; - }>; - }) { - return { - id: args.id, - title: `Status Report ${args.id}`, - status: args.status ?? ("investigating" as const), - workspaceId: 1, - pageId: 1, - createdAt: new Date(), - updatedAt: new Date(), - statusReportsToPageComponents: args.components.map((pcId) => ({ - pageComponent: impactComponent(pcId, pcId * 10), - })), - statusReportUpdates: args.updates.map((u) => ({ - id: u.id, - statusReportId: args.id, - date: u.date, - status: u.status, - message: "m", - createdAt: new Date(), - updatedAt: new Date(), - statusReportUpdateToPageComponents: (u.impacts ?? []).map((r) => ({ - pageComponentId: r.pageComponentId, - impact: r.impact, - })), - })), - }; - } - - describe("getUptime - manual mode weighting", () => { - const day = dayStartUTC(1); - const dayData = [createStatusData(1, 1)]; - - it("legacy report (no impact rows) counts its full duration", () => { - const events = [createLegacyEvent(1, day, hoursAfter(day, 24))]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("0%"); - }); - - it("major_outage counts full downtime", () => { - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "major_outage" }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("0%"); - }); - - it("partial_outage counts half downtime (weight 0.5)", () => { - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("50%"); - }); - - it("degraded_performance counts as up", () => { - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { - from: day, - to: hoursAfter(day, 24), - impact: "degraded_performance", - }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("100%"); - }); - - it("operational rows count as up", () => { - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "operational" }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("100%"); - }); - - it("weights each interval of a multi-update timeline", () => { - // major 6h -> degraded 6h -> operational 12h: only the major 6h count - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 6), impact: "major_outage" }, - { - from: hoursAfter(day, 6), - to: hoursAfter(day, 12), - impact: "degraded_performance", - }, - { - from: hoursAfter(day, 12), - to: hoursAfter(day, 24), - impact: "operational", - }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("75%"); - }); - - it("partial_outage intervals count half in a multi-update timeline", () => { - // major 6h (6h down) -> partial 6h (3h down) -> operational 12h - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 6), impact: "major_outage" }, - { - from: hoursAfter(day, 6), - to: hoursAfter(day, 12), - impact: "partial_outage", - }, - { - from: hoursAfter(day, 12), - to: hoursAfter(day, 24), - impact: "operational", - }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("62.5%"); - }); - - it("mixes legacy and impact reports in one period", () => { - const day2 = dayStartUTC(2); - const data = [createStatusData(2, 1), createStatusData(1, 1)]; - const events = [ - createLegacyEvent(1, day2, hoursAfter(day2, 24)), - createImpactEvent(2, day, hoursAfter(day, 24), [ - { - from: day, - to: hoursAfter(day, 24), - impact: "degraded_performance", - }, - ]), - ]; - expect( - getUptime({ data, events, barType: "manual", cardType: "manual" }), - ).toBe("50%"); - }); - - it("overlapping legacy reports count once and never go negative", () => { - const events = [ - createLegacyEvent(1, day, hoursAfter(day, 24)), - createLegacyEvent(2, day, hoursAfter(day, 24)), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("0%"); - }); - - it("partially overlapping reports merge their downtime", () => { - const day2 = dayStartUTC(2); - const data = [createStatusData(2, 1), createStatusData(1, 1)]; - // 0-24h and 12-36h overlap by 12h: 36h downtime over 48h, not 48h - const events = [ - createLegacyEvent(1, day2, hoursAfter(day2, 24)), - createLegacyEvent(2, hoursAfter(day2, 12), hoursAfter(day2, 36)), - ]; - expect( - getUptime({ data, events, barType: "manual", cardType: "manual" }), - ).toBe("25%"); - }); - - it("overlapping partial_outage reports take the worst weight, not the sum", () => { - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, - ]), - createImpactEvent(2, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("50%"); - }); - - it("overlapping legacy and partial_outage reports take the worst weight", () => { - const events = [ - createLegacyEvent(1, day, hoursAfter(day, 24)), - createImpactEvent(2, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, - ]), - ]; - expect( - getUptime({ - data: dayData, - events, - barType: "manual", - cardType: "manual", - }), - ).toBe("0%"); - }); - }); - - describe("getUptime - duration mode impact downtime", () => { - const day = dayStartUTC(1); - const day2 = dayStartUTC(2); - const twoDayData = [createStatusData(2, 1), createStatusData(1, 1)]; - - function createIncidentEvent(id: number, from: Date, to: Date | null) { - return { - id, - name: "Downtime", - from, - to, - type: "incident" as const, - status: "error" as const, - }; - } - - it("major_outage report intervals count as downtime", () => { - const events = [ - createImpactEvent(1, day2, hoursAfter(day2, 24), [ - { from: day2, to: hoursAfter(day2, 24), impact: "major_outage" }, - ]), - ]; - expect( - getUptime({ - data: twoDayData, - events, - barType: "absolute", - cardType: "duration", - }), - ).toBe("50%"); - }); - - it("partial_outage report intervals count half", () => { - 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", - }), - ).toBe("75%"); - }); - - it("degraded_performance report intervals count as up", () => { - const events = [ - createImpactEvent(1, day2, hoursAfter(day2, 24), [ - { - from: day2, - to: hoursAfter(day2, 24), - impact: "degraded_performance", - }, - ]), - ]; - expect( - getUptime({ - data: twoDayData, - events, - barType: "absolute", - cardType: "duration", - }), - ).toBe("100%"); - }); - - it("incident and overlapping major_outage report count once", () => { - const events = [ - createIncidentEvent(1, day2, hoursAfter(day2, 24)), - createImpactEvent(2, day2, hoursAfter(day2, 24), [ - { from: day2, to: hoursAfter(day2, 24), impact: "major_outage" }, - ]), - ]; - expect( - getUptime({ - data: twoDayData, - events, - barType: "absolute", - cardType: "duration", - }), - ).toBe("50%"); - }); - - it("incident downtime wins over an overlapping partial_outage report", () => { - const events = [ - createIncidentEvent(1, day2, hoursAfter(day2, 24)), - createImpactEvent(2, day2, hoursAfter(day2, 24), [ - { from: day2, to: hoursAfter(day2, 24), impact: "partial_outage" }, - ]), - ]; - expect( - getUptime({ - data: twoDayData, - events, - barType: "absolute", - cardType: "duration", - }), - ).toBe("50%"); - }); - - it("legacy reports stay ignored alongside impact reports", () => { - const events = [ - createLegacyEvent(1, day2, hoursAfter(day2, 24)), - createImpactEvent(2, day, hoursAfter(day, 12), [ - { from: day, to: hoursAfter(day, 12), impact: "major_outage" }, - ]), - ]; - expect( - getUptime({ - data: twoDayData, - events, - barType: "absolute", - cardType: "duration", - }), - ).toBe("75%"); - }); - }); - - describe("getEvents - impact projection", () => { - const day = dayStartUTC(1); - - it("projects the filtered component's own timeline and status", () => { - const report = createImpactReport({ - id: 1, - components: [1, 2], - updates: [ - { - id: 100, - date: day, - status: "investigating", - impacts: [ - { pageComponentId: 1, impact: "major_outage" }, - { pageComponentId: 2, impact: "degraded_performance" }, - ], - }, - ], - }); - - const apiEvents = getEvents({ - maintenances: [], - incidents: [], - reports: [report], - pageComponentId: 1, - }); - expect(apiEvents).toHaveLength(1); - expect(apiEvents[0].status).toBe("error"); - expect(apiEvents[0].impactIntervals).toHaveLength(1); - expect(apiEvents[0].impactIntervals?.[0].impact).toBe("major_outage"); - - const webEvents = getEvents({ - maintenances: [], - incidents: [], - reports: [report], - pageComponentId: 2, - }); - expect(webEvents[0].status).toBe("degraded"); - expect(webEvents[0].impactIntervals?.[0].impact).toBe( - "degraded_performance", - ); - }); - - it("page-level projection takes the worst impact across components", () => { - const report = createImpactReport({ - id: 1, - components: [1, 2], - updates: [ - { - id: 100, - date: day, - status: "investigating", - impacts: [ - { pageComponentId: 1, impact: "major_outage" }, - { pageComponentId: 2, impact: "degraded_performance" }, - ], - }, - ], - }); - - const events = getEvents({ - maintenances: [], - incidents: [], - reports: [report], - }); - expect(events[0].status).toBe("error"); - expect(events[0].impactIntervals?.[0].impact).toBe("major_outage"); - }); - - it("legacy reports carry no impactIntervals and stay degraded", () => { - const report = createImpactReport({ - id: 1, - components: [1], - updates: [{ id: 100, date: day, status: "investigating" }], - }); - - const events = getEvents({ - maintenances: [], - incidents: [], - reports: [report], - pageComponentId: 1, - }); - expect(events[0].status).toBe("degraded"); - expect(events[0].impactIntervals).toBeUndefined(); - }); - - it("an active report whose component recovered reads success", () => { - const report = createImpactReport({ - id: 1, - components: [1], - updates: [ - { - id: 100, - date: day, - status: "investigating", - impacts: [{ pageComponentId: 1, impact: "partial_outage" }], - }, - { - id: 101, - date: hoursAfter(day, 2), - status: "identified", - impacts: [{ pageComponentId: 1, impact: "operational" }], - }, - ], - }); - - const events = getEvents({ - maintenances: [], - incidents: [], - reports: [report], - pageComponentId: 1, - }); - expect(events[0].status).toBe("success"); - // prior interval closed at the second update - expect(events[0].impactIntervals?.[0].to?.getTime()).toBe( - hoursAfter(day, 2).getTime(), - ); - }); - - it("clamps open intervals to the resolved event end", () => { - const report = createImpactReport({ - id: 1, - status: "resolved", - components: [1], - updates: [ - { - id: 100, - date: day, - status: "investigating", - impacts: [{ pageComponentId: 1, impact: "major_outage" }], - }, - { - id: 101, - date: hoursAfter(day, 3), - status: "resolved", - impacts: [{ pageComponentId: 1, impact: "operational" }], - }, - ], - }); - - const events = getEvents({ - maintenances: [], - incidents: [], - reports: [report], - pageComponentId: 1, - }); - expect(events[0].status).toBe("success"); - const intervals = events[0].impactIntervals ?? []; - expect(intervals).toHaveLength(2); - expect(intervals[0].impact).toBe("major_outage"); - expect(intervals[0].to?.getTime()).toBe(hoursAfter(day, 3).getTime()); - // trailing operational interval clamped to the event end, not open - expect(intervals[1].to?.getTime()).toBe(hoursAfter(day, 3).getTime()); - }); - }); - - describe("setDataByType - manual mode colors", () => { - const day = dayStartUTC(1); - const dayData = [createStatusData(1, 1)]; - - function manualBarStatus( - events: Parameters[0]["events"], - ) { - const result = setDataByType({ - events, - data: dayData, - cardType: "manual", - barType: "manual", - }); - return result[0].bar[0].status; - } - - it("legacy report day stays degraded", () => { - expect( - manualBarStatus([createLegacyEvent(1, day, hoursAfter(day, 24))]), - ).toBe("degraded"); - }); - - it("major_outage day renders error", () => { - expect( - manualBarStatus([ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "major_outage" }, - ]), - ]), - ).toBe("error"); - }); - - it("partial_outage day renders degraded", () => { - expect( - manualBarStatus([ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, - ]), - ]), - ).toBe("degraded"); - }); - - it("carries the day's worst impact for the hover card label", () => { - const result = setDataByType({ - events: [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, - ]), - ], - data: dayData, - cardType: "manual", - barType: "manual", - }); - expect(result[0].card[0].status).toBe("degraded"); - expect(result[0].card[0].impact).toBe("partial_outage"); - }); - - it("legacy report day carries no impact (generic label)", () => { - const result = setDataByType({ - events: [createLegacyEvent(1, day, hoursAfter(day, 24))], - data: dayData, - cardType: "manual", - barType: "manual", - }); - expect(result[0].card[0].status).toBe("degraded"); - expect(result[0].card[0].impact).toBeUndefined(); - }); - - it("operational-only day renders success", () => { - expect( - manualBarStatus([ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 24), impact: "operational" }, - ]), - ]), - ).toBe("success"); - }); - }); - - describe("setDataByType - absolute bar impact proportions", () => { - const day = dayStartUTC(1); - const dayData = [createStatusData(1, 100)]; - - it("colors only the major_outage slice red, not the full report", () => { - // 1h major + 23h degraded: error keeps its true 1/24 share - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 1), impact: "major_outage" }, - { - from: hoursAfter(day, 1), - to: hoursAfter(day, 24), - impact: "degraded_performance", - }, - ]), - ]; - - const result = setDataByType({ - events, - data: dayData, - cardType: "requests", - barType: "absolute", - }); - - const bar = result[0].bar; - const errorHeight = bar.find((b) => b.status === "error")?.height ?? 0; - const degradedHeight = - bar.find((b) => b.status === "degraded")?.height ?? 0; - expect(errorHeight).toBeCloseTo(100 / 24, 1); - expect(degradedHeight).toBeCloseTo(100 - 100 / 24, 1); - expect(errorHeight + degradedHeight).toBeCloseTo(100, 5); - }); - - it("shows uptime vs downtime when the only slice is an outage", () => { - // 1h major then operational: 23h green, 1h red — not a full red day - const events = [ - createImpactEvent(1, day, hoursAfter(day, 24), [ - { from: day, to: hoursAfter(day, 1), impact: "major_outage" }, - { - from: hoursAfter(day, 1), - to: hoursAfter(day, 24), - impact: "operational", - }, - ]), - ]; - - const result = setDataByType({ - events, - data: dayData, - cardType: "requests", - barType: "absolute", - }); - - const bar = result[0].bar; - expect(bar.find((b) => b.status === "success")?.height).toBeCloseTo( - 100 - 100 / 24, - 1, - ); - expect(bar.find((b) => b.status === "error")?.height).toBeCloseTo( - 100 / 24, - 1, - ); - }); - }); - - describe("currentImpactByComponent", () => { - const day = dayStartUTC(1); - - it("latest update naming a component wins; resolve clears", () => { - const report = createImpactReport({ - id: 1, - components: [1, 2], - updates: [ - { - id: 100, - date: day, - status: "investigating", - impacts: [ - { pageComponentId: 1, impact: "major_outage" }, - { pageComponentId: 2, impact: "degraded_performance" }, - ], - }, - { - id: 101, - date: hoursAfter(day, 1), - status: "identified", - impacts: [{ pageComponentId: 1, impact: "partial_outage" }], - }, - ], - }); - - const current = currentImpactByComponent(report); - expect(current.get(1)).toBe("partial_outage"); - expect(current.get(2)).toBe("degraded_performance"); - }); - - it("returns an empty map for legacy reports", () => { - const report = createImpactReport({ - id: 1, - components: [1], - updates: [{ id: 100, date: day, status: "investigating" }], - }); - expect(currentImpactByComponent(report).size).toBe(0); - }); - }); -}); - -describe("withTinybirdFallback", () => { - it("returns ok with data when the read resolves in time", async () => { - const result = await withTinybirdFallback(async () => ({ data: [1, 2] })); - expect(result).toEqual({ ok: true, data: { data: [1, 2] } }); - }); - - it("falls back when the read throws", async () => { - const result = await withTinybirdFallback(async () => { - throw new Error("tinybird down"); - }); - expect(result).toEqual({ ok: false, data: null }); - }); - - it("falls back when the read exceeds the timeout", async () => { - // keep a handle on the slow read's timer — deno's leak sanitizer fails - // the test if it outlives the assertion - let slowRead!: ReturnType; - const result = await withTinybirdFallback( - () => - new Promise((resolve) => { - slowRead = setTimeout(resolve, 50); - }), - 10, - ); - clearTimeout(slowRead); - expect(result).toEqual({ ok: false, data: null }); - }); -}); diff --git a/packages/services/package.json b/packages/services/package.json index c353cc42..8d942a40 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -45,6 +45,10 @@ "import": "./src/status-timeline/index.ts", "types": "./src/status-timeline/index.ts" }, + "./status-page": { + "import": "./src/status-page/index.ts", + "types": "./src/status-page/index.ts" + }, "./frozen-uptime": { "import": "./src/frozen-uptime/index.ts", "types": "./src/frozen-uptime/index.ts" diff --git a/packages/services/src/status-page/__tests__/set-data-by-type.test.ts b/packages/services/src/status-page/__tests__/set-data-by-type.test.ts new file mode 100644 index 00000000..adeb6c72 --- /dev/null +++ b/packages/services/src/status-page/__tests__/set-data-by-type.test.ts @@ -0,0 +1,841 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + createImpactEvent, + createIncident, + createLegacyEvent, + createMaintenance, + createReport, + createStatusData, + dayStartUTC, + hoursAfter, +} from "../../../test/timeline-fixtures"; +import type { Event, StatusData } from "../../status-timeline"; +import { setDataByType } from "../set-data-by-type"; + +describe("setDataByType", () => { + describe("barType: absolute", () => { + it("should show proportional bar segments with error-only incident", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createIncident(1, 0, 2)]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + expect(result).toHaveLength(1); + expect(result[0].bar).toHaveLength(2); + expect(result[0].bar[0].status).toBe("success"); + expect(result[0].bar[1].status).toBe("error"); + // Should have uptime and downtime segments + expect(result[0].bar[0].height).toBeGreaterThan(0); + expect(result[0].bar[1].height).toBeGreaterThan(0); + }); + + it("should show proportional segments with multiple event types", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [ + createIncident(1, 0, 1), + createReport(2, 0, 2), + createMaintenance(3, 0, 1), + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + expect(result[0].bar.length).toBeGreaterThan(1); + // Should include info, degraded, and error segments + const statuses = result[0].bar.map((b) => b.status); + expect(statuses).toContain("error"); + expect(statuses).toContain("degraded"); + expect(statuses).toContain("info"); + }); + + it("should keep downtime proportional to the day when maintenance is present", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [ + createMaintenance(1, 0, 2), // 2h maintenance + createIncident(2, 0, 1), // 1h downtime + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + const bar = result[0].bar; + const errorHeight = bar.find((b) => b.status === "error")?.height ?? 0; + const infoHeight = bar.find((b) => b.status === "info")?.height ?? 0; + + // 1h downtime is at most 1/24 of the day (~4.17%), never the old ~33% + expect(errorHeight).toBeGreaterThan(0); + expect(errorHeight).toBeLessThan(5); + expect(errorHeight).toBeLessThan(infoHeight); + // maintenance fills the remaining space, no green uptime + expect(bar.some((b) => b.status === "success")).toBe(false); + expect(errorHeight + infoHeight).toBeCloseTo(100, 5); + }); + + it("should fill the whole day for a maintenance-only day", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createMaintenance(1, 0, 2)]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("info"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should fill the whole day for a report-only day", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createReport(1, 0, 2)]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("degraded"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should show empty bar when no data available", () => { + const data = [createStatusData(0, 0, 0, 0)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("empty"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should show operational bar with duration cardType and no events", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "duration", + barType: "absolute", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("success"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should show proportional status segments with mixed data and no events", () => { + const data = [createStatusData(0, 80, 15, 5)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + expect(result[0].bar.length).toBeGreaterThan(1); + const statuses = result[0].bar.map((b) => b.status); + expect(statuses).toContain("success"); + expect(statuses).toContain("degraded"); + expect(statuses).toContain("error"); + }); + }); + + describe("barType: dominant", () => { + it("should show error as dominant status when incident exists", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createIncident(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("error"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should show degraded when only reports exist", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createReport(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("degraded"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should show info when only maintenance exists", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createMaintenance(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("info"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should prioritize error over other statuses", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [ + createIncident(1, 0), + createReport(2, 0), + createMaintenance(3, 0), + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].bar[0].status).toBe("error"); + }); + + it("should show data status when no events", () => { + const data = [createStatusData(0, 0, 100, 0)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].bar[0].status).toBe("degraded"); + }); + }); + + describe("barType: manual", () => { + it("should show degraded when reports exist", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createReport(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "manual", + barType: "manual", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("degraded"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should show info when only maintenance exists", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createMaintenance(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "manual", + barType: "manual", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("info"); + expect(result[0].bar[0].height).toBe(100); + }); + + it("should ignore incidents and show success", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createIncident(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "manual", + barType: "manual", + }); + + expect(result[0].bar).toHaveLength(1); + expect(result[0].bar[0].status).toBe("success"); + }); + + it("should prioritize reports over maintenance", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createReport(1, 0), createMaintenance(2, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "manual", + barType: "manual", + }); + + expect(result[0].bar[0].status).toBe("degraded"); + }); + }); + + describe("cardType: requests", () => { + it("should show request counts for each status", () => { + const data = [createStatusData(0, 100, 50, 10)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].card.length).toBe(3); + expect(result[0].card.some((c) => c.value.includes("100 reqs"))).toBe( + true, + ); + expect(result[0].card.some((c) => c.value.includes("50 reqs"))).toBe( + true, + ); + expect(result[0].card.some((c) => c.value.includes("10 reqs"))).toBe( + true, + ); + }); + + it("should format large numbers correctly", () => { + const data = [createStatusData(0, 5000, 0, 0)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].card[0].value).toBe("5.0k reqs"); + }); + + it("should show empty card when no data", () => { + const data = [createStatusData(0, 0, 0, 0)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].card).toHaveLength(1); + expect(result[0].card[0].value).toBe(""); + expect(result[0].card[0].status).toBe("empty"); + }); + + it("should show event status in empty card when no data but events exist", () => { + const data = [createStatusData(0, 0, 0, 0)]; + const events = [createIncident(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result[0].card).toHaveLength(1); + expect(result[0].card[0].status).toBe("error"); + }); + }); + + describe("cardType: duration", () => { + it("should calculate duration for events", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [ + createIncident(1, 0, 1), // 1 hour + createReport(2, 0, 2), // 2 hours + ]; + + const result = setDataByType({ + events, + data, + cardType: "duration", + barType: "absolute", + }); + + expect(result[0].card.length).toBeGreaterThan(0); + // Should have durations for error, degraded, and success + const hasError = result[0].card.some( + (c) => c.status === "error" && c.value.includes("h"), + ); + const hasDegraded = result[0].card.some( + (c) => c.status === "degraded" && c.value.includes("h"), + ); + expect(hasError || hasDegraded).toBe(true); + }); + + it("should format duration in hours and minutes", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createIncident(1, 0, 1.5)]; // 1.5 hours = 1h 30m + + const result = setDataByType({ + events, + data, + cardType: "duration", + barType: "absolute", + }); + + const errorCard = result[0].card.find((c) => c.status === "error"); + expect(errorCard).toBeDefined(); + // Should contain hour notation and optionally minutes + expect(errorCard?.value).toMatch(/\d+h(\s\d+m)?/); + }); + + it("should show success duration as remaining time", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createIncident(1, 0, 1)]; // 1 hour downtime + + const result = setDataByType({ + events, + data, + cardType: "duration", + barType: "absolute", + }); + + const successCard = result[0].card.find((c) => c.status === "success"); + expect(successCard).toBeDefined(); + // Success duration should be total time minus downtime + expect(successCard?.value).toBeTruthy(); + }); + + it("should exclude maintenance from success calculation", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createMaintenance(1, 0, 2)]; // 2 hours maintenance + + const result = setDataByType({ + events, + data, + cardType: "duration", + barType: "absolute", + }); + + const successCard = result[0].card.find((c) => c.status === "success"); + // Success should account for maintenance being excluded from total time + expect(successCard).toBeDefined(); + }); + + it("should show empty card when no data", () => { + const data = [createStatusData(0, 0, 0, 0)]; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "duration", + barType: "absolute", + }); + + expect(result[0].card).toHaveLength(1); + expect(result[0].card[0].value).toBe(""); + }); + }); + + describe("cardType: dominant", () => { + it("should show dominant status without value", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createIncident(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "dominant", + barType: "dominant", + }); + + expect(result[0].card).toHaveLength(1); + expect(result[0].card[0].status).toBe("error"); + expect(result[0].card[0].value).toBe(""); + }); + }); + + describe("cardType: manual", () => { + it("should show degraded for reports", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createReport(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "manual", + barType: "manual", + }); + + expect(result[0].card).toHaveLength(1); + expect(result[0].card[0].status).toBe("degraded"); + expect(result[0].card[0].value).toBe(""); + }); + + it("should show success when no manual events", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [createIncident(1, 0)]; + + const result = setDataByType({ + events, + data, + cardType: "manual", + barType: "manual", + }); + + expect(result[0].card[0].status).toBe("success"); + }); + }); + + describe("event bundling", () => { + it("should bundle more than 4 incidents into single event", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [ + createIncident(1, 0), + createIncident(2, 0), + createIncident(3, 0), + createIncident(4, 0), + createIncident(5, 0), + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + // Should have bundled incident with special id -1 + const bundledIncident = result[0].events.find((e) => e.id === -1); + expect(bundledIncident).toBeDefined(); + expect(bundledIncident?.name).toContain("5 incidents"); + }); + + it("should not bundle 4 or fewer incidents", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [ + createIncident(1, 0), + createIncident(2, 0), + createIncident(3, 0), + createIncident(4, 0), + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "absolute", + }); + + // Should not have bundled incident + const bundledIncident = result[0].events.find((e) => e.id === -1); + expect(bundledIncident).toBeUndefined(); + }); + + it("should not bundle incidents for non-absolute bar types", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events = [ + createIncident(1, 0), + createIncident(2, 0), + createIncident(3, 0), + createIncident(4, 0), + createIncident(5, 0), + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + // Should not include any incidents in events array + expect(result[0].events.length).toBe(0); + }); + }); + + describe("multiple days", () => { + it("should handle data across multiple days", () => { + const data = [ + createStatusData(0, 100, 0, 0), + createStatusData(1, 80, 20, 0), + createStatusData(2, 60, 30, 10), + ]; + const events = [ + createIncident(1, 0), + createReport(2, 1), + createMaintenance(3, 2), + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result).toHaveLength(3); + expect(result[0].bar[0].status).toBe("error"); // Day 0 has incident + expect(result[1].bar[0].status).toBe("degraded"); // Day 1 has report + expect(result[2].bar[0].status).toBe("info"); // Day 2 has maintenance + }); + }); + + describe("edge cases", () => { + it("should handle empty data array", () => { + const data: StatusData[] = []; + const events: Event[] = []; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + expect(result).toHaveLength(0); + }); + + it("should handle events with null end date", () => { + const data = [createStatusData(0, 100, 0, 0)]; + const events: Event[] = [ + { + id: 1, + name: "Ongoing Incident", + from: new Date(), + to: null, + type: "incident", + status: "error", + }, + ]; + + const result = setDataByType({ + events, + data, + cardType: "duration", + barType: "absolute", + }); + + expect(result[0].bar.some((b) => b.status === "error")).toBe(true); + }); + + it("should handle events spanning multiple days", () => { + const data = [ + createStatusData(0, 100, 0, 0), + createStatusData(1, 100, 0, 0), + ]; + const from = new Date(); + from.setDate(from.getDate() - 1); + from.setHours(12, 0, 0, 0); + const to = new Date(); + to.setHours(12, 0, 0, 0); + + const events: Event[] = [ + { + id: 1, + name: "Multi-day Incident", + from, + to, + type: "incident", + status: "error", + }, + ]; + + const result = setDataByType({ + events, + data, + cardType: "requests", + barType: "dominant", + }); + + // Both days should show error status + expect(result[0].bar[0].status).toBe("error"); + expect(result[1].bar[0].status).toBe("error"); + }); + }); +}); + +describe("componentImpacts", () => { + describe("setDataByType - manual mode colors", () => { + const day = dayStartUTC(1); + const dayData = [createStatusData(1, 1)]; + + function manualBarStatus( + events: Parameters[0]["events"], + ) { + const result = setDataByType({ + events, + data: dayData, + cardType: "manual", + barType: "manual", + }); + return result[0].bar[0].status; + } + + it("legacy report day stays degraded", () => { + expect( + manualBarStatus([createLegacyEvent(1, day, hoursAfter(day, 24))]), + ).toBe("degraded"); + }); + + it("major_outage day renders error", () => { + expect( + manualBarStatus([ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "major_outage" }, + ]), + ]), + ).toBe("error"); + }); + + it("partial_outage day renders degraded", () => { + expect( + manualBarStatus([ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, + ]), + ]), + ).toBe("degraded"); + }); + + it("carries the day's worst impact for the hover card label", () => { + const result = setDataByType({ + events: [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, + ]), + ], + data: dayData, + cardType: "manual", + barType: "manual", + }); + expect(result[0].card[0].status).toBe("degraded"); + expect(result[0].card[0].impact).toBe("partial_outage"); + }); + + it("legacy report day carries no impact (generic label)", () => { + const result = setDataByType({ + events: [createLegacyEvent(1, day, hoursAfter(day, 24))], + data: dayData, + cardType: "manual", + barType: "manual", + }); + expect(result[0].card[0].status).toBe("degraded"); + expect(result[0].card[0].impact).toBeUndefined(); + }); + + it("operational-only day renders success", () => { + expect( + manualBarStatus([ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "operational" }, + ]), + ]), + ).toBe("success"); + }); + }); + + describe("setDataByType - absolute bar impact proportions", () => { + const day = dayStartUTC(1); + const dayData = [createStatusData(1, 100)]; + + it("colors only the major_outage slice red, not the full report", () => { + // 1h major + 23h degraded: error keeps its true 1/24 share + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 1), impact: "major_outage" }, + { + from: hoursAfter(day, 1), + to: hoursAfter(day, 24), + impact: "degraded_performance", + }, + ]), + ]; + + const result = setDataByType({ + events, + data: dayData, + cardType: "requests", + barType: "absolute", + }); + + const bar = result[0].bar; + const errorHeight = bar.find((b) => b.status === "error")?.height ?? 0; + const degradedHeight = + bar.find((b) => b.status === "degraded")?.height ?? 0; + expect(errorHeight).toBeCloseTo(100 / 24, 1); + expect(degradedHeight).toBeCloseTo(100 - 100 / 24, 1); + expect(errorHeight + degradedHeight).toBeCloseTo(100, 5); + }); + + it("shows uptime vs downtime when the only slice is an outage", () => { + // 1h major then operational: 23h green, 1h red — not a full red day + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 1), impact: "major_outage" }, + { + from: hoursAfter(day, 1), + to: hoursAfter(day, 24), + impact: "operational", + }, + ]), + ]; + + const result = setDataByType({ + events, + data: dayData, + cardType: "requests", + barType: "absolute", + }); + + const bar = result[0].bar; + expect(bar.find((b) => b.status === "success")?.height).toBeCloseTo( + 100 - 100 / 24, + 1, + ); + expect(bar.find((b) => b.status === "error")?.height).toBeCloseTo( + 100 / 24, + 1, + ); + }); + }); +}); diff --git a/packages/services/src/status-page/__tests__/tinybird-fallback.test.ts b/packages/services/src/status-page/__tests__/tinybird-fallback.test.ts new file mode 100644 index 00000000..f9384108 --- /dev/null +++ b/packages/services/src/status-page/__tests__/tinybird-fallback.test.ts @@ -0,0 +1,33 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { withTinybirdFallback } from "../tinybird-fallback"; + +describe("withTinybirdFallback", () => { + it("returns ok with data when the read resolves in time", async () => { + const result = await withTinybirdFallback(async () => ({ data: [1, 2] })); + expect(result).toEqual({ ok: true, data: { data: [1, 2] } }); + }); + + it("falls back when the read throws", async () => { + const result = await withTinybirdFallback(async () => { + throw new Error("tinybird down"); + }); + expect(result).toEqual({ ok: false, data: null }); + }); + + it("falls back when the read exceeds the timeout", async () => { + // keep a handle on the slow read's timer — deno's leak sanitizer fails + // the test if it outlives the assertion + let slowRead!: ReturnType; + const result = await withTinybirdFallback( + () => + new Promise((resolve) => { + slowRead = setTimeout(resolve, 50); + }), + 10, + ); + clearTimeout(slowRead); + expect(result).toEqual({ ok: false, data: null }); + }); +}); diff --git a/packages/services/src/status-page/__tests__/uptime.test.ts b/packages/services/src/status-page/__tests__/uptime.test.ts new file mode 100644 index 00000000..d4975c8b --- /dev/null +++ b/packages/services/src/status-page/__tests__/uptime.test.ts @@ -0,0 +1,556 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + createImpactEvent, + createIncident, + createLegacyEvent, + createReport, + createStatusData, + dayStartUTC, + hoursAfter, +} from "../../../test/timeline-fixtures"; +import type { Event, StatusData } from "../../status-timeline"; +import { getUptime } from "../uptime"; + +describe("getUptime", () => { + describe("manual bar type", () => { + it("should calculate uptime based on report durations", () => { + const data = Array.from({ length: 45 }, (_, i) => + createStatusData(i, 100, 0, 0), + ); + const events = [createReport(1, 0, 24)]; // 1 day downtime + + const uptime = getUptime({ + data, + events, + barType: "manual", + cardType: "manual", + }); + + // Should be approximately 97.78% (44/45 days) + expect(Number.parseFloat(uptime)).toBeGreaterThan(97); + expect(Number.parseFloat(uptime)).toBeLessThan(98); + }); + + it("should only consider reports not incidents", () => { + const data = Array.from({ length: 45 }, (_, i) => + createStatusData(i, 100, 0, 0), + ); + const events = [createIncident(1, 0, 24)]; // Should be ignored + + const uptime = getUptime({ + data, + events, + barType: "manual", + cardType: "manual", + }); + + expect(uptime).toBe("100%"); + }); + + it("should clamp report durations to the lookback window and never go negative", () => { + const data = Array.from({ length: 45 }, (_, i) => + createStatusData(i, 100, 0, 0), + ); + // Create reports that started well before the 45-day window + const events: Event[] = Array.from({ length: 15 }, (_, i) => { + const from = new Date(); + from.setDate(from.getDate() - (60 + i * 30)); // 60 to 480 days ago + const to = new Date(from); + to.setDate(to.getDate() + 2); // each 2 days long + return { + id: i + 1, + name: `Old Report ${i}`, + from, + to, + type: "report" as const, + status: "degraded" as const, + }; + }); + + const uptime = getUptime({ + data, + events, + barType: "manual", + cardType: "manual", + }); + + // Reports are entirely outside the window, so uptime should be 100% + expect(Number.parseFloat(uptime)).toBe(100); + }); + + it("should clamp partially overlapping reports to the window boundary", () => { + const data = Array.from({ length: 45 }, (_, i) => + createStatusData(i, 100, 0, 0), + ); + // Report started 50 days ago (before window) and ended 40 days ago (inside window) + const from = new Date(); + from.setDate(from.getDate() - 50); + const to = new Date(); + to.setDate(to.getDate() - 40); + const events: Event[] = [ + { + id: 1, + name: "Overlapping Report", + from, + to, + type: "report", + status: "degraded", + }, + ]; + + const uptime = getUptime({ + data, + events, + barType: "manual", + cardType: "manual", + }); + + // Only ~5 days should count (from window start to report end), not 10 days + const uptimeNum = Number.parseFloat(uptime); + expect(uptimeNum).toBeGreaterThan(85); + expect(uptimeNum).toBeLessThan(100); + }); + }); + + describe("duration card type", () => { + it("should calculate uptime based on incident durations", () => { + const data = Array.from({ length: 45 }, (_, i) => + createStatusData(i, 100, 0, 0), + ); + const events = [createIncident(1, 0, 24)]; // 1 day downtime + + const uptime = getUptime({ + data, + events, + barType: "absolute", + cardType: "duration", + }); + + // Should be approximately 97.78% (44/45 days) + expect(Number.parseFloat(uptime)).toBeGreaterThan(97); + expect(Number.parseFloat(uptime)).toBeLessThan(98); + }); + it("should ignore legacy reports (no impact rows) when calculating duration uptime", () => { + const data = Array.from({ length: 45 }, (_, i) => + createStatusData(i, 100, 0, 0), + ); + const events = [createReport(2, 0, 24)]; // Should be ignored + + const uptime = getUptime({ + data, + events, + barType: "absolute", + cardType: "duration", + }); + + expect(uptime).toBe("100%"); + }); + }); + + describe("request card type", () => { + it("should calculate uptime based on ok vs total requests", () => { + const data = [ + createStatusData(0, 90, 5, 5), // 95 ok, 100 total + createStatusData(1, 100, 0, 0), // 100 ok, 100 total + ]; + const events: Event[] = []; + + const uptime = getUptime({ + data, + events, + barType: "absolute", + cardType: "requests", + }); + + // (90+5+100) / (90+5+5+100) = 195/200 = 97.5% + expect(uptime).toBe("97.5%"); + }); + + it("should count degraded as ok", () => { + const data = [createStatusData(0, 80, 20, 0)]; + const events: Event[] = []; + + const uptime = getUptime({ + data, + events, + barType: "absolute", + cardType: "requests", + }); + + expect(uptime).toBe("100%"); + }); + + it("should return 100% for empty data", () => { + const data: StatusData[] = []; + const events: Event[] = []; + + const uptime = getUptime({ + data, + events, + barType: "absolute", + cardType: "requests", + }); + + expect(uptime).toBe("100%"); + }); + + it("should return 100% when total is zero", () => { + const data = [createStatusData(0, 0, 0, 0)]; + const events: Event[] = []; + + const uptime = getUptime({ + data, + events, + barType: "absolute", + cardType: "requests", + }); + + expect(uptime).toBe("100%"); + }); + }); +}); + +describe("componentImpacts", () => { + describe("getUptime - manual mode weighting", () => { + const day = dayStartUTC(1); + const dayData = [createStatusData(1, 1)]; + + it("legacy report (no impact rows) counts its full duration", () => { + const events = [createLegacyEvent(1, day, hoursAfter(day, 24))]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("0%"); + }); + + it("major_outage counts full downtime", () => { + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "major_outage" }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("0%"); + }); + + it("partial_outage counts half downtime (weight 0.5)", () => { + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("50%"); + }); + + it("degraded_performance counts as up", () => { + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { + from: day, + to: hoursAfter(day, 24), + impact: "degraded_performance", + }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("100%"); + }); + + it("operational rows count as up", () => { + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "operational" }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("100%"); + }); + + it("weights each interval of a multi-update timeline", () => { + // major 6h -> degraded 6h -> operational 12h: only the major 6h count + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 6), impact: "major_outage" }, + { + from: hoursAfter(day, 6), + to: hoursAfter(day, 12), + impact: "degraded_performance", + }, + { + from: hoursAfter(day, 12), + to: hoursAfter(day, 24), + impact: "operational", + }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("75%"); + }); + + it("partial_outage intervals count half in a multi-update timeline", () => { + // major 6h (6h down) -> partial 6h (3h down) -> operational 12h + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 6), impact: "major_outage" }, + { + from: hoursAfter(day, 6), + to: hoursAfter(day, 12), + impact: "partial_outage", + }, + { + from: hoursAfter(day, 12), + to: hoursAfter(day, 24), + impact: "operational", + }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("62.5%"); + }); + + it("mixes legacy and impact reports in one period", () => { + const day2 = dayStartUTC(2); + const data = [createStatusData(2, 1), createStatusData(1, 1)]; + const events = [ + createLegacyEvent(1, day2, hoursAfter(day2, 24)), + createImpactEvent(2, day, hoursAfter(day, 24), [ + { + from: day, + to: hoursAfter(day, 24), + impact: "degraded_performance", + }, + ]), + ]; + expect( + getUptime({ data, events, barType: "manual", cardType: "manual" }), + ).toBe("50%"); + }); + + it("overlapping legacy reports count once and never go negative", () => { + const events = [ + createLegacyEvent(1, day, hoursAfter(day, 24)), + createLegacyEvent(2, day, hoursAfter(day, 24)), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("0%"); + }); + + it("partially overlapping reports merge their downtime", () => { + const day2 = dayStartUTC(2); + const data = [createStatusData(2, 1), createStatusData(1, 1)]; + // 0-24h and 12-36h overlap by 12h: 36h downtime over 48h, not 48h + const events = [ + createLegacyEvent(1, day2, hoursAfter(day2, 24)), + createLegacyEvent(2, hoursAfter(day2, 12), hoursAfter(day2, 36)), + ]; + expect( + getUptime({ data, events, barType: "manual", cardType: "manual" }), + ).toBe("25%"); + }); + + it("overlapping partial_outage reports take the worst weight, not the sum", () => { + const events = [ + createImpactEvent(1, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, + ]), + createImpactEvent(2, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("50%"); + }); + + it("overlapping legacy and partial_outage reports take the worst weight", () => { + const events = [ + createLegacyEvent(1, day, hoursAfter(day, 24)), + createImpactEvent(2, day, hoursAfter(day, 24), [ + { from: day, to: hoursAfter(day, 24), impact: "partial_outage" }, + ]), + ]; + expect( + getUptime({ + data: dayData, + events, + barType: "manual", + cardType: "manual", + }), + ).toBe("0%"); + }); + }); + + describe("getUptime - duration mode impact downtime", () => { + const day = dayStartUTC(1); + const day2 = dayStartUTC(2); + const twoDayData = [createStatusData(2, 1), createStatusData(1, 1)]; + + function createIncidentEvent(id: number, from: Date, to: Date | null) { + return { + id, + name: "Downtime", + from, + to, + type: "incident" as const, + status: "error" as const, + }; + } + + it("major_outage report intervals count as downtime", () => { + const events = [ + createImpactEvent(1, day2, hoursAfter(day2, 24), [ + { from: day2, to: hoursAfter(day2, 24), impact: "major_outage" }, + ]), + ]; + expect( + getUptime({ + data: twoDayData, + events, + barType: "absolute", + cardType: "duration", + }), + ).toBe("50%"); + }); + + it("partial_outage report intervals count half", () => { + 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", + }), + ).toBe("75%"); + }); + + it("degraded_performance report intervals count as up", () => { + const events = [ + createImpactEvent(1, day2, hoursAfter(day2, 24), [ + { + from: day2, + to: hoursAfter(day2, 24), + impact: "degraded_performance", + }, + ]), + ]; + expect( + getUptime({ + data: twoDayData, + events, + barType: "absolute", + cardType: "duration", + }), + ).toBe("100%"); + }); + + it("incident and overlapping major_outage report count once", () => { + const events = [ + createIncidentEvent(1, day2, hoursAfter(day2, 24)), + createImpactEvent(2, day2, hoursAfter(day2, 24), [ + { from: day2, to: hoursAfter(day2, 24), impact: "major_outage" }, + ]), + ]; + expect( + getUptime({ + data: twoDayData, + events, + barType: "absolute", + cardType: "duration", + }), + ).toBe("50%"); + }); + + it("incident downtime wins over an overlapping partial_outage report", () => { + const events = [ + createIncidentEvent(1, day2, hoursAfter(day2, 24)), + createImpactEvent(2, day2, hoursAfter(day2, 24), [ + { from: day2, to: hoursAfter(day2, 24), impact: "partial_outage" }, + ]), + ]; + expect( + getUptime({ + data: twoDayData, + events, + barType: "absolute", + cardType: "duration", + }), + ).toBe("50%"); + }); + + it("legacy reports stay ignored alongside impact reports", () => { + const events = [ + createLegacyEvent(1, day2, hoursAfter(day2, 24)), + createImpactEvent(2, day, hoursAfter(day, 12), [ + { from: day, to: hoursAfter(day, 12), impact: "major_outage" }, + ]), + ]; + expect( + getUptime({ + data: twoDayData, + events, + barType: "absolute", + cardType: "duration", + }), + ).toBe("75%"); + }); + }); +}); diff --git a/packages/services/src/status-page/index.ts b/packages/services/src/status-page/index.ts new file mode 100644 index 00000000..052e6f75 --- /dev/null +++ b/packages/services/src/status-page/index.ts @@ -0,0 +1,3 @@ +export * from "./set-data-by-type"; +export * from "./tinybird-fallback"; +export * from "./uptime"; diff --git a/packages/api/src/router/statusPage.utils.ts b/packages/services/src/status-page/set-data-by-type.ts similarity index 86% rename from packages/api/src/router/statusPage.utils.ts rename to packages/services/src/status-page/set-data-by-type.ts index 32f97855..d94c8fb5 100644 --- a/packages/api/src/router/statusPage.utils.ts +++ b/packages/services/src/status-page/set-data-by-type.ts @@ -1,62 +1,18 @@ import type { PageComponentImpact } from "@openstatus/db/src/schema"; import { impactToStatusType, worstImpact } from "@openstatus/db/src/schema"; + import { type Event, MS_PER_DAY, type StatusData, - type UptimeWindow, - dayCoverage, - durationDowntimeMs, - floorPct, getHighestPriorityStatus, getWorstVariant, isDateWithinEvent, reportEventDayImpact, reportEventDayStatus, - reportsOnlyDowntimeMs, - requestsTally, -} from "@openstatus/services/status-timeline"; - -export * from "@openstatus/services/status-timeline"; - -// Status pages must render even when Tinybird is degraded. Read latency above -// this budget is treated as an outage and the page falls back to manual mode. -export const TINYBIRD_FALLBACK_TIMEOUT_MS = 5_000; - -// Discriminated result of a guarded Tinybird read: `ok: true` carries the data, -// `ok: false` (timeout or error) carries `null` and signals manual-mode fallback. -export type TinybirdResult = - | { ok: true; data: T } - | { ok: false; data: null }; - -// Races a Tinybird read against the fallback budget. `ok: false` (timeout or -// thrown error) signals the caller to serve manual mode — DB-authored events -// only — instead of hanging or 500ing on Tinybird. -export async function withTinybirdFallback( - fetch: () => Promise, - timeoutMs = TINYBIRD_FALLBACK_TIMEOUT_MS, -): Promise> { - let timer: ReturnType | undefined; - try { - const data = await Promise.race([ - fetch(), - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error("tinybird timeout")), - timeoutMs, - ); - }), - ]); - return { ok: true, data }; - } catch (err) { - console.error("[status-page] tinybird unhealthy, using manual mode:", err); - return { ok: false, data: null }; - } finally { - clearTimeout(timer); - } -} +} from "../status-timeline"; -type UptimeData = { +export type UptimeData = { day: string; events: Event[]; bar: { @@ -637,39 +593,3 @@ export function setDataByType({ }; }); } - -export function getUptime({ - data, - events, - barType, - cardType, -}: { - data: StatusData[]; - events: Event[]; - barType: "absolute" | "dominant" | "manual"; - cardType: "requests" | "duration" | "dominant" | "manual"; -}): string { - if (barType === "manual" || cardType === "duration") { - // Clamp event durations to the data lookback window to avoid - // events outside the window producing negative uptime values. - const timestamps = data.map((d) => new Date(d.day).getTime()); - const { segments: coverage, totalMs: total } = dayCoverage(timestamps); - if (total === 0) return "100%"; - const windowEndDate = new Date(Math.max(...timestamps)); - windowEndDate.setUTCHours(23, 59, 59, 999); - const window: UptimeWindow = { - start: Math.min(...timestamps), - end: windowEndDate.getTime(), - now: Date.now(), - }; - const duration = - barType === "manual" - ? reportsOnlyDowntimeMs(events, window, coverage) - : durationDowntimeMs(events, window, coverage); - return `${floorPct((total - duration) / total)}%`; - } - - const { up, total } = requestsTally(data); - if (total === 0) return "100%"; - return `${floorPct(up / total)}%`; -} diff --git a/packages/services/src/status-page/tinybird-fallback.ts b/packages/services/src/status-page/tinybird-fallback.ts new file mode 100644 index 00000000..ab062a45 --- /dev/null +++ b/packages/services/src/status-page/tinybird-fallback.ts @@ -0,0 +1,36 @@ +// Status pages must render even when Tinybird is degraded. Read latency above +// this budget is treated as an outage and the page falls back to manual mode. +export const TINYBIRD_FALLBACK_TIMEOUT_MS = 5_000; + +// Discriminated result of a guarded Tinybird read: `ok: true` carries the data, +// `ok: false` (timeout or error) carries `null` and signals manual-mode fallback. +export type TinybirdResult = + | { ok: true; data: T } + | { ok: false; data: null }; + +// Races a Tinybird read against the fallback budget. `ok: false` (timeout or +// thrown error) signals the caller to serve manual mode — DB-authored events +// only — instead of hanging or 500ing on Tinybird. +export async function withTinybirdFallback( + fetch: () => Promise, + timeoutMs = TINYBIRD_FALLBACK_TIMEOUT_MS, +): Promise> { + let timer: ReturnType | undefined; + try { + const data = await Promise.race([ + fetch(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("tinybird timeout")), + timeoutMs, + ); + }), + ]); + return { ok: true, data }; + } catch (err) { + console.error("[status-page] tinybird unhealthy, using manual mode:", err); + return { ok: false, data: null }; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/services/src/status-page/uptime.ts b/packages/services/src/status-page/uptime.ts new file mode 100644 index 00000000..27a8ca34 --- /dev/null +++ b/packages/services/src/status-page/uptime.ts @@ -0,0 +1,46 @@ +import { + type Event, + type StatusData, + type UptimeWindow, + dayCoverage, + durationDowntimeMs, + floorPct, + reportsOnlyDowntimeMs, + requestsTally, +} from "../status-timeline"; + +export function getUptime({ + data, + events, + barType, + cardType, +}: { + data: StatusData[]; + events: Event[]; + barType: "absolute" | "dominant" | "manual"; + cardType: "requests" | "duration" | "dominant" | "manual"; +}): string { + if (barType === "manual" || cardType === "duration") { + // Clamp event durations to the data lookback window to avoid + // events outside the window producing negative uptime values. + const timestamps = data.map((d) => new Date(d.day).getTime()); + const { segments: coverage, totalMs: total } = dayCoverage(timestamps); + if (total === 0) return "100%"; + const windowEndDate = new Date(Math.max(...timestamps)); + windowEndDate.setUTCHours(23, 59, 59, 999); + const window: UptimeWindow = { + start: Math.min(...timestamps), + end: windowEndDate.getTime(), + now: Date.now(), + }; + const duration = + barType === "manual" + ? reportsOnlyDowntimeMs(events, window, coverage) + : durationDowntimeMs(events, window, coverage); + return `${floorPct((total - duration) / total)}%`; + } + + const { up, total } = requestsTally(data); + if (total === 0) return "100%"; + return `${floorPct(up / total)}%`; +} diff --git a/packages/services/src/status-timeline/__tests__/events.test.ts b/packages/services/src/status-timeline/__tests__/events.test.ts index 654d1b4a..31183dad 100644 --- a/packages/services/src/status-timeline/__tests__/events.test.ts +++ b/packages/services/src/status-timeline/__tests__/events.test.ts @@ -1,12 +1,28 @@ +import type { + Incident, + Maintenance, + PageComponent, + StatusReport, + StatusReportUpdate, +} from "@openstatus/db/src/schema"; import { expect } from "@std/expect"; -import { describe, test } from "@std/testing/bdd"; +import { describe, it, test } from "@std/testing/bdd"; +import { + createImpactReport, + createStatusData, + dayStartUTC, + hoursAfter, +} from "../../../test/timeline-fixtures"; import { type Event, type ImpactInterval, type StatusData, activeReportStatus, + currentImpactByComponent, eventWorstImpact, + fillStatusDataFor45Days, + getEvents, getHighestPriorityStatus, getWorstVariant, isDateWithinEvent, @@ -296,3 +312,506 @@ describe("resolveDayStatus", () => { expect(resolveDayStatus(makeBucket({ ok: 0 }), []).status).toBe("empty"); }); }); + +describe("fillStatusDataFor45Days", () => { + it("should fill all 45 days", () => { + const data: StatusData[] = []; + const result = fillStatusDataFor45Days(data, "1"); + + expect(result).toHaveLength(45); + }); + + it("should fill exactly 30 days when lookbackPeriod is 30", () => { + const result = fillStatusDataFor45Days([], "1", 30); + + expect(result).toHaveLength(30); + }); + + it("should sort data by day oldest first", () => { + const data: StatusData[] = []; + const result = fillStatusDataFor45Days(data, "1"); + + for (let i = 1; i < result.length; i++) { + const prev = new Date(result[i - 1].day); + const curr = new Date(result[i].day); + expect(curr.getTime()).toBeGreaterThan(prev.getTime()); + } + }); + + it("should preserve existing data", () => { + const existingData = [createStatusData(5, 100, 50, 10)]; + const result = fillStatusDataFor45Days(existingData, "1"); + + expect(result).toHaveLength(45); + const matchingDay = result.find( + (d) => d.ok === 100 && d.degraded === 50 && d.error === 10, + ); + expect(matchingDay).toBeDefined(); + }); + + it("should fill missing days with zeros", () => { + const existingData = [createStatusData(5, 100, 0, 0)]; + const result = fillStatusDataFor45Days(existingData, "1"); + + const emptyDays = result.filter((d) => d.count === 0); + expect(emptyDays.length).toBe(44); + }); +}); + +describe("getEvents - pageComponent filtering", () => { + // Helper to create a mock page component + function createMockPageComponent( + id: number, + monitorId?: number, + ): PageComponent { + return { + id, + workspaceId: 1, + pageId: 1, + type: monitorId ? ("monitor" as const) : ("static" as const), + monitorId: monitorId ?? null, + name: `Component ${id}`, + description: null, + order: 0, + groupId: null, + groupOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + } + + // Helper to create a mock maintenance + function createMockMaintenance( + id: number, + pageComponentIds: number[], + ): Maintenance & { + maintenancesToPageComponents: { + pageComponent: PageComponent | null; + }[]; + } { + const now = new Date(); + const from = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago + const to = new Date(now.getTime() + 1000 * 60 * 60); // 1 hour from now + + return { + id, + title: `Maintenance ${id}`, + message: "Test maintenance", + from, + to, + workspaceId: 1, + pageId: 1, + createdAt: new Date(), + updatedAt: new Date(), + maintenancesToPageComponents: pageComponentIds.map((pcId) => ({ + pageComponent: createMockPageComponent(pcId, pcId * 10), + })), + }; + } + + // Helper to create a mock status report + function createMockStatusReport( + id: number, + pageComponentIds: number[], + status: "investigating" | "resolved" = "investigating", + ): StatusReport & { + statusReportsToPageComponents: { + pageComponent: PageComponent | null; + }[]; + statusReportUpdates: StatusReportUpdate[]; + } { + const now = new Date(); + const updateDate = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago + + return { + id, + title: `Status Report ${id}`, + status, + workspaceId: 1, + pageId: 1, + createdAt: new Date(), + updatedAt: new Date(), + statusReportsToPageComponents: pageComponentIds.map((pcId) => ({ + pageComponent: createMockPageComponent(pcId, pcId * 10), + })), + statusReportUpdates: [ + { + id: id * 100, + statusReportId: id, + date: updateDate, + status: "investigating", + message: "Investigating the issue", + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }; + } + + // Helper to create a mock incident + function createMockIncident(id: number, monitorId: number): Incident { + const now = new Date(); + const startedAt = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago + + return { + id, + title: `Incident ${id}`, + summary: "Test incident", + status: "investigating", + monitorId, + workspaceId: 1, + startedAt, + acknowledgedAt: null, + acknowledgedBy: null, + resolvedAt: null, + resolvedBy: null, + incidentScreenshotUrl: null, + recoveryScreenshotUrl: null, + autoResolved: false, + createdAt: startedAt, + updatedAt: new Date(), + }; + } + + it("should filter maintenances by pageComponentId", () => { + const maintenances = [ + createMockMaintenance(1, [1, 2]), + createMockMaintenance(2, [3, 4]), + createMockMaintenance(3, [1, 5]), + ]; + + const events = getEvents({ + maintenances, + incidents: [], + reports: [], + pageComponentId: 1, + pastDays: 365, + }); + + const maintenanceEvents = events.filter((e) => e.type === "maintenance"); + expect(maintenanceEvents).toHaveLength(2); + expect(maintenanceEvents.map((e) => e.id).sort()).toEqual([1, 3]); + }); + + it("should filter status reports by pageComponentId", () => { + const reports = [ + createMockStatusReport(1, [1, 2]), + createMockStatusReport(2, [3, 4]), + createMockStatusReport(3, [1, 5]), + ]; + + const events = getEvents({ + maintenances: [], + incidents: [], + reports, + pageComponentId: 1, + pastDays: 365, + }); + + const reportEvents = events.filter((e) => e.type === "report"); + expect(reportEvents).toHaveLength(2); + expect(reportEvents.map((e) => e.id).sort()).toEqual([1, 3]); + }); + + it("should exclude incidents for static components", () => { + const incidents = [createMockIncident(1, 10), createMockIncident(2, 20)]; + + const events = getEvents({ + maintenances: [], + incidents, + reports: [], + componentType: "static", + pastDays: 365, + }); + + const incidentEvents = events.filter((e) => e.type === "incident"); + expect(incidentEvents).toHaveLength(0); + }); + + it("should include incidents for monitor components", () => { + const incidents = [createMockIncident(1, 10), createMockIncident(2, 20)]; + + const events = getEvents({ + maintenances: [], + incidents, + reports: [], + monitorId: 10, + componentType: "monitor", + pastDays: 365, + }); + + const incidentEvents = events.filter((e) => e.type === "incident"); + expect(incidentEvents).toHaveLength(1); + expect(incidentEvents[0].id).toBe(1); + }); + + it("should maintain backward compatibility with monitorId filtering", () => { + const maintenances = [ + createMockMaintenance(1, [1]), + createMockMaintenance(2, [2]), + ]; + + const events = getEvents({ + maintenances, + incidents: [], + reports: [], + monitorId: 10, + pastDays: 365, + }); + + const maintenanceEvents = events.filter((e) => e.type === "maintenance"); + expect(maintenanceEvents).toHaveLength(1); + expect(maintenanceEvents[0].id).toBe(1); + }); + + it("should prioritize pageComponentId over monitorId", () => { + const maintenances = [ + createMockMaintenance(1, [1]), + createMockMaintenance(2, [2]), + ]; + + const events = getEvents({ + maintenances, + incidents: [], + reports: [], + pageComponentId: 1, + monitorId: 20, + pastDays: 365, + }); + + const maintenanceEvents = events.filter((e) => e.type === "maintenance"); + expect(maintenanceEvents).toHaveLength(1); + expect(maintenanceEvents[0].id).toBe(1); + }); + + it("should return success status for resolved reports", () => { + const reports = [createMockStatusReport(1, [1], "resolved")]; + + const events = getEvents({ + maintenances: [], + incidents: [], + reports, + pageComponentId: 1, + pastDays: 365, + }); + + const reportEvents = events.filter((e) => e.type === "report"); + expect(reportEvents).toHaveLength(1); + expect(reportEvents[0].status).toBe("success"); + }); + + it("should return degraded status for unresolved reports", () => { + const reports = [createMockStatusReport(1, [1], "investigating")]; + + const events = getEvents({ + maintenances: [], + incidents: [], + reports, + pageComponentId: 1, + pastDays: 365, + }); + + const reportEvents = events.filter((e) => e.type === "report"); + expect(reportEvents).toHaveLength(1); + expect(reportEvents[0].status).toBe("degraded"); + }); +}); + +describe("componentImpacts", () => { + describe("getEvents - impact projection", () => { + const day = dayStartUTC(1); + + it("projects the filtered component's own timeline and status", () => { + const report = createImpactReport({ + id: 1, + components: [1, 2], + updates: [ + { + id: 100, + date: day, + status: "investigating", + impacts: [ + { pageComponentId: 1, impact: "major_outage" }, + { pageComponentId: 2, impact: "degraded_performance" }, + ], + }, + ], + }); + + const apiEvents = getEvents({ + maintenances: [], + incidents: [], + reports: [report], + pageComponentId: 1, + }); + expect(apiEvents).toHaveLength(1); + expect(apiEvents[0].status).toBe("error"); + expect(apiEvents[0].impactIntervals).toHaveLength(1); + expect(apiEvents[0].impactIntervals?.[0].impact).toBe("major_outage"); + + const webEvents = getEvents({ + maintenances: [], + incidents: [], + reports: [report], + pageComponentId: 2, + }); + expect(webEvents[0].status).toBe("degraded"); + expect(webEvents[0].impactIntervals?.[0].impact).toBe( + "degraded_performance", + ); + }); + + it("page-level projection takes the worst impact across components", () => { + const report = createImpactReport({ + id: 1, + components: [1, 2], + updates: [ + { + id: 100, + date: day, + status: "investigating", + impacts: [ + { pageComponentId: 1, impact: "major_outage" }, + { pageComponentId: 2, impact: "degraded_performance" }, + ], + }, + ], + }); + + const events = getEvents({ + maintenances: [], + incidents: [], + reports: [report], + }); + expect(events[0].status).toBe("error"); + expect(events[0].impactIntervals?.[0].impact).toBe("major_outage"); + }); + + it("legacy reports carry no impactIntervals and stay degraded", () => { + const report = createImpactReport({ + id: 1, + components: [1], + updates: [{ id: 100, date: day, status: "investigating" }], + }); + + const events = getEvents({ + maintenances: [], + incidents: [], + reports: [report], + pageComponentId: 1, + }); + expect(events[0].status).toBe("degraded"); + expect(events[0].impactIntervals).toBeUndefined(); + }); + + it("an active report whose component recovered reads success", () => { + const report = createImpactReport({ + id: 1, + components: [1], + updates: [ + { + id: 100, + date: day, + status: "investigating", + impacts: [{ pageComponentId: 1, impact: "partial_outage" }], + }, + { + id: 101, + date: hoursAfter(day, 2), + status: "identified", + impacts: [{ pageComponentId: 1, impact: "operational" }], + }, + ], + }); + + const events = getEvents({ + maintenances: [], + incidents: [], + reports: [report], + pageComponentId: 1, + }); + expect(events[0].status).toBe("success"); + // prior interval closed at the second update + expect(events[0].impactIntervals?.[0].to?.getTime()).toBe( + hoursAfter(day, 2).getTime(), + ); + }); + + it("clamps open intervals to the resolved event end", () => { + const report = createImpactReport({ + id: 1, + status: "resolved", + components: [1], + updates: [ + { + id: 100, + date: day, + status: "investigating", + impacts: [{ pageComponentId: 1, impact: "major_outage" }], + }, + { + id: 101, + date: hoursAfter(day, 3), + status: "resolved", + impacts: [{ pageComponentId: 1, impact: "operational" }], + }, + ], + }); + + const events = getEvents({ + maintenances: [], + incidents: [], + reports: [report], + pageComponentId: 1, + }); + expect(events[0].status).toBe("success"); + const intervals = events[0].impactIntervals ?? []; + expect(intervals).toHaveLength(2); + expect(intervals[0].impact).toBe("major_outage"); + expect(intervals[0].to?.getTime()).toBe(hoursAfter(day, 3).getTime()); + // trailing operational interval clamped to the event end, not open + expect(intervals[1].to?.getTime()).toBe(hoursAfter(day, 3).getTime()); + }); + }); + + describe("currentImpactByComponent", () => { + const day = dayStartUTC(1); + + it("latest update naming a component wins; resolve clears", () => { + const report = createImpactReport({ + id: 1, + components: [1, 2], + updates: [ + { + id: 100, + date: day, + status: "investigating", + impacts: [ + { pageComponentId: 1, impact: "major_outage" }, + { pageComponentId: 2, impact: "degraded_performance" }, + ], + }, + { + id: 101, + date: hoursAfter(day, 1), + status: "identified", + impacts: [{ pageComponentId: 1, impact: "partial_outage" }], + }, + ], + }); + + const current = currentImpactByComponent(report); + expect(current.get(1)).toBe("partial_outage"); + expect(current.get(2)).toBe("degraded_performance"); + }); + + it("returns an empty map for legacy reports", () => { + const report = createImpactReport({ + id: 1, + components: [1], + updates: [{ id: 100, date: day, status: "investigating" }], + }); + expect(currentImpactByComponent(report).size).toBe(0); + }); + }); +}); diff --git a/packages/services/test/timeline-fixtures.ts b/packages/services/test/timeline-fixtures.ts new file mode 100644 index 00000000..51b635e4 --- /dev/null +++ b/packages/services/test/timeline-fixtures.ts @@ -0,0 +1,190 @@ +import type { PageComponent } from "@openstatus/db/src/schema"; + +import type { Event, StatusData } from "../src/status-timeline"; + +// Helper functions to create test data +export function createStatusData( + daysAgo: number, + ok = 0, + degraded = 0, + error = 0, +): StatusData { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + date.setUTCHours(0, 0, 0, 0); + + return { + day: date.toISOString(), + count: ok + degraded + error, + ok, + degraded, + error, + monitorId: "1", + }; +} + +export function createIncident( + id: number, + daysAgo: number, + durationHours = 1, +): Event { + const from = new Date(); + from.setDate(from.getDate() - daysAgo); + from.setHours(from.getHours() - durationHours); + + const to = new Date(from); + to.setHours(to.getHours() + durationHours); + + return { + id, + name: "Downtime", + from, + to, + type: "incident", + status: "error", + }; +} + +export function createReport( + id: number, + daysAgo: number, + durationHours = 2, +): Event { + const from = new Date(); + from.setDate(from.getDate() - daysAgo); + from.setHours(from.getHours() - durationHours); + + const to = new Date(from); + to.setHours(to.getHours() + durationHours); + + return { + id, + name: "Performance Issues", + from, + to, + type: "report", + status: "degraded", + }; +} + +export function createMaintenance( + id: number, + daysAgo: number, + durationHours = 1, +): Event { + const from = new Date(); + from.setDate(from.getDate() - daysAgo); + from.setHours(from.getHours() - durationHours); + + const to = new Date(from); + to.setHours(to.getHours() + durationHours); + + return { + id, + name: "Scheduled Maintenance", + from, + to, + type: "maintenance", + status: "info", + }; +} + +export type Impact = + | "operational" + | "degraded_performance" + | "partial_outage" + | "major_outage"; + +export function dayStartUTC(daysAgo: number): Date { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - daysAgo); + d.setUTCHours(0, 0, 0, 0); + return d; +} + +export function hoursAfter(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} + +export function createImpactEvent( + id: number, + from: Date, + to: Date | null, + impactIntervals: { from: Date; to: Date | null; impact: Impact }[], +) { + return { + id, + name: `Impact Report ${id}`, + from, + to, + type: "report" as const, + status: "degraded" as const, + impactIntervals, + }; +} + +export function createLegacyEvent(id: number, from: Date, to: Date | null) { + return { + id, + name: `Legacy Report ${id}`, + from, + to, + type: "report" as const, + status: "degraded" as const, + }; +} + +export function impactComponent(id: number, monitorId?: number): PageComponent { + return { + id, + workspaceId: 1, + pageId: 1, + type: monitorId ? ("monitor" as const) : ("static" as const), + monitorId: monitorId ?? null, + name: `Component ${id}`, + description: null, + order: 0, + groupId: null, + groupOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +export function createImpactReport(args: { + id: number; + status?: "investigating" | "resolved"; + components: number[]; + updates: Array<{ + id: number; + date: Date; + status: "investigating" | "identified" | "monitoring" | "resolved"; + impacts?: Array<{ pageComponentId: number; impact: Impact }>; + }>; +}) { + return { + id: args.id, + title: `Status Report ${args.id}`, + status: args.status ?? ("investigating" as const), + workspaceId: 1, + pageId: 1, + createdAt: new Date(), + updatedAt: new Date(), + statusReportsToPageComponents: args.components.map((pcId) => ({ + pageComponent: impactComponent(pcId, pcId * 10), + })), + statusReportUpdates: args.updates.map((u) => ({ + id: u.id, + statusReportId: args.id, + date: u.date, + status: u.status, + message: "m", + createdAt: new Date(), + updatedAt: new Date(), + statusReportUpdateToPageComponents: (u.impacts ?? []).map((r) => ({ + pageComponentId: r.pageComponentId, + impact: r.impact, + })), + })), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9c5b56c..0e04af74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -674,9 +674,6 @@ importers: '@openstatus/tinybird': specifier: workspace:* version: link:../../packages/tinybird - '@openstatus/tracker': - specifier: workspace:* - version: link:../../packages/tracker '@openstatus/ui': specifier: workspace:* version: link:../../packages/ui @@ -1139,9 +1136,6 @@ importers: '@openstatus/tinybird': specifier: workspace:* version: link:../../packages/tinybird - '@openstatus/tracker': - specifier: workspace:* - version: link:../../packages/tracker '@openstatus/ui': specifier: workspace:* version: link:../../packages/ui -- 2.51.2