From f44ba7717e542a677dd9e00f54807ad8ad99be1b Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 4 Aug 2026 03:18:28 +0800 Subject: [PATCH] fix: include private locations in region count, fix graph bug (#2501) * fix: filter out soft-deleted private location assignments Added isNull(deletedAt) filter to privateLocationToMonitors query to exclude soft-deleted assignments when counting private locations per monitor. This was causing incorrect region counts. * fix: add privateLocationCount to monitor schema Added privateLocationCount field to selectPublicMonitorWithStatusSchema so Zod doesn't strip it during response parsing. This was the root cause of the field not appearing in the frontend. * fix: detect all regions across all data points in chart Changed region detection from only checking data[0] to checking all data points using flatMap and Set. This ensures all regions appear in the legend even if they don't have data at the most recent timestamp. Fixes issue where only 1 private location appeared in graph despite all 3 having data. * fix: add missing privateLocationToMonitors import and fix any type usage * ci: apply automated fixes * fix: add privateLocationCount field to status page monitors Adds a computed field that counts private locations per monitor to fix the region count display issue. The field is computed by querying the privateLocationToMonitors junction table and counting assignments per monitor ID. This approach avoids modifying the existing regions array which has a strict Zod schema validation for cloud region codes only. Instead, privateLocationCount is appended as a new field on each monitor, following the existing pattern of computed fields like status, events, monitorGroupId, etc. * fix: improve type safety by replacing 'as any' with 'as Record' Addresses code review feedback about scattered type casts * fix: remove leftover merge conflict marker Removes conflict marker that was missed during cherry-pick resolution * ci: apply automated fixes * fix: use UTC time in test event helpers for consistent date overlap Changed createIncident, createReport, and createMaintenance to use UTC noon instead of local time. This ensures events reliably overlap with UTC-midnight status data days, fixing test failures caused by the services package refactor. Fixes: - Line 560: maintenance exclusion test now has proper overlap - Line 719: multiple days test now correctly detects maintenance as 'info' * fix: adjust event helpers to use UTC midnight for accurate duration calculations Changed from UTC noon to UTC midnight (00:00) so 24-hour events properly cover full days. This fixes uptime calculation tests while maintaining reliable overlap with UTC-midnight status data days. * refactor: remove unsafe type assertions for privateLocationCount Use optional chaining directly instead of type assertions since privateLocationCount is already defined as optional in the schema. Addresses maintainer feedback to avoid using 'as' type assertions. * refactor: remove type cast by using immutable map for privateLocationCount Replace mutating loop with non-mutating map to avoid 'as Record' cast. Creates monitorsWithPrivateLocationCount array instead of mutating monitors directly, improving type safety and following CLAUDE.md Type Cast Discipline. * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../[locale]/(public)/monitors/[id]/page.tsx | 4 +- .../components/chart/chart-line-regions.tsx | 8 +++- packages/api/src/router/statusPage.ts | 47 ++++++++++++++++--- .../api/src/router/statusPage.utils.test.ts | 12 ++--- packages/db/src/schema/shared.ts | 1 + 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx index 86cdc83d..a55be499 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/monitors/[id]/page.tsx @@ -254,7 +254,9 @@ export default function Page() { ) : ( - {tempMonitor?.regions.length} {t("regions")}{" "} + {(tempMonitor?.regions.length ?? 0) + + (tempMonitor?.privateLocationCount ?? 0)}{" "} + {t("regions")}{" "} 0 - ? Object.keys(data[0]).filter((item) => item !== "timestamp") + ? Array.from( + new Set( + data + .flatMap((item) => Object.keys(item)) + .filter((key) => key !== "timestamp"), + ), + ) : []; return regions diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 1659d28e..684acc4b 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -1,10 +1,11 @@ import { Events } from "@openstatus/analytics"; -import { and, eq, inArray, sql } from "@openstatus/db"; +import { and, eq, inArray, isNull, sql } from "@openstatus/db"; import { maintenance, page, pageComponent, pageConfigurationSchema, + privateLocationToMonitors, selectMaintenancePageSchema, selectPageComponentWithMonitorRelation, selectPageSchema, @@ -247,13 +248,46 @@ export const statusPageRouter = createTRPCRouter({ }; }); + // Add privateLocationCount to each monitor + const privateLocationCounts = new Map(); + if (monitors.length > 0) { + const monitorIds = monitors.map((m) => m.id); + const privateLocations = + await opts.ctx.db.query.privateLocationToMonitors.findMany({ + where: and( + inArray(privateLocationToMonitors.monitorId, monitorIds), + isNull(privateLocationToMonitors.deletedAt), + ), + columns: { + monitorId: true, + }, + }); + + // Count private locations per monitor + for (const pl of privateLocations) { + if (pl.monitorId === null) continue; + privateLocationCounts.set( + pl.monitorId, + (privateLocationCounts.get(pl.monitorId) ?? 0) + 1, + ); + } + } + + // Create new array with privateLocationCount included (no mutation/cast) + const monitorsWithPrivateLocationCount = monitors.map((m) => ({ + ...m, + privateLocationCount: privateLocationCounts.get(m.id) ?? 0, + })); + // no barType gate: incident-driven error is already suppressed per // monitor in manual mode; report-driven error (major_outage) must show - const status = monitors.some((m) => m.status === "error") + const status = monitorsWithPrivateLocationCount.some( + (m) => m.status === "error", + ) ? "error" - : monitors.some((m) => m.status === "degraded") + : monitorsWithPrivateLocationCount.some((m) => m.status === "degraded") ? "degraded" - : monitors.some((m) => m.status === "info") + : monitorsWithPrivateLocationCount.some((m) => m.status === "info") ? "info" : "success"; @@ -430,10 +464,11 @@ export const statusPageRouter = createTRPCRouter({ return selectPublicPageSchemaWithRelation.parse({ ..._page, customTheme, - monitors, + monitors: monitorsWithPrivateLocationCount, monitorGroups, trackers, - incidents: monitors.flatMap((m) => m.incidents) ?? [], + incidents: + monitorsWithPrivateLocationCount.flatMap((m) => m.incidents) ?? [], statusReports, maintenances, workspacePlan: _page.workspace.plan, diff --git a/packages/api/src/router/statusPage.utils.test.ts b/packages/api/src/router/statusPage.utils.test.ts index a9ae1c57..8b582653 100644 --- a/packages/api/src/router/statusPage.utils.test.ts +++ b/packages/api/src/router/statusPage.utils.test.ts @@ -59,10 +59,10 @@ function createStatusData( function createIncident(id: number, daysAgo: number, durationHours = 1): Event { const from = new Date(); from.setDate(from.getDate() - daysAgo); - from.setHours(from.getHours() - durationHours); + from.setUTCHours(0, 0, 0, 0); // Set to midnight UTC for full-day coverage const to = new Date(from); - to.setHours(to.getHours() + durationHours); + to.setUTCHours(from.getUTCHours() + durationHours); return { id, @@ -77,10 +77,10 @@ function createIncident(id: number, daysAgo: number, durationHours = 1): Event { function createReport(id: number, daysAgo: number, durationHours = 2): Event { const from = new Date(); from.setDate(from.getDate() - daysAgo); - from.setHours(from.getHours() - durationHours); + from.setUTCHours(0, 0, 0, 0); // Set to midnight UTC for full-day coverage const to = new Date(from); - to.setHours(to.getHours() + durationHours); + to.setUTCHours(from.getUTCHours() + durationHours); return { id, @@ -99,10 +99,10 @@ function createMaintenance( ): Event { const from = new Date(); from.setDate(from.getDate() - daysAgo); - from.setHours(from.getHours() - durationHours); + from.setUTCHours(0, 0, 0, 0); // Set to midnight UTC for full-day coverage const to = new Date(from); - to.setHours(to.getHours() + durationHours); + to.setUTCHours(from.getUTCHours() + durationHours); return { id, diff --git a/packages/db/src/schema/shared.ts b/packages/db/src/schema/shared.ts index 7d7855f9..8237a0cf 100644 --- a/packages/db/src/schema/shared.ts +++ b/packages/db/src/schema/shared.ts @@ -95,6 +95,7 @@ const selectPublicMonitorWithStatusSchema = selectPublicMonitorBaseSchema monitorGroupId: z.number().nullable().optional(), order: z.number().default(0).optional(), groupOrder: z.number().default(0).nullish(), + privateLocationCount: z.number().optional(), }) .transform((data) => ({ ...data, -- 2.51.2