Openstatus www.openstatus.dev

feat: page-components [status-reports] [maintenance] [Part 2] (#1753)

* feat: page-components status-page

* fix: status page and maintenance to monitor transformation

* chore: apply zod schema on noop

* feat: page-components status-report and maintenance

* fix: typo

* fix: sync test

* fix: column badge link

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

authored by

Maximilian Kaske
Copilot
and committed by
GitHub
e37c9423 c3ba430b

+315 -128
+4 -4
apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx
··· 32 32 const sendMaintenanceUpdateMutation = useMutation( 33 33 trpc.emailRouter.sendMaintenance.mutationOptions(), 34 34 ); 35 - const createStatusPageMutation = useMutation( 35 + const createMaintenanceMutation = useMutation( 36 36 trpc.maintenance.new.mutationOptions({ 37 37 onSuccess: (maintenance) => { 38 38 // TODO: move to server ··· 63 63 </SectionHeader> 64 64 <div> 65 65 <FormSheetMaintenance 66 - monitors={statusPage.monitors} 66 + pageComponents={statusPage.pageComponents} 67 67 onSubmit={async (values) => { 68 - await createStatusPageMutation.mutateAsync({ 68 + await createMaintenanceMutation.mutateAsync({ 69 69 pageId: Number.parseInt(id), 70 70 title: values.title, 71 71 message: values.message, 72 72 startDate: values.startDate, 73 73 endDate: values.endDate, 74 - monitors: values.monitors, 74 + pageComponents: values.pageComponents, 75 75 notifySubscribers: values.notifySubscribers, 76 76 }); 77 77 }}
+3 -4
apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx
··· 29 29 const { data: statusReports, refetch } = useQuery( 30 30 trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), 31 31 ); 32 - const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); 33 32 const sendStatusReportUpdateMutation = useMutation( 34 33 trpc.emailRouter.sendStatusReport.mutationOptions(), 35 34 ); ··· 51 50 }), 52 51 ); 53 52 54 - if (!statusReports || !monitors || !page) return null; 53 + if (!statusReports || !page) return null; 55 54 56 55 const hasUnresolvedIssue = statusReports.some( 57 56 (report) => report.status !== "resolved", ··· 82 81 </> 83 82 ) : undefined 84 83 } 85 - monitors={page.monitors} 84 + pageComponents={page.pageComponents} 86 85 onSubmit={async (values) => { 87 86 // NOTE: for type safety, we need to check if the values have a date property 88 87 // because of the union type ··· 91 90 title: values.title, 92 91 status: values.status, 93 92 pageId: Number.parseInt(id), 94 - monitors: values.monitors, 93 + pageComponents: values.pageComponents, 95 94 date: values.date, 96 95 message: values.message, 97 96 notifySubscribers: values.notifySubscribers,
+3 -3
apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx
··· 74 74 }} 75 75 /> 76 76 <FormSheetMaintenance 77 - monitors={statusPage?.monitors ?? []} 77 + pageComponents={statusPage?.pageComponents ?? []} 78 78 defaultValues={{ 79 79 title: row.original.title, 80 80 message: row.original.message, 81 81 startDate: row.original.from, 82 82 endDate: row.original.to, 83 - monitors: row.original.monitors ?? [], 83 + pageComponents: row.original.pageComponents?.map((c) => c.id) ?? [], 84 84 }} 85 85 onSubmit={async (values) => { 86 86 await updateMaintenanceMutation.mutateAsync({ ··· 89 89 message: values.message, 90 90 startDate: values.startDate, 91 91 endDate: values.endDate, 92 - monitors: values.monitors, 92 + pageComponents: values.pageComponents, 93 93 }); 94 94 }} 95 95 >
+12 -8
apps/dashboard/src/components/data-table/status-reports/columns.tsx
··· 86 86 }, 87 87 }, 88 88 { 89 - id: "monitors", 90 - accessorFn: (row) => row.monitors, 89 + id: "pageComponents", 90 + accessorFn: (row) => row?.pageComponents, 91 91 header: "Affected", 92 92 cell: ({ row }) => { 93 - const value = row.getValue("monitors"); 93 + const value = row.getValue("pageComponents"); 94 94 if (Array.isArray(value) && value.length > 0 && "name" in value[0]) { 95 95 return ( 96 96 <div className="flex flex-wrap gap-1"> 97 - {value.map((m) => ( 98 - <Link href={`/monitors/${m.id}`} key={m.id}> 99 - <TableCellBadge value={m.name} /> 100 - </Link> 101 - ))} 97 + {value.map((m) => 98 + m.monitorId ? ( 99 + <Link href={`/monitors/${m.monitorId}/overview`} key={m.id}> 100 + <TableCellBadge value={m.name} /> 101 + </Link> 102 + ) : ( 103 + <TableCellBadge value={m.name} key={m.id} /> 104 + ), 105 + )} 102 106 </div> 103 107 ); 104 108 }
+3 -3
apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx
··· 123 123 }} 124 124 /> 125 125 <FormSheetStatusReport 126 - monitors={page?.monitors ?? []} 126 + pageComponents={page?.pageComponents ?? []} 127 127 defaultValues={{ 128 128 title: row.original.title, 129 129 status: row.original.status, 130 - monitors: row.original.monitors.map((m) => m.id), 130 + pageComponents: row.original.pageComponents?.map((c) => c.id) ?? [], 131 131 }} 132 132 onSubmit={async (values) => { 133 133 await updateStatusReportMutation.mutateAsync({ 134 134 id: row.original.id, 135 - monitors: values.monitors, 135 + pageComponents: values.pageComponents, 136 136 title: values.title, 137 137 status: values.status, 138 138 });
+15 -13
apps/dashboard/src/components/forms/maintenance/form.tsx
··· 52 52 message: z.string(), 53 53 startDate: z.date(), 54 54 endDate: z.date(), 55 - monitors: z.array(z.number()), 55 + pageComponents: z.array(z.number()), 56 56 notifySubscribers: z.boolean().optional(), 57 57 }) 58 58 .refine((data) => data.endDate > data.startDate, { ··· 66 66 defaultValues, 67 67 onSubmit, 68 68 className, 69 - monitors, 69 + pageComponents, 70 70 ...props 71 71 }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 72 72 defaultValues?: FormValues; 73 - monitors: { id: number; name: string; url: string }[]; 73 + pageComponents: { id: number; name: string }[]; 74 74 onSubmit: (values: FormValues) => Promise<void>; 75 75 }) { 76 76 const trpc = useTRPC(); ··· 86 86 message: "", 87 87 startDate: new Date(), 88 88 endDate: addDays(new Date(), 1), 89 - monitors: [], 89 + pageComponents: [], 90 90 notifySubscribers: true, 91 91 }, 92 92 }); ··· 416 416 <FormCardContent> 417 417 <FormField 418 418 control={form.control} 419 - name="monitors" 419 + name="pageComponents" 420 420 render={({ field }) => ( 421 421 <FormItem> 422 - <FormLabel>Monitors</FormLabel> 422 + <FormLabel>Page Components</FormLabel> 423 423 <FormDescription> 424 - Connected monitors will be automatically deactivated for the 425 - period of time. 424 + Connected page components will be affected for the period of 425 + time. 426 426 </FormDescription> 427 - {monitors.length ? ( 427 + {pageComponents.length ? ( 428 428 <div className="grid gap-3"> 429 429 <div className="flex items-center gap-2"> 430 430 <FormControl> 431 431 <Checkbox 432 432 id="all" 433 - checked={field.value?.length === monitors.length} 433 + checked={ 434 + field.value?.length === pageComponents.length 435 + } 434 436 onCheckedChange={(checked) => { 435 437 field.onChange( 436 - checked ? monitors.map((m) => m.id) : [], 438 + checked ? pageComponents.map((c) => c.id) : [], 437 439 ); 438 440 }} 439 441 /> 440 442 </FormControl> 441 443 <Label htmlFor="all">Select all</Label> 442 444 </div> 443 - {monitors.map((item) => ( 445 + {pageComponents.map((item) => ( 444 446 <div key={item.id} className="flex items-center gap-2"> 445 447 <FormControl> 446 448 <Checkbox ··· 460 462 </div> 461 463 ) : ( 462 464 <EmptyStateContainer> 463 - <EmptyStateTitle>No monitors found</EmptyStateTitle> 465 + <EmptyStateTitle>No page components found</EmptyStateTitle> 464 466 </EmptyStateContainer> 465 467 )} 466 468 <FormMessage />
+3 -3
apps/dashboard/src/components/forms/maintenance/sheet.tsx
··· 22 22 children, 23 23 defaultValues, 24 24 onSubmit, 25 - monitors, 25 + pageComponents, 26 26 ...props 27 27 }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { 28 28 defaultValues?: FormValues; 29 - monitors: { id: number; name: string; url: string }[]; 29 + pageComponents: { id: number; name: string }[]; 30 30 onSubmit: (values: FormValues) => Promise<void>; 31 31 }) { 32 32 const [open, setOpen] = useState(false); ··· 46 46 <FormCardGroup className="overflow-y-auto"> 47 47 <FormCard className="overflow-auto rounded-none border-none"> 48 48 <FormMaintenance 49 - monitors={monitors} 49 + pageComponents={pageComponents} 50 50 onSubmit={async (values) => { 51 51 await onSubmit(values); 52 52 setOpen(false);
+18 -13
apps/dashboard/src/components/forms/status-report/form.tsx
··· 45 45 import { useTRPC } from "@/lib/trpc/client"; 46 46 import { cn } from "@/lib/utils"; 47 47 import { zodResolver } from "@hookform/resolvers/zod"; 48 - import { statusReportStatus } from "@openstatus/db/src/schema"; 48 + import { 49 + type PageComponent, 50 + statusReportStatus, 51 + } from "@openstatus/db/src/schema"; 49 52 import { useQuery } from "@tanstack/react-query"; 50 53 import { isTRPCClientError } from "@trpc/client"; 51 54 import { format } from "date-fns"; ··· 60 63 title: z.string(), 61 64 message: z.string(), 62 65 date: z.date(), 63 - monitors: z.array(z.number()), 66 + pageComponents: z.array(z.number()), 64 67 notifySubscribers: z.boolean().optional(), 65 68 }); 66 69 ··· 76 79 defaultValues, 77 80 onSubmit, 78 81 className, 79 - monitors, 82 + pageComponents, 80 83 ...props 81 84 }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 82 85 defaultValues?: FormValues; 83 86 onSubmit: (values: FormValues) => Promise<void>; 84 - monitors: { id: number; name: string }[]; 87 + pageComponents: Pick<PageComponent, "id" | "name" | "type">[]; 85 88 }) { 86 89 const trpc = useTRPC(); 87 90 const { data: workspace } = useQuery( ··· 96 99 title: "", 97 100 message: "", 98 101 date: new Date(), 99 - monitors: [], 102 + pageComponents: [], 100 103 notifySubscribers: true, 101 104 }, 102 105 }); ··· 349 352 <FormCardContent> 350 353 <FormField 351 354 control={form.control} 352 - name="monitors" 355 + name="pageComponents" 353 356 render={({ field }) => ( 354 357 <FormItem> 355 - <FormLabel>Monitors</FormLabel> 358 + <FormLabel>Page Components</FormLabel> 356 359 <FormDescription> 357 - Select the monitors you want to notify. 360 + Select the page components you want to notify. 358 361 </FormDescription> 359 - {monitors.length ? ( 362 + {pageComponents.length ? ( 360 363 <div className="grid gap-3"> 361 364 <div className="flex items-center gap-2"> 362 365 <FormControl> 363 366 <Checkbox 364 367 id="all" 365 - checked={field.value?.length === monitors.length} 368 + checked={ 369 + field.value?.length === pageComponents.length 370 + } 366 371 onCheckedChange={(checked) => { 367 372 field.onChange( 368 - checked ? monitors.map((m) => m.id) : [], 373 + checked ? pageComponents.map((c) => c.id) : [], 369 374 ); 370 375 }} 371 376 /> 372 377 </FormControl> 373 378 <Label htmlFor="all">Select all</Label> 374 379 </div> 375 - {monitors.map((item) => ( 380 + {pageComponents.map((item) => ( 376 381 <div key={item.id} className="flex items-center gap-2"> 377 382 <FormControl> 378 383 <Checkbox ··· 392 397 </div> 393 398 ) : ( 394 399 <EmptyStateContainer> 395 - <EmptyStateTitle>No monitors found</EmptyStateTitle> 400 + <EmptyStateTitle>No page components found</EmptyStateTitle> 396 401 </EmptyStateContainer> 397 402 )} 398 403 <FormMessage />
+4 -3
apps/dashboard/src/components/forms/status-report/sheet.tsx
··· 16 16 } from "@/components/forms/status-report/form"; 17 17 import { Button } from "@/components/ui/button"; 18 18 import { Separator } from "@/components/ui/separator"; 19 + import type { PageComponent } from "@openstatus/db/src/schema"; 19 20 import { useState } from "react"; 20 21 21 22 export function FormSheetStatusReport({ 22 23 children, 23 24 defaultValues, 24 25 onSubmit, 25 - monitors, 26 + pageComponents, 26 27 warning, 27 28 }: Omit<React.ComponentProps<typeof FormSheetTrigger>, "onSubmit"> & { 28 29 defaultValues?: FormValues; 29 30 onSubmit: (values: FormValues) => Promise<void>; 30 - monitors: { id: number; name: string }[]; 31 + pageComponents: Pick<PageComponent, "id" | "name" | "type">[]; 31 32 warning?: React.ReactNode; 32 33 }) { 33 34 const [open, setOpen] = useState(false); ··· 58 59 setOpen(false); 59 60 }} 60 61 defaultValues={defaultValues} 61 - monitors={monitors} 62 + pageComponents={pageComponents} 62 63 /> 63 64 </FormCard> 64 65 </FormCardGroup>
+63 -45
packages/api/src/router/maintenance.ts
··· 9 9 gte, 10 10 inArray, 11 11 lte, 12 - syncMaintenanceToMonitorDeleteByMaintenance, 13 - syncMaintenanceToMonitorInsertMany, 12 + syncMaintenanceToPageComponentDeleteByMaintenance, 13 + syncMaintenanceToPageComponentInsertMany, 14 14 } from "@openstatus/db"; 15 15 import { 16 16 maintenance, 17 17 maintenancesToMonitors, 18 - monitor, 18 + maintenancesToPageComponents, 19 + pageComponent, 19 20 selectMaintenanceSchema, 21 + selectPageComponentSchema, 20 22 } from "@openstatus/db/src/schema"; 21 23 22 24 import { Events } from "@openstatus/analytics"; ··· 148 150 opts.input?.order === "asc" 149 151 ? asc(maintenance.createdAt) 150 152 : desc(maintenance.createdAt), 151 - with: { maintenancesToMonitors: true }, 153 + with: { 154 + maintenancesToMonitors: true, 155 + maintenancesToPageComponents: { with: { pageComponent: true } }, 156 + }, 152 157 }); 153 158 154 159 const result = await query; 155 160 156 - return selectMaintenanceSchema.array().parse( 157 - result.map((m) => ({ 158 - ...m, 159 - monitors: m.maintenancesToMonitors.map((m) => m.monitorId), 160 - })), 161 - ); 161 + return selectMaintenanceSchema 162 + .extend({ 163 + pageComponents: z.array(selectPageComponentSchema).prefault([]), 164 + }) 165 + .array() 166 + .parse( 167 + result.map((m) => ({ 168 + ...m, 169 + monitors: m.maintenancesToMonitors.map((m) => m.monitorId), 170 + pageComponents: m.maintenancesToPageComponents.map( 171 + ({ pageComponent }) => pageComponent, 172 + ), 173 + })), 174 + ); 162 175 }), 163 176 164 177 new: protectedProcedure ··· 170 183 message: z.string(), 171 184 startDate: z.coerce.date(), 172 185 endDate: z.coerce.date(), 173 - monitors: z.array(z.number()).optional(), 186 + pageComponents: z.array(z.number()).optional(), 174 187 notifySubscribers: z.boolean().nullish(), 175 188 }), 176 189 ) 177 190 .mutation(async (opts) => { 178 191 // Check if the user has access to the monitors 179 - if (opts.input.monitors?.length) { 192 + if (opts.input.pageComponents?.length) { 180 193 const whereConditions: SQL[] = [ 181 - eq(monitor.workspaceId, opts.ctx.workspace.id), 182 - inArray(monitor.id, opts.input.monitors), 194 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 195 + inArray(pageComponent.id, opts.input.pageComponents), 183 196 ]; 184 - const monitors = await opts.ctx.db 197 + const pageComponents = await opts.ctx.db 185 198 .select() 186 - .from(monitor) 199 + .from(pageComponent) 187 200 .where(and(...whereConditions)) 188 201 .all(); 189 202 190 - if (monitors.length !== opts.input.monitors.length) { 203 + if (pageComponents.length !== opts.input.pageComponents.length) { 191 204 throw new TRPCError({ 192 205 code: "BAD_REQUEST", 193 - message: "You do not have access to all the monitors", 206 + message: "You do not have access to all the page components", 194 207 }); 195 208 } 196 209 } ··· 209 222 .returning() 210 223 .get(); 211 224 212 - if (opts.input.monitors?.length) { 213 - await tx.insert(maintenancesToMonitors).values( 214 - opts.input.monitors.map((monitorId) => ({ 225 + if (opts.input.pageComponents?.length) { 226 + await tx.insert(maintenancesToPageComponents).values( 227 + opts.input.pageComponents.map((pageComponentId) => ({ 215 228 maintenanceId: newMaintenance.id, 216 - monitorId, 229 + pageComponentId, 217 230 })), 218 231 ); 219 - // Sync to page components 220 - await syncMaintenanceToMonitorInsertMany( 232 + // Sync to monitors (inverse sync for backward compatibility) 233 + await syncMaintenanceToPageComponentInsertMany( 221 234 tx, 222 235 newMaintenance.id, 223 - opts.input.monitors, 236 + opts.input.pageComponents, 224 237 ); 225 238 } 226 239 ··· 242 255 message: z.string(), 243 256 startDate: z.coerce.date(), 244 257 endDate: z.coerce.date(), 245 - monitors: z.array(z.number()).optional(), 258 + pageComponents: z.array(z.number()).optional(), 246 259 }), 247 260 ) 248 261 .mutation(async (opts) => { 249 262 // Check if the user has access to the monitors 250 - if (opts.input.monitors?.length) { 263 + if (opts.input.pageComponents?.length) { 251 264 const whereConditions: SQL[] = [ 252 - eq(monitor.workspaceId, opts.ctx.workspace.id), 253 - inArray(monitor.id, opts.input.monitors), 265 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 266 + inArray(pageComponent.id, opts.input.pageComponents), 254 267 ]; 255 - const monitors = await opts.ctx.db 268 + const pageComponents = await opts.ctx.db 256 269 .select() 257 - .from(monitor) 270 + .from(pageComponent) 258 271 .where(and(...whereConditions)) 259 272 .all(); 260 273 261 - if (monitors.length !== opts.input.monitors.length) { 274 + if (pageComponents.length !== opts.input.pageComponents.length) { 262 275 throw new TRPCError({ 263 276 code: "BAD_REQUEST", 264 - message: "You do not have access to all the monitors", 277 + message: "You do not have access to all the page components", 265 278 }); 266 279 } 267 280 } ··· 289 302 290 303 // Delete all existing relations 291 304 await tx 292 - .delete(maintenancesToMonitors) 293 - .where(eq(maintenancesToMonitors.maintenanceId, _maintenance.id)) 305 + .delete(maintenancesToPageComponents) 306 + .where( 307 + eq(maintenancesToPageComponents.maintenanceId, _maintenance.id), 308 + ) 294 309 .run(); 295 - // Sync delete to page components 296 - await syncMaintenanceToMonitorDeleteByMaintenance(tx, _maintenance.id); 310 + // Sync to monitors (inverse sync for backward compatibility) 311 + await syncMaintenanceToPageComponentDeleteByMaintenance( 312 + tx, 313 + _maintenance.id, 314 + ); 297 315 298 - // Create new relations if monitors are provided 299 - if (opts.input.monitors?.length) { 300 - await tx.insert(maintenancesToMonitors).values( 301 - opts.input.monitors.map((monitorId) => ({ 316 + // Create new relations if page components are provided 317 + if (opts.input.pageComponents?.length) { 318 + await tx.insert(maintenancesToPageComponents).values( 319 + opts.input.pageComponents.map((pageComponentId) => ({ 302 320 maintenanceId: _maintenance.id, 303 - monitorId, 321 + pageComponentId, 304 322 })), 305 323 ); 306 - // Sync to page components 307 - await syncMaintenanceToMonitorInsertMany( 324 + // Sync to monitors (inverse sync for backward compatibility) 325 + await syncMaintenanceToPageComponentInsertMany( 308 326 tx, 309 327 _maintenance.id, 310 - opts.input.monitors, 328 + opts.input.pageComponents, 311 329 ); 312 330 } 313 331
+4
packages/api/src/router/page.ts
··· 30 30 selectMaintenanceSchema, 31 31 selectMonitorGroupSchema, 32 32 selectMonitorSchema, 33 + selectPageComponentSchema, 33 34 selectPageSchema, 34 35 selectPageSchemaWithMonitorsRelation, 35 36 statusReport, ··· 513 514 with: { 514 515 monitorsToPages: { with: { monitor: true, monitorGroup: true } }, 515 516 maintenances: true, 517 + pageComponents: true, 516 518 }, 517 519 }); 518 520 ··· 529 531 .prefault([]), 530 532 monitorGroups: z.array(selectMonitorGroupSchema).prefault([]), 531 533 maintenances: z.array(selectMaintenanceSchema).prefault([]), 534 + pageComponents: z.array(selectPageComponentSchema).prefault([]), 532 535 }) 533 536 .parse({ 534 537 ...data, ··· 546 549 ).values(), 547 550 ), 548 551 maintenances: data?.maintenances, 552 + pageComponents: data?.pageComponents, 549 553 }); 550 554 }), 551 555
+39 -25
packages/api/src/router/statusReport.ts
··· 10 10 inArray, 11 11 sql, 12 12 syncStatusReportToMonitorDelete, 13 - syncStatusReportToMonitorDeleteByStatusReport, 14 13 syncStatusReportToMonitorInsertMany, 14 + syncStatusReportToPageComponentDeleteByStatusReport, 15 + syncStatusReportToPageComponentInsertMany, 15 16 } from "@openstatus/db"; 16 17 import { 17 18 insertStatusReportSchema, ··· 19 20 monitorsToStatusReport, 20 21 page, 21 22 selectMonitorSchema, 23 + selectPageComponentSchema, 22 24 selectPageSchema, 23 25 selectPublicStatusReportSchemaWithRelation, 24 26 selectStatusReportSchema, ··· 27 29 statusReportStatus, 28 30 statusReportStatusSchema, 29 31 statusReportUpdate, 32 + statusReportsToPageComponents, 30 33 } from "@openstatus/db/src/schema"; 31 34 32 35 import { Events } from "@openstatus/analytics"; ··· 420 423 with: { 421 424 statusReportUpdates: true, 422 425 monitorsToStatusReports: { with: { monitor: true } }, 423 - page: true, 426 + statusReportsToPageComponents: { with: { pageComponent: true } }, 427 + page: { with: { pageComponents: true } }, 424 428 }, 425 429 orderBy: (statusReport) => [ 426 430 opts.input.order === "asc" ··· 433 437 .extend({ 434 438 updates: z.array(selectStatusReportUpdateSchema).prefault([]), 435 439 monitors: z.array(selectMonitorSchema).prefault([]), 436 - page: selectPageSchema, 440 + pageComponents: z.array(selectPageComponentSchema).prefault([]), 441 + page: selectPageSchema.extend({ 442 + pageComponents: z.array(selectPageComponentSchema).prefault([]), 443 + }), 437 444 }) 438 445 .array() 439 446 .parse( ··· 443 450 monitors: report.monitorsToStatusReports.map( 444 451 ({ monitor }) => monitor, 445 452 ), 453 + pageComponents: report.statusReportsToPageComponents.map( 454 + ({ pageComponent }) => pageComponent, 455 + ), 446 456 })), 447 457 ); 448 458 }), ··· 454 464 title: z.string(), 455 465 status: z.enum(statusReportStatus), 456 466 pageId: z.number(), 457 - monitors: z.array(z.number()), 467 + pageComponents: z.array(z.number()), 458 468 date: z.coerce.date(), 459 469 message: z.string(), 460 470 notifySubscribers: z.boolean().nullish(), ··· 484 494 .returning() 485 495 .get(); 486 496 487 - if (opts.input.monitors.length > 0) { 497 + if (opts.input.pageComponents.length > 0) { 488 498 await tx 489 - .insert(monitorsToStatusReport) 499 + .insert(statusReportsToPageComponents) 490 500 .values( 491 - opts.input.monitors.map((monitor) => ({ 492 - monitorId: monitor, 501 + opts.input.pageComponents.map((pageComponent) => ({ 502 + pageComponentId: pageComponent, 493 503 statusReportId: newStatusReport.id, 494 504 })), 495 505 ) 496 - .returning() 497 - .get(); 498 - // Sync to page components 499 - await syncStatusReportToMonitorInsertMany( 506 + .run(); 507 + // Reverse sync: page components -> monitors (for backward compatibility) 508 + await syncStatusReportToPageComponentInsertMany( 500 509 tx, 501 510 newStatusReport.id, 502 - opts.input.monitors, 511 + opts.input.pageComponents, 503 512 ); 504 513 } 505 514 ··· 515 524 .input( 516 525 z.object({ 517 526 id: z.number(), 518 - monitors: z.array(z.number()), 527 + pageComponents: z.array(z.number()), 519 528 title: z.string(), 520 529 status: z.enum(statusReportStatus), 521 530 }), ··· 538 547 .run(); 539 548 540 549 await tx 541 - .delete(monitorsToStatusReport) 542 - .where(eq(monitorsToStatusReport.statusReportId, opts.input.id)) 550 + .delete(statusReportsToPageComponents) 551 + .where( 552 + eq(statusReportsToPageComponents.statusReportId, opts.input.id), 553 + ) 543 554 .run(); 544 - // Sync delete to page components 545 - await syncStatusReportToMonitorDeleteByStatusReport(tx, opts.input.id); 555 + // Reverse sync: delete from monitors (for backward compatibility) 556 + await syncStatusReportToPageComponentDeleteByStatusReport( 557 + tx, 558 + opts.input.id, 559 + ); 546 560 547 - if (opts.input.monitors.length > 0) { 561 + if (opts.input.pageComponents.length > 0) { 548 562 await tx 549 - .insert(monitorsToStatusReport) 563 + .insert(statusReportsToPageComponents) 550 564 .values( 551 - opts.input.monitors.map((monitor) => ({ 552 - monitorId: monitor, 565 + opts.input.pageComponents.map((pageComponent) => ({ 566 + pageComponentId: pageComponent, 553 567 statusReportId: opts.input.id, 554 568 })), 555 569 ) 556 570 .run(); 557 - // Sync to page components 558 - await syncStatusReportToMonitorInsertMany( 571 + // Reverse sync: page components -> monitors (for backward compatibility) 572 + await syncStatusReportToPageComponentInsertMany( 559 573 tx, 560 574 opts.input.id, 561 - opts.input.monitors, 575 + opts.input.pageComponents, 562 576 ); 563 577 } 564 578 });
+18 -4
packages/api/src/router/sync.test.ts
··· 63 63 const TEST_PREFIX = "sync-test"; 64 64 let testPageId: number; 65 65 let testMonitorId: number; 66 + let testPageComponentId: number; 66 67 67 68 const monitorData = { 68 69 name: `${TEST_PREFIX}-monitor`, ··· 109 110 const caller = appRouter.createCaller(ctx); 110 111 const createdMonitor = await caller.monitor.create(monitorData); 111 112 testMonitorId = createdMonitor.id; 113 + 114 + const createdPageComponent = await db 115 + .insert(pageComponent) 116 + .values({ 117 + workspaceId: 1, 118 + pageId: testPageId, 119 + monitorId: testMonitorId, 120 + type: "monitor", 121 + name: `${TEST_PREFIX}-monitor`, 122 + }) 123 + .returning() 124 + .get(); 125 + testPageComponentId = createdPageComponent.id; 112 126 }); 113 127 114 128 afterAll(async () => { ··· 377 391 startDate: from, 378 392 endDate: to, 379 393 pageId: testPageId, 380 - monitors: [testMonitorId], 394 + pageComponents: [testPageComponentId], 381 395 }); 382 396 testMaintenanceId = createdMaintenance.id; 383 397 ··· 428 442 message: "Updated maintenance", 429 443 startDate: from, 430 444 endDate: to, 431 - monitors: [], 445 + pageComponents: [], 432 446 }); 433 447 434 448 // Verify maintenance_to_monitor was deleted ··· 496 510 status: "investigating", 497 511 message: "Test status report for sync", 498 512 pageId: testPageId, 499 - monitors: [testMonitorId], 513 + pageComponents: [testPageComponentId], 500 514 date: new Date(), 501 515 }); 502 516 testStatusReportId = createdReport.statusReportId; ··· 544 558 await caller.statusReport.updateStatus({ 545 559 id: testStatusReportId, 546 560 status: "resolved", 547 - monitors: [], 561 + pageComponents: [], 548 562 title: `${TEST_PREFIX} Status Report`, 549 563 }); 550 564
+126
packages/db/src/sync.ts
··· 3 3 import type { db } from "./db"; 4 4 import { 5 5 maintenance, 6 + maintenancesToMonitors, 6 7 maintenancesToPageComponents, 7 8 monitor, 9 + monitorsToStatusReport, 8 10 pageComponent, 9 11 pageComponentGroup, 10 12 statusReport, ··· 364 366 ); 365 367 } 366 368 369 + /** 370 + * Syncs status_report_to_page_component inserts to status_report_to_monitors 371 + * This is the inverse of syncStatusReportToMonitorInsertMany 372 + */ 373 + export async function syncStatusReportToPageComponentInsertMany( 374 + db: DB | Transaction, 375 + statusReportId: number, 376 + pageComponentIds: number[], 377 + ) { 378 + if (pageComponentIds.length === 0) return; 379 + 380 + // Find monitor IDs from the page components 381 + // Only get components that have a monitorId (not external components) 382 + const components = await db 383 + .select({ monitorId: pageComponent.monitorId }) 384 + .from(pageComponent) 385 + .where( 386 + and( 387 + inArray(pageComponent.id, pageComponentIds), 388 + eq(pageComponent.type, "monitor"), 389 + ), 390 + ); 391 + 392 + if (components.length === 0) return; 393 + 394 + // Extract unique monitor IDs (filter out nulls) 395 + const monitorIds = [ 396 + ...new Set( 397 + components 398 + .map((c) => c.monitorId) 399 + .filter((id): id is number => id !== null), 400 + ), 401 + ]; 402 + 403 + if (monitorIds.length === 0) return; 404 + 405 + // Insert into monitorsToStatusReport 406 + await db 407 + .insert(monitorsToStatusReport) 408 + .values( 409 + monitorIds.map((monitorId) => ({ 410 + statusReportId, 411 + monitorId, 412 + })), 413 + ) 414 + .onConflictDoNothing(); 415 + } 416 + 417 + /** 418 + * Syncs status_report_to_page_component deletes to status_report_to_monitors 419 + * This is the inverse of syncStatusReportToMonitorDeleteByStatusReport 420 + * When page components are removed from a status report, remove the corresponding monitors 421 + */ 422 + export async function syncStatusReportToPageComponentDeleteByStatusReport( 423 + db: DB | Transaction, 424 + statusReportId: number, 425 + ) { 426 + await db 427 + .delete(monitorsToStatusReport) 428 + .where(eq(monitorsToStatusReport.statusReportId, statusReportId)); 429 + } 430 + 367 431 // ============================================================================ 368 432 // Maintenance to Monitor <-> Maintenance to Page Component Sync 369 433 // ============================================================================ ··· 513 577 ), 514 578 ); 515 579 } 580 + 581 + /** 582 + * Syncs maintenance_to_page_component inserts to maintenance_to_monitors 583 + * This is the inverse of syncMaintenanceToMonitorInsertMany 584 + */ 585 + export async function syncMaintenanceToPageComponentInsertMany( 586 + db: DB | Transaction, 587 + maintenanceId: number, 588 + pageComponentIds: number[], 589 + ) { 590 + if (pageComponentIds.length === 0) return; 591 + 592 + // Find monitor IDs from the page components 593 + // Only get components that have a monitorId (not external components) 594 + const components = await db 595 + .select({ monitorId: pageComponent.monitorId }) 596 + .from(pageComponent) 597 + .where( 598 + and( 599 + inArray(pageComponent.id, pageComponentIds), 600 + eq(pageComponent.type, "monitor"), 601 + ), 602 + ); 603 + 604 + if (components.length === 0) return; 605 + 606 + // Extract unique monitor IDs (filter out nulls) 607 + const monitorIds = [ 608 + ...new Set( 609 + components 610 + .map((c) => c.monitorId) 611 + .filter((id): id is number => id !== null), 612 + ), 613 + ]; 614 + 615 + if (monitorIds.length === 0) return; 616 + 617 + // Insert into maintenancesToMonitors 618 + await db 619 + .insert(maintenancesToMonitors) 620 + .values( 621 + monitorIds.map((monitorId) => ({ 622 + maintenanceId, 623 + monitorId, 624 + })), 625 + ) 626 + .onConflictDoNothing(); 627 + } 628 + 629 + /** 630 + * Syncs maintenance_to_page_component deletes to maintenance_to_monitors 631 + * This is the inverse of syncMaintenanceToMonitorDeleteByMaintenance 632 + * When page components are removed from a maintenance, remove the corresponding monitors 633 + */ 634 + export async function syncMaintenanceToPageComponentDeleteByMaintenance( 635 + db: DB | Transaction, 636 + maintenanceId: number, 637 + ) { 638 + await db 639 + .delete(maintenancesToMonitors) 640 + .where(eq(maintenancesToMonitors.maintenanceId, maintenanceId)); 641 + }