Openstatus www.openstatus.dev

feat: page-components tracker statuspage (#1788)

* feat: page-components tracker statuspage

* fix: filter

* fix: unused var

* fix: status-banner prefix link

authored by

Maximilian Kaske and committed by
GitHub
3452a96a 41600599

+625 -293
+4 -4
apps/status-page/src/app/(public)/client.tsx
··· 343 343 <StatusEventTitle className="inline-flex gap-1"> 344 344 {report.title} 345 345 </StatusEventTitle> 346 - {report.monitorsToStatusReports.length > 0 ? ( 346 + {report.statusReportsToPageComponents.length > 0 ? ( 347 347 <StatusEventAffected> 348 - {report.monitorsToStatusReports.map((affected) => ( 349 - <StatusEventAffectedBadge key={affected.monitor.id}> 350 - {affected.monitor.name} 348 + {report.statusReportsToPageComponents.map((affected) => ( 349 + <StatusEventAffectedBadge key={affected.pageComponent.id}> 350 + {affected.pageComponent.name} 351 351 </StatusEventAffectedBadge> 352 352 ))} 353 353 </StatusEventAffected>
+14 -10
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx
··· 77 77 <StatusEventTitleCheck /> 78 78 ) : null} 79 79 </StatusEventTitle> 80 - {report.monitorsToStatusReports.length > 0 ? ( 80 + {report.statusReportsToPageComponents.length > 0 ? ( 81 81 <StatusEventAffected> 82 - {report.monitorsToStatusReports.map((affected) => ( 83 - <StatusEventAffectedBadge key={affected.monitor.id}> 84 - {affected.monitor.name} 85 - </StatusEventAffectedBadge> 86 - ))} 82 + {report.statusReportsToPageComponents.map( 83 + (affected) => ( 84 + <StatusEventAffectedBadge 85 + key={affected.pageComponent.id} 86 + > 87 + {affected.pageComponent.name} 88 + </StatusEventAffectedBadge> 89 + ), 90 + )} 87 91 </StatusEventAffected> 88 92 ) : null} 89 93 <StatusEventTimelineReport ··· 115 119 > 116 120 <StatusEventContent> 117 121 <StatusEventTitle>{maintenance.title}</StatusEventTitle> 118 - {maintenance.maintenancesToMonitors.length > 0 ? ( 122 + {maintenance.maintenancesToPageComponents.length > 0 ? ( 119 123 <StatusEventAffected> 120 - {maintenance.maintenancesToMonitors.map( 124 + {maintenance.maintenancesToPageComponents.map( 121 125 (affected) => ( 122 126 <StatusEventAffectedBadge 123 - key={affected.monitor.id} 127 + key={affected.pageComponent.id} 124 128 > 125 - {affected.monitor.name} 129 + {affected.pageComponent.name} 126 130 </StatusEventAffectedBadge> 127 131 ), 128 132 )}
+3 -3
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/page.tsx
··· 50 50 <StatusEventContent hoverable={false}> 51 51 <StatusEventTitle>{maintenance.title}</StatusEventTitle> 52 52 <StatusEventAffected> 53 - {maintenance.maintenancesToMonitors.map((affected) => ( 54 - <StatusEventAffectedBadge key={affected.monitor.id}> 55 - {affected.monitor.name} 53 + {maintenance.maintenancesToPageComponents.map((affected) => ( 54 + <StatusEventAffectedBadge key={affected.pageComponent.id}> 55 + {affected.pageComponent.name} 56 56 </StatusEventAffectedBadge> 57 57 ))} 58 58 </StatusEventAffected>
+4 -4
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx
··· 59 59 {report.title} 60 60 {isReportResolvedOnly ? <StatusEventTitleCheck /> : null} 61 61 </StatusEventTitle> 62 - {report.monitorsToStatusReports.length > 0 ? ( 62 + {report.statusReportsToPageComponents.length > 0 ? ( 63 63 <StatusEventAffected> 64 - {report.monitorsToStatusReports.map((affected) => ( 65 - <StatusEventAffectedBadge key={affected.monitor.id}> 66 - {affected.monitor.name} 64 + {report.statusReportsToPageComponents.map((affected) => ( 65 + <StatusEventAffectedBadge key={affected.pageComponent.id}> 66 + {affected.pageComponent.name} 67 67 </StatusEventAffectedBadge> 68 68 ))} 69 69 </StatusEventAffected>
+8 -6
apps/status-page/src/app/(status-page)/[domain]/(public)/feed/json/route.ts
··· 73 73 from: maintenance.from, 74 74 to: maintenance.to, 75 75 updatedAt: maintenance.updatedAt, 76 - // @deprecated Use components instead 77 - monitors: maintenance.maintenancesToMonitors.map( 78 - (item) => item.monitor.id, 79 - ), 76 + // @deprecated Use components instead - returning monitor IDs for backwards compatibility 77 + monitors: maintenance.maintenancesToPageComponents 78 + .map((item) => item.pageComponent.monitorId) 79 + .filter((id): id is number => id !== null), 80 80 // New field - references page component IDs 81 81 pageComponents: maintenance.maintenancesToPageComponents.map( 82 82 (item) => item.pageComponentId, ··· 87 87 title: report.title, 88 88 updatedAt: report.updatedAt, 89 89 status: report.status, 90 - // @deprecated Use components instead 91 - monitors: report.monitorsToStatusReports.map((item) => item.monitor.id), 90 + // @deprecated Use components instead - returning monitor IDs for backwards compatibility 91 + monitors: report.statusReportsToPageComponents 92 + .map((item) => item.pageComponent.monitorId) 93 + .filter((id): id is number => id !== null), 92 94 // New field - references page component IDs 93 95 pageComponents: report.statusReportsToPageComponents.map( 94 96 (item) => item.pageComponentId,
+44 -28
apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx
··· 27 27 import { StatusMonitor } from "@/components/status-page/status-monitor"; 28 28 import { StatusTrackerGroup } from "@/components/status-page/status-tracker-group"; 29 29 import { Separator } from "@/components/ui/separator"; 30 + import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 30 31 import { useTRPC } from "@/lib/trpc/client"; 31 32 import { cn } from "@/lib/utils"; 32 33 import { useQuery } from "@tanstack/react-query"; ··· 35 36 import { useMemo } from "react"; 36 37 37 38 export default function Page() { 39 + const prefix = usePathnamePrefix(); 38 40 const { domain } = useParams<{ domain: string }>(); 39 41 const { cardType, barType, showUptime } = useStatusPage(); 40 42 const trpc = useTRPC(); ··· 72 74 const { data: uptimeData, isLoading } = useQuery({ 73 75 ...trpc.statusPage.getUptime.queryOptions({ 74 76 slug: domain, 75 - monitorIds: 76 - pageInitial?.monitors?.map((monitor) => monitor.id.toString()) || [], 77 + pageComponentIds: 78 + pageInitial?.pageComponents?.map((c) => c.id.toString()) || [], 77 79 cardType, 78 80 barType, 79 81 }), 80 - enabled: !!pageInitial && pageInitial.monitors.length > 0, 82 + enabled: !!pageInitial && pageInitial.pageComponents.length > 0, 81 83 }); 82 84 83 85 // NOTE: we need to filter out the incidents as we don't want to show all of them in the banner - a single one is enough ··· 149 151 key={`${e.type}-${e.id}`} 150 152 > 151 153 <Link 152 - href={`./events/report/${report.id}`} 154 + href={`${prefix ? `/${prefix}` : ""}/events/report/${report.id}`} 153 155 className="rounded-lg" 154 156 > 155 157 <StatusBannerContainer status={e.status}> ··· 160 162 isLast={true} 161 163 withSeparator={false} 162 164 /> 163 - {report.monitorsToStatusReports.length > 0 ? ( 165 + {report.statusReportsToPageComponents.length > 0 ? ( 164 166 <StatusEventAffected> 165 - {report.monitorsToStatusReports.map( 167 + {report.statusReportsToPageComponents.map( 166 168 (affected) => ( 167 169 <StatusEventAffectedBadge 168 - key={affected.monitor.id} 170 + key={affected.pageComponent.id} 169 171 > 170 - {affected.monitor.name} 172 + {affected.pageComponent.name} 171 173 </StatusEventAffectedBadge> 172 174 ), 173 175 )} ··· 190 192 key={e.id} 191 193 > 192 194 <Link 193 - href={`./events/maintenance/${maintenance.id}`} 195 + href={`${prefix ? `/${prefix}` : ""}/events/maintenance/${maintenance.id}`} 194 196 className="rounded-lg" 195 197 > 196 198 <StatusBannerContainer status={e.status}> ··· 199 201 maintenance={maintenance} 200 202 withDot={false} 201 203 /> 202 - {maintenance.maintenancesToMonitors.length > 0 ? ( 204 + {maintenance.maintenancesToPageComponents.length > 205 + 0 ? ( 203 206 <StatusEventAffected> 204 - {maintenance.maintenancesToMonitors.map( 207 + {maintenance.maintenancesToPageComponents.map( 205 208 (affected) => ( 206 209 <StatusEventAffectedBadge 207 - key={affected.monitor.id} 210 + key={affected.pageComponent.id} 208 211 > 209 - {affected.monitor.name} 212 + {affected.pageComponent.name} 210 213 </StatusEventAffectedBadge> 211 214 ), 212 215 )} ··· 239 242 {page.trackers.length > 0 ? ( 240 243 <StatusContent className="gap-5"> 241 244 {page.trackers.map((tracker, index) => { 242 - if (tracker.type === "monitor") { 243 - const monitor = tracker.monitor; 245 + if (tracker.type === "component") { 246 + const component = tracker.component; 247 + 248 + // Fetch uptime data by component ID 244 249 const { data, uptime } = 245 - uptimeData?.find((m) => m.id === monitor.id) ?? {}; 250 + uptimeData?.find((u) => u.pageComponentId === component.id) ?? 251 + {}; 252 + 246 253 return ( 247 254 <StatusMonitor 248 - key={`monitor-${monitor.id}`} 249 - status={monitor.status} 255 + key={`component-${component.id}`} 256 + status={component.status} 250 257 data={data} 251 - monitor={monitor} 258 + monitor={{ 259 + name: component.name, 260 + description: component.description, 261 + }} 252 262 uptime={uptime} 253 263 showUptime={showUptime} 254 264 isLoading={isLoading} ··· 264 274 // NOTE: we only want to open the first group if it is the first one 265 275 defaultOpen={firstGroupIndex === index && index === 0} 266 276 > 267 - {tracker.monitors.map((monitor) => { 277 + {tracker.components.map((component) => { 268 278 const { data, uptime } = 269 - uptimeData?.find((m) => m.id === monitor.id) ?? {}; 279 + uptimeData?.find( 280 + (u) => u.pageComponentId === component.id, 281 + ) ?? {}; 282 + 270 283 return ( 271 284 <StatusMonitor 272 - key={`monitor-${monitor.id}`} 273 - status={monitor.status} 285 + key={`component-${component.id}`} 286 + status={component.status} 274 287 data={data} 275 - monitor={monitor} 288 + monitor={{ 289 + name: component.name, 290 + description: component.description, 291 + }} 276 292 uptime={uptime} 277 293 showUptime={showUptime} 278 294 isLoading={isLoading} ··· 295 311 ) 296 312 .map((report) => ({ 297 313 ...report, 298 - affected: report.monitorsToStatusReports.map( 299 - (monitor) => monitor.monitor.name, 314 + affected: report.statusReportsToPageComponents.map( 315 + (component) => component.pageComponent.name, 300 316 ), 301 317 updates: report.statusReportUpdates, 302 318 }))} ··· 309 325 ) 310 326 .map((maintenance) => ({ 311 327 ...maintenance, 312 - affected: maintenance.maintenancesToMonitors.map( 313 - (monitor) => monitor.monitor.name, 328 + affected: maintenance.maintenancesToPageComponents.map( 329 + (component) => component.pageComponent.name, 314 330 ), 315 331 }))} 316 332 />
+1 -1
apps/status-page/src/components/status-page/status-monitor.tsx
··· 43 43 uptime?: string; 44 44 monitor: { 45 45 name: string; 46 - description: string; 46 + description?: string | null; 47 47 }; 48 48 data?: Data; 49 49 isLoading?: boolean;
+16 -8
packages/api/src/router/statusPage.e2e.test.ts
··· 823 823 expect(tracker).toHaveProperty("type"); 824 824 expect(tracker).toHaveProperty("order"); 825 825 826 - if (tracker.type === "monitor") { 827 - expect(tracker).toHaveProperty("monitor"); 828 - expect(tracker.monitor).toHaveProperty("id"); 829 - expect(tracker.monitor).toHaveProperty("name"); 830 - expect(tracker.monitor).toHaveProperty("status"); 826 + if (tracker.type === "component") { 827 + expect(tracker).toHaveProperty("component"); 828 + expect(tracker.component).toHaveProperty("id"); 829 + expect(tracker.component).toHaveProperty("name"); 830 + expect(tracker.component).toHaveProperty("status"); 831 + expect(tracker.component).toHaveProperty("type"); 832 + expect(["monitor", "external"]).toContain(tracker.component.type); 831 833 expect(["success", "degraded", "error", "info"]).toContain( 832 - tracker.monitor.status, 834 + tracker.component.status, 833 835 ); 836 + 837 + // Monitor-type components should have monitor relation 838 + if (tracker.component.type === "monitor") { 839 + expect(tracker.component).toHaveProperty("monitor"); 840 + expect(tracker.component.monitor).toBeDefined(); 841 + } 834 842 } else if (tracker.type === "group") { 835 843 expect(tracker).toHaveProperty("groupId"); 836 844 expect(tracker).toHaveProperty("groupName"); 837 - expect(tracker).toHaveProperty("monitors"); 845 + expect(tracker).toHaveProperty("components"); 838 846 expect(tracker).toHaveProperty("status"); 839 - expect(Array.isArray(tracker.monitors)).toBe(true); 847 + expect(Array.isArray(tracker.components)).toBe(true); 840 848 expect(["success", "degraded", "error", "info"]).toContain( 841 849 tracker.status, 842 850 );
+181 -163
packages/api/src/router/statusPage.ts
··· 21 21 import { endOfDay, startOfDay, subDays } from "date-fns"; 22 22 import { createTRPCRouter, publicProcedure } from "../trpc"; 23 23 import { 24 + type StatusData, 24 25 fillStatusDataFor45Days, 25 26 fillStatusDataFor45DaysNoop, 26 27 getEvents, ··· 28 29 getWorstVariant, 29 30 isMonitorComponent, 30 31 setDataByType, 31 - transformMaintenanceWithPageComponents, 32 - transformStatusReportWithPageComponents, 33 - transformToMaintenancesToMonitors, 34 - transformToMonitorsToStatusReports, 35 32 } from "./statusPage.utils"; 36 33 import { 37 34 getMetricsLatencyMultiProcedure, ··· 128 125 129 126 const monitorComponents = pageComponents.filter(isMonitorComponent); 130 127 128 + // Transform all page components (both monitor and external types) 129 + const components = pageComponents.map((c) => { 130 + const events = getEvents({ 131 + maintenances: _page.maintenances, 132 + incidents: c.monitor?.incidents ?? [], 133 + reports: _page.statusReports, 134 + pageComponentId: c.id, 135 + monitorId: c.monitorId ?? undefined, 136 + componentType: c.type, 137 + }); 138 + 139 + // Calculate status based on component type 140 + let status: "success" | "degraded" | "error" | "info"; 141 + 142 + if (c.type === "external") { 143 + // External: only reports and maintenances affect status 144 + status = events.some((e) => e.type === "report" && !e.to) 145 + ? "degraded" 146 + : events.some( 147 + (e) => 148 + e.type === "maintenance" && 149 + e.to && 150 + e.from.getTime() <= new Date().getTime() && 151 + e.to.getTime() >= new Date().getTime(), 152 + ) 153 + ? "info" 154 + : "success"; 155 + } else { 156 + // Monitor: incidents, reports, and maintenances affect status 157 + status = 158 + events.some((e) => e.type === "incident" && !e.to) && 159 + barType !== "manual" 160 + ? "error" 161 + : events.some((e) => e.type === "report" && !e.to) 162 + ? "degraded" 163 + : events.some( 164 + (e) => 165 + e.type === "maintenance" && 166 + e.to && 167 + e.from.getTime() <= new Date().getTime() && 168 + e.to.getTime() >= new Date().getTime(), 169 + ) 170 + ? "info" 171 + : "success"; 172 + } 173 + 174 + return { 175 + ...c, 176 + status, 177 + events, 178 + }; 179 + }); 180 + 181 + // Keep monitors for backward compatibility with existing fields 131 182 const monitors = monitorComponents.map((c) => { 132 183 const events = getEvents({ 133 184 maintenances: _page.maintenances, ··· 208 259 209 260 const monitorGroups = _page.pageComponentGroups; 210 261 211 - // Create trackers array with grouped and ungrouped monitors 262 + // Create trackers array with grouped and ungrouped components 212 263 const groupedMap = new Map< 213 264 number | null, 214 265 { 215 266 groupId: number | null; 216 267 groupName: string | null; 217 - monitors: typeof monitors; 268 + components: typeof components; 218 269 minOrder: number; 219 270 } 220 271 >(); 221 272 222 - monitors.forEach((monitor) => { 223 - const groupId = monitor.monitorGroupId ?? null; 273 + components.forEach((component) => { 274 + const groupId = component.groupId ?? null; 224 275 const group = groupId 225 276 ? monitorGroups.find((g) => g?.id === groupId) 226 277 : null; ··· 230 281 groupedMap.set(groupId, { 231 282 groupId, 232 283 groupName, 233 - monitors: [], 234 - minOrder: monitor.order ?? 0, 284 + components: [], 285 + minOrder: component.order ?? 0, 235 286 }); 236 287 } 237 288 const currentGroup = groupedMap.get(groupId); 238 289 if (currentGroup) { 239 - currentGroup.monitors.push(monitor); 290 + currentGroup.components.push(component); 240 291 currentGroup.minOrder = Math.min( 241 292 currentGroup.minOrder, 242 - monitor.order ?? 0, 293 + component.order ?? 0, 243 294 ); 244 295 } 245 296 }); 246 297 247 298 // Convert to trackers array 248 - type MonitorTracker = { 249 - type: "monitor"; 250 - monitor: (typeof monitors)[number]; 299 + type PageComponentTracker = { 300 + type: "component"; 301 + component: (typeof components)[number]; 251 302 order: number; 252 303 }; 253 304 ··· 255 306 type: "group"; 256 307 groupId: number; 257 308 groupName: string; 258 - monitors: typeof monitors; 309 + components: typeof components; 259 310 status: "success" | "degraded" | "error" | "info" | "empty"; 260 311 order: number; 261 312 }; 262 313 263 - type Tracker = MonitorTracker | GroupTracker; 314 + type Tracker = PageComponentTracker | GroupTracker; 264 315 265 316 const trackers: Tracker[] = Array.from(groupedMap.values()) 266 317 .flatMap((group): Tracker[] => { 267 318 if (group.groupId === null) { 268 - // Ungrouped monitors - return as individual trackers 269 - return group.monitors.map( 270 - (monitor): MonitorTracker => ({ 271 - type: "monitor", 272 - monitor, 273 - order: monitor.order ?? 0, 319 + // Ungrouped components - return as individual trackers 320 + return group.components.map( 321 + (component): PageComponentTracker => ({ 322 + type: "component", 323 + component, 324 + order: component.order ?? 0, 274 325 }), 275 326 ); 276 327 } 277 - // Grouped monitors - return as single group tracker 278 - const sortedMonitors = group.monitors.sort( 328 + // Grouped components - return as single group tracker 329 + const sortedComponents = group.components.sort( 279 330 (a, b) => (a.groupOrder ?? 0) - (b.groupOrder ?? 0), 280 331 ); 281 332 return [ ··· 283 334 type: "group", 284 335 groupId: group.groupId, 285 336 groupName: group.groupName ?? "", 286 - monitors: sortedMonitors, 337 + components: sortedComponents, 287 338 status: getWorstVariant( 288 - group.monitors.map( 289 - (m) => m.status as "success" | "degraded" | "error" | "info", 339 + group.components.map( 340 + (c) => c.status as "success" | "degraded" | "error" | "info", 290 341 ), 291 342 ), 292 343 order: group.minOrder, ··· 297 348 298 349 const whiteLabel = ws.data?.limits["white-label"] ?? false; 299 350 300 - // Pre-build a Map for O(1) lookups to avoid N+1 query problem 301 - const monitorByIdMap = new Map( 302 - pageComponents 303 - .filter(isMonitorComponent) 304 - .map((c) => [c.monitorId, c.monitor]), 305 - ); 306 - 307 351 // Transform statusReports to include monitorsToStatusReports format 308 - const statusReports = _page.statusReports 309 - .sort((a, b) => { 310 - // Sort reports without updates to the beginning 311 - if ( 312 - a.statusReportUpdates.length === 0 && 313 - b.statusReportUpdates.length === 0 314 - ) 315 - return 0; 316 - if (a.statusReportUpdates.length === 0) return -1; 317 - if (b.statusReportUpdates.length === 0) return -1; 318 - return ( 319 - b.statusReportUpdates[ 320 - b.statusReportUpdates.length - 1 321 - ].date.getTime() - 322 - a.statusReportUpdates[ 323 - a.statusReportUpdates.length - 1 324 - ].date.getTime() 325 - ); 326 - }) 327 - .map((report) => 328 - transformStatusReportWithPageComponents(report, monitorByIdMap), 352 + const statusReports = _page.statusReports.sort((a, b) => { 353 + // Sort reports without updates to the beginning 354 + if ( 355 + a.statusReportUpdates.length === 0 && 356 + b.statusReportUpdates.length === 0 357 + ) 358 + return 0; 359 + if (a.statusReportUpdates.length === 0) return -1; 360 + if (b.statusReportUpdates.length === 0) return -1; 361 + return ( 362 + b.statusReportUpdates[ 363 + b.statusReportUpdates.length - 1 364 + ].date.getTime() - 365 + a.statusReportUpdates[a.statusReportUpdates.length - 1].date.getTime() 329 366 ); 367 + }); 330 368 331 - // Transform maintenances to include maintenancesToMonitors format 332 - const maintenances = _page.maintenances.map((m) => 333 - transformMaintenanceWithPageComponents(m, monitorByIdMap), 369 + const maintenances = _page.maintenances.sort( 370 + (a, b) => a.from.getTime() - b.from.getTime(), 334 371 ); 335 372 336 373 return selectPublicPageSchemaWithRelation.parse({ ··· 380 417 381 418 if (!_maintenance) return null; 382 419 383 - const pageComponents = selectPageComponentWithMonitorRelation 384 - .array() 385 - .parse( 386 - _maintenance.maintenancesToPageComponents.map((m) => m.pageComponent), 387 - ); 388 - 389 - // Transform to expected format (maintenancesToMonitors) 390 - const maintenancesToMonitors = transformToMaintenancesToMonitors( 391 - _maintenance.id, 392 - pageComponents, 393 - ); 394 - 395 - return selectMaintenancePageSchema.parse({ 396 - ..._maintenance, 397 - maintenancesToMonitors, 398 - }); 420 + const props: z.infer<typeof selectMaintenancePageSchema> = _maintenance; 421 + return selectMaintenancePageSchema.parse(props); 399 422 }), 400 423 401 424 getUptime: publicProcedure 402 425 .input( 403 426 z.object({ 404 427 slug: z.string().toLowerCase(), 405 - monitorIds: z.string().array(), 428 + pageComponentIds: z.string().array(), 406 429 cardType: z 407 430 .enum(["requests", "duration", "dominant", "manual"]) 408 431 .prefault("requests"), ··· 430 453 }, 431 454 pageComponents: { 432 455 where: inArray( 433 - pageComponent.monitorId, 434 - opts.input.monitorIds.map(Number), 456 + pageComponent.id, 457 + opts.input.pageComponentIds.map(Number), 435 458 ), 436 459 with: { 437 460 monitor: { ··· 450 473 .array() 451 474 .parse(_page.pageComponents); 452 475 453 - const monitors = pageComponents.filter(isMonitorComponent); 476 + // Early return if no components to process 477 + if (pageComponents.length === 0) return []; 454 478 455 - if (monitors.length !== opts.input.monitorIds.length) return null; 479 + const monitors = pageComponents.filter(isMonitorComponent); 456 480 457 481 const monitorsByType = { 458 482 http: monitors.filter((c) => c.monitor.jobType === "http"), ··· 486 510 | Awaited<ReturnType<(typeof proceduresByType)["dns"]>>["data"] 487 511 >(); 488 512 489 - if (statusHttp?.data) { 490 - statusHttp.data.forEach((status) => { 491 - const monitorId = status.monitorId; 492 - if (!statusDataByMonitorId.has(monitorId)) { 493 - statusDataByMonitorId.set(monitorId, []); 494 - } 495 - statusDataByMonitorId.get(monitorId)?.push(status); 496 - }); 497 - } 498 - 499 - if (statusTcp?.data) { 500 - statusTcp.data.forEach((status) => { 501 - const monitorId = status.monitorId; 502 - if (!statusDataByMonitorId.has(monitorId)) { 503 - statusDataByMonitorId.set(monitorId, []); 504 - } 505 - statusDataByMonitorId.get(monitorId)?.push(status); 506 - }); 507 - } 508 - 509 - if (statusDns?.data) { 510 - statusDns.data.forEach((status) => { 511 - const monitorId = status.monitorId; 512 - if (!statusDataByMonitorId.has(monitorId)) { 513 - statusDataByMonitorId.set(monitorId, []); 514 - } 515 - statusDataByMonitorId.get(monitorId)?.push(status); 516 - }); 513 + // Consolidate status data from all monitor types into the map 514 + for (const statusResult of [statusHttp, statusTcp, statusDns]) { 515 + if (statusResult?.data) { 516 + statusResult.data.forEach((status) => { 517 + const monitorId = status.monitorId; 518 + if (!statusDataByMonitorId.has(monitorId)) { 519 + statusDataByMonitorId.set(monitorId, []); 520 + } 521 + statusDataByMonitorId.get(monitorId)?.push(status); 522 + }); 523 + } 517 524 } 518 525 519 526 const lookbackPeriod = WORKSPACES.includes(_page.workspaceId ?? 0) 520 527 ? 30 521 528 : 45; 522 529 523 - return monitors.map((c) => { 524 - const monitorId = c.monitor.id.toString(); 530 + return pageComponents.map((c) => { 525 531 const events = getEvents({ 526 532 maintenances: _page.maintenances, 527 - incidents: c.monitor.incidents ?? [], 533 + incidents: c.monitor?.incidents ?? [], 528 534 reports: _page.statusReports, 529 - monitorId: c.monitor.id, 535 + pageComponentId: c.id, 536 + monitorId: c.monitorId ?? undefined, 537 + componentType: c.type, 530 538 }); 531 - const rawData = statusDataByMonitorId.get(monitorId) || []; 532 - const filledData = 533 - process.env.NOOP_UPTIME === "true" 534 - ? fillStatusDataFor45DaysNoop({ 535 - errorDays: [], 536 - degradedDays: [], 537 - lookbackPeriod, 538 - }) 539 - : fillStatusDataFor45Days(rawData, monitorId, lookbackPeriod); 539 + 540 + // Determine whether to use real Tinybird data or synthetic data 541 + const shouldUseRealData = 542 + c.type === "monitor" && 543 + c.monitor && 544 + opts.input.barType !== "manual" && 545 + process.env.NOOP_UPTIME !== "true"; 546 + 547 + let filledData: StatusData[]; 548 + if (shouldUseRealData) { 549 + // Monitor components with real data: use Tinybird data 550 + const monitorId = c.monitor?.id.toString() || ""; 551 + const rawData = statusDataByMonitorId.get(monitorId) || []; 552 + filledData = fillStatusDataFor45Days( 553 + rawData, 554 + monitorId, 555 + lookbackPeriod, 556 + ); 557 + } else { 558 + // External components, manual mode, or NOOP mode: use synthetic data 559 + filledData = fillStatusDataFor45DaysNoop({ 560 + errorDays: [], 561 + degradedDays: [], 562 + lookbackPeriod, 563 + }); 564 + } 565 + 566 + // External components always use manual mode since they don't have real monitoring data 567 + const effectiveBarType = 568 + c.type === "external" ? "manual" : opts.input.barType; 569 + const effectiveCardType = 570 + c.type === "external" ? "manual" : opts.input.cardType; 571 + 540 572 const processedData = setDataByType({ 541 573 events, 542 574 data: filledData, 543 - cardType: opts.input.cardType, 544 - barType: opts.input.barType, 575 + cardType: effectiveCardType, 576 + barType: effectiveBarType, 545 577 }); 546 578 const uptime = getUptime({ 547 579 data: filledData, 548 580 events, 549 - barType: opts.input.barType, 550 - cardType: opts.input.cardType, 581 + barType: effectiveBarType, 582 + cardType: effectiveCardType, 551 583 }); 552 584 553 585 return { 554 - ...c.monitor, 586 + id: c.id, 587 + pageComponentId: c.id, 588 + name: c.name, 589 + description: c.description, 590 + type: c.type, 591 + // For monitor-type components, include monitor fields 592 + ...(c.monitor ? { monitor: c.monitor } : {}), 555 593 data: processedData, 556 594 uptime, 557 595 }; ··· 617 655 618 656 if (!_report) return null; 619 657 620 - const pageComponents = selectPageComponentWithMonitorRelation 621 - .array() 622 - .parse( 623 - _report.statusReportsToPageComponents.map((r) => r.pageComponent), 624 - ); 625 - 626 - // Transform to expected format (monitorsToStatusReports) 627 - const monitorsToStatusReports = transformToMonitorsToStatusReports( 628 - _report.id, 629 - pageComponents, 630 - ); 631 - 632 - return selectStatusReportPageSchema.parse({ 633 - ..._report, 634 - monitorsToStatusReports, 635 - }); 658 + const result: z.infer<typeof selectStatusReportPageSchema> = _report; 659 + return selectStatusReportPageSchema.parse(result); 636 660 }), 637 661 638 662 getNoopReport: publicProcedure.query(async () => { ··· 643 667 const identifiedDate = new Date(date.setMinutes(date.getMinutes() - 32)); 644 668 const investigatingDate = new Date(date.setMinutes(date.getMinutes() - 4)); 645 669 646 - return selectStatusReportPageSchema.parse({ 670 + const props: z.infer<typeof selectStatusReportPageSchema> = { 647 671 id: 1, 648 672 pageId: 1, 673 + workspaceId: 1, 649 674 status: "investigating" as const, 650 675 title: "API Latency Issues", 651 - message: "We are currently investigating elevated API response times.", 652 676 createdAt: new Date(new Date().setDate(new Date().getDate() - 2)), 653 677 updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)), 654 - monitorsToStatusReports: [ 678 + statusReportsToPageComponents: [ 655 679 { 656 - monitorId: 1, 680 + pageComponentId: 1, 657 681 statusReportId: 1, 658 - monitor: { 682 + pageComponent: { 683 + workspaceId: 1, 684 + pageId: 1, 659 685 id: 1, 660 - jobType: "http" as const, 661 - periodicity: "30s" as const, 662 - status: "active" as const, 663 - active: true, 664 - regions: ["ams", "fra"], 665 - url: "https://api.example.com", 666 686 name: "API Monitor", 687 + type: "monitor" as const, 688 + monitorId: 1, 689 + order: 1, 690 + groupId: null, 691 + groupOrder: null, 667 692 description: "Main API endpoint", 668 - headers: null, 669 - body: null, 670 - method: "GET" as const, 671 - public: true, 672 - deletedAt: null, 673 693 createdAt: new Date(new Date().setDate(new Date().getDate() - 30)), 674 694 updatedAt: new Date(new Date().setDate(new Date().getDate() - 30)), 675 - workspaceId: 1, 676 - timeout: 30000, 677 - degradedAfter: null, 678 - assertions: null, 679 695 }, 680 696 }, 681 697 ], ··· 720 736 updatedAt: investigatingDate, 721 737 }, 722 738 ], 723 - }); 739 + }; 740 + 741 + return selectStatusReportPageSchema.parse(props); 724 742 }), 725 743 726 744 getMonitors: publicProcedure
+267
packages/api/src/router/statusPage.utils.test.ts
··· 1 1 import { describe, expect, it } from "bun:test"; 2 + import type { 3 + Incident, 4 + Maintenance, 5 + PageComponent, 6 + StatusReport, 7 + StatusReportUpdate, 8 + } from "@openstatus/db/src/schema"; 2 9 import { 3 10 fillStatusDataFor45Days, 11 + getEvents, 4 12 getUptime, 5 13 setDataByType, 6 14 } from "./statusPage.utils"; ··· 896 904 }); 897 905 }); 898 906 }); 907 + 908 + describe("getEvents - pageComponent filtering", () => { 909 + // Helper to create a mock page component 910 + function createMockPageComponent( 911 + id: number, 912 + monitorId?: number, 913 + ): PageComponent { 914 + return { 915 + id, 916 + workspaceId: 1, 917 + pageId: 1, 918 + type: monitorId ? ("monitor" as const) : ("external" as const), 919 + monitorId: monitorId ?? null, 920 + name: `Component ${id}`, 921 + description: null, 922 + order: 0, 923 + groupId: null, 924 + groupOrder: 0, 925 + createdAt: new Date(), 926 + updatedAt: new Date(), 927 + }; 928 + } 929 + 930 + // Helper to create a mock maintenance 931 + function createMockMaintenance( 932 + id: number, 933 + pageComponentIds: number[], 934 + ): Maintenance & { 935 + maintenancesToPageComponents: { 936 + pageComponent: PageComponent | null; 937 + }[]; 938 + } { 939 + const now = new Date(); 940 + const from = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago 941 + const to = new Date(now.getTime() + 1000 * 60 * 60); // 1 hour from now 942 + 943 + return { 944 + id, 945 + title: `Maintenance ${id}`, 946 + message: "Test maintenance", 947 + from, 948 + to, 949 + workspaceId: 1, 950 + pageId: 1, 951 + createdAt: new Date(), 952 + updatedAt: new Date(), 953 + maintenancesToPageComponents: pageComponentIds.map((pcId) => ({ 954 + pageComponent: createMockPageComponent(pcId, pcId * 10), 955 + })), 956 + }; 957 + } 958 + 959 + // Helper to create a mock status report 960 + function createMockStatusReport( 961 + id: number, 962 + pageComponentIds: number[], 963 + status: "investigating" | "resolved" = "investigating", 964 + ): StatusReport & { 965 + statusReportsToPageComponents: { 966 + pageComponent: PageComponent | null; 967 + }[]; 968 + statusReportUpdates: StatusReportUpdate[]; 969 + } { 970 + const now = new Date(); 971 + const updateDate = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago 972 + 973 + return { 974 + id, 975 + title: `Status Report ${id}`, 976 + status, 977 + workspaceId: 1, 978 + pageId: 1, 979 + createdAt: new Date(), 980 + updatedAt: new Date(), 981 + statusReportsToPageComponents: pageComponentIds.map((pcId) => ({ 982 + pageComponent: createMockPageComponent(pcId, pcId * 10), 983 + })), 984 + statusReportUpdates: [ 985 + { 986 + id: id * 100, 987 + statusReportId: id, 988 + date: updateDate, 989 + status: "investigating", 990 + message: "Investigating the issue", 991 + createdAt: new Date(), 992 + updatedAt: new Date(), 993 + }, 994 + ], 995 + }; 996 + } 997 + 998 + // Helper to create a mock incident 999 + function createMockIncident(id: number, monitorId: number): Incident { 1000 + const now = new Date(); 1001 + const startedAt = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago 1002 + 1003 + return { 1004 + id, 1005 + title: `Incident ${id}`, 1006 + summary: "Test incident", 1007 + status: "investigating", 1008 + monitorId, 1009 + workspaceId: 1, 1010 + startedAt, 1011 + acknowledgedAt: null, 1012 + acknowledgedBy: null, 1013 + resolvedAt: null, 1014 + resolvedBy: null, 1015 + incidentScreenshotUrl: null, 1016 + recoveryScreenshotUrl: null, 1017 + autoResolved: false, 1018 + createdAt: startedAt, 1019 + updatedAt: new Date(), 1020 + }; 1021 + } 1022 + 1023 + it("should filter maintenances by pageComponentId", () => { 1024 + const maintenances = [ 1025 + createMockMaintenance(1, [1, 2]), 1026 + createMockMaintenance(2, [3, 4]), 1027 + createMockMaintenance(3, [1, 5]), 1028 + ]; 1029 + 1030 + const events = getEvents({ 1031 + maintenances, 1032 + incidents: [], 1033 + reports: [], 1034 + pageComponentId: 1, 1035 + pastDays: 365, 1036 + }); 1037 + 1038 + const maintenanceEvents = events.filter((e) => e.type === "maintenance"); 1039 + expect(maintenanceEvents).toHaveLength(2); 1040 + expect(maintenanceEvents.map((e) => e.id).sort()).toEqual([1, 3]); 1041 + }); 1042 + 1043 + it("should filter status reports by pageComponentId", () => { 1044 + const reports = [ 1045 + createMockStatusReport(1, [1, 2]), 1046 + createMockStatusReport(2, [3, 4]), 1047 + createMockStatusReport(3, [1, 5]), 1048 + ]; 1049 + 1050 + const events = getEvents({ 1051 + maintenances: [], 1052 + incidents: [], 1053 + reports, 1054 + pageComponentId: 1, 1055 + pastDays: 365, 1056 + }); 1057 + 1058 + const reportEvents = events.filter((e) => e.type === "report"); 1059 + expect(reportEvents).toHaveLength(2); 1060 + expect(reportEvents.map((e) => e.id).sort()).toEqual([1, 3]); 1061 + }); 1062 + 1063 + it("should exclude incidents for external components", () => { 1064 + const incidents = [createMockIncident(1, 10), createMockIncident(2, 20)]; 1065 + 1066 + const events = getEvents({ 1067 + maintenances: [], 1068 + incidents, 1069 + reports: [], 1070 + componentType: "external", 1071 + pastDays: 365, 1072 + }); 1073 + 1074 + const incidentEvents = events.filter((e) => e.type === "incident"); 1075 + expect(incidentEvents).toHaveLength(0); 1076 + }); 1077 + 1078 + it("should include incidents for monitor components", () => { 1079 + const incidents = [createMockIncident(1, 10), createMockIncident(2, 20)]; 1080 + 1081 + const events = getEvents({ 1082 + maintenances: [], 1083 + incidents, 1084 + reports: [], 1085 + monitorId: 10, 1086 + componentType: "monitor", 1087 + pastDays: 365, 1088 + }); 1089 + 1090 + const incidentEvents = events.filter((e) => e.type === "incident"); 1091 + expect(incidentEvents).toHaveLength(1); 1092 + expect(incidentEvents[0].id).toBe(1); 1093 + }); 1094 + 1095 + it("should maintain backward compatibility with monitorId filtering", () => { 1096 + const maintenances = [ 1097 + createMockMaintenance(1, [1]), 1098 + createMockMaintenance(2, [2]), 1099 + ]; 1100 + 1101 + const events = getEvents({ 1102 + maintenances, 1103 + incidents: [], 1104 + reports: [], 1105 + monitorId: 10, 1106 + pastDays: 365, 1107 + }); 1108 + 1109 + const maintenanceEvents = events.filter((e) => e.type === "maintenance"); 1110 + expect(maintenanceEvents).toHaveLength(1); 1111 + expect(maintenanceEvents[0].id).toBe(1); 1112 + }); 1113 + 1114 + it("should prioritize pageComponentId over monitorId", () => { 1115 + const maintenances = [ 1116 + createMockMaintenance(1, [1]), 1117 + createMockMaintenance(2, [2]), 1118 + ]; 1119 + 1120 + const events = getEvents({ 1121 + maintenances, 1122 + incidents: [], 1123 + reports: [], 1124 + pageComponentId: 1, 1125 + monitorId: 20, 1126 + pastDays: 365, 1127 + }); 1128 + 1129 + const maintenanceEvents = events.filter((e) => e.type === "maintenance"); 1130 + expect(maintenanceEvents).toHaveLength(1); 1131 + expect(maintenanceEvents[0].id).toBe(1); 1132 + }); 1133 + 1134 + it("should return success status for resolved reports", () => { 1135 + const reports = [createMockStatusReport(1, [1], "resolved")]; 1136 + 1137 + const events = getEvents({ 1138 + maintenances: [], 1139 + incidents: [], 1140 + reports, 1141 + pageComponentId: 1, 1142 + pastDays: 365, 1143 + }); 1144 + 1145 + const reportEvents = events.filter((e) => e.type === "report"); 1146 + expect(reportEvents).toHaveLength(1); 1147 + expect(reportEvents[0].status).toBe("success"); 1148 + }); 1149 + 1150 + it("should return degraded status for unresolved reports", () => { 1151 + const reports = [createMockStatusReport(1, [1], "investigating")]; 1152 + 1153 + const events = getEvents({ 1154 + maintenances: [], 1155 + incidents: [], 1156 + reports, 1157 + pageComponentId: 1, 1158 + pastDays: 365, 1159 + }); 1160 + 1161 + const reportEvents = events.filter((e) => e.type === "report"); 1162 + expect(reportEvents).toHaveLength(1); 1163 + expect(reportEvents[0].status).toBe("degraded"); 1164 + }); 1165 + });
+51 -30
packages/api/src/router/statusPage.utils.ts
··· 129 129 }; 130 130 } 131 131 132 - type StatusData = { 132 + export type StatusData = { 133 133 day: string; 134 134 count: number; 135 135 ok: number; ··· 224 224 maintenances, 225 225 incidents, 226 226 reports, 227 + pageComponentId, 227 228 monitorId, 229 + componentType, 228 230 pastDays = 45, 229 231 }: { 230 232 maintenances: (Maintenance & { ··· 239 241 }[]; 240 242 statusReportUpdates: StatusReportUpdate[]; 241 243 })[]; 244 + pageComponentId?: number; 242 245 monitorId?: number; 246 + componentType?: "monitor" | "external"; 243 247 pastDays?: number; 244 248 }): Event[] { 245 249 const events: Event[] = []; 246 250 const pastThreshod = new Date(); 247 251 pastThreshod.setDate(pastThreshod.getDate() - pastDays); 248 252 249 - // Filter maintenances - if monitorId is provided, filter by monitor via pageComponent, otherwise include all 253 + // Filter maintenances - prioritize pageComponentId, fallback to monitorId for backward compatibility 250 254 maintenances 251 - .filter((maintenance) => 252 - monitorId 253 - ? maintenance.maintenancesToPageComponents.some( 254 - (m) => m.pageComponent?.monitorId === monitorId, 255 - ) 256 - : true, 257 - ) 255 + .filter((maintenance) => { 256 + if (pageComponentId) { 257 + return maintenance.maintenancesToPageComponents.some( 258 + (m) => m.pageComponent?.id === pageComponentId, 259 + ); 260 + } 261 + if (monitorId) { 262 + return maintenance.maintenancesToPageComponents.some( 263 + (m) => m.pageComponent?.monitorId === monitorId, 264 + ); 265 + } 266 + return true; 267 + }) 258 268 .forEach((maintenance) => { 259 269 if (maintenance.from < pastThreshod) return; 260 270 events.push({ ··· 267 277 }); 268 278 }); 269 279 270 - // Filter incidents - if monitorId is provided, filter by monitor, otherwise include all 271 - incidents 272 - .filter((incident) => (monitorId ? incident.monitorId === monitorId : true)) 273 - .forEach((incident) => { 274 - if (!incident.createdAt || incident.createdAt < pastThreshod) return; 275 - events.push({ 276 - id: incident.id, 277 - name: "Downtime", 278 - from: incident.createdAt, 279 - to: incident.resolvedAt, 280 - type: "incident", 281 - status: "error" as const, 280 + // Filter incidents - only for monitor-type components 281 + // External components don't have incidents 282 + if (componentType !== "external") { 283 + incidents 284 + .filter((incident) => 285 + monitorId ? incident.monitorId === monitorId : true, 286 + ) 287 + .forEach((incident) => { 288 + if (!incident.createdAt || incident.createdAt < pastThreshod) return; 289 + events.push({ 290 + id: incident.id, 291 + name: "Downtime", 292 + from: incident.createdAt, 293 + to: incident.resolvedAt, 294 + type: "incident", 295 + status: "error" as const, 296 + }); 282 297 }); 283 - }); 298 + } 284 299 285 - // Filter reports - if monitorId is provided, filter by monitor via pageComponent, otherwise include all 300 + // Filter reports - prioritize pageComponentId, fallback to monitorId for backward compatibility 286 301 reports 287 - .filter((report) => 288 - monitorId 289 - ? report.statusReportsToPageComponents.some( 290 - (m) => m.pageComponent?.monitorId === monitorId, 291 - ) 292 - : true, 293 - ) 302 + .filter((report) => { 303 + if (pageComponentId) { 304 + return report.statusReportsToPageComponents.some( 305 + (r) => r.pageComponent?.id === pageComponentId, 306 + ); 307 + } 308 + if (monitorId) { 309 + return report.statusReportsToPageComponents.some( 310 + (r) => r.pageComponent?.monitorId === monitorId, 311 + ); 312 + } 313 + return true; 314 + }) 294 315 .map((report) => { 295 316 const updates = report.statusReportUpdates.sort( 296 317 (a, b) => a.date.getTime() - b.date.getTime(),
-6
packages/api/src/router/statusReport.ts
··· 12 12 } from "@openstatus/db"; 13 13 import { 14 14 insertStatusReportUpdateSchema, 15 - selectMonitorSchema, 16 15 selectPageComponentSchema, 17 16 selectPageSchema, 18 17 selectStatusReportSchema, ··· 111 110 where: and(...whereConditions), 112 111 with: { 113 112 statusReportUpdates: true, 114 - monitorsToStatusReports: { with: { monitor: true } }, 115 113 statusReportsToPageComponents: { with: { pageComponent: true } }, 116 114 page: { with: { pageComponents: true } }, 117 115 }, ··· 125 123 return selectStatusReportSchema 126 124 .extend({ 127 125 updates: z.array(selectStatusReportUpdateSchema).prefault([]), 128 - monitors: z.array(selectMonitorSchema).prefault([]), 129 126 pageComponents: z.array(selectPageComponentSchema).prefault([]), 130 127 page: selectPageSchema.extend({ 131 128 pageComponents: z.array(selectPageComponentSchema).prefault([]), ··· 136 133 result.map((report) => ({ 137 134 ...report, 138 135 updates: report.statusReportUpdates, 139 - monitors: report.monitorsToStatusReports.map( 140 - ({ monitor }) => monitor, 141 - ), 142 136 pageComponents: report.statusReportsToPageComponents.map( 143 137 ({ pageComponent }) => pageComponent, 144 138 ),
+32 -30
packages/db/src/schema/shared.ts
··· 37 37 z.object({ 38 38 pageComponentId: z.number(), 39 39 statusReportId: z.number(), 40 - }), 41 - ) 42 - .prefault([]), 43 - monitorsToStatusReports: z 44 - .array( 45 - z.object({ 46 - monitorId: z.number(), 47 - statusReportId: z.number(), 48 - monitor: selectPublicMonitorSchema, 40 + pageComponent: selectPageComponentSchema, 49 41 }), 50 42 ) 51 43 .prefault([]), ··· 57 49 z.object({ 58 50 pageComponentId: z.number(), 59 51 maintenanceId: z.number(), 60 - }), 61 - ) 62 - .prefault([]), 63 - maintenancesToMonitors: z 64 - .array( 65 - z.object({ 66 - monitorId: z.number(), 67 - maintenanceId: z.number(), 68 - monitor: selectPublicMonitorSchema, 52 + pageComponent: selectPageComponentSchema, 69 53 }), 70 54 ) 71 55 .prefault([]), ··· 124 108 name: data.externalName || data.name, 125 109 })); 126 110 111 + export const statusPageEventSchema = z.object({ 112 + id: z.number(), 113 + name: z.string(), 114 + from: z.date(), 115 + to: z.date().nullable(), 116 + status: z.enum(["success", "degraded", "error", "info"]).prefault("success"), 117 + type: z.enum(["maintenance", "incident", "report"]), 118 + }); 119 + 120 + // Page component with status - used for new tracker system 121 + const selectPublicPageComponentWithStatusSchema = 122 + selectPageComponentSchema.extend({ 123 + status: z 124 + .enum(["success", "degraded", "error", "info"]) 125 + .prefault("success"), 126 + events: z.array(statusPageEventSchema).prefault([]), 127 + // For monitor-type components - omit status since it's now at component level 128 + monitor: selectPublicMonitorBaseSchema 129 + .extend({ 130 + incidents: selectIncidentSchema.array().nullish(), 131 + }) 132 + .nullish(), 133 + }); 134 + 127 135 const trackersSchema = z 128 136 .array( 129 137 z.discriminatedUnion("type", [ 130 138 z.object({ 131 - type: z.literal("monitor"), 132 - monitor: selectPublicMonitorWithStatusSchema, 139 + type: z.literal("component"), 140 + component: selectPublicPageComponentWithStatusSchema, 133 141 order: z.number(), 134 142 }), 135 143 z.object({ 136 144 type: z.literal("group"), 137 145 groupId: z.number(), 138 146 groupName: z.string(), 139 - monitors: z.array(selectPublicMonitorWithStatusSchema), 147 + components: z.array(selectPublicPageComponentWithStatusSchema), 140 148 status: z 141 149 .enum(["success", "degraded", "error", "info"]) 142 150 .prefault("success"), ··· 145 153 ]), 146 154 ) 147 155 .prefault([]); 148 - 149 - export const statusPageEventSchema = z.object({ 150 - id: z.number(), 151 - name: z.string(), 152 - from: z.date(), 153 - to: z.date().nullable(), 154 - status: z.enum(["success", "degraded", "error", "info"]).prefault("success"), 155 - type: z.enum(["maintenance", "incident", "report"]), 156 - }); 157 156 158 157 export const selectPageComponentWithMonitorRelation = 159 158 selectPageComponentSchema.extend({ ··· 214 213 export type PublicPage = z.infer< 215 214 typeof legacy_selectPublicPageSchemaWithRelation 216 215 >; 216 + export type PublicPageComponentWithStatus = z.infer< 217 + typeof selectPublicPageComponentWithStatusSchema 218 + >;