Openstatus www.openstatus.dev

chore: page-components clean up (#1790)

* chore: clean up

* chore: clean up

* chore: more clean up

* refactor: external to static page component type

* fix: tsc

* fix: sync

* chore: form-components

* refactor: move configuration to page components

* fix: nullable

* fix: tsc

* chore: constraint db migration

* chore: events

* chore: third-party

* chore: add warning on component removal

* chore: add docs

* chore: remove migration note

* fix: logger

* refactor: migrate getPageBySlug to statusPage router with pageComponents

Move `getPageBySlug` from page router to statusPage router and refactor
to use the modern pageComponents architecture with a single database query.

Changes:
- Add `selectPublicPageLightSchemaWithRelation` schema with pageComponents support
- Implement `statusPage.getPageBySlug` procedure with optimized single query
- Deprecate `page.getPageBySlug` with JSDoc notice
- Update consumers (feed routes, OG image) to use new procedure
- Add comprehensive tests for pageComponents architecture
- Maintain backwards compatibility with legacy monitors array

Performance: Reduces 7 DB queries to 1 query (~50-60% faster)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* chore: remove test

* fix: test

* chore: public status

* fix: tests

* ci: apply automated fixes

* chore: add limits

* chore: product page

* chore: clean up

* ci: apply automated fixes

* fix: unsubscribed users

* fix: missing components in pricing

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Maximilian Kaske
Claude Sonnet 4.5
autofix-ci[bot]
and committed by
GitHub
4e1b9eeb 27977e48

+5901 -1315
+5
apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx
··· 159 159 max={workspace.limits["status-pages"]} 160 160 /> 161 161 <BillingProgress 162 + label="Page Components" 163 + value={workspace.usage?.pageComponents ?? 0} 164 + max={workspace.limits["page-components"]} 165 + /> 166 + <BillingProgress 162 167 label="Notifications" 163 168 value={workspace.usage?.notifications ?? 0} 164 169 max={workspace.limits["notification-channels"]}
+5 -5
apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx
··· 111 111 }, 112 112 { 113 113 label: "Monitors", 114 - items: statusPage.monitors.flatMap((monitor) => { 114 + items: statusPage.pageComponents.flatMap((component) => { 115 115 const arr = []; 116 116 arr.push({ 117 117 label: "Name", 118 118 value: ( 119 119 <TableCellLink 120 - href={`/monitors/${monitor.id}/overview`} 121 - value={monitor.name} 120 + href={`/status-pages/${statusPage.id}/components`} 121 + value={component.name} 122 122 /> 123 123 ), 124 124 }); 125 125 arr.push({ 126 - label: "URL", 127 - value: monitor.url, 126 + label: "Type", 127 + value: component.type, 128 128 isNested: true, 129 129 }); 130 130 return arr;
+316 -223
apps/dashboard/src/components/forms/components/form-components.tsx
··· 5 5 EmptyStateContainer, 6 6 EmptyStateTitle, 7 7 } from "@/components/content/empty-state"; 8 + import { UpgradeDialog } from "@/components/dialogs/upgrade"; 8 9 import { 9 10 FormCard, 10 11 FormCardContent, ··· 80 81 GripVertical, 81 82 Link2, 82 83 Link2Off, 84 + Plug, 83 85 Plus, 84 86 Trash2, 85 87 } from "lucide-react"; ··· 90 92 91 93 type PageComponent = RouterOutputs["pageComponent"]["list"][number]; 92 94 type Monitor = RouterOutputs["monitor"]["list"][number]; 95 + type Workspace = RouterOutputs["workspace"]["get"]; 93 96 94 97 type ComponentGroup = { 95 98 id: number; ··· 103 106 order: z.number(), 104 107 name: z.string().min(1, { message: "Name is required" }), 105 108 description: z.string().optional(), 106 - type: z.enum(["monitor", "external"]), 109 + type: z.enum(["monitor", "static"]), 107 110 }); 108 111 109 112 const schema = z.object({ ··· 126 129 id: number; 127 130 order: number; 128 131 name?: string; 129 - type?: "monitor" | "external"; 132 + type?: "monitor" | "static"; 130 133 monitorId?: number | null; 131 134 }[], 132 135 monitors: Monitor[], ··· 149 152 componentMap.set(c.id, { 150 153 id: c.id, 151 154 name: c.name ?? "", 152 - type: c.type ?? "external", 155 + type: c.type ?? "static", 153 156 monitorId: c.monitorId ?? null, 154 157 monitor: monitor ?? null, 155 158 groupId: null, ··· 174 177 id: number; 175 178 order: number; 176 179 name?: string; 177 - type?: "monitor" | "external"; 180 + type?: "monitor" | "static"; 178 181 monitorId?: number | null; 179 182 }[], 180 183 groups: Array<{ ··· 185 188 id: number; 186 189 order: number; 187 190 name?: string; 188 - type?: "monitor" | "external"; 191 + type?: "monitor" | "static"; 189 192 monitorId?: number | null; 190 193 }>; 191 194 }>, ··· 210 213 componentMap.set(c.id, { 211 214 id: c.id, 212 215 name: c.name ?? "", 213 - type: c.type ?? "external", 216 + type: c.type ?? "static", 214 217 monitorId: c.monitorId ?? null, 215 218 monitor: monitor ?? null, 216 219 groupId: null, ··· 259 262 pageComponents, 260 263 allPageComponents, 261 264 monitors, 262 - legacy, 265 + workspace, 263 266 ...props 264 267 }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 265 268 defaultValues?: FormValues; ··· 270 273 /** Monitors available for selection */ 271 274 monitors: Monitor[]; 272 275 /** 273 - * Whether the status page is legacy or new 276 + * The workspace the page belongs to 274 277 */ 275 - legacy: boolean; 278 + workspace: Workspace; 276 279 onSubmit: (values: FormValues) => Promise<void>; 277 280 }) { 278 281 const form = useForm<FormValues>({ ··· 282 285 const [isPending, startTransition] = useTransition(); 283 286 const watchComponents = form.watch("components"); 284 287 const watchGroups = form.watch("groups"); 288 + const [openUpgradeDialog, setOpenUpgradeDialog] = useState(false); 285 289 const [data, setData] = useState<(PageComponent | ComponentGroup)[]>( 286 290 getSortedItems( 287 291 allPageComponents, ··· 312 316 setData(sortedItems); 313 317 }, [watchComponents, watchGroups, allPageComponents, monitors]); 314 318 319 + const validateLimit = useCallback(() => { 320 + const limitReached = workspace.limits["page-components"] <= data.length; 321 + if (limitReached) { 322 + setOpenUpgradeDialog(true); 323 + return false; 324 + } 325 + return true; 326 + }, [workspace, data.length]); 327 + 315 328 const onValueChange = useCallback( 316 329 (newItems: (PageComponent | ComponentGroup)[]) => { 317 330 setData(newItems); ··· 372 385 ); 373 386 374 387 const handleAddGroup = useCallback(() => { 388 + if (!validateLimit()) return; 389 + 375 390 const newGroupId = Date.now(); 376 391 const existingGroups = form.getValues("groups") ?? []; 377 392 const existingComponents = form.getValues("components") ?? []; ··· 382 397 ]; 383 398 form.setValue("groups", newGroups); 384 399 setData((prev) => [...prev, { id: newGroupId, name: "", components: [] }]); 385 - }, [form]); 400 + }, [form, validateLimit]); 386 401 387 402 const handleDeleteGroup = useCallback( 388 403 (groupId: number) => { ··· 437 452 form={form} 438 453 allPageComponents={allPageComponents} 439 454 monitors={monitors} 455 + validateLimit={validateLimit} 440 456 /> 441 457 ); 442 458 }, ··· 447 463 allPageComponents, 448 464 monitors, 449 465 handleDeleteComponent, 466 + validateLimit, 450 467 ], 451 468 ); 452 469 ··· 474 491 } 475 492 476 493 return ( 477 - <Form {...form}> 478 - <form onSubmit={form.handleSubmit(submitAction)} {...props}> 479 - <FormCard> 480 - <FormCardHeader> 481 - <FormCardTitle>Components</FormCardTitle> 482 - <FormCardDescription> 483 - Manage your page components 484 - </FormCardDescription> 485 - </FormCardHeader> 486 - <FormCardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4"> 487 - <FormField 488 - control={form.control} 489 - name="components" 490 - render={({ field }) => ( 491 - <FormItem className="flex flex-col"> 492 - <FormLabel className="sr-only">Components</FormLabel> 493 - <DropdownMenu> 494 - <DropdownMenuTrigger asChild> 495 - <Button variant="outline" className="w-full"> 496 - <Plus /> 497 - Add Component 498 - </Button> 499 - </DropdownMenuTrigger> 500 - <DropdownMenuContent 501 - align="end" 502 - className="w-[var(--radix-dropdown-menu-trigger-width)]" 503 - > 504 - <DropdownMenuGroup> 505 - <DropdownMenuItem 506 - disabled 507 - onClick={() => { 508 - form.setValue("components", [ 509 - ...field.value, 510 - { 511 - id: Date.now(), 512 - monitorId: null, 513 - order: watchComponents.length, 514 - name: "", 515 - description: "", 516 - type: "external" as const, 517 - }, 518 - ]); 519 - }} 520 - > 521 - <Link2Off className="text-muted-foreground" /> 522 - Add External (soon) 523 - </DropdownMenuItem> 524 - <DropdownMenuSub> 525 - <DropdownMenuSubTrigger className="gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0"> 526 - <Link2 className="text-muted-foreground" /> 527 - Add Monitor 528 - </DropdownMenuSubTrigger> 529 - <DropdownMenuSubContent className="p-0"> 530 - <Command> 531 - <CommandInput 532 - placeholder="Search monitors..." 533 - className="h-9" 534 - /> 535 - <CommandList> 536 - <CommandEmpty>No monitors found.</CommandEmpty> 537 - <CommandGroup> 538 - {monitors.map((monitor) => { 539 - const isUsed = usedMonitorIds.has( 540 - monitor.id, 541 - ); 542 - const isSelected = field.value.some( 543 - (c) => c.monitorId === monitor.id, 544 - ); 545 - return ( 546 - <CommandItem 547 - value={monitor.name} 548 - key={monitor.id} 549 - disabled={isUsed} 550 - onSelect={() => { 551 - if (isSelected) { 552 - form.setValue( 553 - "components", 554 - field.value.filter( 555 - (c) => 556 - c.monitorId !== monitor.id, 557 - ), 558 - ); 559 - } else { 560 - form.setValue("components", [ 561 - ...field.value, 562 - { 563 - id: Date.now(), 564 - monitorId: monitor.id, 565 - order: watchComponents.length, 566 - name: monitor.name, 567 - description: 568 - monitor.description, 569 - type: "monitor" as const, 570 - }, 571 - ]); 572 - } 573 - }} 574 - > 575 - {monitor.name} 576 - <Check 577 - className={cn( 578 - "ml-auto", 579 - isSelected 580 - ? "opacity-100" 581 - : "opacity-0", 582 - )} 583 - /> 584 - </CommandItem> 585 - ); 586 - })} 587 - </CommandGroup> 588 - </CommandList> 589 - </Command> 590 - </DropdownMenuSubContent> 591 - </DropdownMenuSub> 592 - </DropdownMenuGroup> 593 - </DropdownMenuContent> 594 - </DropdownMenu> 595 - <FormMessage /> 596 - </FormItem> 597 - )} 598 - /> 599 - <Button 600 - variant="outline" 601 - type="button" 602 - className="w-full" 603 - onClick={handleAddGroup} 604 - > 605 - <Plus /> 606 - Add Component Group 607 - </Button> 608 - </FormCardContent> 609 - <FormCardSeparator /> 610 - <FormCardContent> 611 - <Sortable 612 - value={data} 613 - onValueChange={onValueChange} 614 - getItemValue={getItemValue} 615 - orientation="vertical" 616 - > 617 - {data.length ? ( 618 - <SortableContent className="grid gap-2"> 619 - {data.map((item) => { 620 - if ("type" in item) { 621 - const components = form.getValues("components") ?? []; 622 - const componentIndex = components.findIndex( 623 - (c) => c.id === item.id, 494 + <> 495 + <Form {...form}> 496 + <form onSubmit={form.handleSubmit(submitAction)} {...props}> 497 + <FormCard> 498 + <FormCardHeader> 499 + <FormCardTitle>Components</FormCardTitle> 500 + <FormCardDescription> 501 + Manage your page components 502 + </FormCardDescription> 503 + </FormCardHeader> 504 + <FormCardContent className="flex flex-row gap-2"> 505 + <Button variant="outline" type="button" onClick={handleAddGroup}> 506 + <Plus /> 507 + Add Component Group 508 + </Button> 509 + <FormField 510 + control={form.control} 511 + name="components" 512 + render={({ field }) => ( 513 + <FormItem className="flex flex-col"> 514 + <FormLabel className="sr-only">Components</FormLabel> 515 + <DropdownMenu> 516 + <DropdownMenuTrigger asChild> 517 + <Button variant="outline" className="w-full"> 518 + <Plus /> 519 + Add Component 520 + </Button> 521 + </DropdownMenuTrigger> 522 + <DropdownMenuContent align="start"> 523 + <DropdownMenuGroup> 524 + <DropdownMenuItem 525 + onClick={() => { 526 + if (!validateLimit()) return; 527 + form.setValue("components", [ 528 + ...field.value, 529 + { 530 + id: Date.now(), 531 + monitorId: null, 532 + order: watchComponents.length, 533 + name: "", 534 + description: "", 535 + type: "static" as const, 536 + }, 537 + ]); 538 + }} 539 + > 540 + <Link2Off className="text-muted-foreground" /> 541 + Add Static Component 542 + </DropdownMenuItem> 543 + <DropdownMenuSub> 544 + <DropdownMenuSubTrigger className="gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0"> 545 + <Link2 className="text-muted-foreground" /> 546 + Add Monitor Component 547 + </DropdownMenuSubTrigger> 548 + <DropdownMenuSubContent className="p-0"> 549 + <Command> 550 + <CommandInput 551 + placeholder="Search monitors..." 552 + className="h-9" 553 + /> 554 + <CommandList> 555 + <CommandEmpty> 556 + No monitors found. 557 + </CommandEmpty> 558 + <CommandGroup> 559 + {monitors.map((monitor) => { 560 + const isUsed = usedMonitorIds.has( 561 + monitor.id, 562 + ); 563 + const isSelected = field.value.some( 564 + (c) => c.monitorId === monitor.id, 565 + ); 566 + return ( 567 + <CommandItem 568 + value={monitor.name} 569 + key={monitor.id} 570 + disabled={isUsed} 571 + onSelect={() => { 572 + if (isSelected) { 573 + form.setValue( 574 + "components", 575 + field.value.filter( 576 + (c) => 577 + c.monitorId !== monitor.id, 578 + ), 579 + ); 580 + } else { 581 + if (!validateLimit()) return; 582 + form.setValue("components", [ 583 + ...field.value, 584 + { 585 + id: Date.now(), 586 + monitorId: monitor.id, 587 + order: watchComponents.length, 588 + name: monitor.name, 589 + description: 590 + monitor.description, 591 + type: "monitor" as const, 592 + }, 593 + ]); 594 + } 595 + }} 596 + > 597 + {monitor.name} 598 + <Check 599 + className={cn( 600 + "ml-auto", 601 + isSelected 602 + ? "opacity-100" 603 + : "opacity-0", 604 + )} 605 + /> 606 + </CommandItem> 607 + ); 608 + })} 609 + </CommandGroup> 610 + </CommandList> 611 + </Command> 612 + </DropdownMenuSubContent> 613 + </DropdownMenuSub> 614 + <DropdownMenuItem disabled> 615 + <Plug className="text-muted-foreground" /> 616 + Add Third-Party Component 617 + </DropdownMenuItem> 618 + </DropdownMenuGroup> 619 + </DropdownMenuContent> 620 + </DropdownMenu> 621 + <FormMessage /> 622 + </FormItem> 623 + )} 624 + /> 625 + </FormCardContent> 626 + <FormCardSeparator /> 627 + <FormCardContent> 628 + <Sortable 629 + value={data} 630 + onValueChange={onValueChange} 631 + getItemValue={getItemValue} 632 + orientation="vertical" 633 + > 634 + {data.length ? ( 635 + <SortableContent className="grid gap-2"> 636 + {data.map((item) => { 637 + if ("type" in item) { 638 + const components = form.getValues("components") ?? []; 639 + const componentIndex = components.findIndex( 640 + (c) => c.id === item.id, 641 + ); 642 + return ( 643 + <ComponentRow 644 + key={`${item.id}-component`} 645 + className="border-transparent border-x px-2" 646 + component={item} 647 + form={form} 648 + onDelete={handleDeleteComponent} 649 + fieldNamePrefix={ 650 + componentIndex >= 0 651 + ? `components.${componentIndex}` 652 + : undefined 653 + } 654 + /> 655 + ); 656 + } 657 + const groups = form.getValues("groups") ?? []; 658 + const groupIndex = groups.findIndex( 659 + (g) => g.id === item.id, 624 660 ); 625 661 return ( 626 - <ComponentRow 627 - key={`${item.id}-component`} 628 - className="border-transparent border-x px-2" 629 - component={item} 662 + <ComponentGroupRow 663 + key={`${item.id}-group`} 664 + group={item} 665 + groupIndex={groupIndex} 666 + onDeleteGroup={handleDeleteGroup} 630 667 form={form} 631 - onDelete={handleDeleteComponent} 632 - fieldNamePrefix={ 633 - componentIndex >= 0 634 - ? `components.${componentIndex}` 635 - : undefined 636 - } 668 + allPageComponents={allPageComponents} 669 + monitors={monitors} 670 + validateLimit={validateLimit} 637 671 /> 638 672 ); 639 - } 640 - const groups = form.getValues("groups") ?? []; 641 - const groupIndex = groups.findIndex( 642 - (g) => g.id === item.id, 643 - ); 644 - return ( 645 - <ComponentGroupRow 646 - key={`${item.id}-group`} 647 - group={item} 648 - groupIndex={groupIndex} 649 - onDeleteGroup={handleDeleteGroup} 650 - form={form} 651 - allPageComponents={allPageComponents} 652 - monitors={monitors} 653 - /> 654 - ); 655 - })} 656 - <SortableOverlay>{renderOverlay}</SortableOverlay> 657 - </SortableContent> 658 - ) : ( 659 - <EmptyStateContainer> 660 - <EmptyStateTitle>No components selected</EmptyStateTitle> 661 - </EmptyStateContainer> 662 - )} 663 - </Sortable> 664 - </FormCardContent> 665 - <FormCardFooter> 666 - <FormCardFooterInfo> 667 - Learn more about{" "} 668 - <Link href="https://docs.openstatus.dev/reference/status-page/#page-components"> 669 - page components 670 - </Link> 671 - . 672 - </FormCardFooterInfo> 673 - <Button type="submit" disabled={isPending}> 674 - {isPending ? "Submitting..." : "Submit"} 675 - </Button> 676 - </FormCardFooter> 677 - </FormCard> 678 - </form> 679 - </Form> 673 + })} 674 + <SortableOverlay>{renderOverlay}</SortableOverlay> 675 + </SortableContent> 676 + ) : ( 677 + <EmptyStateContainer> 678 + <EmptyStateTitle>No components selected</EmptyStateTitle> 679 + </EmptyStateContainer> 680 + )} 681 + </Sortable> 682 + </FormCardContent> 683 + <FormCardFooter> 684 + <FormCardFooterInfo> 685 + Learn more about{" "} 686 + <Link href="https://docs.openstatus.dev/reference/status-page/#page-components"> 687 + page components 688 + </Link> 689 + . 690 + </FormCardFooterInfo> 691 + <Button type="submit" disabled={isPending}> 692 + {isPending ? "Submitting..." : "Submit"} 693 + </Button> 694 + </FormCardFooter> 695 + </FormCard> 696 + </form> 697 + </Form> 698 + <UpgradeDialog 699 + limit="page-components" 700 + open={openUpgradeDialog} 701 + onOpenChange={setOpenUpgradeDialog} 702 + /> 703 + </> 680 704 ); 681 705 } 682 706 ··· 773 797 <Link 774 798 href={`/monitors/${component.monitorId}/overview`} 775 799 onClick={(e) => e.stopPropagation()} 776 - className="flex w-full items-center gap-2 truncate text-sm" 800 + className="flex w-full items-center gap-2 truncate py-1.5 text-sm" 777 801 > 778 802 <Link2 className="size-4 shrink-0" />{" "} 779 803 <span className="truncate">{component.monitor.name}</span> ··· 781 805 ) : ( 782 806 <span className="flex items-center gap-2 text-muted-foreground text-sm"> 783 807 <Link2Off className="size-4 shrink-0" />{" "} 784 - <span className="truncate">External Component</span> 808 + <span className="truncate">Static Component</span> 785 809 </span> 786 810 )} 787 811 </div> ··· 836 860 <AlertDialogHeader> 837 861 <AlertDialogTitle>Are you sure?</AlertDialogTitle> 838 862 <AlertDialogDescription> 839 - You are about to delete this component. 863 + Once saved, this will unlink the component from attached 864 + status reports and maintenances. 840 865 </AlertDialogDescription> 866 + <ComponentAttachments 867 + statusReports={component.statusReports ?? []} 868 + maintenances={component.maintenances ?? []} 869 + /> 841 870 </AlertDialogHeader> 842 871 <AlertDialogFooter> 843 872 <AlertDialogCancel>Cancel</AlertDialogCancel> ··· 845 874 className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" 846 875 onClick={() => onDelete(component.id)} 847 876 > 848 - Delete 877 + Remove 849 878 </AlertDialogAction> 850 879 </AlertDialogFooter> 851 880 </AlertDialogContent> ··· 864 893 form: UseFormReturn<FormValues>; 865 894 allPageComponents: PageComponent[]; 866 895 monitors: Monitor[]; 896 + validateLimit: () => boolean; 867 897 } 868 898 869 899 function ComponentGroupRow({ ··· 873 903 form, 874 904 allPageComponents, 875 905 monitors, 906 + validateLimit, 876 907 }: ComponentGroupRowProps) { 877 908 const watchGroup = form.watch(`groups.${groupIndex}`); 878 909 const watchComponents = form.watch("components"); ··· 997 1028 Add Component 998 1029 </Button> 999 1030 </DropdownMenuTrigger> 1000 - <DropdownMenuContent 1001 - align="end" 1002 - className="w-[var(--radix-dropdown-menu-trigger-width)]" 1003 - > 1031 + <DropdownMenuContent align="start"> 1004 1032 <DropdownMenuGroup> 1005 1033 <DropdownMenuItem 1006 - disabled 1007 1034 onClick={() => { 1035 + if (!validateLimit()) return; 1008 1036 const current = field.value ?? []; 1009 1037 form.setValue(`groups.${groupIndex}.components`, [ 1010 1038 ...current, ··· 1014 1042 order: current.length, 1015 1043 name: "", 1016 1044 description: "", 1017 - type: "external" as const, 1045 + type: "static" as const, 1018 1046 }, 1019 1047 ]); 1020 1048 }} 1021 1049 > 1022 1050 <Link2Off className="text-muted-foreground" /> 1023 - Add External (soon) 1051 + Add Static Component 1024 1052 </DropdownMenuItem> 1025 1053 <DropdownMenuSub> 1026 1054 <DropdownMenuSubTrigger className="gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0"> 1027 1055 <Link2 className="text-muted-foreground" /> 1028 - Add Monitor 1056 + Add Monitor Component 1029 1057 </DropdownMenuSubTrigger> 1030 1058 <DropdownMenuSubContent className="p-0"> 1031 1059 <Command> ··· 1046 1074 <CommandItem 1047 1075 value={monitor.name} 1048 1076 key={monitor.id} 1049 - disabled={isTaken} 1077 + disabled={isTaken || isSelected} 1050 1078 onSelect={() => { 1051 1079 if (isSelected) { 1052 1080 form.setValue( ··· 1056 1084 ), 1057 1085 ); 1058 1086 } else { 1087 + if (!validateLimit()) return; 1059 1088 form.setValue( 1060 1089 `groups.${groupIndex}.components`, 1061 1090 [ ··· 1090 1119 </Command> 1091 1120 </DropdownMenuSubContent> 1092 1121 </DropdownMenuSub> 1122 + <DropdownMenuItem disabled> 1123 + <Plug className="text-muted-foreground" /> 1124 + Add Third-Party Component 1125 + </DropdownMenuItem> 1093 1126 </DropdownMenuGroup> 1094 1127 </DropdownMenuContent> 1095 1128 </DropdownMenu> ··· 1118 1151 <AlertDialogHeader> 1119 1152 <AlertDialogTitle>Are you sure?</AlertDialogTitle> 1120 1153 <AlertDialogDescription> 1121 - You are about to delete this group and all its components. 1154 + Once saved, this will delete all components in the group and 1155 + unlink them from attached status reports and maintenances. 1122 1156 </AlertDialogDescription> 1157 + <ComponentAttachments 1158 + statusReports={Array.from( 1159 + new Map( 1160 + group.components 1161 + .flatMap((c) => c.statusReports ?? []) 1162 + .map((sr) => [sr.id, sr]), 1163 + ).values(), 1164 + )} 1165 + maintenances={Array.from( 1166 + new Map( 1167 + group.components 1168 + .flatMap((c) => c.maintenances ?? []) 1169 + .map((m) => [m.id, m]), 1170 + ).values(), 1171 + )} 1172 + /> 1123 1173 </AlertDialogHeader> 1124 1174 <AlertDialogFooter> 1125 1175 <AlertDialogCancel>Cancel</AlertDialogCancel> ··· 1127 1177 className="bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40" 1128 1178 onClick={() => onDeleteGroup(group.id)} 1129 1179 > 1130 - Delete 1180 + Remove 1131 1181 </AlertDialogAction> 1132 1182 </AlertDialogFooter> 1133 1183 </AlertDialogContent> ··· 1175 1225 </SortableItem> 1176 1226 ); 1177 1227 } 1228 + 1229 + function ComponentAttachments({ 1230 + statusReports, 1231 + maintenances, 1232 + }: { 1233 + statusReports: Array<{ id: number; title: string }>; 1234 + maintenances: Array<{ id: number; title: string }>; 1235 + }) { 1236 + if (statusReports.length === 0 && maintenances.length === 0) { 1237 + return null; 1238 + } 1239 + 1240 + const allItems = [ 1241 + ...statusReports.map((report) => ({ 1242 + id: `report-${report.id}`, 1243 + title: report.title, 1244 + type: "report" as const, 1245 + })), 1246 + ...maintenances.map((maintenance) => ({ 1247 + id: `maintenance-${maintenance.id}`, 1248 + title: maintenance.title, 1249 + type: "maintenance" as const, 1250 + })), 1251 + ]; 1252 + 1253 + const displayLimit = 3; 1254 + const displayedItems = allItems.slice(0, displayLimit); 1255 + const remainingCount = allItems.length - displayLimit; 1256 + 1257 + return ( 1258 + <ul className="list-inside list-disc space-y-1 text-foreground text-sm"> 1259 + {displayedItems.map((item) => ( 1260 + <li key={item.id}> 1261 + {item.title}{" "} 1262 + <span className="text-muted-foreground">({item.type})</span> 1263 + </li> 1264 + ))} 1265 + {remainingCount > 0 && ( 1266 + <li className="text-muted-foreground">+ {remainingCount} more</li> 1267 + )} 1268 + </ul> 1269 + ); 1270 + }
+34 -2
apps/dashboard/src/components/forms/components/update.tsx
··· 5 5 import { useMutation, useQuery } from "@tanstack/react-query"; 6 6 import { useParams } from "next/navigation"; 7 7 import { FormCardGroup } from "../form-card"; 8 + import { FormConfiguration } from "../status-page/form-configuration"; 8 9 9 10 export function FormComponentsUpdate() { 10 11 const { id } = useParams<{ id: string }>(); ··· 16 17 trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), 17 18 ); 18 19 const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); 20 + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); 19 21 20 22 const updateComponentsMutation = useMutation( 21 23 trpc.pageComponent.updateOrder.mutationOptions({ ··· 26 28 }), 27 29 ); 28 30 29 - if (!statusPage || !pageComponents || !monitors) return null; 31 + const updatePageConfigurationMutation = useMutation( 32 + trpc.page.updatePageConfiguration.mutationOptions({ 33 + onSuccess: () => refetch(), 34 + }), 35 + ); 36 + 37 + if (!statusPage || !pageComponents || !monitors || !workspace) return null; 30 38 31 39 // Separate standalone components from grouped components 32 40 const standaloneComponents = pageComponents.filter((c) => !c.groupId); ··· 67 75 groups, 68 76 }; 69 77 78 + const configLink = `https://${ 79 + statusPage.slug 80 + }.stpg.dev?configuration-token=${statusPage.createdAt?.getTime().toString()}`; 81 + 70 82 return ( 71 83 <FormCardGroup> 72 84 <FormComponents ··· 74 86 monitors={monitors} 75 87 allPageComponents={pageComponents} 76 88 defaultValues={defaultValues} 77 - legacy={statusPage.legacyPage} 89 + workspace={workspace} 78 90 onSubmit={async (values) => { 79 91 await updateComponentsMutation.mutateAsync({ 80 92 pageId: Number.parseInt(id), ··· 85 97 })), 86 98 }); 87 99 }} 100 + /> 101 + <FormConfiguration 102 + defaultValues={{ 103 + configuration: statusPage.configuration ?? {}, 104 + }} 105 + onSubmit={async (values) => { 106 + await updatePageConfigurationMutation.mutateAsync({ 107 + id: Number.parseInt(id), 108 + configuration: { 109 + uptime: 110 + typeof values.configuration.uptime === "boolean" 111 + ? values.configuration.uptime 112 + : values.configuration.uptime === "true", 113 + value: values.configuration.value ?? "duration", 114 + type: values.configuration.type ?? "absolute", 115 + theme: values.configuration.theme ?? undefined, 116 + }, 117 + }); 118 + }} 119 + configLink={configLink} 88 120 /> 89 121 </FormCardGroup> 90 122 );
+22 -23
apps/dashboard/src/components/forms/status-page/form-configuration.tsx
··· 139 139 <> 140 140 <Form {...form}> 141 141 <form id="redesign" onSubmit={form.handleSubmit(submitAction)}> 142 - <FormCard variant="info"> 142 + <FormCard> 143 143 <FormCardHeader> 144 - <FormCardTitle>Tracker Configuration</FormCardTitle> 144 + <FormCardTitle>Components Configuration</FormCardTitle> 145 145 <FormCardDescription> 146 - Configure which data should be shown in the monitor tracker. 146 + Configure which data should be shown for your components. 147 147 </FormCardDescription> 148 148 </FormCardHeader> 149 149 <FormCardSeparator /> ··· 153 153 name="configuration.type" 154 154 render={({ field }) => ( 155 155 <FormItem> 156 - <FormLabel>Bar Type</FormLabel> 156 + <FormLabel>Bar Type*</FormLabel> 157 157 <Select 158 158 onValueChange={field.onChange} 159 159 defaultValue={String(field.value) ?? "absolute"} ··· 184 184 name="configuration.value" 185 185 render={({ field }) => ( 186 186 <FormItem> 187 - <FormLabel>Card Value</FormLabel> 187 + <FormLabel>Card Value*</FormLabel> 188 188 <Select 189 189 onValueChange={field.onChange} 190 190 defaultValue={String(field.value) ?? "duration"} ··· 242 242 </FormItem> 243 243 )} 244 244 /> 245 + <p className="col-span-full text-foreground/70 text-sm"> 246 + *Configuration settings only apply to monitor components. 247 + </p> 245 248 <Note className="col-span-full"> 246 249 <ul className="list-inside list-disc"> 247 250 <li> 251 + <span>Bar Type </span> 248 252 <span className="font-medium"> 249 - Bar Type{" "} 250 - <span className="font-semibold"> 251 - {watchConfigurationType} 252 - </span> 253 + {watchConfigurationType} 253 254 </span> 254 - : {message.type[watchConfigurationType]} 255 + : <span>{message.type[watchConfigurationType]}</span> 255 256 </li> 256 257 <li> 258 + <span>Card Value </span> 257 259 <span className="font-medium"> 258 - Card Value{" "} 259 - <span className="font-semibold"> 260 - {watchConfigurationValue} 261 - </span> 260 + {watchConfigurationValue} 262 261 </span> 263 262 :{" "} 264 - {message.value[watchConfigurationValue] ?? 265 - message.value.default} 263 + <span> 264 + {message.value[watchConfigurationValue] ?? 265 + message.value.default} 266 + </span> 266 267 </li> 267 268 <li> 268 - <span className="font-medium"> 269 - Show Uptime{" "} 270 - <span className="font-semibold"> 271 - {watchConfigurationUptime} 272 - </span> 269 + <span>Show Uptime </span> 270 + <span className="font-medium capitalize"> 271 + {String(watchConfigurationUptime)} 273 272 </span> 274 - : {message.uptime[watchConfigurationUptime]} 273 + : <span>{message.uptime[watchConfigurationUptime]}</span> 275 274 </li> 276 275 </ul> 277 276 </Note> 278 277 </FormCardContent> 279 - <FormCardFooter variant="info"> 278 + <FormCardFooter> 280 279 <FormCardFooterInfo> 281 280 Learn more about{" "} 282 281 <Link
-31
apps/dashboard/src/components/forms/status-page/update.tsx
··· 6 6 import { Info } from "lucide-react"; 7 7 import { useParams, useRouter } from "next/navigation"; 8 8 import { FormAppearance } from "./form-appearance"; 9 - import { FormConfiguration } from "./form-configuration"; 10 9 import { FormCustomDomain } from "./form-custom-domain"; 11 10 import { FormDangerZone } from "./form-danger-zone"; 12 11 import { FormGeneral } from "./form-general"; ··· 69 68 }), 70 69 ); 71 70 72 - const updatePageConfigurationMutation = useMutation( 73 - trpc.page.updatePageConfiguration.mutationOptions({ 74 - onSuccess: () => refetch(), 75 - }), 76 - ); 77 - 78 71 const updateLinksMutation = useMutation( 79 72 trpc.page.updateLinks.mutationOptions({ 80 73 onSuccess: () => refetch(), ··· 83 76 84 77 if (!statusPage || !monitors || !workspace) return null; 85 78 86 - const configLink = `https://${ 87 - statusPage.slug 88 - }.stpg.dev?configuration-token=${statusPage.createdAt?.getTime().toString()}`; 89 - 90 79 return ( 91 80 <FormCardGroup> 92 81 <Note color="warning"> ··· 153 142 configuration: values.configuration, 154 143 }); 155 144 }} 156 - /> 157 - <FormConfiguration 158 - defaultValues={{ 159 - configuration: statusPage.configuration ?? {}, 160 - }} 161 - onSubmit={async (values) => { 162 - await updatePageConfigurationMutation.mutateAsync({ 163 - id: Number.parseInt(id), 164 - configuration: { 165 - uptime: 166 - typeof values.configuration.uptime === "boolean" 167 - ? values.configuration.uptime 168 - : values.configuration.uptime === "true", 169 - value: values.configuration.value ?? "duration", 170 - type: values.configuration.type ?? "absolute", 171 - theme: values.configuration.theme ?? undefined, 172 - }, 173 - }); 174 - }} 175 - configLink={configLink} 176 145 /> 177 146 <FormPageAccess 178 147 lockedMap={
+4
apps/docs/astro.config.mjs
··· 192 192 slug: "reference/status-page", 193 193 }, 194 194 { 195 + label: "Page Components", 196 + slug: "reference/page-components", 197 + }, 198 + { 195 199 label: "Status Report", 196 200 slug: "reference/status-report", 197 201 },
+304
apps/docs/src/content/docs/reference/page-components.mdx
··· 1 + --- 2 + title: Page Components Reference 3 + description: Complete specification for OpenStatus page components on status pages. 4 + --- 5 + 6 + import { Aside } from '@astrojs/starlight/components'; 7 + 8 + ## Overview 9 + 10 + Page components are the individual elements displayed on your status page that show the operational status of your services. They provide a flexible way to organize and present both monitored services and static content on your status page. 11 + 12 + **Key features:** 13 + - Support for both monitor-linked and static components. 14 + - Organize components into logical groups. 15 + - Custom ordering and arrangement. 16 + - Individual status tracking with incidents, reports, and maintenances. 17 + - Granular control over what appears on your status page. 18 + 19 + ## Component Types 20 + 21 + Page components come in two distinct types, each serving different purposes on your status page. 22 + 23 + ### Monitor Components 24 + 25 + **Type:** `monitor` 26 + 27 + Monitor components are linked to an active monitor in your workspace. They automatically inherit the monitor's status and display real-time health information. 28 + 29 + **Characteristics:** 30 + - Display live monitor status (up, degraded, down). 31 + - Show active incidents from the linked monitor. 32 + - Include historical uptime data. 33 + - Reflect the monitor's current operational state. 34 + - Automatically update when the monitor changes. 35 + 36 + **Use cases:** 37 + - Displaying API endpoint health. 38 + - Showing website availability. 39 + - Tracking critical service dependencies. 40 + - Monitoring infrastructure components. 41 + 42 + #### Configuring Monitor Components 43 + 44 + When you create a monitor component, you link it to an existing monitor in your workspace. This connection provides several benefits: 45 + 46 + **Automatic incident tracking:** 47 + When your monitor detects a failure (connection timeout, HTTP error, assertion failure), an incident is automatically created and displayed on the status page. The component will show an **error** status until the monitor recovers. 48 + 49 + **Real-time status updates:** 50 + The component reflects the current operational state of your monitor. If the monitor is actively checking and healthy, your visitors see a **success** status. If checks fail, they immediately see the issue. 51 + 52 + **Historical data visualization:** 53 + Monitor components display historical uptime data through status trackers. Depending on your [tracker configuration](/tutorial/how-to-configure-status-page#1-tracker-configuration), you can show: 54 + - **Absolute bar with duration card**: Shows the time spent in each status (success, error, degraded, maintenance). 55 + - **Absolute bar with request card**: Shows the number of successful vs. failed requests. 56 + - **Manual bar**: Shows only the most significant status of each day. 57 + 58 + **Uptime calculations:** 59 + Monitor components calculate uptime percentages based on: 60 + - Duration of successful vs. failed checks (for duration-based tracking). 61 + - Number of successful vs. failed requests (for request-based tracking). 62 + - Includes incidents and status reports in the calculation. 63 + 64 + **Monitor selection:** 65 + When adding a monitor component, the dashboard shows you available monitors with indicators for: 66 + - **Public/Private status**: Whether the monitor is already public. 67 + - **Active status**: Only active monitors can be linked. 68 + - **Already linked**: Monitors already used on this status page are unavailable. 69 + 70 + <Aside type="tip"> 71 + You can customize the component name to be different from the monitor name. For example, your monitor might be named "prod-api-health-check" internally, but the component can display as "API Server" for your visitors. 72 + </Aside> 73 + 74 + **Status hierarchy:** 75 + 1. **Error** - Active incidents from the linked monitor. 76 + 2. **Degraded** - Unresolved status reports affecting this component. 77 + 3. **Info** - Ongoing scheduled maintenance. 78 + 4. **Success** - Healthy and operational. 79 + 80 + **What affects monitor components:** 81 + - ✅ Automatic incidents (from monitor failures) 82 + - ✅ Manual status reports 83 + - ✅ Scheduled maintenances 84 + 85 + ### Static Components 86 + 87 + **Type:** `static` 88 + 89 + Static components are independent elements not linked to any monitor. They allow you to display services or systems that you manually manage through status reports and maintenance windows only. 90 + 91 + **Characteristics:** 92 + - No automatic status updates. 93 + - Status controlled exclusively by manual status reports and scheduled maintenances. 94 + - Useful for third-party services or manual tracking. 95 + - Do not display incidents (no automatic incident creation). 96 + - Provide flexibility for non-monitored services. 97 + 98 + **Use cases:** 99 + - Third-party service dependencies (e.g., payment providers like Stripe, email services like SendGrid). 100 + - Manual status tracking for systems without monitors. 101 + - Services monitored through external tools. 102 + - Components that only need maintenance window communication. 103 + - Legacy systems without API endpoints to monitor. 104 + 105 + <Aside type="caution"> 106 + Static components **only** respond to manual status reports and scheduled maintenances. They never automatically detect issues or create incidents. If you need automatic failure detection, use a monitor component instead. 107 + </Aside> 108 + 109 + #### Managing Static Components 110 + 111 + Static components give you full manual control over what your visitors see: 112 + 113 + **Status reports:** 114 + Create status reports to indicate issues or degraded performance for static components. For example: 115 + - "Stripe payment processing experiencing delays" (degraded status). 116 + - "Email delivery service partially unavailable" (degraded status). 117 + 118 + Once you resolve the issue and mark the status report as resolved, the component returns to a success status. 119 + 120 + **Maintenance windows:** 121 + Schedule maintenance windows to inform visitors about planned downtime: 122 + - "Scheduled database backup - Sunday 2:00 AM - 4:00 AM" (info status). 123 + - "Third-party CDN maintenance window" (info status). 124 + 125 + During the maintenance window, the component shows an info status. After the window ends, it returns to its previous status. 126 + 127 + **No automatic monitoring:** 128 + Static components do not perform any health checks or generate incidents. You are responsible for: 129 + - Monitoring the service through other means. 130 + - Creating status reports when issues occur. 131 + - Updating reports when issues are resolved. 132 + - Communicating maintenance windows in advance. 133 + 134 + **Status hierarchy:** 135 + 1. **Degraded** - Unresolved status reports affecting this component. 136 + 2. **Info** - Ongoing scheduled maintenance. 137 + 3. **Success** - No active reports or maintenances. 138 + 139 + **What affects static components:** 140 + - ✅ Manual status reports 141 + - ✅ Scheduled maintenances 142 + - ❌ Automatic incidents (not supported) 143 + 144 + ## Component Groups 145 + 146 + Component groups allow you to organize related page components into logical sections on your status page. Groups improve readability and help visitors understand your service architecture. 147 + 148 + **Benefits:** 149 + - Visual organization of related services. 150 + - Collapsible sections for better page structure. 151 + - Independent ordering within groups. 152 + - Clear service categorization. 153 + 154 + **Examples of grouping strategies:** 155 + 156 + | Group Name | Components | 157 + |------------|------------| 158 + | **API Services** | Authentication API, Data API, WebSocket API | 159 + | **Infrastructure** | Database, Cache, Message Queue | 160 + | **External Dependencies** | Payment Provider, Email Service, CDN | 161 + | **Regional Services** | US Region, EU Region, APAC Region | 162 + 163 + **Group configuration:** 164 + - **Name:** The group heading displayed on your status page. 165 + - **Order:** Position of the group relative to other groups and ungrouped components. 166 + - **Components:** The page components contained within this group. 167 + ## Events and Status 168 + 169 + Page components can be affected by up to three types of events that influence their displayed status. The type of component determines which events apply: 170 + 171 + | Event Type | Monitor Components | Static Components | 172 + |------------|-------------------|-------------------| 173 + | **Incidents** | ✅ Automatic | ❌ Not supported | 174 + | **Status Reports** | ✅ Manual | ✅ Manual | 175 + | **Maintenances** | ✅ Manual | ✅ Manual | 176 + 177 + ### Incidents 178 + 179 + **Applies to:** Monitor components only 180 + 181 + Incidents are automatically generated when a monitor detects a failure. They represent unplanned outages or degraded performance discovered through active monitoring. 182 + 183 + **How incidents are created:** 184 + - Monitor check fails (connection timeout, HTTP error, DNS failure). 185 + - Monitor assertion fails (wrong status code, unexpected response body). 186 + - Monitor reaches degraded threshold (response time too slow). 187 + 188 + **Status impact:** Components with active incidents show an **error** status. This takes the highest priority in the status hierarchy. 189 + 190 + **Resolution:** Incidents are automatically resolved when the monitor recovers and checks succeed again. 191 + 192 + <Aside> 193 + Static components **never** generate incidents because they are not linked to monitors. Use status reports for manual issue tracking on static components. 194 + </Aside> 195 + 196 + ### Status Reports 197 + 198 + **Applies to:** Both monitor and static components 199 + 200 + Status reports are manually created updates about component health or issues. They provide a way to communicate problems that may not trigger automatic monitoring or to manually report issues with static components. 201 + 202 + **Status impact:** Components with unresolved status reports show a **degraded** status (unless overridden by an incident for monitor components). 203 + 204 + **Use cases for monitor components:** 205 + - Reporting known issues that don't cause complete outages. 206 + - Communicating performance degradation not captured by monitoring. 207 + - Providing context for intermittent issues. 208 + 209 + **Use cases for static components:** 210 + - Reporting third-party service issues (e.g., "Stripe processing delays"). 211 + - Communicating external service degradation. 212 + - Announcing partial outages of non-monitored systems. 213 + 214 + **Attaching to components:** When creating a status report, you can select which components are affected. Multiple components can be attached to a single report. 215 + 216 + ### Maintenances 217 + 218 + **Applies to:** Both monitor and static components 219 + 220 + Maintenances are scheduled maintenance windows that you create in advance. They inform visitors about planned downtime or service interruptions for both monitored and static components. 221 + 222 + **Status impact:** Components with ongoing maintenances show an **info** status (unless overridden by incidents or reports). 223 + 224 + **Use cases for monitor components:** 225 + - Scheduled system upgrades that will cause downtime. 226 + - Infrastructure changes that affect monitored services. 227 + - Planned deployments requiring service restarts. 228 + 229 + **Use cases for static components:** 230 + - Third-party maintenance windows (e.g., "Payment provider scheduled maintenance"). 231 + - External service upgrade notifications. 232 + - Planned downtime for non-monitored dependencies. 233 + 234 + **Scheduling:** Maintenances have a defined start and end time. The info status automatically appears during the window and disappears when the maintenance ends. 235 + 236 + **Attaching to components:** When creating a maintenance, you select which components will be affected. This allows you to communicate maintenance impact across multiple services. 237 + 238 + ## Managing Components 239 + 240 + ### Adding Components 241 + 242 + You can add components to your status page in two ways: 243 + 244 + 1. **Individual components:** Add a single component outside of any group. 245 + 2. **Components within groups:** Add a component directly into a new or existing group. 246 + 247 + When adding a monitor component, you can only select from monitors that: 248 + - Are currently active. 249 + - Have not been deleted. 250 + - Are not already linked to another component on this status page. 251 + 252 + ### Reordering Components 253 + 254 + Components and groups can be reordered using drag-and-drop functionality in the dashboard. The order determines how they appear on your status page from top to bottom. 255 + 256 + **Ordering tips:** 257 + - Place your most critical services at the top. 258 + - Group related services together. 259 + - Consider visitor priorities when ordering. 260 + 261 + ### Editing Components 262 + 263 + You can modify the following properties of existing components: 264 + - Component name and description. 265 + - Group assignment (move between groups or make ungrouped). 266 + - Display order. 267 + 268 + **Note:** You cannot change a component's type (monitor to static or vice versa) after creation. To change types, delete the component and create a new one. 269 + 270 + ### Deleting Components 271 + 272 + When you delete a component, any associations with status reports and maintenances are automatically removed. The linked monitor (if applicable) is not deleted and remains available in your workspace. 273 + 274 + **Warning:** Deletion is permanent and cannot be undone. Ensure you want to remove the component before confirming deletion. 275 + 276 + ## Deprecation Notice 277 + 278 + The legacy monitor-only system for status pages is deprecated in favor of the more flexible page component system. 279 + 280 + **Deprecated approach:** 281 + - Status pages directly referenced monitors. 282 + - No support for static content. 283 + - Limited organizational flexibility. 284 + 285 + **Current approach (page components):** 286 + - Status pages contain page components. 287 + - Components can be monitors or static content. 288 + - Full support for grouping and custom ordering. 289 + - Better separation between monitoring and status page presentation. 290 + 291 + ### API Compatibility 292 + 293 + **v1 API (backward compatibility):** 294 + The v1 API continues to display `monitorIds` and `monitors` fields in status page responses to avoid breaking changes for existing integrations. However, these fields now only include page components that are explicitly of type `monitor`. Static components are not included in these legacy fields. 295 + 296 + **Future API versions:** 297 + Newer API versions will primarily use the `pageComponents` structure. The legacy `monitors` and `monitorIds` fields will be removed in future API versions. We recommend migrating your integrations to use `pageComponents` for full feature support. 298 + 299 + ## Related resources 300 + 301 + - **[Status Page Reference](/reference/status-page)** - Complete status page configuration reference. 302 + - **[Status Report Reference](/reference/status-report)** - Details on creating and managing status reports. 303 + - **[Create Status Page](/status-page/create-status-page)** - Step-by-step tutorial on creating a status page. 304 + - **[HTTP Monitor Reference](/reference/http-monitor)** - Technical specification for HTTP monitors that can be linked to components.
-4
apps/server/src/libs/test/preload.ts
··· 1 1 import { mock } from "bun:test"; 2 2 3 - import { app } from "@/index"; 4 - 5 - console.log(app); 6 - 7 3 mock.module("@openstatus/upstash", () => ({ 8 4 Redis: { 9 5 fromEnv() {
+501
apps/server/src/routes/public/status.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { app } from "@/index"; 3 + import { db, eq } from "@openstatus/db"; 4 + import { 5 + incidentTable, 6 + maintenance, 7 + monitor, 8 + page, 9 + pageComponent, 10 + statusReport, 11 + } from "@openstatus/db/src/schema"; 12 + 13 + /** 14 + * Status Route Tests: Verify the status route uses pageComponents with single DB call 15 + * 16 + * These tests verify that the /public/status/:slug endpoint: 17 + * - Uses pageComponents instead of monitorsToPages 18 + * - Makes a single database query 19 + * - Correctly filters for active monitors only 20 + * - Correctly identifies ongoing incidents 21 + * - Correctly identifies unresolved status reports 22 + * - Correctly identifies ongoing maintenances 23 + * - Returns correct status based on the Tracker logic 24 + */ 25 + 26 + const TEST_PREFIX = "status-test"; 27 + let testPageId: number; 28 + let testMonitorId: number; 29 + let testMonitor2Id: number; 30 + let testIncidentId: number; 31 + let testStatusReportId: number; 32 + let testMaintenanceId: number; 33 + 34 + beforeAll(async () => { 35 + // Clean up any existing test data by slug/name before creating new ones 36 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-page`)); 37 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-private-page`)); 38 + await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-cache-test`)); 39 + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor-1`)); 40 + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor-2`)); 41 + 42 + // Create test page 43 + const testPage = await db 44 + .insert(page) 45 + .values({ 46 + workspaceId: 1, 47 + title: "Status Test Page", 48 + description: "A test page for status route tests", 49 + slug: `${TEST_PREFIX}-page`, 50 + customDomain: "", 51 + accessType: "public", 52 + }) 53 + .returning() 54 + .get(); 55 + testPageId = testPage.id; 56 + 57 + // Create first test monitor (active) 58 + const testMonitor = await db 59 + .insert(monitor) 60 + .values({ 61 + workspaceId: 1, 62 + name: `${TEST_PREFIX}-monitor-1`, 63 + url: "https://status-test-1.example.com", 64 + periodicity: "1m", 65 + active: true, 66 + regions: "ams", 67 + jobType: "http", 68 + }) 69 + .returning() 70 + .get(); 71 + testMonitorId = testMonitor.id; 72 + 73 + // Create second test monitor (inactive) 74 + const testMonitor2 = await db 75 + .insert(monitor) 76 + .values({ 77 + workspaceId: 1, 78 + name: `${TEST_PREFIX}-monitor-2`, 79 + url: "https://status-test-2.example.com", 80 + periodicity: "1m", 81 + active: false, // Inactive monitor 82 + regions: "ams", 83 + jobType: "http", 84 + }) 85 + .returning() 86 + .get(); 87 + testMonitor2Id = testMonitor2.id; 88 + 89 + // Create page components for both monitors 90 + await db.insert(pageComponent).values({ 91 + workspaceId: 1, 92 + pageId: testPageId, 93 + monitorId: testMonitorId, 94 + type: "monitor", 95 + name: `${TEST_PREFIX}-monitor-1`, 96 + order: 1, 97 + }); 98 + 99 + await db.insert(pageComponent).values({ 100 + workspaceId: 1, 101 + pageId: testPageId, 102 + monitorId: testMonitor2Id, 103 + type: "monitor", 104 + name: `${TEST_PREFIX}-monitor-2`, 105 + order: 2, 106 + }); 107 + }); 108 + 109 + afterAll(async () => { 110 + // Clean up test data 111 + if (testIncidentId) { 112 + await db 113 + .delete(incidentTable) 114 + .where(eq(incidentTable.id, testIncidentId)) 115 + .catch(() => {}); 116 + } 117 + if (testStatusReportId) { 118 + await db 119 + .delete(statusReport) 120 + .where(eq(statusReport.id, testStatusReportId)) 121 + .catch(() => {}); 122 + } 123 + if (testMaintenanceId) { 124 + await db 125 + .delete(maintenance) 126 + .where(eq(maintenance.id, testMaintenanceId)) 127 + .catch(() => {}); 128 + } 129 + await db 130 + .delete(pageComponent) 131 + .where(eq(pageComponent.pageId, testPageId)) 132 + .catch(() => {}); 133 + await db 134 + .delete(page) 135 + .where(eq(page.id, testPageId)) 136 + .catch(() => {}); 137 + await db 138 + .delete(monitor) 139 + .where(eq(monitor.id, testMonitorId)) 140 + .catch(() => {}); 141 + await db 142 + .delete(monitor) 143 + .where(eq(monitor.id, testMonitor2Id)) 144 + .catch(() => {}); 145 + }); 146 + 147 + describe("Status Route: Basic functionality", () => { 148 + test("returns operational status for page with no incidents", async () => { 149 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 150 + 151 + expect(res.status).toBe(200); 152 + 153 + const data = await res.json(); 154 + expect(data.status).toBe("operational"); 155 + }); 156 + 157 + test("returns unknown status for non-existent page", async () => { 158 + const res = await app.request("/public/status/non-existent-page"); 159 + 160 + expect(res.status).toBe(200); 161 + 162 + const data = await res.json(); 163 + expect(data.status).toBe("unknown"); 164 + }); 165 + 166 + test("returns unknown status for non-public page", async () => { 167 + // Create a private page 168 + const privatePage = await db 169 + .insert(page) 170 + .values({ 171 + workspaceId: 1, 172 + title: "Private Test Page", 173 + description: "A private test page", 174 + slug: `${TEST_PREFIX}-private-page`, 175 + customDomain: "", 176 + accessType: "password", 177 + password: "secret", 178 + }) 179 + .returning() 180 + .get(); 181 + 182 + const res = await app.request(`/public/status/${TEST_PREFIX}-private-page`); 183 + 184 + expect(res.status).toBe(200); 185 + 186 + const data = await res.json(); 187 + expect(data.status).toBe("unknown"); 188 + 189 + // Clean up 190 + await db.delete(page).where(eq(page.id, privatePage.id)); 191 + }); 192 + }); 193 + 194 + describe("Status Route: Active monitor filtering", () => { 195 + test("only considers active monitors for status calculation", async () => { 196 + // Create an incident for the inactive monitor 197 + const inactiveIncident = await db 198 + .insert(incidentTable) 199 + .values({ 200 + monitorId: testMonitor2Id, 201 + title: "Inactive Monitor Incident", 202 + status: "investigating", 203 + }) 204 + .returning() 205 + .get(); 206 + 207 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 208 + 209 + expect(res.status).toBe(200); 210 + 211 + const data = await res.json(); 212 + // Status should still be operational because the monitor is inactive 213 + expect(data.status).toBe("operational"); 214 + 215 + // Clean up 216 + await db 217 + .delete(incidentTable) 218 + .where(eq(incidentTable.id, inactiveIncident.id)); 219 + }); 220 + }); 221 + 222 + describe("Status Route: Incident detection", () => { 223 + test("returns incident status with ongoing incident", async () => { 224 + // Create an ongoing incident for the active monitor 225 + const incident = await db 226 + .insert(incidentTable) 227 + .values({ 228 + monitorId: testMonitorId, 229 + title: "Test Incident", 230 + status: "investigating", 231 + // resolvedAt is null, meaning it's ongoing 232 + }) 233 + .returning() 234 + .get(); 235 + testIncidentId = incident.id; 236 + 237 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 238 + 239 + expect(res.status).toBe(200); 240 + 241 + const data = await res.json(); 242 + expect(data.status).toBe("incident"); 243 + 244 + // Clean up 245 + await db.delete(incidentTable).where(eq(incidentTable.id, testIncidentId)); 246 + testIncidentId = 0; 247 + }); 248 + 249 + test("ignores resolved incidents", async () => { 250 + // First clean up the ongoing incident from previous test if it still exists 251 + if (testIncidentId) { 252 + await db 253 + .delete(incidentTable) 254 + .where(eq(incidentTable.id, testIncidentId)) 255 + .catch(() => {}); 256 + testIncidentId = 0; 257 + } 258 + 259 + // Create a resolved incident 260 + const resolvedIncident = await db 261 + .insert(incidentTable) 262 + .values({ 263 + monitorId: testMonitorId, 264 + title: "Resolved Incident", 265 + status: "resolved", 266 + resolvedAt: new Date(), 267 + }) 268 + .returning() 269 + .get(); 270 + 271 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 272 + 273 + expect(res.status).toBe(200); 274 + 275 + const data = await res.json(); 276 + // Status should be operational because the incident is resolved 277 + expect(data.status).toBe("operational"); 278 + 279 + // Clean up 280 + await db 281 + .delete(incidentTable) 282 + .where(eq(incidentTable.id, resolvedIncident.id)); 283 + }); 284 + }); 285 + 286 + describe("Status Route: Status report detection", () => { 287 + test("returns degraded_performance status with unresolved status report", async () => { 288 + // Create an unresolved status report 289 + const report = await db 290 + .insert(statusReport) 291 + .values({ 292 + workspaceId: 1, 293 + pageId: testPageId, 294 + title: "Test Status Report", 295 + status: "investigating", 296 + }) 297 + .returning() 298 + .get(); 299 + testStatusReportId = report.id; 300 + 301 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 302 + 303 + expect(res.status).toBe(200); 304 + 305 + const data = await res.json(); 306 + expect(data.status).toBe("degraded_performance"); 307 + 308 + // Clean up 309 + await db 310 + .delete(statusReport) 311 + .where(eq(statusReport.id, testStatusReportId)); 312 + testStatusReportId = 0; 313 + }); 314 + 315 + test("ignores resolved status reports", async () => { 316 + // First clean up the ongoing status report from previous test if it still exists 317 + if (testStatusReportId) { 318 + await db 319 + .delete(statusReport) 320 + .where(eq(statusReport.id, testStatusReportId)) 321 + .catch(() => {}); 322 + testStatusReportId = 0; 323 + } 324 + 325 + // Create a resolved status report 326 + const resolvedReport = await db 327 + .insert(statusReport) 328 + .values({ 329 + workspaceId: 1, 330 + pageId: testPageId, 331 + title: "Resolved Status Report", 332 + status: "resolved", 333 + }) 334 + .returning() 335 + .get(); 336 + 337 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 338 + 339 + expect(res.status).toBe(200); 340 + 341 + const data = await res.json(); 342 + // Status should be operational because the report is resolved 343 + expect(data.status).toBe("operational"); 344 + 345 + // Clean up 346 + await db.delete(statusReport).where(eq(statusReport.id, resolvedReport.id)); 347 + }); 348 + }); 349 + 350 + describe("Status Route: Maintenance detection", () => { 351 + test("returns under_maintenance status with ongoing maintenance", async () => { 352 + // Create an ongoing maintenance 353 + const now = new Date(); 354 + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); 355 + const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000); 356 + 357 + const maint = await db 358 + .insert(maintenance) 359 + .values({ 360 + workspaceId: 1, 361 + pageId: testPageId, 362 + title: "Test Maintenance", 363 + message: "Ongoing maintenance", 364 + from: oneHourAgo, 365 + to: oneHourFromNow, 366 + }) 367 + .returning() 368 + .get(); 369 + testMaintenanceId = maint.id; 370 + 371 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 372 + 373 + expect(res.status).toBe(200); 374 + 375 + const data = await res.json(); 376 + expect(data.status).toBe("under_maintenance"); 377 + 378 + // Clean up 379 + await db.delete(maintenance).where(eq(maintenance.id, testMaintenanceId)); 380 + testMaintenanceId = 0; 381 + }); 382 + 383 + test("ignores past maintenances", async () => { 384 + // First clean up the ongoing maintenance from previous test if it still exists 385 + if (testMaintenanceId) { 386 + await db 387 + .delete(maintenance) 388 + .where(eq(maintenance.id, testMaintenanceId)) 389 + .catch(() => {}); 390 + testMaintenanceId = 0; 391 + } 392 + 393 + // Create a past maintenance 394 + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); 395 + const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); 396 + 397 + const pastMaint = await db 398 + .insert(maintenance) 399 + .values({ 400 + workspaceId: 1, 401 + pageId: testPageId, 402 + title: "Past Maintenance", 403 + message: "Past maintenance", 404 + from: twoDaysAgo, 405 + to: oneDayAgo, 406 + }) 407 + .returning() 408 + .get(); 409 + 410 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 411 + 412 + expect(res.status).toBe(200); 413 + 414 + const data = await res.json(); 415 + // Status should be operational because the maintenance is in the past 416 + expect(data.status).toBe("operational"); 417 + 418 + // Clean up 419 + await db.delete(maintenance).where(eq(maintenance.id, pastMaint.id)); 420 + }); 421 + 422 + test("ignores future maintenances", async () => { 423 + // First clean up any ongoing maintenance from previous test if it still exists 424 + if (testMaintenanceId) { 425 + await db 426 + .delete(maintenance) 427 + .where(eq(maintenance.id, testMaintenanceId)) 428 + .catch(() => {}); 429 + testMaintenanceId = 0; 430 + } 431 + 432 + // Create a future maintenance 433 + const oneDayFromNow = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); 434 + const twoDaysFromNow = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); 435 + 436 + const futureMaint = await db 437 + .insert(maintenance) 438 + .values({ 439 + workspaceId: 1, 440 + pageId: testPageId, 441 + title: "Future Maintenance", 442 + message: "Future maintenance", 443 + from: oneDayFromNow, 444 + to: twoDaysFromNow, 445 + }) 446 + .returning() 447 + .get(); 448 + 449 + const res = await app.request(`/public/status/${TEST_PREFIX}-page`); 450 + 451 + expect(res.status).toBe(200); 452 + 453 + const data = await res.json(); 454 + // Status should be operational because the maintenance is in the future 455 + expect(data.status).toBe("operational"); 456 + 457 + // Clean up 458 + await db.delete(maintenance).where(eq(maintenance.id, futureMaint.id)); 459 + }); 460 + }); 461 + 462 + describe("Status Route: Cache functionality", () => { 463 + test("returns cached status on second request if cache is available", async () => { 464 + const slug = `${TEST_PREFIX}-cache-test`; 465 + 466 + // Create a test page for cache testing 467 + const cachePage = await db 468 + .insert(page) 469 + .values({ 470 + workspaceId: 1, 471 + title: "Cache Test Page", 472 + description: "A test page for cache testing", 473 + slug, 474 + customDomain: "", 475 + accessType: "public", 476 + }) 477 + .returning() 478 + .get(); 479 + 480 + // First request should hit the database 481 + const res1 = await app.request(`/public/status/${slug}`); 482 + expect(res1.status).toBe(200); 483 + const data1 = await res1.json(); 484 + expect(data1.status).toBe("operational"); 485 + expect(res1.headers.get("OpenStatus-Cache")).toBeNull(); 486 + 487 + // Second request may hit the cache if Redis is configured 488 + const res2 = await app.request(`/public/status/${slug}`); 489 + expect(res2.status).toBe(200); 490 + const data2 = await res2.json(); 491 + expect(data2.status).toBe("operational"); 492 + // Cache header may be "HIT" if Redis is available, or null if not 493 + const cacheHeader = res2.headers.get("OpenStatus-Cache"); 494 + if (cacheHeader !== null) { 495 + expect(cacheHeader).toBe("HIT"); 496 + } 497 + 498 + // Clean up 499 + await db.delete(page).where(eq(page.id, cachePage.id)); 500 + }); 501 + });
+46 -114
apps/server/src/routes/public/status.ts
··· 2 2 import { Hono } from "hono"; 3 3 import { endTime, setMetric, startTime } from "hono/timing"; 4 4 5 - import { and, db, eq, gte, inArray, isNull, lte, ne } from "@openstatus/db"; 5 + import { db, eq } from "@openstatus/db"; 6 + import { page } from "@openstatus/db/src/schema"; 6 7 7 8 const logger = getLogger("api-server"); 8 - import { 9 - incidentTable, 10 - maintenance, 11 - monitor, 12 - monitorsToPages, 13 - monitorsToStatusReport, 14 - page, 15 - statusReport, 16 - } from "@openstatus/db/src/schema"; 17 9 import { Status, Tracker } from "@openstatus/tracker"; 18 10 19 11 import { redis } from "@/libs/clients"; 20 - import { notEmpty } from "@/utils/not-empty"; 21 12 22 13 // TODO: include ratelimiting 23 14 ··· 36 27 37 28 startTime(c, "database"); 38 29 39 - const currentPage = await db 40 - .select() 41 - .from(page) 42 - .where(eq(page.slug, slug)) 43 - .get(); 30 + // Single query with all relations 31 + const currentPage = await db.query.page.findFirst({ 32 + where: eq(page.slug, slug), 33 + with: { 34 + pageComponents: { 35 + with: { 36 + monitor: { 37 + with: { 38 + incidents: true, 39 + }, 40 + }, 41 + }, 42 + }, 43 + statusReports: true, 44 + maintenances: true, 45 + }, 46 + }); 47 + 48 + endTime(c, "database"); 44 49 45 50 if (!currentPage) { 46 51 return c.json({ status: Status.Unknown }); ··· 50 55 return c.json({ status: Status.Unknown }); 51 56 } 52 57 53 - const { 54 - pageStatusReportData, 55 - monitorStatusReportData, 56 - ongoingIncidents, 57 - maintenanceData, 58 - } = await getStatusPageData(currentPage.id); 59 - endTime(c, "database"); 58 + // Extract active monitor components 59 + const monitorComponents = currentPage.pageComponents.filter( 60 + (c) => 61 + c.type === "monitor" && 62 + c.monitor && 63 + c.monitor.active && 64 + !c.monitor.deletedAt, 65 + ); 60 66 61 - const statusReports = [...monitorStatusReportData].map((item) => { 62 - return item.status_report; 63 - }); 67 + // Extract all ongoing incidents from active monitors 68 + const ongoingIncidents = monitorComponents.flatMap( 69 + (c) => c.monitor?.incidents?.filter((inc) => !inc.resolvedAt) ?? [], 70 + ); 64 71 65 - statusReports.push(...pageStatusReportData); 72 + // Filter for unresolved status reports 73 + const unresolvedStatusReports = currentPage.statusReports.filter( 74 + (report) => report.status !== "resolved", 75 + ); 76 + 77 + // Filter for ongoing maintenances 78 + const now = new Date(); 79 + const ongoingMaintenances = currentPage.maintenances.filter( 80 + (m) => m.from <= now && m.to >= now, 81 + ); 66 82 83 + // Use the tracker to determine status 67 84 const tracker = new Tracker({ 68 85 incidents: ongoingIncidents, 69 - statusReports, 70 - maintenances: maintenanceData, 86 + statusReports: unresolvedStatusReports, 87 + maintenances: ongoingMaintenances, 71 88 }); 72 89 73 90 const status = tracker.currentStatus; ··· 82 99 return c.json({ status: Status.Unknown }); 83 100 } 84 101 }); 85 - 86 - async function getStatusPageData(pageId: number) { 87 - const monitorData = await db 88 - .select() 89 - .from(monitorsToPages) 90 - .innerJoin( 91 - monitor, 92 - // REMINDER: query only active monitors as they are the ones that are displayed on the status page 93 - and( 94 - eq(monitorsToPages.monitorId, monitor.id), 95 - eq(monitor.active, true), 96 - eq(monitorsToPages.pageId, pageId), 97 - ), 98 - ) 99 - 100 - .all(); 101 - 102 - const monitorIds = monitorData.map((i) => i.monitor?.id).filter(notEmpty); 103 - if (monitorIds.length === 0) { 104 - return { 105 - monitorData, 106 - pageStatusReportData: [], 107 - monitorStatusReportData: [], 108 - ongoingIncidents: [], 109 - }; 110 - } 111 - 112 - const monitorStatusReportQuery = db 113 - .select() 114 - .from(monitorsToStatusReport) 115 - .innerJoin( 116 - statusReport, 117 - eq(monitorsToStatusReport.statusReportId, statusReport.id), 118 - ) 119 - .where(inArray(monitorsToStatusReport.monitorId, monitorIds)) 120 - .all(); 121 - 122 - const ongoingIncidentsQuery = db 123 - .select() 124 - .from(incidentTable) 125 - .where( 126 - and( 127 - isNull(incidentTable.resolvedAt), 128 - inArray(incidentTable.monitorId, monitorIds), 129 - ), 130 - ) 131 - .all(); 132 - 133 - const ongoingMaintenancesQuery = db 134 - .select() 135 - .from(maintenance) 136 - .where( 137 - and( 138 - eq(maintenance.pageId, pageId), 139 - lte(maintenance.from, new Date()), 140 - gte(maintenance.to, new Date()), 141 - ), 142 - ); 143 - 144 - const pageStatusReportDataQuery = db 145 - .select() 146 - .from(statusReport) 147 - .where( 148 - and(eq(statusReport.pageId, pageId), ne(statusReport.status, "resolved")), 149 - ); 150 - 151 - const [ 152 - pageStatusReportData, 153 - monitorStatusReportData, 154 - ongoingIncidents, 155 - maintenanceData, 156 - ] = await Promise.all([ 157 - pageStatusReportDataQuery, 158 - monitorStatusReportQuery, 159 - ongoingIncidentsQuery, 160 - ongoingMaintenancesQuery, 161 - ]); 162 - 163 - return { 164 - pageStatusReportData, 165 - monitorStatusReportData, 166 - maintenanceData, 167 - ongoingIncidents, 168 - }; 169 - }
+2 -2
apps/server/src/routes/rpc/services/status-report/__tests__/status-report.test.ts
··· 74 74 .values({ 75 75 workspaceId: 1, 76 76 pageId: 1, 77 - type: "external", 77 + type: "static", 78 78 name: `${TEST_PREFIX}-component`, 79 79 description: "Test component for status report tests", 80 80 order: 100, ··· 102 102 .values({ 103 103 workspaceId: 1, 104 104 pageId: testPage2Id, 105 - type: "external", 105 + type: "static", 106 106 name: `${TEST_PREFIX}-component-2`, 107 107 description: "Test component on page 2", 108 108 order: 100,
+2 -2
apps/server/src/routes/v1/check/http/post.ts
··· 115 115 workspace_id: workspaceId, 116 116 validation_errors: parsed.error, 117 117 }); 118 - throw new Error(`Failed to parse response: ${parsed.error}`); 118 + throw new Error(`Failed to parse response: ${parsed.error.message}`); 119 119 } 120 120 121 121 fulfilledRequest.push(parsed.data); ··· 198 198 logger.error("Failed to parse aggregated response", { 199 199 validation_errors: parsed.error, 200 200 }); 201 - throw new Error(`Failed to parse response: ${parsed.error}`); 201 + throw new Error(`Failed to parse response: ${parsed.error.message}`); 202 202 } 203 203 204 204 return parsed.data;
+1
apps/server/src/routes/v1/maintenances/post.ts
··· 137 137 and( 138 138 eq(pageSubscriber.pageId, _newMaintenance.pageId), 139 139 isNotNull(pageSubscriber.acceptedAt), 140 + isNull(pageSubscriber.unsubscribedAt), 140 141 ), 141 142 ) 142 143 .all();
+1
apps/server/src/routes/v1/pageSubscribers/post.ts
··· 76 76 eq(pageSubscriber.email, input.email), 77 77 eq(pageSubscriber.pageId, Number(id)), 78 78 isNotNull(pageSubscriber.acceptedAt), 79 + isNotNull(pageSubscriber.unsubscribedAt), 79 80 ), 80 81 ) 81 82 .get();
+386 -1
apps/server/src/routes/v1/pages/post.test.ts
··· 2 2 3 3 import { app } from "@/index"; 4 4 import { db, eq } from "@openstatus/db"; 5 - import { page } from "@openstatus/db/src/schema"; 5 + import { 6 + monitor, 7 + monitorsToPages, 8 + page, 9 + pageComponent, 10 + } from "@openstatus/db/src/schema"; 6 11 import { PageSchema } from "./schema"; 7 12 8 13 test("create a valid page", async () => { ··· 95 100 96 101 expect(res.status).toBe(401); 97 102 }); 103 + 104 + test("create a page with custom domain without limits should return 402", async () => { 105 + const res = await app.request("/v1/page", { 106 + method: "POST", 107 + headers: { 108 + "x-openstatus-key": "2", // Free plan 109 + "content-type": "application/json", 110 + }, 111 + body: JSON.stringify({ 112 + title: "OpenStatus", 113 + description: "OpenStatus website", 114 + slug: `custom-domain-${Date.now()}`, 115 + customDomain: "status.example.com", 116 + }), 117 + }); 118 + 119 + expect(res.status).toBe(402); 120 + const json = await res.json(); 121 + expect(json.message).toBe("Upgrade for custom domains"); 122 + }); 123 + 124 + test("create a page with custom domain containing 'openstatus' should return 400", async () => { 125 + const res = await app.request("/v1/page", { 126 + method: "POST", 127 + headers: { 128 + "x-openstatus-key": "1", 129 + "content-type": "application/json", 130 + }, 131 + body: JSON.stringify({ 132 + title: "OpenStatus", 133 + description: "OpenStatus website", 134 + slug: `openstatus-domain-${Date.now()}`, 135 + customDomain: "status.openstatus.dev", 136 + }), 137 + }); 138 + 139 + expect(res.status).toBe(400); 140 + const json = await res.json(); 141 + expect(json.message).toBe("Domain cannot contain 'openstatus'"); 142 + }); 143 + 144 + test("create a page with reserved slug should return 400", async () => { 145 + const res = await app.request("/v1/page", { 146 + method: "POST", 147 + headers: { 148 + "x-openstatus-key": "1", 149 + "content-type": "application/json", 150 + }, 151 + body: JSON.stringify({ 152 + title: "OpenStatus", 153 + description: "OpenStatus website", 154 + slug: "api", // Reserved slug 155 + }), 156 + }); 157 + 158 + expect(res.status).toBe(400); 159 + const json = await res.json(); 160 + expect(json.message).toBe("Slug is reserved"); 161 + }); 162 + 163 + test("create a page with duplicate slug should return 400", async () => { 164 + const uniqueSlug = `duplicate-test-${Date.now()}`; 165 + 166 + // Create first page 167 + const res1 = await app.request("/v1/page", { 168 + method: "POST", 169 + headers: { 170 + "x-openstatus-key": "1", 171 + "content-type": "application/json", 172 + }, 173 + body: JSON.stringify({ 174 + title: "OpenStatus First", 175 + description: "First page", 176 + slug: uniqueSlug, 177 + }), 178 + }); 179 + 180 + expect(res1.status).toBe(200); 181 + const result1 = PageSchema.safeParse(await res1.json()); 182 + expect(result1.success).toBe(true); 183 + 184 + // Try to create second page with same slug 185 + const res2 = await app.request("/v1/page", { 186 + method: "POST", 187 + headers: { 188 + "x-openstatus-key": "1", 189 + "content-type": "application/json", 190 + }, 191 + body: JSON.stringify({ 192 + title: "OpenStatus Second", 193 + description: "Second page", 194 + slug: uniqueSlug, 195 + }), 196 + }); 197 + 198 + expect(res2.status).toBe(400); 199 + const json = await res2.json(); 200 + expect(json.message).toBe("Slug has to be unique and has already been taken"); 201 + 202 + // Cleanup 203 + if (result1.success) { 204 + await db.delete(page).where(eq(page.id, result1.data.id)); 205 + } 206 + }); 207 + 208 + test("create a page with email domain protection on free plan should return 402", async () => { 209 + const res = await app.request("/v1/page", { 210 + method: "POST", 211 + headers: { 212 + "x-openstatus-key": "2", // Free plan 213 + "content-type": "application/json", 214 + }, 215 + body: JSON.stringify({ 216 + title: "OpenStatus", 217 + description: "OpenStatus website", 218 + slug: `email-domain-${Date.now()}`, 219 + accessType: "email-domain", 220 + authEmailDomains: ["example.com"], 221 + }), 222 + }); 223 + 224 + expect(res.status).toBe(402); 225 + const json = await res.json(); 226 + expect(json.message).toBe("Upgrade for email domain protection"); 227 + }); 228 + 229 + test("create a page with accessType password on free plan should return 402", async () => { 230 + const res = await app.request("/v1/page", { 231 + method: "POST", 232 + headers: { 233 + "x-openstatus-key": "2", // Free plan 234 + "content-type": "application/json", 235 + }, 236 + body: JSON.stringify({ 237 + title: "OpenStatus", 238 + description: "OpenStatus website", 239 + slug: `access-type-password-${Date.now()}`, 240 + accessType: "password", 241 + password: "secret123", 242 + }), 243 + }); 244 + 245 + expect(res.status).toBe(402); 246 + const json = await res.json(); 247 + expect(json.message).toBe("Upgrade for password protection"); 248 + }); 249 + 250 + test("create a page with monitors as objects with order", async () => { 251 + const uniqueSlug = `ordered-monitors-${Date.now()}`; 252 + const res = await app.request("/v1/page", { 253 + method: "POST", 254 + headers: { 255 + "x-openstatus-key": "1", 256 + "content-type": "application/json", 257 + }, 258 + body: JSON.stringify({ 259 + title: "OpenStatus Ordered", 260 + description: "Page with ordered monitors", 261 + slug: uniqueSlug, 262 + monitors: [ 263 + { monitorId: 1, order: 1 }, 264 + { monitorId: 2, order: 0 }, 265 + ], 266 + }), 267 + }); 268 + 269 + expect(res.status).toBe(200); 270 + const result = PageSchema.safeParse(await res.json()); 271 + expect(result.success).toBe(true); 272 + 273 + if (result.success) { 274 + // Verify pageComponent entries were created with correct order 275 + const components = await db 276 + .select() 277 + .from(pageComponent) 278 + .where(eq(pageComponent.pageId, result.data.id)) 279 + .all(); 280 + 281 + expect(components.length).toBe(2); 282 + expect(components.find((c) => c.monitorId === 1)?.order).toBe(1); 283 + expect(components.find((c) => c.monitorId === 2)?.order).toBe(0); 284 + 285 + // Verify sync to legacy table 286 + const legacyEntries = await db 287 + .select() 288 + .from(monitorsToPages) 289 + .where(eq(monitorsToPages.pageId, result.data.id)) 290 + .all(); 291 + 292 + expect(legacyEntries.length).toBe(2); 293 + expect(legacyEntries.find((e) => e.monitorId === 1)?.order).toBe(1); 294 + expect(legacyEntries.find((e) => e.monitorId === 2)?.order).toBe(0); 295 + 296 + // Cleanup 297 + await db.delete(page).where(eq(page.id, result.data.id)); 298 + } 299 + }); 300 + 301 + test("create a page without monitors should succeed", async () => { 302 + const uniqueSlug = `no-monitors-${Date.now()}`; 303 + const res = await app.request("/v1/page", { 304 + method: "POST", 305 + headers: { 306 + "x-openstatus-key": "1", 307 + "content-type": "application/json", 308 + }, 309 + body: JSON.stringify({ 310 + title: "OpenStatus No Monitors", 311 + description: "Page without monitors", 312 + slug: uniqueSlug, 313 + }), 314 + }); 315 + 316 + expect(res.status).toBe(200); 317 + const result = PageSchema.safeParse(await res.json()); 318 + expect(result.success).toBe(true); 319 + 320 + if (result.success) { 321 + // Verify no pageComponent entries were created 322 + const components = await db 323 + .select() 324 + .from(pageComponent) 325 + .where(eq(pageComponent.pageId, result.data.id)) 326 + .all(); 327 + 328 + expect(components.length).toBe(0); 329 + 330 + // Cleanup 331 + await db.delete(page).where(eq(page.id, result.data.id)); 332 + } 333 + }); 334 + 335 + test("create a page with monitors as number array should use index as order", async () => { 336 + const uniqueSlug = `number-array-${Date.now()}`; 337 + const res = await app.request("/v1/page", { 338 + method: "POST", 339 + headers: { 340 + "x-openstatus-key": "1", 341 + "content-type": "application/json", 342 + }, 343 + body: JSON.stringify({ 344 + title: "OpenStatus Number Array", 345 + description: "Page with monitors as numbers", 346 + slug: uniqueSlug, 347 + monitors: [2, 1], 348 + }), 349 + }); 350 + 351 + expect(res.status).toBe(200); 352 + const result = PageSchema.safeParse(await res.json()); 353 + expect(result.success).toBe(true); 354 + 355 + if (result.success) { 356 + // Verify pageComponent entries were created with index as order 357 + const components = await db 358 + .select() 359 + .from(pageComponent) 360 + .where(eq(pageComponent.pageId, result.data.id)) 361 + .all(); 362 + 363 + expect(components.length).toBe(2); 364 + expect(components.find((c) => c.monitorId === 2)?.order).toBe(0); 365 + expect(components.find((c) => c.monitorId === 1)?.order).toBe(1); 366 + 367 + // Cleanup 368 + await db.delete(page).where(eq(page.id, result.data.id)); 369 + } 370 + }); 371 + 372 + test("create a page with partial invalid monitors should return 400", async () => { 373 + const res = await app.request("/v1/page", { 374 + method: "POST", 375 + headers: { 376 + "x-openstatus-key": "1", 377 + "content-type": "application/json", 378 + }, 379 + body: JSON.stringify({ 380 + title: "OpenStatus", 381 + description: "OpenStatus website", 382 + slug: `partial-invalid-${Date.now()}`, 383 + monitors: [1, 999], // 1 exists, 999 doesn't 384 + }), 385 + }); 386 + 387 + expect(res.status).toBe(400); 388 + const json = await res.json(); 389 + expect(json.message).toContain("not found"); 390 + }); 391 + 392 + test("create a page syncs correctly to pageComponent and legacy monitorsToPages", async () => { 393 + const uniqueSlug = `sync-test-${Date.now()}`; 394 + const res = await app.request("/v1/page", { 395 + method: "POST", 396 + headers: { 397 + "x-openstatus-key": "1", 398 + "content-type": "application/json", 399 + }, 400 + body: JSON.stringify({ 401 + title: "Sync Test", 402 + description: "Testing sync to both tables", 403 + slug: uniqueSlug, 404 + monitors: [{ monitorId: 1, order: 0 }], 405 + }), 406 + }); 407 + 408 + expect(res.status).toBe(200); 409 + const result = PageSchema.safeParse(await res.json()); 410 + expect(result.success).toBe(true); 411 + 412 + if (result.success) { 413 + // Verify pageComponent (primary table) 414 + const components = await db 415 + .select() 416 + .from(pageComponent) 417 + .where(eq(pageComponent.pageId, result.data.id)) 418 + .all(); 419 + 420 + expect(components.length).toBe(1); 421 + expect(components[0].monitorId).toBe(1); 422 + expect(components[0].type).toBe("monitor"); 423 + 424 + // Verify monitorsToPages (legacy table) 425 + const legacyEntries = await db 426 + .select() 427 + .from(monitorsToPages) 428 + .where(eq(monitorsToPages.pageId, result.data.id)) 429 + .all(); 430 + 431 + expect(legacyEntries.length).toBe(1); 432 + expect(legacyEntries[0].monitorId).toBe(1); 433 + 434 + // Cleanup 435 + await db.delete(page).where(eq(page.id, result.data.id)); 436 + } 437 + }); 438 + 439 + test("create a page uses monitor externalName when available", async () => { 440 + const uniqueSlug = `external-name-${Date.now()}`; 441 + 442 + // First, check if monitor has externalName set 443 + const monitorData = await db 444 + .select() 445 + .from(monitor) 446 + .where(eq(monitor.id, 1)) 447 + .get(); 448 + 449 + const res = await app.request("/v1/page", { 450 + method: "POST", 451 + headers: { 452 + "x-openstatus-key": "1", 453 + "content-type": "application/json", 454 + }, 455 + body: JSON.stringify({ 456 + title: "External Name Test", 457 + description: "Testing monitor external name", 458 + slug: uniqueSlug, 459 + monitors: [1], 460 + }), 461 + }); 462 + 463 + expect(res.status).toBe(200); 464 + const result = PageSchema.safeParse(await res.json()); 465 + expect(result.success).toBe(true); 466 + 467 + if (result.success) { 468 + const components = await db 469 + .select() 470 + .from(pageComponent) 471 + .where(eq(pageComponent.pageId, result.data.id)) 472 + .all(); 473 + 474 + expect(components.length).toBe(1); 475 + // Should use externalName if available, otherwise name 476 + const expectedName = monitorData?.externalName || monitorData?.name; 477 + expect(components[0].name).toBe(expectedName); 478 + 479 + // Cleanup 480 + await db.delete(page).where(eq(page.id, result.data.id)); 481 + } 482 + });
+49 -14
apps/server/src/routes/v1/pages/post.ts
··· 6 6 inArray, 7 7 isNull, 8 8 sql, 9 - syncMonitorsToPageInsert, 9 + syncPageComponentToMonitorsToPageInsertMany, 10 10 } from "@openstatus/db"; 11 11 import { db } from "@openstatus/db/src/db"; 12 12 import { 13 13 monitor, 14 - monitorsToPages, 15 14 page, 15 + pageComponent, 16 16 subdomainSafeList, 17 17 } from "@openstatus/db/src/schema"; 18 18 ··· 171 171 .values({ 172 172 ...rest, 173 173 workspaceId: workspaceId, 174 - customDomain: rest.customDomain ?? "", // TODO: make database migration to allow null 174 + customDomain: rest.customDomain ?? "", // TODO : make database migration to allow null 175 175 accessType: 176 176 rest.accessType ?? (rest.passwordProtected ? "password" : "public"), 177 177 authEmailDomains: rest.authEmailDomains?.join(","), ··· 181 181 182 182 // TODO: missing order 183 183 if (monitors?.length) { 184 - for (const monitor of monitors) { 185 - const values = 186 - typeof monitor === "number" ? { monitorId: monitor } : monitor; 184 + for (const [index, m] of monitors.entries()) { 185 + const values = typeof m === "number" ? { monitorId: m } : m; 186 + 187 + const _monitor = await db.query.monitor.findFirst({ 188 + where: and( 189 + eq(monitor.id, values.monitorId), 190 + eq(monitor.workspaceId, workspaceId), 191 + isNull(monitor.deletedAt), 192 + ), 193 + }); 187 194 195 + if (!_monitor) { 196 + throw new OpenStatusApiError({ 197 + code: "BAD_REQUEST", 198 + message: `Monitor ${values.monitorId} not found`, 199 + }); 200 + } 201 + 202 + // Insert to pageComponent (primary table) 188 203 await db 189 - .insert(monitorsToPages) 190 - .values({ pageId: _page.id, ...values }) 204 + .insert(pageComponent) 205 + .values({ 206 + workspaceId: _page.workspaceId, 207 + pageId: _page.id, 208 + type: "monitor", 209 + monitorId: values.monitorId, 210 + name: _monitor.externalName || _monitor.name, 211 + order: "order" in values ? values.order : index, 212 + groupId: null, 213 + groupOrder: 0, 214 + }) 191 215 .run(); 192 - // Sync to page components 193 - await syncMonitorsToPageInsert(db, { 216 + } 217 + 218 + // Sync to legacy table for backwards compatibility 219 + const monitorsToPageValues = monitors.map((m, index) => { 220 + const values = typeof m === "number" ? { monitorId: m } : m; 221 + return { 222 + pageId: _page.id, 194 223 monitorId: values.monitorId, 195 - pageId: _page.id, 196 - order: "order" in values ? values.order : undefined, 197 - }); 198 - } 224 + order: "order" in values ? values.order : index, 225 + monitorGroupId: null, 226 + groupOrder: 0, 227 + }; 228 + }); 229 + 230 + await syncPageComponentToMonitorsToPageInsertMany( 231 + db, 232 + monitorsToPageValues, 233 + ); 199 234 } 200 235 const data = transformPageData(PageSchema.parse(_page)); 201 236 return c.json(data, 200);
+84 -31
apps/server/src/routes/v1/pages/put.ts
··· 2 2 3 3 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 4 4 import { trackMiddleware } from "@/libs/middlewares"; 5 + import { notEmpty } from "@/utils/not-empty"; 5 6 import { Events } from "@openstatus/analytics"; 6 7 import { 7 8 and, ··· 9 10 inArray, 10 11 isNull, 11 12 sql, 12 - syncMonitorsToPageDelete, 13 - syncMonitorsToPageInsert, 13 + syncPageComponentToMonitorsToPageDelete, 14 + syncPageComponentToMonitorsToPageInsertMany, 14 15 } from "@openstatus/db"; 15 16 import { db } from "@openstatus/db/src/db"; 16 17 import { 17 18 monitor, 18 - monitorsToPages, 19 19 page, 20 + pageComponent, 20 21 subdomainSafeList, 21 22 } from "@openstatus/db/src/schema"; 22 23 import { isNumberArray } from "../utils"; ··· 184 185 .returning() 185 186 .get(); 186 187 187 - const currentMonitorsToPages = await db 188 + // Query current pageComponents instead of monitorsToPages 189 + const currentPageComponents = await db 188 190 .select() 189 - .from(monitorsToPages) 190 - .where(eq(monitorsToPages.pageId, _page.id)); 191 + .from(pageComponent) 192 + .where(eq(pageComponent.pageId, _page.id)) 193 + .all(); 191 194 192 - const removedMonitors = currentMonitorsToPages 193 - .map(({ monitorId }) => monitorId) 194 - .filter((x) => !monitorIds?.includes(x)); 195 + const currentMonitorIds = currentPageComponents 196 + .filter((pc) => pc.type === "monitor" && pc.monitorId !== null) 197 + .map((pc) => pc.monitorId as number); 198 + 199 + const removedMonitorIds = currentMonitorIds.filter( 200 + (id) => !monitorIds?.includes(id), 201 + ); 195 202 196 - if (removedMonitors.length) { 203 + // Delete removed monitors from pageComponent (primary table) 204 + if (removedMonitorIds.length) { 197 205 await db 198 - .delete(monitorsToPages) 206 + .delete(pageComponent) 199 207 .where( 200 208 and( 201 - inArray(monitorsToPages.monitorId, removedMonitors), 202 - eq(monitorsToPages.pageId, newPage.id), 209 + inArray(pageComponent.monitorId, removedMonitorIds), 210 + eq(pageComponent.pageId, newPage.id), 203 211 ), 204 212 ); 205 - // Sync delete to page components 206 - for (const monitorId of removedMonitors) { 207 - await syncMonitorsToPageDelete(db, { monitorId, pageId: newPage.id }); 213 + // Reverse sync delete to monitorsToPages (for backwards compatibility) 214 + for (const monitorId of removedMonitorIds) { 215 + await syncPageComponentToMonitorsToPageDelete(db, { 216 + monitorId, 217 + pageId: newPage.id, 218 + }); 208 219 } 209 220 } 210 221 222 + // Insert or update pageComponents (primary table) 211 223 if (monitors) { 212 - for (const monitor of monitors) { 213 - const values = 214 - typeof monitor === "number" ? { monitorId: monitor } : monitor; 224 + for (const [index, m] of monitors.entries()) { 225 + const values = typeof m === "number" ? { monitorId: m } : m; 226 + 227 + const _monitor = await db.query.monitor.findFirst({ 228 + where: and( 229 + eq(monitor.id, values.monitorId), 230 + eq(monitor.workspaceId, workspaceId), 231 + isNull(monitor.deletedAt), 232 + ), 233 + }); 215 234 235 + if (!_monitor) { 236 + throw new OpenStatusApiError({ 237 + code: "BAD_REQUEST", 238 + message: `Monitor ${values.monitorId} not found`, 239 + }); 240 + } 241 + 242 + // Insert or update pageComponent 216 243 await db 217 - .insert(monitorsToPages) 218 - .values({ pageId: newPage.id, ...values }) 244 + .insert(pageComponent) 245 + .values({ 246 + workspaceId: newPage.workspaceId, 247 + pageId: newPage.id, 248 + type: "monitor", 249 + monitorId: values.monitorId, 250 + name: _monitor.externalName || _monitor.name, 251 + order: "order" in values ? values.order : index, 252 + groupId: null, 253 + groupOrder: 0, 254 + }) 219 255 .onConflictDoUpdate({ 220 - target: [monitorsToPages.monitorId, monitorsToPages.pageId], 221 - set: { order: sql.raw("excluded.`order`") }, 222 - }); 223 - // Sync to page components (existing ones will be ignored due to onConflictDoNothing in sync) 224 - await syncMonitorsToPageInsert(db, { 225 - monitorId: values.monitorId, 226 - pageId: newPage.id, 227 - order: "order" in values ? values.order : undefined, 228 - }); 256 + target: [pageComponent.monitorId, pageComponent.pageId], 257 + set: { 258 + order: sql.raw("excluded.`order`"), 259 + name: sql.raw("excluded.`name`"), 260 + }, 261 + }) 262 + .run(); 229 263 } 264 + 265 + // Reverse sync to monitorsToPages (for backwards compatibility) 266 + const monitorsToPageValues = monitors.map((m, index) => { 267 + const values = typeof m === "number" ? { monitorId: m } : m; 268 + return { 269 + pageId: newPage.id, 270 + monitorId: values.monitorId, 271 + order: "order" in values ? values.order : index, 272 + monitorGroupId: null, 273 + groupOrder: 0, 274 + }; 275 + }); 276 + 277 + await syncPageComponentToMonitorsToPageInsertMany( 278 + db, 279 + monitorsToPageValues, 280 + ); 230 281 } 231 282 232 283 const data = transformPageData( 233 284 PageSchema.parse({ 234 285 ...newPage, 235 - monitors: monitors || currentMonitorsToPages, 286 + monitors: 287 + monitors || 288 + currentPageComponents.map((pc) => pc.monitorId).filter(notEmpty), 236 289 }), 237 290 ); 238 291
-7
apps/server/src/routes/v1/statusReportUpdates/post.ts
··· 106 106 107 107 const _page = await db.query.page.findFirst({ 108 108 where: eq(page.id, _statusReport.pageId), 109 - with: { 110 - monitorsToPages: { 111 - with: { 112 - monitor: true, 113 - }, 114 - }, 115 - }, 116 109 }); 117 110 118 111 const validSubscribers = subscribers.filter(
+271
apps/server/src/routes/v1/statusReports/post.test.ts
··· 1 1 import { expect, test } from "bun:test"; 2 2 3 3 import { app } from "@/index"; 4 + import { db, eq } from "@openstatus/db"; 5 + import { 6 + monitorsToStatusReport, 7 + pageComponent, 8 + statusReport, 9 + statusReportsToPageComponents, 10 + } from "@openstatus/db/src/schema"; 4 11 import { StatusReportSchema } from "./schema"; 5 12 6 13 test("create a valid status report", async () => { ··· 158 165 159 166 expect(res.status).toBe(401); 160 167 }); 168 + 169 + test("create a status report syncs correctly to statusReportsToPageComponents and legacy monitorsToStatusReport", async () => { 170 + const date = new Date(); 171 + date.setMilliseconds(0); 172 + 173 + const res = await app.request("/v1/status_report", { 174 + method: "POST", 175 + headers: { 176 + "x-openstatus-key": "1", 177 + "content-type": "application/json", 178 + }, 179 + body: JSON.stringify({ 180 + status: "investigating", 181 + title: "Sync Test Status Report", 182 + message: "Testing sync to both tables", 183 + monitorIds: [1], 184 + date: date.toISOString(), 185 + pageId: 1, 186 + }), 187 + }); 188 + 189 + expect(res.status).toBe(200); 190 + const result = StatusReportSchema.safeParse(await res.json()); 191 + expect(result.success).toBe(true); 192 + 193 + if (result.success) { 194 + const statusReportId = result.data.id; 195 + 196 + // Verify statusReportsToPageComponents (primary table) 197 + const components = await db 198 + .select() 199 + .from(statusReportsToPageComponents) 200 + .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) 201 + .all(); 202 + 203 + expect(components.length).toBeGreaterThan(0); 204 + 205 + // Get the page component to verify it's linked correctly 206 + const pageComponents = await db 207 + .select() 208 + .from(pageComponent) 209 + .where(eq(pageComponent.id, components[0].pageComponentId)) 210 + .all(); 211 + 212 + expect(pageComponents.length).toBe(1); 213 + expect(pageComponents[0].monitorId).toBe(1); 214 + expect(pageComponents[0].pageId).toBe(1); 215 + expect(pageComponents[0].type).toBe("monitor"); 216 + 217 + // Verify monitorsToStatusReport (legacy table) 218 + const legacyEntries = await db 219 + .select() 220 + .from(monitorsToStatusReport) 221 + .where(eq(monitorsToStatusReport.statusReportId, statusReportId)) 222 + .all(); 223 + 224 + expect(legacyEntries.length).toBe(1); 225 + expect(legacyEntries[0].monitorId).toBe(1); 226 + 227 + // Cleanup 228 + await db 229 + .delete(statusReport) 230 + .where(eq(statusReport.id, statusReportId)) 231 + .run(); 232 + } 233 + }); 234 + 235 + test("create a status report with multiple monitors syncs correctly to both tables", async () => { 236 + const date = new Date(); 237 + date.setMilliseconds(0); 238 + 239 + // First, check which monitors from [1, 2] exist as page components on page 1 240 + const existingPageComponents = await db 241 + .select() 242 + .from(pageComponent) 243 + .where(eq(pageComponent.pageId, 1)) 244 + .all(); 245 + 246 + const existingMonitorIds = existingPageComponents 247 + .filter( 248 + (c) => 249 + c.monitorId !== null && 250 + c.type === "monitor" && 251 + [1, 2].includes(c.monitorId), 252 + ) 253 + .map((c) => c.monitorId as number); 254 + 255 + const res = await app.request("/v1/status_report", { 256 + method: "POST", 257 + headers: { 258 + "x-openstatus-key": "1", 259 + "content-type": "application/json", 260 + }, 261 + body: JSON.stringify({ 262 + status: "investigating", 263 + title: "Multi-Monitor Sync Test", 264 + message: "Testing sync with multiple monitors", 265 + monitorIds: [1, 2], 266 + date: date.toISOString(), 267 + pageId: 1, 268 + }), 269 + }); 270 + 271 + expect(res.status).toBe(200); 272 + const result = StatusReportSchema.safeParse(await res.json()); 273 + expect(result.success).toBe(true); 274 + 275 + if (result.success) { 276 + const statusReportId = result.data.id; 277 + 278 + // Verify statusReportsToPageComponents (primary table) 279 + const components = await db 280 + .select() 281 + .from(statusReportsToPageComponents) 282 + .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) 283 + .all(); 284 + 285 + // Should only link monitors that exist as page components on this page 286 + expect(components.length).toBe(existingMonitorIds.length); 287 + 288 + // Verify monitorsToStatusReport (legacy table) 289 + const legacyEntries = await db 290 + .select() 291 + .from(monitorsToStatusReport) 292 + .where(eq(monitorsToStatusReport.statusReportId, statusReportId)) 293 + .all(); 294 + 295 + // Both tables should have the same number of entries 296 + expect(legacyEntries.length).toBe(components.length); 297 + 298 + // Verify the monitor IDs in legacy table match what we expect 299 + const legacyMonitorIds = legacyEntries.map((e) => e.monitorId).sort(); 300 + expect(legacyMonitorIds).toEqual(existingMonitorIds.sort()); 301 + 302 + // Cleanup 303 + await db 304 + .delete(statusReport) 305 + .where(eq(statusReport.id, statusReportId)) 306 + .run(); 307 + } 308 + }); 309 + 310 + test("create a status report without monitorIds should not create sync entries", async () => { 311 + const date = new Date(); 312 + date.setMilliseconds(0); 313 + 314 + const res = await app.request("/v1/status_report", { 315 + method: "POST", 316 + headers: { 317 + "x-openstatus-key": "1", 318 + "content-type": "application/json", 319 + }, 320 + body: JSON.stringify({ 321 + status: "investigating", 322 + title: "No Monitors Status Report", 323 + message: "No specific monitors affected", 324 + date: date.toISOString(), 325 + pageId: 1, 326 + }), 327 + }); 328 + 329 + expect(res.status).toBe(200); 330 + const result = StatusReportSchema.safeParse(await res.json()); 331 + expect(result.success).toBe(true); 332 + 333 + if (result.success) { 334 + const statusReportId = result.data.id; 335 + 336 + // Verify no statusReportsToPageComponents entries 337 + const components = await db 338 + .select() 339 + .from(statusReportsToPageComponents) 340 + .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) 341 + .all(); 342 + 343 + expect(components.length).toBe(0); 344 + 345 + // Verify no monitorsToStatusReport entries 346 + const legacyEntries = await db 347 + .select() 348 + .from(monitorsToStatusReport) 349 + .where(eq(monitorsToStatusReport.statusReportId, statusReportId)) 350 + .all(); 351 + 352 + expect(legacyEntries.length).toBe(0); 353 + 354 + // Cleanup 355 + await db 356 + .delete(statusReport) 357 + .where(eq(statusReport.id, statusReportId)) 358 + .run(); 359 + } 360 + }); 361 + 362 + test("create a status report only links monitors that exist as page components", async () => { 363 + const date = new Date(); 364 + date.setMilliseconds(0); 365 + 366 + // First, check which monitors exist as page components on page 1 367 + const existingComponents = await db 368 + .select() 369 + .from(pageComponent) 370 + .where(eq(pageComponent.pageId, 1)) 371 + .all(); 372 + 373 + const existingMonitorIds = existingComponents 374 + .filter((c) => c.monitorId !== null && c.type === "monitor") 375 + .map((c) => c.monitorId as number); 376 + 377 + if (existingMonitorIds.length === 0) { 378 + // Skip test if no monitors exist on the page 379 + return; 380 + } 381 + 382 + const res = await app.request("/v1/status_report", { 383 + method: "POST", 384 + headers: { 385 + "x-openstatus-key": "1", 386 + "content-type": "application/json", 387 + }, 388 + body: JSON.stringify({ 389 + status: "investigating", 390 + title: "Page Component Link Test", 391 + message: "Testing page component linking", 392 + monitorIds: existingMonitorIds, 393 + date: date.toISOString(), 394 + pageId: 1, 395 + }), 396 + }); 397 + 398 + expect(res.status).toBe(200); 399 + const result = StatusReportSchema.safeParse(await res.json()); 400 + expect(result.success).toBe(true); 401 + 402 + if (result.success) { 403 + const statusReportId = result.data.id; 404 + 405 + // Verify statusReportsToPageComponents entries match existing components 406 + const components = await db 407 + .select() 408 + .from(statusReportsToPageComponents) 409 + .where(eq(statusReportsToPageComponents.statusReportId, statusReportId)) 410 + .all(); 411 + 412 + // Each linked component should correspond to a page component 413 + for (const component of components) { 414 + const pageComp = await db 415 + .select() 416 + .from(pageComponent) 417 + .where(eq(pageComponent.id, component.pageComponentId)) 418 + .get(); 419 + 420 + expect(pageComp).toBeDefined(); 421 + expect(pageComp?.pageId).toBe(1); 422 + expect(existingMonitorIds).toContain(pageComp?.monitorId as number); 423 + } 424 + 425 + // Cleanup 426 + await db 427 + .delete(statusReport) 428 + .where(eq(statusReport.id, statusReportId)) 429 + .run(); 430 + } 431 + });
+41 -28
apps/server/src/routes/v1/statusReports/post.ts
··· 7 7 inArray, 8 8 isNotNull, 9 9 isNull, 10 - syncStatusReportToMonitorInsertMany, 10 + syncStatusReportToPageComponentInsertMany, 11 11 } from "@openstatus/db"; 12 12 import { 13 13 monitor, 14 - monitorsToStatusReport, 15 14 page, 15 + pageComponent, 16 16 pageSubscriber, 17 17 statusReport, 18 18 statusReportUpdate, 19 + statusReportsToPageComponents, 19 20 } from "@openstatus/db/src/schema"; 20 21 21 22 import { env } from "@/env"; ··· 129 130 .returning() 130 131 .get(); 131 132 133 + if (!_newStatusReport.pageId) { 134 + throw new OpenStatusApiError({ 135 + code: "BAD_REQUEST", 136 + message: "Page ID is required", 137 + }); 138 + } 139 + 132 140 if (input.monitorIds?.length) { 133 - await db 134 - .insert(monitorsToStatusReport) 135 - .values( 136 - input.monitorIds.map((id) => { 137 - return { 138 - monitorId: id, 139 - statusReportId: _newStatusReport.id, 140 - }; 141 - }), 141 + // Find matching page_components for the monitors on this page 142 + const components = await db 143 + .select({ id: pageComponent.id }) 144 + .from(pageComponent) 145 + .where( 146 + and( 147 + inArray(pageComponent.monitorId, input.monitorIds), 148 + eq(pageComponent.pageId, _newStatusReport.pageId), 149 + eq(pageComponent.type, "monitor"), 150 + ), 142 151 ) 143 - .returning(); 144 - // Sync to page components 145 - await syncStatusReportToMonitorInsertMany( 146 - db, 147 - _newStatusReport.id, 148 - input.monitorIds, 149 - ); 152 + .all(); 153 + 154 + // Insert to statusReportsToPageComponents (primary table) 155 + if (components.length > 0) { 156 + await db 157 + .insert(statusReportsToPageComponents) 158 + .values( 159 + components.map((c) => ({ 160 + statusReportId: _newStatusReport.id, 161 + pageComponentId: c.id, 162 + })), 163 + ) 164 + .run(); 165 + 166 + // Sync to legacy table for backwards compatibility 167 + await syncStatusReportToPageComponentInsertMany( 168 + db, 169 + _newStatusReport.id, 170 + components.map((c) => c.id), 171 + ); 172 + } 150 173 } 151 174 152 175 if (limits["status-subscribers"] && _newStatusReport.pageId) { ··· 164 187 165 188 const pageInfo = await db.query.page.findFirst({ 166 189 where: eq(page.id, _newStatusReport.pageId), 167 - with: { 168 - monitorsToPages: { 169 - with: { 170 - monitor: true, 171 - }, 172 - }, 173 - }, 174 190 }); 175 191 176 192 const _statusReport = await db.query.statusReport.findFirst({ 177 193 where: eq(statusReport.id, _newStatusReport.id), 178 194 with: { 179 - monitorsToStatusReports: { 180 - with: { monitor: true }, 181 - }, 182 195 statusReportsToPageComponents: { 183 196 with: { pageComponent: true }, 184 197 },
+4
apps/server/src/routes/v1/statusReports/subscriber-filtering.integration.test.ts
··· 196 196 where: eq(pageSubscriber.email, "unsubscribed-sub@test.com"), 197 197 }); 198 198 199 + if (!unsubscribedSub) { 200 + throw new Error("Unsubscribed subscriber not found"); 201 + } 202 + 199 203 expect(unsubscribedSub?.unsubscribedAt).not.toBeNull(); 200 204 201 205 // Simulate re-subscription by clearing unsubscribedAt
+9 -4
apps/server/src/routes/v1/statusReports/update/post.ts
··· 1 1 import { env } from "@/env"; 2 2 import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 3 + import { notEmpty } from "@/utils/not-empty"; 3 4 import { createRoute } from "@hono/zod-openapi"; 4 5 import { and, db, eq, isNotNull } from "@openstatus/db"; 5 6 import { ··· 142 143 where: eq(statusReport.id, Number(id)), 143 144 with: { 144 145 statusReportUpdates: true, 145 - monitorsToStatusReports: true, 146 + statusReportsToPageComponents: { 147 + with: { 148 + pageComponent: true, 149 + }, 150 + }, 146 151 }, 147 152 }); 148 153 ··· 158 163 statusReportUpdateIds: fullStatusReport.statusReportUpdates.map( 159 164 (u) => u.id, 160 165 ), 161 - monitorIds: fullStatusReport.monitorsToStatusReports.map( 162 - (m) => m.monitorId, 163 - ), 166 + monitorIds: fullStatusReport.statusReportsToPageComponents 167 + .map((m) => m.pageComponent.monitorId) 168 + .filter(notEmpty), 164 169 }); 165 170 166 171 return c.json(data, 200);
+21
apps/server/src/utils/page-component.ts
··· 1 + import type { PageComponentType } from "@openstatus/db/src/schema"; 2 + 3 + /** 4 + * Type guard to check if a pageComponent is a monitor type with a valid monitor relation 5 + * Filters out static components and ensures the monitor is active and not deleted 6 + */ 7 + export function isMonitorComponent(component: { 8 + type: PageComponentType; 9 + monitor?: { active: boolean | null; deletedAt: Date | null } | null; 10 + }): component is { 11 + type: "monitor"; 12 + monitor: { active: true; deletedAt: null }; 13 + } { 14 + return ( 15 + component.type === "monitor" && 16 + component.monitor !== null && 17 + component.monitor !== undefined && 18 + component.monitor.active === true && 19 + component.monitor.deletedAt === null 20 + ); 21 + }
+1 -1
apps/status-page/src/app/(status-page)/[domain]/(public)/feed/[type]/route.ts
··· 25 25 if (!["rss", "atom"].includes(type)) return notFound(); 26 26 27 27 const _page = await queryClient.fetchQuery( 28 - trpc.page.getPageBySlug.queryOptions({ slug: domain }), 28 + trpc.statusPage.getLight.queryOptions({ slug: domain }), 29 29 ); 30 30 if (!_page) return notFound(); 31 31
+1 -1
apps/status-page/src/app/(status-page)/[domain]/(public)/feed/json/route.ts
··· 13 13 const { domain } = await props.params; 14 14 15 15 const _page = await queryClient.fetchQuery( 16 - trpc.page.getPageBySlug.queryOptions({ slug: domain }), 16 + trpc.statusPage.getLight.queryOptions({ slug: domain }), 17 17 ); 18 18 19 19 if (!_page) return notFound();
+1 -1
apps/web/src/app/api/og/page/route.tsx
··· 21 21 22 22 const slug = searchParams.has("slug") ? searchParams.get("slug") : undefined; 23 23 24 - const page = await api.page.getPageBySlug.query({ slug: slug || "" }); 24 + const page = await api.statusPage.getLight.query({ slug: slug || "" }); 25 25 const _protected = page?.accessType !== "public"; 26 26 const title = page ? page.title : TITLE; 27 27 const description = page ? "" : DESCRIPTION;
+9 -6
apps/web/src/content/pages/product/status-page.mdx
··· 31 31 32 32 We provide you with a good **mix of customization and opinionated options**. 33 33 34 - ### Monitors (or Components) 34 + ### Page Components 35 35 36 36 You can attach specific monitors to a status page. You can either populate the data from the **aggregated uptime** data **or manually** manage them. Read more [about uptime monitoring](/uptime-monitoring). 37 37 38 - You can **group monitors** by their services, locations,... and they will be collabsible. 38 + Page components provide a **flexible structure** that supports both: 39 + - **Monitors**: Automatically synced with your uptime monitoring data 40 + - **External Services**: Manually managed components for third-party services or systems you don't directly monitor 41 + 42 + You can **group page components** by their services, locations, or any logical grouping, and they will be collapsible for better organization. 39 43 40 44 ### Customization 41 45 ··· 59 63 60 64 - **Emails** 61 65 - **RSS/Atom feeds** 66 + - **JSON** 62 67 63 68 [Contact us](mailto:ping@openstatus.dev) if you are looking for specific a channel. 64 69 65 - ### Visibility 70 + ### Audience 66 71 67 - You can **password protect** your status page. By default, a status page is for the broad public, but if you want to share it internally or with clients, a password helps you from unwanted visitors. 68 - 69 - The RSS feed will still work. Append your `?pw=` to the URL **search params** to share private pages. 72 + By default, your status page is public. For internal or client-specific pages, you can protect access using **password protection** or **magic link** authentication to control who can view your updates.
+1
apps/web/src/content/pages/unrelated/pricing.mdx
··· 28 28 ["Number of on-demand checks", "30/mo.", "100/mo.", "300/mo."], 29 29 [<a href="/status-page"><strong>Status Pages</strong></a>, "", "", ""], 30 30 ["Number of status pages", "1", "1 +$20/mo./each", "5 +$20/mo./each"], 31 + ["Number of components", "3", "20", "50"], 31 32 ["Maintenance status", "+", "+", "+"], 32 33 ["Toggle numbers visibility", "+", "+", "+"], 33 34 ["Subscribers", "", "+", "+"],
+8
packages/analytics/src/events.ts
··· 60 60 name: "page_deleted", 61 61 channel: "page", 62 62 }, 63 + DeletePageComponent: { 64 + name: "page_component_deleted", 65 + channel: "page", 66 + }, 67 + UpdatePageComponentOrder: { 68 + name: "page_component_order_updated", 69 + channel: "page", 70 + }, 63 71 SubscribePage: { 64 72 name: "user_subscribed", 65 73 channel: "page",
-5
packages/api/src/router/email/index.ts
··· 42 42 pageSubscribers: { 43 43 where: isNotNull(pageSubscriber.acceptedAt), 44 44 }, 45 - monitorsToPages: { 46 - with: { 47 - monitor: true, 48 - }, 49 - }, 50 45 }, 51 46 }, 52 47 },
-2
packages/api/src/router/maintenance.ts
··· 72 72 ? asc(maintenance.createdAt) 73 73 : desc(maintenance.createdAt), 74 74 with: { 75 - maintenancesToMonitors: true, 76 75 maintenancesToPageComponents: { with: { pageComponent: true } }, 77 76 }, 78 77 }); ··· 87 86 .parse( 88 87 result.map((m) => ({ 89 88 ...m, 90 - monitors: m.maintenancesToMonitors.map((m) => m.monitorId), 91 89 pageComponents: m.maintenancesToPageComponents.map( 92 90 ({ pageComponent }) => pageComponent, 93 91 ),
-97
packages/api/src/router/monitor.ts
··· 22 22 inArray, 23 23 isNull, 24 24 syncMaintenanceToMonitorDeleteByMonitors, 25 - syncMonitorsToPageDelete, 26 25 syncMonitorsToPageDeleteByMonitors, 27 - syncMonitorsToPageInsertMany, 28 26 syncStatusReportToMonitorDeleteByMonitors, 29 27 } from "@openstatus/db"; 30 28 import { ··· 39 37 monitorsToStatusReport, 40 38 notification, 41 39 notificationsToMonitors, 42 - page, 43 40 privateLocationToMonitors, 44 41 selectIncidentSchema, 45 - selectMaintenanceSchema, 46 42 selectMonitorSchema, 47 43 selectMonitorTagSchema, 48 44 selectNotificationSchema, 49 - selectPageSchema, 50 45 selectPrivateLocationSchema, 51 46 } from "@openstatus/db/src/schema"; 52 47 ··· 240 235 monitorsToNotifications: { 241 236 with: { notification: true }, 242 237 }, 243 - monitorsToPages: { 244 - with: { page: true }, 245 - }, 246 238 monitorTagsToMonitors: { 247 239 with: { monitorTag: true }, 248 240 }, 249 - maintenancesToMonitors: { 250 - with: { maintenance: true }, 251 - }, 252 241 incidents: true, 253 242 privateLocationToMonitors: { 254 243 with: { privateLocation: true }, ··· 261 250 return selectMonitorSchema 262 251 .extend({ 263 252 notifications: z.array(selectNotificationSchema).prefault([]), 264 - pages: z.array(selectPageSchema).prefault([]), 265 253 tags: z.array(selectMonitorTagSchema).prefault([]), 266 - maintenances: z.array(selectMaintenanceSchema).prefault([]), 267 254 incidents: z.array(selectIncidentSchema).prefault([]), 268 255 privateLocations: z.array(selectPrivateLocationSchema).prefault([]), 269 256 }) ··· 272 259 notifications: data.monitorsToNotifications.map( 273 260 (m) => m.notification, 274 261 ), 275 - pages: data.monitorsToPages.map((p) => p.page), 276 262 tags: data.monitorTagsToMonitors.map((t) => t.monitorTag), 277 - maintenances: data.maintenancesToMonitors.map((m) => m.maintenance), 278 263 incidents: data.incidents, 279 264 privateLocations: data.privateLocationToMonitors.map( 280 265 (p) => p.privateLocation, ··· 552 537 })), 553 538 ); 554 539 } 555 - }); 556 - }), 557 - 558 - updateStatusPages: protectedProcedure 559 - .meta({ track: Events.UpdateMonitor }) 560 - .input( 561 - z.object({ 562 - id: z.number(), 563 - statusPages: z.array(z.number()), 564 - description: z.string().optional(), 565 - externalName: z.string().optional(), 566 - }), 567 - ) 568 - .mutation(async ({ ctx, input }) => { 569 - const allPages = await ctx.db.query.page.findMany({ 570 - where: and( 571 - eq(page.workspaceId, ctx.workspace.id), 572 - inArray(page.id, input.statusPages), 573 - ), 574 - }); 575 - 576 - if (allPages.length !== input.statusPages.length) { 577 - throw new TRPCError({ 578 - code: "FORBIDDEN", 579 - message: "You don't have access to this status page.", 580 - }); 581 - } 582 - 583 - await ctx.db.transaction(async (tx) => { 584 - // REMINDER: why do we need to do this complex logic instead of just deleting and inserting? 585 - // Because we need to preserve the group information when updating the status pages. 586 - 587 - const existingEntries = await tx.query.monitorsToPages.findMany({ 588 - where: eq(monitorsToPages.monitorId, input.id), 589 - }); 590 - 591 - const existingPageIds = new Set( 592 - existingEntries.map((entry) => entry.pageId), 593 - ); 594 - const inputPageIds = new Set(input.statusPages); 595 - 596 - const pageIdsToDelete = existingEntries 597 - .filter((entry) => !inputPageIds.has(entry.pageId)) 598 - .map((entry) => entry.pageId); 599 - 600 - const pageIdsToInsert = input.statusPages.filter( 601 - (pageId) => !existingPageIds.has(pageId), 602 - ); 603 - 604 - if (pageIdsToDelete.length > 0) { 605 - await tx 606 - .delete(monitorsToPages) 607 - .where( 608 - and( 609 - eq(monitorsToPages.monitorId, input.id), 610 - inArray(monitorsToPages.pageId, pageIdsToDelete), 611 - ), 612 - ); 613 - // Sync delete to page components 614 - for (const pageId of pageIdsToDelete) { 615 - await syncMonitorsToPageDelete(tx, { monitorId: input.id, pageId }); 616 - } 617 - } 618 - 619 - if (pageIdsToInsert.length > 0) { 620 - const values = pageIdsToInsert.map((pageId) => ({ 621 - monitorId: input.id, 622 - pageId, 623 - })); 624 - await tx.insert(monitorsToPages).values(values); 625 - // Sync to page components 626 - await syncMonitorsToPageInsertMany(tx, values); 627 - } 628 - 629 - await tx 630 - .update(monitor) 631 - .set({ 632 - description: input.description, 633 - externalName: input.externalName, 634 - updatedAt: new Date(), 635 - }) 636 - .where(and(eq(monitor.id, input.id))); 637 540 }); 638 541 }), 639 542
-47
packages/api/src/router/page.test.ts
··· 1 - import { expect, test } from "bun:test"; 2 - 3 - import { edgeRouter } from "../edge"; 4 - import { createInnerTRPCContext } from "../trpc"; 5 - 6 - test("Get Test Page", async () => { 7 - const ctx = createInnerTRPCContext({ 8 - req: undefined, 9 - // @ts-expect-error 10 - auth: { 11 - userId: "1", 12 - sessionId: "1", 13 - }, 14 - }); 15 - 16 - const caller = edgeRouter.createCaller(ctx); 17 - const result = await caller.page.getPageBySlug({ slug: "status" }); 18 - expect(result).toMatchObject({ 19 - createdAt: expect.any(Date), 20 - customDomain: expect.any(String), 21 - description: expect.any(String), 22 - icon: expect.any(String), 23 - statusReports: expect.any(Array), 24 - monitors: expect.any(Array), 25 - incidents: expect.any(Array), 26 - maintenances: expect.any(Array), 27 - published: expect.any(Boolean), 28 - slug: expect.any(String), 29 - title: expect.any(String), 30 - updatedAt: expect.any(Date), 31 - }); 32 - }); 33 - 34 - test("No Page", async () => { 35 - const ctx = createInnerTRPCContext({ 36 - req: undefined, 37 - // @ts-expect-error 38 - auth: { 39 - userId: "1", 40 - sessionId: "1", 41 - }, 42 - }); 43 - 44 - const caller = edgeRouter.createCaller(ctx); 45 - const result = await caller.page.getPageBySlug({ slug: "Dont Exist" }); 46 - expect(result).toBeUndefined(); 47 - });
+1 -319
packages/api/src/router/page.ts
··· 9 9 inArray, 10 10 isNull, 11 11 sql, 12 - syncMonitorGroupDeleteMany, 13 - syncMonitorGroupInsert, 14 - syncMonitorsToPageDelete, 15 - syncMonitorsToPageUpsertMany, 16 12 syncPageComponentToMonitorsToPageInsertMany, 17 13 } from "@openstatus/db"; 18 14 import { 19 - incidentTable, 20 15 insertPageSchema, 21 - legacy_selectPublicPageSchemaWithRelation, 22 - maintenance, 23 16 monitor, 24 - monitorGroup, 25 - monitorsToPages, 26 17 page, 27 18 pageAccessTypes, 28 19 pageComponent, 29 20 selectMaintenanceSchema, 30 - selectMonitorGroupSchema, 31 - selectMonitorSchema, 32 21 selectPageComponentGroupSchema, 33 22 selectPageComponentSchema, 34 23 selectPageSchema, 35 - statusReport, 36 24 subdomainSafeList, 37 - workspace, 38 25 } from "@openstatus/db/src/schema"; 39 26 40 27 import { Events } from "@openstatus/analytics"; 41 28 import { env } from "../env"; 42 - import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 29 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 43 30 44 31 if (process.env.NODE_ENV === "test") { 45 32 require("../test/preload"); ··· 200 187 .where(and(...whereConditions)) 201 188 .run(); 202 189 }), 203 - // public if we use trpc hooks to get the page from the url 204 - getPageBySlug: publicProcedure 205 - .input(z.object({ slug: z.string().toLowerCase() })) 206 - .output(legacy_selectPublicPageSchemaWithRelation.nullish()) 207 - .query(async (opts) => { 208 - if (!opts.input.slug) return; 209 - 210 - const result = await opts.ctx.db 211 - .select() 212 - .from(page) 213 - .where( 214 - sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 215 - ) 216 - .get(); 217 - 218 - if (!result) return; 219 - 220 - const [workspaceResult, monitorsToPagesResult] = await Promise.all([ 221 - opts.ctx.db 222 - .select() 223 - .from(workspace) 224 - .where(eq(workspace.id, result.workspaceId)) 225 - .get(), 226 - opts.ctx.db 227 - .select() 228 - .from(monitorsToPages) 229 - .leftJoin(monitor, eq(monitorsToPages.monitorId, monitor.id)) 230 - .where( 231 - // make sur only active monitors are returned! 232 - and( 233 - eq(monitorsToPages.pageId, result.id), 234 - eq(monitor.active, true), 235 - ), 236 - ) 237 - .all(), 238 - ]); 239 - 240 - // FIXME: There is probably a better way to do this 241 - 242 - const monitorsId = monitorsToPagesResult.map( 243 - ({ monitors_to_pages }) => monitors_to_pages.monitorId, 244 - ); 245 - 246 - const statusReports = await opts.ctx.db.query.statusReport.findMany({ 247 - where: eq(statusReport.pageId, result.id), 248 - with: { 249 - statusReportUpdates: { 250 - orderBy: (reports, { desc }) => desc(reports.date), 251 - }, 252 - monitorsToStatusReports: { with: { monitor: true } }, 253 - }, 254 - }); 255 - 256 - const monitorQuery = 257 - monitorsId.length > 0 258 - ? opts.ctx.db 259 - .select() 260 - .from(monitor) 261 - .where( 262 - and( 263 - inArray(monitor.id, monitorsId), 264 - eq(monitor.active, true), 265 - isNull(monitor.deletedAt), 266 - ), // REMINDER: this is hardcoded 267 - ) 268 - .all() 269 - : []; 270 - 271 - const maintenancesQuery = opts.ctx.db.query.maintenance.findMany({ 272 - where: eq(maintenance.pageId, result.id), 273 - with: { maintenancesToMonitors: { with: { monitor: true } } }, 274 - orderBy: (maintenances, { desc }) => desc(maintenances.from), 275 - }); 276 - 277 - const incidentsQuery = 278 - monitorsId.length > 0 279 - ? await opts.ctx.db 280 - .select() 281 - .from(incidentTable) 282 - .where(inArray(incidentTable.monitorId, monitorsId)) 283 - .all() 284 - : []; 285 - // TODO: monitorsToPagesResult has the result already, no need to query again 286 - const [monitors, maintenances, incidents] = await Promise.all([ 287 - monitorQuery, 288 - maintenancesQuery, 289 - incidentsQuery, 290 - ]); 291 - 292 - return legacy_selectPublicPageSchemaWithRelation.parse({ 293 - ...result, 294 - // TODO: improve performance and move into SQLite query 295 - monitors: monitors.sort((a, b) => { 296 - const aIndex = 297 - monitorsToPagesResult.find((m) => m.monitor?.id === a.id) 298 - ?.monitors_to_pages.order || 0; 299 - const bIndex = 300 - monitorsToPagesResult.find((m) => m.monitor?.id === b.id) 301 - ?.monitors_to_pages.order || 0; 302 - return aIndex - bIndex; 303 - }), 304 - incidents, 305 - statusReports, 306 - maintenances: maintenances.map((m) => ({ 307 - ...m, 308 - monitors: m.maintenancesToMonitors.map((m) => m.monitorId), 309 - })), 310 - workspacePlan: workspaceResult?.plan, 311 - }); 312 - }), 313 190 314 191 getSlugUniqueness: protectedProcedure 315 192 .input(z.object({ slug: z.string().toLowerCase() })) ··· 384 261 const data = await opts.ctx.db.query.page.findFirst({ 385 262 where: and(...whereConditions), 386 263 with: { 387 - monitorsToPages: { with: { monitor: true, monitorGroup: true } }, 388 264 maintenances: true, 389 265 pageComponents: true, 390 266 pageComponentGroups: true, ··· 393 269 394 270 return selectPageSchema 395 271 .extend({ 396 - monitors: z 397 - .array( 398 - selectMonitorSchema.extend({ 399 - order: z.number().prefault(0), 400 - groupOrder: z.number().prefault(0), 401 - groupId: z.number().nullable(), 402 - }), 403 - ) 404 - .prefault([]), 405 - monitorGroups: z.array(selectMonitorGroupSchema).prefault([]), 406 272 pageComponentGroups: z 407 273 .array(selectPageComponentGroupSchema) 408 274 .prefault([]), ··· 411 277 }) 412 278 .parse({ 413 279 ...data, 414 - monitors: data?.monitorsToPages.map((m) => ({ 415 - ...m.monitor, 416 - order: m.order, 417 - groupId: m.monitorGroupId, 418 - groupOrder: m.groupOrder, 419 - })), 420 - monitorGroups: Array.from( 421 - new Map( 422 - data?.monitorsToPages 423 - .filter((m) => m.monitorGroup) 424 - .map((m) => [m.monitorGroup?.id, m.monitorGroup]), 425 - ).values(), 426 - ), 427 280 pageComponentGroups: data?.pageComponentGroups ?? [], 428 281 maintenances: data?.maintenances, 429 282 pageComponents: data?.pageComponents, ··· 803 656 }) 804 657 .where(and(...whereConditions)) 805 658 .run(); 806 - }), 807 - 808 - updateMonitors: protectedProcedure 809 - .meta({ track: Events.UpdatePage }) 810 - .input( 811 - z.object({ 812 - id: z.number(), 813 - monitors: z.array(z.object({ id: z.number(), order: z.number() })), 814 - groups: z.array( 815 - z.object({ 816 - // id: z.number(), // we dont need it as we are deleting and adding 817 - order: z.number(), 818 - name: z.string(), 819 - monitors: z.array(z.object({ id: z.number(), order: z.number() })), 820 - }), 821 - ), 822 - }), 823 - ) 824 - .mutation(async (opts) => { 825 - const monitorIds = opts.input.monitors.map((m) => m.id); 826 - const groupMonitorIds = opts.input.groups.flatMap((g) => 827 - g.monitors.map((m) => m.id), 828 - ); 829 - 830 - const allMonitorIds = [...new Set([...monitorIds, ...groupMonitorIds])]; 831 - // check if the monitors are in the workspace 832 - const monitors = await opts.ctx.db.query.monitor.findMany({ 833 - where: and( 834 - inArray(monitor.id, allMonitorIds), 835 - eq(monitor.workspaceId, opts.ctx.workspace.id), 836 - ), 837 - }); 838 - 839 - if (monitors.length !== allMonitorIds.length) { 840 - throw new TRPCError({ 841 - code: "FORBIDDEN", 842 - message: "You don't have access to all the monitors.", 843 - }); 844 - } 845 - 846 - await opts.ctx.db.transaction(async (tx) => { 847 - // Get existing state 848 - const existingMonitorsToPages = await tx 849 - .select() 850 - .from(monitorsToPages) 851 - .where(eq(monitorsToPages.pageId, opts.input.id)) 852 - .all(); 853 - 854 - const existingGroups = await tx.query.monitorGroup.findMany({ 855 - where: eq(monitorGroup.pageId, opts.input.id), 856 - }); 857 - const existingGroupIds = existingGroups.map((g) => g.id); 858 - 859 - // Calculate what monitors are in the new input vs existing 860 - const existingMonitorIds = existingMonitorsToPages.map( 861 - (m) => m.monitorId, 862 - ); 863 - 864 - // Find monitors that are being removed (in DB but not in input) 865 - const removedMonitorIds = existingMonitorIds.filter( 866 - (id) => !allMonitorIds.includes(id), 867 - ); 868 - 869 - // Delete removed monitors from monitorsToPages and page components 870 - if (removedMonitorIds.length > 0) { 871 - await tx 872 - .delete(monitorsToPages) 873 - .where( 874 - and( 875 - eq(monitorsToPages.pageId, opts.input.id), 876 - inArray(monitorsToPages.monitorId, removedMonitorIds), 877 - ), 878 - ); 879 - 880 - // Sync delete to page components 881 - for (const monitorId of removedMonitorIds) { 882 - await syncMonitorsToPageDelete(tx, { 883 - monitorId, 884 - pageId: opts.input.id, 885 - }); 886 - } 887 - } 888 - 889 - // Clear monitorGroupId from all monitorsToPages before deleting groups 890 - // This prevents foreign key constraint errors 891 - if (existingGroupIds.length > 0) { 892 - await tx 893 - .update(monitorsToPages) 894 - .set({ monitorGroupId: null }) 895 - .where( 896 - and( 897 - eq(monitorsToPages.pageId, opts.input.id), 898 - inArray(monitorsToPages.monitorGroupId, existingGroupIds), 899 - ), 900 - ); 901 - } 902 - 903 - // Handle groups: delete old groups and create new ones 904 - if (existingGroupIds.length > 0) { 905 - await tx 906 - .delete(monitorGroup) 907 - .where(eq(monitorGroup.pageId, opts.input.id)); 908 - 909 - // Sync delete page component groups 910 - await syncMonitorGroupDeleteMany(tx, existingGroupIds); 911 - } 912 - 913 - // Create new monitor groups 914 - let monitorGroups: Array<{ id: number; name: string }> = []; 915 - if (opts.input.groups.length > 0) { 916 - monitorGroups = await tx 917 - .insert(monitorGroup) 918 - .values( 919 - opts.input.groups.map((g) => ({ 920 - workspaceId: opts.ctx.workspace.id, 921 - pageId: opts.input.id, 922 - name: g.name, 923 - })), 924 - ) 925 - .returning(); 926 - 927 - // Sync new monitor groups to page component groups 928 - for (const group of monitorGroups) { 929 - await syncMonitorGroupInsert(tx, { 930 - id: group.id, 931 - workspaceId: opts.ctx.workspace.id, 932 - pageId: opts.input.id, 933 - name: group.name, 934 - }); 935 - } 936 - } 937 - 938 - // Prepare values for upsert - both grouped and ungrouped monitors 939 - const groupMonitorValues = opts.input.groups.flatMap((g, i) => 940 - g.monitors.map((m) => ({ 941 - pageId: opts.input.id, 942 - monitorId: m.id, 943 - order: g.order, 944 - monitorGroupId: monitorGroups[i].id, 945 - groupOrder: m.order, 946 - })), 947 - ); 948 - 949 - const monitorValues = opts.input.monitors.map((m) => ({ 950 - pageId: opts.input.id, 951 - monitorId: m.id, 952 - order: m.order, 953 - monitorGroupId: null as number | null, 954 - groupOrder: 0, 955 - })); 956 - 957 - const allValues = [...groupMonitorValues, ...monitorValues]; 958 - 959 - // Upsert all monitors (update existing, insert new) 960 - if (allValues.length > 0) { 961 - await tx 962 - .insert(monitorsToPages) 963 - .values(allValues) 964 - .onConflictDoUpdate({ 965 - target: [monitorsToPages.monitorId, monitorsToPages.pageId], 966 - set: { 967 - order: sql.raw("excluded.`order`"), 968 - monitorGroupId: sql.raw("excluded.`monitor_group_id`"), 969 - groupOrder: sql.raw("excluded.`group_order`"), 970 - }, 971 - }); 972 - 973 - // Sync upsert to page components (updates existing, inserts new) 974 - await syncMonitorsToPageUpsertMany(tx, allValues); 975 - } 976 - }); 977 659 }), 978 660 });
+83 -112
packages/api/src/router/pageComponent.ts
··· 2 2 3 3 import { type SQL, and, asc, desc, eq, inArray, sql } from "@openstatus/db"; 4 4 import { 5 - page, 6 5 pageComponent, 7 6 pageComponentGroup, 7 + selectMaintenanceSchema, 8 + selectMonitorSchema, 9 + selectPageComponentGroupSchema, 10 + selectPageComponentSchema, 11 + selectStatusReportSchema, 8 12 } from "@openstatus/db/src/schema"; 9 13 14 + import { Events } from "@openstatus/analytics"; 10 15 import { TRPCError } from "@trpc/server"; 11 16 import { createTRPCRouter, protectedProcedure } from "../trpc"; 12 17 ··· 29 34 whereConditions.push(eq(pageComponent.pageId, opts.input.pageId)); 30 35 } 31 36 32 - const query = opts.ctx.db.query.pageComponent.findMany({ 37 + const result = await opts.ctx.db.query.pageComponent.findMany({ 33 38 where: and(...whereConditions), 34 39 orderBy: 35 40 opts.input?.order === "desc" ··· 38 43 with: { 39 44 monitor: true, 40 45 group: true, 46 + statusReportsToPageComponents: { 47 + with: { 48 + statusReport: true, 49 + }, 50 + orderBy: (statusReportsToPageComponents, { desc }) => 51 + desc(statusReportsToPageComponents.createdAt), 52 + }, 53 + maintenancesToPageComponents: { 54 + with: { 55 + maintenance: true, 56 + }, 57 + orderBy: (maintenancesToPageComponents, { desc }) => 58 + desc(maintenancesToPageComponents.createdAt), 59 + }, 41 60 }, 42 61 }); 43 62 44 - const result = await query; 45 - 46 - return result; 63 + // Transform and parse the result to flatten the junction tables 64 + return selectPageComponentSchema 65 + .extend({ 66 + monitor: selectMonitorSchema.nullish(), 67 + group: selectPageComponentGroupSchema.nullish(), 68 + statusReports: z.array(selectStatusReportSchema).default([]), 69 + maintenances: z.array(selectMaintenanceSchema).default([]), 70 + }) 71 + .array() 72 + .parse( 73 + result.map((component) => ({ 74 + ...component, 75 + statusReports: 76 + component.statusReportsToPageComponents?.map( 77 + (sr) => sr.statusReport, 78 + ) ?? [], 79 + maintenances: 80 + component.maintenancesToPageComponents?.map( 81 + (m) => m.maintenance, 82 + ) ?? [], 83 + })), 84 + ); 47 85 }), 48 86 49 87 delete: protectedProcedure 88 + .meta({ track: Events.DeletePageComponent, trackProps: ["id"] }) 50 89 .input(z.object({ id: z.number() })) 51 90 .mutation(async (opts) => { 52 91 return await opts.ctx.db ··· 60 99 .returning(); 61 100 }), 62 101 63 - new: protectedProcedure 64 - .input( 65 - z.object({ 66 - pageId: z.number(), 67 - name: z.string().min(1), 68 - description: z.string().optional(), 69 - type: z.enum(["external", "monitor"]).default("monitor"), 70 - monitorId: z.number().optional(), 71 - order: z.number().optional(), 72 - groupId: z.number().optional(), 73 - }), 74 - ) 75 - .mutation(async (opts) => { 76 - const _page = await opts.ctx.db.query.page.findFirst({ 77 - where: and( 78 - eq(page.id, opts.input.pageId), 79 - eq(page.workspaceId, opts.ctx.workspace.id), 80 - ), 81 - }); 82 - 83 - if (!_page) { 84 - throw new TRPCError({ code: "NOT_FOUND", message: "Page not found" }); 85 - } 86 - 87 - const newPageComponent = await opts.ctx.db 88 - .insert(pageComponent) 89 - .values({ 90 - pageId: opts.input.pageId, 91 - workspaceId: opts.ctx.workspace.id, 92 - name: opts.input.name, 93 - description: opts.input.description, 94 - type: opts.input.type, 95 - monitorId: opts.input.monitorId, 96 - order: opts.input.order, 97 - groupId: opts.input.groupId, 98 - }) 99 - .returning() 100 - .get(); 101 - 102 - return newPageComponent; 103 - }), 104 - 105 - update: protectedProcedure 106 - .input( 107 - z.object({ 108 - id: z.number(), 109 - name: z.string().min(1), 110 - description: z.string().optional(), 111 - type: z.enum(["external", "monitor"]).optional(), 112 - monitorId: z.number().optional(), 113 - order: z.number().optional(), 114 - groupId: z.number().optional(), 115 - }), 116 - ) 117 - .mutation(async (opts) => { 118 - const whereConditions: SQL[] = [ 119 - eq(pageComponent.id, opts.input.id), 120 - eq(pageComponent.workspaceId, opts.ctx.workspace.id), 121 - ]; 122 - 123 - const _pageComponent = await opts.ctx.db 124 - .update(pageComponent) 125 - .set({ 126 - name: opts.input.name, 127 - description: opts.input.description, 128 - type: opts.input.type, 129 - monitorId: opts.input.monitorId, 130 - order: opts.input.order, 131 - groupId: opts.input.groupId, 132 - updatedAt: new Date(), 133 - }) 134 - .where(and(...whereConditions)) 135 - .returning() 136 - .get(); 137 - 138 - return _pageComponent; 139 - }), 140 - 141 102 updateOrder: protectedProcedure 103 + .meta({ track: Events.UpdatePageComponentOrder, trackProps: ["pageId"] }) 142 104 .input( 143 105 z.object({ 144 106 pageId: z.number(), ··· 149 111 order: z.number(), 150 112 name: z.string(), 151 113 description: z.string().nullish(), 152 - type: z.enum(["monitor", "external"]), 114 + type: z.enum(["monitor", "static"]), 153 115 }), 154 116 ), 155 117 groups: z.array( ··· 163 125 order: z.number(), 164 126 name: z.string(), 165 127 description: z.string().nullish(), 166 - type: z.enum(["monitor", "external"]), 128 + type: z.enum(["monitor", "static"]), 167 129 }), 168 130 ), 169 131 }), ··· 172 134 ) 173 135 .mutation(async (opts) => { 174 136 await opts.ctx.db.transaction(async (tx) => { 137 + const pageComponentLimit = opts.ctx.workspace.limits["page-components"]; 138 + 175 139 // Get existing state 176 140 const existingComponents = await tx 177 141 .select() ··· 184 148 ) 185 149 .all(); 186 150 151 + if (existingComponents.length >= pageComponentLimit) { 152 + throw new TRPCError({ 153 + code: "FORBIDDEN", 154 + message: "You reached your page component limits.", 155 + }); 156 + } 157 + 187 158 const existingGroups = await tx 188 159 .select() 189 160 .from(pageComponentGroup) ··· 209 180 ), 210 181 ] as number[]; 211 182 212 - // Collect IDs for external components that have IDs in input 213 - const inputExternalComponentIds = [ 183 + // Collect IDs for static components that have IDs in input 184 + const inputStaticComponentIds = [ 214 185 ...opts.input.components 215 - .filter((c) => c.type === "external" && c.id) 186 + .filter((c) => c.type === "static" && c.id) 216 187 .map((c) => c.id), 217 188 ...opts.input.groups.flatMap((g) => 218 189 g.components 219 - .filter((c) => c.type === "external" && c.id) 190 + .filter((c) => c.type === "static" && c.id) 220 191 .map((c) => c.id), 221 192 ), 222 193 ] as number[]; 223 194 224 195 // Find components that are being removed 225 196 // For monitor components: those with monitorIds not in the input 226 - // For external components with IDs: those with IDs not in the input 227 - // For external components without IDs in input: delete all existing external components 197 + // For static components with IDs: those with IDs not in the input 198 + // For static components without IDs in input: delete all existing static components 228 199 const removedMonitorComponents = existingComponents.filter( 229 200 (c) => 230 201 c.type === "monitor" && ··· 232 203 !inputMonitorIds.includes(c.monitorId), 233 204 ); 234 205 235 - const hasExternalComponentsInInput = 236 - opts.input.components.some((c) => c.type === "external") || 206 + const hasStaticComponentsInInput = 207 + opts.input.components.some((c) => c.type === "static") || 237 208 opts.input.groups.some((g) => 238 - g.components.some((c) => c.type === "external"), 209 + g.components.some((c) => c.type === "static"), 239 210 ); 240 211 241 - // If input has external components but they don't have IDs, we need to delete old ones 242 - // If input has external components with IDs, only delete those not in input 243 - const removedExternalComponents = existingComponents.filter((c) => { 244 - if (c.type !== "external") return false; 245 - // If we have external components in input 246 - if (hasExternalComponentsInInput) { 212 + // If input has static components but they don't have IDs, we need to delete old ones 213 + // If input has static components with IDs, only delete those not in input 214 + const removedStaticComponents = existingComponents.filter((c) => { 215 + if (c.type !== "static") return false; 216 + // If we have static components in input 217 + if (hasStaticComponentsInInput) { 247 218 // If the input has IDs, only remove those not in the list 248 - if (inputExternalComponentIds.length > 0) { 249 - return !inputExternalComponentIds.includes(c.id); 219 + if (inputStaticComponentIds.length > 0) { 220 + return !inputStaticComponentIds.includes(c.id); 250 221 } 251 - // If input doesn't have IDs, remove all existing external components 222 + // If input doesn't have IDs, remove all existing static components 252 223 return true; 253 224 } 254 - // If no external components in input at all, remove existing ones 225 + // If no static components in input at all, remove existing ones 255 226 return true; 256 227 }); 257 228 258 229 const removedComponentIds = [ 259 230 ...removedMonitorComponents.map((c) => c.id), 260 - ...removedExternalComponents.map((c) => c.id), 231 + ...removedStaticComponents.map((c) => c.id), 261 232 ]; 262 233 263 234 // Delete removed components ··· 350 321 ...standaloneComponentValues, 351 322 ]; 352 323 353 - // Separate monitor and external components for different upsert strategies 324 + // Separate monitor and static components for different upsert strategies 354 325 const monitorComponents = allComponentValues.filter( 355 326 (c) => c.type === "monitor" && c.monitorId, 356 327 ); 357 - const externalComponents = allComponentValues.filter( 358 - (c) => c.type === "external", 328 + const staticComponents = allComponentValues.filter( 329 + (c) => c.type === "static", 359 330 ); 360 331 361 332 // Upsert monitor components using SQL-level conflict resolution ··· 377 348 }); 378 349 } 379 350 380 - // Handle external components 351 + // Handle static components 381 352 // If they have IDs, update them; otherwise insert new ones 382 - for (const componentValue of externalComponents) { 353 + for (const componentValue of staticComponents) { 383 354 if (componentValue.id) { 384 - // Update existing external component (preserves ID and relationships) 355 + // Update existing static component (preserves ID and relationships) 385 356 await tx 386 357 .update(pageComponent) 387 358 .set({ ··· 402 373 ), 403 374 ); 404 375 } else { 405 - // Insert new external component 376 + // Insert new static component 406 377 await tx.insert(pageComponent).values({ 407 378 pageId: componentValue.pageId, 408 379 workspaceId: componentValue.workspaceId,
+1 -1
packages/api/src/router/statusPage.e2e.test.ts
··· 829 829 expect(tracker.component).toHaveProperty("name"); 830 830 expect(tracker.component).toHaveProperty("status"); 831 831 expect(tracker.component).toHaveProperty("type"); 832 - expect(["monitor", "external"]).toContain(tracker.component.type); 832 + expect(["monitor", "static"]).toContain(tracker.component.type); 833 833 expect(["success", "degraded", "error", "info"]).toContain( 834 834 tracker.component.status, 835 835 );
+83 -8
packages/api/src/router/statusPage.ts
··· 10 10 selectMaintenancePageSchema, 11 11 selectPageComponentWithMonitorRelation, 12 12 selectPublicMonitorSchema, 13 + selectPublicPageLightSchemaWithRelation, 13 14 selectPublicPageSchemaWithRelation, 14 15 selectStatusReportPageSchema, 15 16 selectWorkspaceSchema, ··· 125 126 126 127 const monitorComponents = pageComponents.filter(isMonitorComponent); 127 128 128 - // Transform all page components (both monitor and external types) 129 + // Transform all page components (both monitor and static types) 129 130 const components = pageComponents.map((c) => { 130 131 const events = getEvents({ 131 132 maintenances: _page.maintenances, ··· 139 140 // Calculate status based on component type 140 141 let status: "success" | "degraded" | "error" | "info"; 141 142 142 - if (c.type === "external") { 143 - // External: only reports and maintenances affect status 143 + if (c.type === "static") { 144 + // Static: only reports and maintenances affect status 144 145 status = events.some((e) => e.type === "report" && !e.to) 145 146 ? "degraded" 146 147 : events.some( ··· 348 349 349 350 const whiteLabel = ws.data?.limits["white-label"] ?? false; 350 351 351 - // Transform statusReports to include monitorsToStatusReports format 352 352 const statusReports = _page.statusReports.sort((a, b) => { 353 353 // Sort reports without updates to the beginning 354 354 if ( ··· 388 388 }); 389 389 }), 390 390 391 + getLight: publicProcedure 392 + .input(z.object({ slug: z.string().toLowerCase() })) 393 + .query(async (opts) => { 394 + if (!opts.input.slug) return null; 395 + 396 + // Single query with all relations 397 + const _page = await opts.ctx.db.query.page.findFirst({ 398 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 399 + with: { 400 + workspace: true, 401 + statusReports: { 402 + with: { 403 + statusReportUpdates: { 404 + orderBy: (reports, { desc }) => desc(reports.date), 405 + }, 406 + statusReportsToPageComponents: { with: { pageComponent: true } }, 407 + }, 408 + }, 409 + maintenances: { 410 + with: { 411 + maintenancesToPageComponents: { with: { pageComponent: true } }, 412 + }, 413 + orderBy: (maintenances, { desc }) => desc(maintenances.from), 414 + }, 415 + pageComponents: { 416 + with: { 417 + monitor: { with: { incidents: true } }, 418 + group: true, 419 + }, 420 + orderBy: (pageComponents, { asc }) => asc(pageComponents.order), 421 + }, 422 + pageComponentGroups: true, 423 + }, 424 + }); 425 + 426 + if (!_page) return null; 427 + 428 + // Extract monitor components for backwards compatibility 429 + const monitorComponents = _page.pageComponents.filter( 430 + (c) => 431 + c.type === "monitor" && 432 + c.monitor && 433 + c.monitor.active && 434 + !c.monitor.deletedAt, 435 + ); 436 + 437 + // Build legacy monitors array (sorted by order) 438 + const monitors = monitorComponents 439 + .map((c) => ({ 440 + ...c.monitor, 441 + name: c.monitor?.externalName ?? c.monitor?.name ?? "", 442 + })) 443 + .sort((a, b) => { 444 + const aComp = monitorComponents.find((m) => m.monitor?.id === a.id); 445 + const bComp = monitorComponents.find((m) => m.monitor?.id === b.id); 446 + return (aComp?.order ?? 0) - (bComp?.order ?? 0); 447 + }); 448 + 449 + // Extract all incidents from monitor components 450 + const incidents = monitorComponents.flatMap( 451 + (c) => c.monitor?.incidents ?? [], 452 + ); 453 + 454 + return selectPublicPageLightSchemaWithRelation.parse({ 455 + ..._page, 456 + monitors, 457 + incidents, 458 + statusReports: _page.statusReports, 459 + maintenances: _page.maintenances, 460 + pageComponents: _page.pageComponents, 461 + pageComponentGroups: _page.pageComponentGroups, 462 + workspacePlan: _page.workspace.plan, 463 + }); 464 + }), 465 + 391 466 getMaintenance: publicProcedure 392 467 .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 393 468 .query(async (opts) => { ··· 555 630 lookbackPeriod, 556 631 ); 557 632 } else { 558 - // External components, manual mode, or NOOP mode: use synthetic data 633 + // Static components, manual mode, or NOOP mode: use synthetic data 559 634 filledData = fillStatusDataFor45DaysNoop({ 560 635 errorDays: [], 561 636 degradedDays: [], ··· 563 638 }); 564 639 } 565 640 566 - // External components always use manual mode since they don't have real monitoring data 641 + // Static components always use manual mode since they don't have real monitoring data 567 642 const effectiveBarType = 568 - c.type === "external" ? "manual" : opts.input.barType; 643 + c.type === "static" ? "manual" : opts.input.barType; 569 644 const effectiveCardType = 570 - c.type === "external" ? "manual" : opts.input.cardType; 645 + c.type === "static" ? "manual" : opts.input.cardType; 571 646 572 647 const processedData = setDataByType({ 573 648 events,
+3 -3
packages/api/src/router/statusPage.utils.test.ts
··· 915 915 id, 916 916 workspaceId: 1, 917 917 pageId: 1, 918 - type: monitorId ? ("monitor" as const) : ("external" as const), 918 + type: monitorId ? ("monitor" as const) : ("static" as const), 919 919 monitorId: monitorId ?? null, 920 920 name: `Component ${id}`, 921 921 description: null, ··· 1060 1060 expect(reportEvents.map((e) => e.id).sort()).toEqual([1, 3]); 1061 1061 }); 1062 1062 1063 - it("should exclude incidents for external components", () => { 1063 + it("should exclude incidents for static components", () => { 1064 1064 const incidents = [createMockIncident(1, 10), createMockIncident(2, 20)]; 1065 1065 1066 1066 const events = getEvents({ 1067 1067 maintenances: [], 1068 1068 incidents, 1069 1069 reports: [], 1070 - componentType: "external", 1070 + componentType: "static", 1071 1071 pastDays: 365, 1072 1072 }); 1073 1073
+4 -3
packages/api/src/router/statusPage.utils.ts
··· 2 2 Incident, 3 3 Maintenance, 4 4 PageComponent, 5 + PageComponentType, 5 6 PageComponentWithMonitorRelation, 6 7 StatusReport, 7 8 StatusReportUpdate, ··· 243 244 })[]; 244 245 pageComponentId?: number; 245 246 monitorId?: number; 246 - componentType?: "monitor" | "external"; 247 + componentType?: PageComponentType; 247 248 pastDays?: number; 248 249 }): Event[] { 249 250 const events: Event[] = []; ··· 278 279 }); 279 280 280 281 // Filter incidents - only for monitor-type components 281 - // External components don't have incidents 282 - if (componentType !== "external") { 282 + // Static components don't have incidents 283 + if (componentType !== "static") { 283 284 incidents 284 285 .filter((incident) => 285 286 monitorId ? incident.monitorId === monitorId : true,
+45 -175
packages/api/src/router/sync.test.ts
··· 16 16 statusReportsToPageComponents, 17 17 } from "@openstatus/db/src/schema"; 18 18 import { flyRegions } from "@openstatus/db/src/schema/constants"; 19 + import { syncMonitorsToPageInsert } from "@openstatus/db/src/sync"; 19 20 20 21 import { appRouter } from "../root"; 21 22 import { createInnerTRPCContext } from "../trpc"; ··· 192 193 .where(eq(monitor.name, `${TEST_PREFIX}-deletable-monitor`)); 193 194 }); 194 195 195 - describe("Sync: monitors_to_pages -> page_component", () => { 196 - test("Creating monitor-to-page relation syncs to page_component", async () => { 197 - const ctx = getTestContext(); 198 - const caller = appRouter.createCaller(ctx); 199 - 200 - // Update monitor to add it to the test page 201 - await caller.monitor.updateStatusPages({ 202 - id: testMonitorId, 203 - statusPages: [testPageId], 204 - }); 205 - 206 - // Verify monitors_to_pages was created 207 - const monitorToPage = await db.query.monitorsToPages.findFirst({ 208 - where: and( 209 - eq(monitorsToPages.monitorId, testMonitorId), 210 - eq(monitorsToPages.pageId, testPageId), 211 - ), 212 - }); 213 - expect(monitorToPage).toBeDefined(); 214 - 215 - // Verify page_component was synced 216 - const component = await db.query.pageComponent.findFirst({ 217 - where: and( 218 - eq(pageComponent.monitorId, testMonitorId), 219 - eq(pageComponent.pageId, testPageId), 220 - ), 221 - }); 222 - expect(component).toBeDefined(); 223 - expect(component?.type).toBe("monitor"); 224 - expect(component?.workspaceId).toBe(1); 225 - }); 226 - 227 - test("Removing monitor-to-page relation syncs delete to page_component", async () => { 228 - const ctx = getTestContext(); 229 - const caller = appRouter.createCaller(ctx); 230 - 231 - // First add the monitor to page if not already 232 - await caller.monitor.updateStatusPages({ 233 - id: testMonitorId, 234 - statusPages: [testPageId], 235 - }); 236 - 237 - // Verify page_component exists 238 - let component = await db.query.pageComponent.findFirst({ 239 - where: and( 240 - eq(pageComponent.monitorId, testMonitorId), 241 - eq(pageComponent.pageId, testPageId), 242 - ), 243 - }); 244 - expect(component).toBeDefined(); 245 - 246 - // Remove monitor from page 247 - await caller.monitor.updateStatusPages({ 248 - id: testMonitorId, 249 - statusPages: [], 250 - }); 251 - 252 - // Verify page_component was deleted 253 - component = await db.query.pageComponent.findFirst({ 254 - where: and( 255 - eq(pageComponent.monitorId, testMonitorId), 256 - eq(pageComponent.pageId, testPageId), 257 - ), 258 - }); 259 - expect(component).toBeUndefined(); 260 - }); 261 - }); 262 - 263 - describe("Sync: page.updateMonitors -> page_component and page_component_groups", () => { 264 - test("Adding monitors with groups syncs to page_component and page_component_groups", async () => { 265 - const ctx = getTestContext(); 266 - const caller = appRouter.createCaller(ctx); 267 - 268 - // Update page with monitor in a group 269 - await caller.page.updateMonitors({ 270 - id: testPageId, 271 - monitors: [], 272 - groups: [ 273 - { 274 - name: `${TEST_PREFIX}-group`, 275 - order: 0, 276 - monitors: [{ id: testMonitorId, order: 0 }], 277 - }, 278 - ], 279 - }); 280 - 281 - // Verify monitor_group was created 282 - const group = await db.query.monitorGroup.findFirst({ 283 - where: and( 284 - eq(monitorGroup.pageId, testPageId), 285 - eq(monitorGroup.name, `${TEST_PREFIX}-group`), 286 - ), 287 - }); 288 - expect(group).toBeDefined(); 289 - 290 - if (!group) { 291 - throw new Error("Group not found"); 292 - } 293 - 294 - // Verify page_component_groups was synced 295 - const componentGroup = await db.query.pageComponentGroup.findFirst({ 296 - where: eq(pageComponentGroup.id, group.id), 297 - }); 298 - expect(componentGroup).toBeDefined(); 299 - expect(componentGroup?.name).toBe(`${TEST_PREFIX}-group`); 300 - expect(componentGroup?.pageId).toBe(testPageId); 301 - 302 - // Verify page_component was synced with group reference 303 - const component = await db.query.pageComponent.findFirst({ 304 - where: and( 305 - eq(pageComponent.monitorId, testMonitorId), 306 - eq(pageComponent.pageId, testPageId), 307 - ), 308 - }); 309 - expect(component).toBeDefined(); 310 - expect(component?.groupId).toBe(group.id); 311 - }); 312 - 313 - test("Updating page monitors syncs changes to page_component", async () => { 314 - const ctx = getTestContext(); 315 - const caller = appRouter.createCaller(ctx); 316 - 317 - // First, set up with a group 318 - await caller.page.updateMonitors({ 319 - id: testPageId, 320 - monitors: [], 321 - groups: [ 322 - { 323 - name: `${TEST_PREFIX}-group`, 324 - order: 0, 325 - monitors: [{ id: testMonitorId, order: 0 }], 326 - }, 327 - ], 328 - }); 329 - 330 - // Now update to remove the group and add monitor directly 331 - await caller.page.updateMonitors({ 332 - id: testPageId, 333 - monitors: [{ id: testMonitorId, order: 0 }], 334 - groups: [], 335 - }); 336 - 337 - // Verify monitor_group was deleted 338 - const group = await db.query.monitorGroup.findFirst({ 339 - where: and( 340 - eq(monitorGroup.pageId, testPageId), 341 - eq(monitorGroup.name, `${TEST_PREFIX}-group`), 342 - ), 343 - }); 344 - expect(group).toBeUndefined(); 345 - 346 - // Verify page_component still exists but without group 347 - const component = await db.query.pageComponent.findFirst({ 348 - where: and( 349 - eq(pageComponent.monitorId, testMonitorId), 350 - eq(pageComponent.pageId, testPageId), 351 - ), 352 - }); 353 - expect(component).toBeDefined(); 354 - expect(component?.groupId).toBeNull(); 355 - }); 356 - }); 196 + describe("Sync: monitors_to_pages -> page_component", () => {}); 357 197 358 198 describe("Sync: maintenance_to_monitor -> maintenance_to_page_component", () => { 359 199 let testMaintenanceId: number; 360 200 361 201 beforeAll(async () => { 362 - const ctx = getTestContext(); 363 - const caller = appRouter.createCaller(ctx); 202 + // Ensure monitor is on the page first - use manual db call 203 + await db 204 + .insert(monitorsToPages) 205 + .values({ 206 + monitorId: testMonitorId, 207 + pageId: testPageId, 208 + order: 0, 209 + }) 210 + .onConflictDoNothing(); 364 211 365 - // Ensure monitor is on the page first 366 - await caller.monitor.updateStatusPages({ 367 - id: testMonitorId, 368 - statusPages: [testPageId], 212 + // Sync to page_component 213 + await syncMonitorsToPageInsert(db, { 214 + monitorId: testMonitorId, 215 + pageId: testPageId, 216 + order: 0, 369 217 }); 370 218 }); 371 219 ··· 477 325 478 326 beforeAll(async () => { 479 327 const ctx = getTestContext(); 480 - const caller = appRouter.createCaller(ctx); 328 + const _caller = appRouter.createCaller(ctx); 481 329 482 330 // Ensure monitor is on the page first 483 - await caller.monitor.updateStatusPages({ 484 - id: testMonitorId, 485 - statusPages: [testPageId], 331 + await db 332 + .insert(monitorsToPages) 333 + .values({ 334 + monitorId: testMonitorId, 335 + pageId: testPageId, 336 + order: 0, 337 + }) 338 + .onConflictDoNothing(); 339 + 340 + // Sync to page_component 341 + await syncMonitorsToPageInsert(db, { 342 + monitorId: testMonitorId, 343 + pageId: testPageId, 344 + order: 0, 486 345 }); 487 346 }); 488 347 ··· 608 467 deletableMonitorId = deletableMonitor.id; 609 468 610 469 // Add monitor to page 611 - await caller.monitor.updateStatusPages({ 612 - id: deletableMonitorId, 613 - statusPages: [testPageId], 470 + await db 471 + .insert(monitorsToPages) 472 + .values({ 473 + monitorId: deletableMonitorId, 474 + pageId: testPageId, 475 + order: 0, 476 + }) 477 + .onConflictDoNothing(); 478 + 479 + // Sync to page_component 480 + await syncMonitorsToPageInsert(db, { 481 + monitorId: deletableMonitorId, 482 + pageId: testPageId, 483 + order: 0, 614 484 }); 615 485 }); 616 486
+7 -1
packages/api/src/router/workspace.ts
··· 26 26 const result = await opts.ctx.db.query.workspace.findFirst({ 27 27 where: and(...whereConditions), 28 28 with: { 29 - pages: true, 29 + pages: { 30 + with: { 31 + pageComponents: true, 32 + }, 33 + }, 30 34 monitors: { 31 35 where: isNull(monitor.deletedAt), 32 36 }, ··· 40 44 monitors: result?.monitors?.length || 0, 41 45 notifications: result?.notifications?.length || 0, 42 46 pages: result?.pages?.length || 0, 47 + pageComponents: 48 + result?.pages?.flatMap((page) => page.pageComponents)?.length || 0, 43 49 // checks: result?.checks?.length || 0, 44 50 checks: 0, 45 51 },
+26
packages/db/drizzle/0055_spicy_bastion.sql
··· 1 + PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 + CREATE TABLE `__new_page_component` ( 3 + `id` integer PRIMARY KEY NOT NULL, 4 + `workspace_id` integer NOT NULL, 5 + `page_id` integer NOT NULL, 6 + `type` text DEFAULT 'monitor' NOT NULL, 7 + `monitor_id` integer, 8 + `name` text NOT NULL, 9 + `description` text, 10 + `order` integer DEFAULT 0, 11 + `group_id` integer, 12 + `group_order` integer DEFAULT 0, 13 + `created_at` integer DEFAULT (strftime('%s', 'now')), 14 + `updated_at` integer DEFAULT (strftime('%s', 'now')), 15 + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade, 16 + FOREIGN KEY (`page_id`) REFERENCES `page`(`id`) ON UPDATE no action ON DELETE cascade, 17 + FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, 18 + FOREIGN KEY (`group_id`) REFERENCES `page_component_groups`(`id`) ON UPDATE no action ON DELETE set null, 19 + CONSTRAINT "page_component_type_check" CHECK("__new_page_component"."type" = 'monitor' AND "__new_page_component"."monitor_id" IS NOT NULL OR "__new_page_component"."type" = 'static' AND "__new_page_component"."monitor_id" IS NULL) 20 + ); 21 + --> statement-breakpoint 22 + INSERT INTO `__new_page_component`("id", "workspace_id", "page_id", "type", "monitor_id", "name", "description", "order", "group_id", "group_order", "created_at", "updated_at") SELECT "id", "workspace_id", "page_id", "type", "monitor_id", "name", "description", "order", "group_id", "group_order", "created_at", "updated_at" FROM `page_component`;--> statement-breakpoint 23 + DROP TABLE `page_component`;--> statement-breakpoint 24 + ALTER TABLE `__new_page_component` RENAME TO `page_component`;--> statement-breakpoint 25 + PRAGMA foreign_keys=ON;--> statement-breakpoint 26 + CREATE UNIQUE INDEX `page_component_page_id_monitor_id_unique` ON `page_component` (`page_id`,`monitor_id`);
+3459
packages/db/drizzle/meta/0055_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "4f344b53-2e8a-400d-be39-8018d77ff0a9", 5 + "prevId": "fa90adb7-69d9-419c-baf9-3a73922e9e43", 6 + "tables": { 7 + "workspace": { 8 + "name": "workspace", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "slug": { 18 + "name": "slug", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "name": { 25 + "name": "name", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false 30 + }, 31 + "stripe_id": { 32 + "name": "stripe_id", 33 + "type": "text(256)", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "subscription_id": { 39 + "name": "subscription_id", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "plan": { 46 + "name": "plan", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "ends_at": { 53 + "name": "ends_at", 54 + "type": "integer", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false 58 + }, 59 + "paid_until": { 60 + "name": "paid_until", 61 + "type": "integer", 62 + "primaryKey": false, 63 + "notNull": false, 64 + "autoincrement": false 65 + }, 66 + "limits": { 67 + "name": "limits", 68 + "type": "text", 69 + "primaryKey": false, 70 + "notNull": true, 71 + "autoincrement": false, 72 + "default": "'{}'" 73 + }, 74 + "created_at": { 75 + "name": "created_at", 76 + "type": "integer", 77 + "primaryKey": false, 78 + "notNull": false, 79 + "autoincrement": false, 80 + "default": "(strftime('%s', 'now'))" 81 + }, 82 + "updated_at": { 83 + "name": "updated_at", 84 + "type": "integer", 85 + "primaryKey": false, 86 + "notNull": false, 87 + "autoincrement": false, 88 + "default": "(strftime('%s', 'now'))" 89 + }, 90 + "dsn": { 91 + "name": "dsn", 92 + "type": "text", 93 + "primaryKey": false, 94 + "notNull": false, 95 + "autoincrement": false 96 + } 97 + }, 98 + "indexes": { 99 + "workspace_slug_unique": { 100 + "name": "workspace_slug_unique", 101 + "columns": [ 102 + "slug" 103 + ], 104 + "isUnique": true 105 + }, 106 + "workspace_stripe_id_unique": { 107 + "name": "workspace_stripe_id_unique", 108 + "columns": [ 109 + "stripe_id" 110 + ], 111 + "isUnique": true 112 + }, 113 + "workspace_id_dsn_unique": { 114 + "name": "workspace_id_dsn_unique", 115 + "columns": [ 116 + "id", 117 + "dsn" 118 + ], 119 + "isUnique": true 120 + } 121 + }, 122 + "foreignKeys": {}, 123 + "compositePrimaryKeys": {}, 124 + "uniqueConstraints": {}, 125 + "checkConstraints": {} 126 + }, 127 + "account": { 128 + "name": "account", 129 + "columns": { 130 + "user_id": { 131 + "name": "user_id", 132 + "type": "integer", 133 + "primaryKey": false, 134 + "notNull": true, 135 + "autoincrement": false 136 + }, 137 + "type": { 138 + "name": "type", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false 143 + }, 144 + "provider": { 145 + "name": "provider", 146 + "type": "text", 147 + "primaryKey": false, 148 + "notNull": true, 149 + "autoincrement": false 150 + }, 151 + "provider_account_id": { 152 + "name": "provider_account_id", 153 + "type": "text", 154 + "primaryKey": false, 155 + "notNull": true, 156 + "autoincrement": false 157 + }, 158 + "refresh_token": { 159 + "name": "refresh_token", 160 + "type": "text", 161 + "primaryKey": false, 162 + "notNull": false, 163 + "autoincrement": false 164 + }, 165 + "access_token": { 166 + "name": "access_token", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": false, 170 + "autoincrement": false 171 + }, 172 + "expires_at": { 173 + "name": "expires_at", 174 + "type": "integer", 175 + "primaryKey": false, 176 + "notNull": false, 177 + "autoincrement": false 178 + }, 179 + "token_type": { 180 + "name": "token_type", 181 + "type": "text", 182 + "primaryKey": false, 183 + "notNull": false, 184 + "autoincrement": false 185 + }, 186 + "scope": { 187 + "name": "scope", 188 + "type": "text", 189 + "primaryKey": false, 190 + "notNull": false, 191 + "autoincrement": false 192 + }, 193 + "id_token": { 194 + "name": "id_token", 195 + "type": "text", 196 + "primaryKey": false, 197 + "notNull": false, 198 + "autoincrement": false 199 + }, 200 + "session_state": { 201 + "name": "session_state", 202 + "type": "text", 203 + "primaryKey": false, 204 + "notNull": false, 205 + "autoincrement": false 206 + } 207 + }, 208 + "indexes": {}, 209 + "foreignKeys": { 210 + "account_user_id_user_id_fk": { 211 + "name": "account_user_id_user_id_fk", 212 + "tableFrom": "account", 213 + "tableTo": "user", 214 + "columnsFrom": [ 215 + "user_id" 216 + ], 217 + "columnsTo": [ 218 + "id" 219 + ], 220 + "onDelete": "cascade", 221 + "onUpdate": "no action" 222 + } 223 + }, 224 + "compositePrimaryKeys": { 225 + "account_provider_provider_account_id_pk": { 226 + "columns": [ 227 + "provider", 228 + "provider_account_id" 229 + ], 230 + "name": "account_provider_provider_account_id_pk" 231 + } 232 + }, 233 + "uniqueConstraints": {}, 234 + "checkConstraints": {} 235 + }, 236 + "session": { 237 + "name": "session", 238 + "columns": { 239 + "session_token": { 240 + "name": "session_token", 241 + "type": "text", 242 + "primaryKey": true, 243 + "notNull": true, 244 + "autoincrement": false 245 + }, 246 + "user_id": { 247 + "name": "user_id", 248 + "type": "integer", 249 + "primaryKey": false, 250 + "notNull": true, 251 + "autoincrement": false 252 + }, 253 + "expires": { 254 + "name": "expires", 255 + "type": "integer", 256 + "primaryKey": false, 257 + "notNull": true, 258 + "autoincrement": false 259 + } 260 + }, 261 + "indexes": {}, 262 + "foreignKeys": { 263 + "session_user_id_user_id_fk": { 264 + "name": "session_user_id_user_id_fk", 265 + "tableFrom": "session", 266 + "tableTo": "user", 267 + "columnsFrom": [ 268 + "user_id" 269 + ], 270 + "columnsTo": [ 271 + "id" 272 + ], 273 + "onDelete": "cascade", 274 + "onUpdate": "no action" 275 + } 276 + }, 277 + "compositePrimaryKeys": {}, 278 + "uniqueConstraints": {}, 279 + "checkConstraints": {} 280 + }, 281 + "user": { 282 + "name": "user", 283 + "columns": { 284 + "id": { 285 + "name": "id", 286 + "type": "integer", 287 + "primaryKey": true, 288 + "notNull": true, 289 + "autoincrement": false 290 + }, 291 + "tenant_id": { 292 + "name": "tenant_id", 293 + "type": "text(256)", 294 + "primaryKey": false, 295 + "notNull": false, 296 + "autoincrement": false 297 + }, 298 + "first_name": { 299 + "name": "first_name", 300 + "type": "text", 301 + "primaryKey": false, 302 + "notNull": false, 303 + "autoincrement": false, 304 + "default": "''" 305 + }, 306 + "last_name": { 307 + "name": "last_name", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": false, 311 + "autoincrement": false, 312 + "default": "''" 313 + }, 314 + "photo_url": { 315 + "name": "photo_url", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": false, 319 + "autoincrement": false, 320 + "default": "''" 321 + }, 322 + "name": { 323 + "name": "name", 324 + "type": "text", 325 + "primaryKey": false, 326 + "notNull": false, 327 + "autoincrement": false 328 + }, 329 + "email": { 330 + "name": "email", 331 + "type": "text", 332 + "primaryKey": false, 333 + "notNull": false, 334 + "autoincrement": false, 335 + "default": "''" 336 + }, 337 + "emailVerified": { 338 + "name": "emailVerified", 339 + "type": "integer", 340 + "primaryKey": false, 341 + "notNull": false, 342 + "autoincrement": false 343 + }, 344 + "created_at": { 345 + "name": "created_at", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false, 349 + "autoincrement": false, 350 + "default": "(strftime('%s', 'now'))" 351 + }, 352 + "updated_at": { 353 + "name": "updated_at", 354 + "type": "integer", 355 + "primaryKey": false, 356 + "notNull": false, 357 + "autoincrement": false, 358 + "default": "(strftime('%s', 'now'))" 359 + } 360 + }, 361 + "indexes": { 362 + "user_tenant_id_unique": { 363 + "name": "user_tenant_id_unique", 364 + "columns": [ 365 + "tenant_id" 366 + ], 367 + "isUnique": true 368 + } 369 + }, 370 + "foreignKeys": {}, 371 + "compositePrimaryKeys": {}, 372 + "uniqueConstraints": {}, 373 + "checkConstraints": {} 374 + }, 375 + "users_to_workspaces": { 376 + "name": "users_to_workspaces", 377 + "columns": { 378 + "user_id": { 379 + "name": "user_id", 380 + "type": "integer", 381 + "primaryKey": false, 382 + "notNull": true, 383 + "autoincrement": false 384 + }, 385 + "workspace_id": { 386 + "name": "workspace_id", 387 + "type": "integer", 388 + "primaryKey": false, 389 + "notNull": true, 390 + "autoincrement": false 391 + }, 392 + "role": { 393 + "name": "role", 394 + "type": "text", 395 + "primaryKey": false, 396 + "notNull": true, 397 + "autoincrement": false, 398 + "default": "'member'" 399 + }, 400 + "created_at": { 401 + "name": "created_at", 402 + "type": "integer", 403 + "primaryKey": false, 404 + "notNull": false, 405 + "autoincrement": false, 406 + "default": "(strftime('%s', 'now'))" 407 + } 408 + }, 409 + "indexes": {}, 410 + "foreignKeys": { 411 + "users_to_workspaces_user_id_user_id_fk": { 412 + "name": "users_to_workspaces_user_id_user_id_fk", 413 + "tableFrom": "users_to_workspaces", 414 + "tableTo": "user", 415 + "columnsFrom": [ 416 + "user_id" 417 + ], 418 + "columnsTo": [ 419 + "id" 420 + ], 421 + "onDelete": "no action", 422 + "onUpdate": "no action" 423 + }, 424 + "users_to_workspaces_workspace_id_workspace_id_fk": { 425 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 426 + "tableFrom": "users_to_workspaces", 427 + "tableTo": "workspace", 428 + "columnsFrom": [ 429 + "workspace_id" 430 + ], 431 + "columnsTo": [ 432 + "id" 433 + ], 434 + "onDelete": "no action", 435 + "onUpdate": "no action" 436 + } 437 + }, 438 + "compositePrimaryKeys": { 439 + "users_to_workspaces_user_id_workspace_id_pk": { 440 + "columns": [ 441 + "user_id", 442 + "workspace_id" 443 + ], 444 + "name": "users_to_workspaces_user_id_workspace_id_pk" 445 + } 446 + }, 447 + "uniqueConstraints": {}, 448 + "checkConstraints": {} 449 + }, 450 + "verification_token": { 451 + "name": "verification_token", 452 + "columns": { 453 + "identifier": { 454 + "name": "identifier", 455 + "type": "text", 456 + "primaryKey": false, 457 + "notNull": true, 458 + "autoincrement": false 459 + }, 460 + "token": { 461 + "name": "token", 462 + "type": "text", 463 + "primaryKey": false, 464 + "notNull": true, 465 + "autoincrement": false 466 + }, 467 + "expires": { 468 + "name": "expires", 469 + "type": "integer", 470 + "primaryKey": false, 471 + "notNull": true, 472 + "autoincrement": false 473 + } 474 + }, 475 + "indexes": {}, 476 + "foreignKeys": {}, 477 + "compositePrimaryKeys": { 478 + "verification_token_identifier_token_pk": { 479 + "columns": [ 480 + "identifier", 481 + "token" 482 + ], 483 + "name": "verification_token_identifier_token_pk" 484 + } 485 + }, 486 + "uniqueConstraints": {}, 487 + "checkConstraints": {} 488 + }, 489 + "status_report_to_monitors": { 490 + "name": "status_report_to_monitors", 491 + "columns": { 492 + "monitor_id": { 493 + "name": "monitor_id", 494 + "type": "integer", 495 + "primaryKey": false, 496 + "notNull": true, 497 + "autoincrement": false 498 + }, 499 + "status_report_id": { 500 + "name": "status_report_id", 501 + "type": "integer", 502 + "primaryKey": false, 503 + "notNull": true, 504 + "autoincrement": false 505 + }, 506 + "created_at": { 507 + "name": "created_at", 508 + "type": "integer", 509 + "primaryKey": false, 510 + "notNull": false, 511 + "autoincrement": false, 512 + "default": "(strftime('%s', 'now'))" 513 + } 514 + }, 515 + "indexes": {}, 516 + "foreignKeys": { 517 + "status_report_to_monitors_monitor_id_monitor_id_fk": { 518 + "name": "status_report_to_monitors_monitor_id_monitor_id_fk", 519 + "tableFrom": "status_report_to_monitors", 520 + "tableTo": "monitor", 521 + "columnsFrom": [ 522 + "monitor_id" 523 + ], 524 + "columnsTo": [ 525 + "id" 526 + ], 527 + "onDelete": "cascade", 528 + "onUpdate": "no action" 529 + }, 530 + "status_report_to_monitors_status_report_id_status_report_id_fk": { 531 + "name": "status_report_to_monitors_status_report_id_status_report_id_fk", 532 + "tableFrom": "status_report_to_monitors", 533 + "tableTo": "status_report", 534 + "columnsFrom": [ 535 + "status_report_id" 536 + ], 537 + "columnsTo": [ 538 + "id" 539 + ], 540 + "onDelete": "cascade", 541 + "onUpdate": "no action" 542 + } 543 + }, 544 + "compositePrimaryKeys": { 545 + "status_report_to_monitors_monitor_id_status_report_id_pk": { 546 + "columns": [ 547 + "monitor_id", 548 + "status_report_id" 549 + ], 550 + "name": "status_report_to_monitors_monitor_id_status_report_id_pk" 551 + } 552 + }, 553 + "uniqueConstraints": {}, 554 + "checkConstraints": {} 555 + }, 556 + "status_report": { 557 + "name": "status_report", 558 + "columns": { 559 + "id": { 560 + "name": "id", 561 + "type": "integer", 562 + "primaryKey": true, 563 + "notNull": true, 564 + "autoincrement": false 565 + }, 566 + "status": { 567 + "name": "status", 568 + "type": "text", 569 + "primaryKey": false, 570 + "notNull": true, 571 + "autoincrement": false 572 + }, 573 + "title": { 574 + "name": "title", 575 + "type": "text(256)", 576 + "primaryKey": false, 577 + "notNull": true, 578 + "autoincrement": false 579 + }, 580 + "workspace_id": { 581 + "name": "workspace_id", 582 + "type": "integer", 583 + "primaryKey": false, 584 + "notNull": false, 585 + "autoincrement": false 586 + }, 587 + "page_id": { 588 + "name": "page_id", 589 + "type": "integer", 590 + "primaryKey": false, 591 + "notNull": false, 592 + "autoincrement": false 593 + }, 594 + "created_at": { 595 + "name": "created_at", 596 + "type": "integer", 597 + "primaryKey": false, 598 + "notNull": false, 599 + "autoincrement": false, 600 + "default": "(strftime('%s', 'now'))" 601 + }, 602 + "updated_at": { 603 + "name": "updated_at", 604 + "type": "integer", 605 + "primaryKey": false, 606 + "notNull": false, 607 + "autoincrement": false, 608 + "default": "(strftime('%s', 'now'))" 609 + } 610 + }, 611 + "indexes": {}, 612 + "foreignKeys": { 613 + "status_report_workspace_id_workspace_id_fk": { 614 + "name": "status_report_workspace_id_workspace_id_fk", 615 + "tableFrom": "status_report", 616 + "tableTo": "workspace", 617 + "columnsFrom": [ 618 + "workspace_id" 619 + ], 620 + "columnsTo": [ 621 + "id" 622 + ], 623 + "onDelete": "no action", 624 + "onUpdate": "no action" 625 + }, 626 + "status_report_page_id_page_id_fk": { 627 + "name": "status_report_page_id_page_id_fk", 628 + "tableFrom": "status_report", 629 + "tableTo": "page", 630 + "columnsFrom": [ 631 + "page_id" 632 + ], 633 + "columnsTo": [ 634 + "id" 635 + ], 636 + "onDelete": "cascade", 637 + "onUpdate": "no action" 638 + } 639 + }, 640 + "compositePrimaryKeys": {}, 641 + "uniqueConstraints": {}, 642 + "checkConstraints": {} 643 + }, 644 + "status_report_update": { 645 + "name": "status_report_update", 646 + "columns": { 647 + "id": { 648 + "name": "id", 649 + "type": "integer", 650 + "primaryKey": true, 651 + "notNull": true, 652 + "autoincrement": false 653 + }, 654 + "status": { 655 + "name": "status", 656 + "type": "text", 657 + "primaryKey": false, 658 + "notNull": true, 659 + "autoincrement": false 660 + }, 661 + "date": { 662 + "name": "date", 663 + "type": "integer", 664 + "primaryKey": false, 665 + "notNull": true, 666 + "autoincrement": false 667 + }, 668 + "message": { 669 + "name": "message", 670 + "type": "text", 671 + "primaryKey": false, 672 + "notNull": true, 673 + "autoincrement": false 674 + }, 675 + "status_report_id": { 676 + "name": "status_report_id", 677 + "type": "integer", 678 + "primaryKey": false, 679 + "notNull": true, 680 + "autoincrement": false 681 + }, 682 + "created_at": { 683 + "name": "created_at", 684 + "type": "integer", 685 + "primaryKey": false, 686 + "notNull": false, 687 + "autoincrement": false, 688 + "default": "(strftime('%s', 'now'))" 689 + }, 690 + "updated_at": { 691 + "name": "updated_at", 692 + "type": "integer", 693 + "primaryKey": false, 694 + "notNull": false, 695 + "autoincrement": false, 696 + "default": "(strftime('%s', 'now'))" 697 + } 698 + }, 699 + "indexes": {}, 700 + "foreignKeys": { 701 + "status_report_update_status_report_id_status_report_id_fk": { 702 + "name": "status_report_update_status_report_id_status_report_id_fk", 703 + "tableFrom": "status_report_update", 704 + "tableTo": "status_report", 705 + "columnsFrom": [ 706 + "status_report_id" 707 + ], 708 + "columnsTo": [ 709 + "id" 710 + ], 711 + "onDelete": "cascade", 712 + "onUpdate": "no action" 713 + } 714 + }, 715 + "compositePrimaryKeys": {}, 716 + "uniqueConstraints": {}, 717 + "checkConstraints": {} 718 + }, 719 + "integration": { 720 + "name": "integration", 721 + "columns": { 722 + "id": { 723 + "name": "id", 724 + "type": "integer", 725 + "primaryKey": true, 726 + "notNull": true, 727 + "autoincrement": false 728 + }, 729 + "name": { 730 + "name": "name", 731 + "type": "text(256)", 732 + "primaryKey": false, 733 + "notNull": true, 734 + "autoincrement": false 735 + }, 736 + "workspace_id": { 737 + "name": "workspace_id", 738 + "type": "integer", 739 + "primaryKey": false, 740 + "notNull": false, 741 + "autoincrement": false 742 + }, 743 + "credential": { 744 + "name": "credential", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": false, 748 + "autoincrement": false 749 + }, 750 + "external_id": { 751 + "name": "external_id", 752 + "type": "text", 753 + "primaryKey": false, 754 + "notNull": true, 755 + "autoincrement": false 756 + }, 757 + "created_at": { 758 + "name": "created_at", 759 + "type": "integer", 760 + "primaryKey": false, 761 + "notNull": false, 762 + "autoincrement": false, 763 + "default": "(strftime('%s', 'now'))" 764 + }, 765 + "updated_at": { 766 + "name": "updated_at", 767 + "type": "integer", 768 + "primaryKey": false, 769 + "notNull": false, 770 + "autoincrement": false, 771 + "default": "(strftime('%s', 'now'))" 772 + }, 773 + "data": { 774 + "name": "data", 775 + "type": "text", 776 + "primaryKey": false, 777 + "notNull": true, 778 + "autoincrement": false 779 + } 780 + }, 781 + "indexes": {}, 782 + "foreignKeys": { 783 + "integration_workspace_id_workspace_id_fk": { 784 + "name": "integration_workspace_id_workspace_id_fk", 785 + "tableFrom": "integration", 786 + "tableTo": "workspace", 787 + "columnsFrom": [ 788 + "workspace_id" 789 + ], 790 + "columnsTo": [ 791 + "id" 792 + ], 793 + "onDelete": "no action", 794 + "onUpdate": "no action" 795 + } 796 + }, 797 + "compositePrimaryKeys": {}, 798 + "uniqueConstraints": {}, 799 + "checkConstraints": {} 800 + }, 801 + "page": { 802 + "name": "page", 803 + "columns": { 804 + "id": { 805 + "name": "id", 806 + "type": "integer", 807 + "primaryKey": true, 808 + "notNull": true, 809 + "autoincrement": false 810 + }, 811 + "workspace_id": { 812 + "name": "workspace_id", 813 + "type": "integer", 814 + "primaryKey": false, 815 + "notNull": true, 816 + "autoincrement": false 817 + }, 818 + "title": { 819 + "name": "title", 820 + "type": "text", 821 + "primaryKey": false, 822 + "notNull": true, 823 + "autoincrement": false 824 + }, 825 + "description": { 826 + "name": "description", 827 + "type": "text", 828 + "primaryKey": false, 829 + "notNull": true, 830 + "autoincrement": false 831 + }, 832 + "icon": { 833 + "name": "icon", 834 + "type": "text(256)", 835 + "primaryKey": false, 836 + "notNull": false, 837 + "autoincrement": false, 838 + "default": "''" 839 + }, 840 + "slug": { 841 + "name": "slug", 842 + "type": "text(256)", 843 + "primaryKey": false, 844 + "notNull": true, 845 + "autoincrement": false 846 + }, 847 + "custom_domain": { 848 + "name": "custom_domain", 849 + "type": "text(256)", 850 + "primaryKey": false, 851 + "notNull": true, 852 + "autoincrement": false 853 + }, 854 + "published": { 855 + "name": "published", 856 + "type": "integer", 857 + "primaryKey": false, 858 + "notNull": false, 859 + "autoincrement": false, 860 + "default": false 861 + }, 862 + "force_theme": { 863 + "name": "force_theme", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": true, 867 + "autoincrement": false, 868 + "default": "'system'" 869 + }, 870 + "password": { 871 + "name": "password", 872 + "type": "text(256)", 873 + "primaryKey": false, 874 + "notNull": false, 875 + "autoincrement": false 876 + }, 877 + "password_protected": { 878 + "name": "password_protected", 879 + "type": "integer", 880 + "primaryKey": false, 881 + "notNull": false, 882 + "autoincrement": false, 883 + "default": false 884 + }, 885 + "access_type": { 886 + "name": "access_type", 887 + "type": "text", 888 + "primaryKey": false, 889 + "notNull": false, 890 + "autoincrement": false, 891 + "default": "'public'" 892 + }, 893 + "auth_email_domains": { 894 + "name": "auth_email_domains", 895 + "type": "text", 896 + "primaryKey": false, 897 + "notNull": false, 898 + "autoincrement": false 899 + }, 900 + "homepage_url": { 901 + "name": "homepage_url", 902 + "type": "text(256)", 903 + "primaryKey": false, 904 + "notNull": false, 905 + "autoincrement": false 906 + }, 907 + "contact_url": { 908 + "name": "contact_url", 909 + "type": "text(256)", 910 + "primaryKey": false, 911 + "notNull": false, 912 + "autoincrement": false 913 + }, 914 + "legacy_page": { 915 + "name": "legacy_page", 916 + "type": "integer", 917 + "primaryKey": false, 918 + "notNull": true, 919 + "autoincrement": false, 920 + "default": true 921 + }, 922 + "configuration": { 923 + "name": "configuration", 924 + "type": "text", 925 + "primaryKey": false, 926 + "notNull": false, 927 + "autoincrement": false 928 + }, 929 + "show_monitor_values": { 930 + "name": "show_monitor_values", 931 + "type": "integer", 932 + "primaryKey": false, 933 + "notNull": false, 934 + "autoincrement": false, 935 + "default": true 936 + }, 937 + "created_at": { 938 + "name": "created_at", 939 + "type": "integer", 940 + "primaryKey": false, 941 + "notNull": false, 942 + "autoincrement": false, 943 + "default": "(strftime('%s', 'now'))" 944 + }, 945 + "updated_at": { 946 + "name": "updated_at", 947 + "type": "integer", 948 + "primaryKey": false, 949 + "notNull": false, 950 + "autoincrement": false, 951 + "default": "(strftime('%s', 'now'))" 952 + } 953 + }, 954 + "indexes": { 955 + "page_slug_unique": { 956 + "name": "page_slug_unique", 957 + "columns": [ 958 + "slug" 959 + ], 960 + "isUnique": true 961 + } 962 + }, 963 + "foreignKeys": { 964 + "page_workspace_id_workspace_id_fk": { 965 + "name": "page_workspace_id_workspace_id_fk", 966 + "tableFrom": "page", 967 + "tableTo": "workspace", 968 + "columnsFrom": [ 969 + "workspace_id" 970 + ], 971 + "columnsTo": [ 972 + "id" 973 + ], 974 + "onDelete": "cascade", 975 + "onUpdate": "no action" 976 + } 977 + }, 978 + "compositePrimaryKeys": {}, 979 + "uniqueConstraints": {}, 980 + "checkConstraints": {} 981 + }, 982 + "monitor": { 983 + "name": "monitor", 984 + "columns": { 985 + "id": { 986 + "name": "id", 987 + "type": "integer", 988 + "primaryKey": true, 989 + "notNull": true, 990 + "autoincrement": false 991 + }, 992 + "job_type": { 993 + "name": "job_type", 994 + "type": "text", 995 + "primaryKey": false, 996 + "notNull": true, 997 + "autoincrement": false, 998 + "default": "'http'" 999 + }, 1000 + "periodicity": { 1001 + "name": "periodicity", 1002 + "type": "text", 1003 + "primaryKey": false, 1004 + "notNull": true, 1005 + "autoincrement": false, 1006 + "default": "'other'" 1007 + }, 1008 + "status": { 1009 + "name": "status", 1010 + "type": "text", 1011 + "primaryKey": false, 1012 + "notNull": true, 1013 + "autoincrement": false, 1014 + "default": "'active'" 1015 + }, 1016 + "active": { 1017 + "name": "active", 1018 + "type": "integer", 1019 + "primaryKey": false, 1020 + "notNull": false, 1021 + "autoincrement": false, 1022 + "default": false 1023 + }, 1024 + "regions": { 1025 + "name": "regions", 1026 + "type": "text", 1027 + "primaryKey": false, 1028 + "notNull": true, 1029 + "autoincrement": false, 1030 + "default": "''" 1031 + }, 1032 + "url": { 1033 + "name": "url", 1034 + "type": "text(2048)", 1035 + "primaryKey": false, 1036 + "notNull": true, 1037 + "autoincrement": false 1038 + }, 1039 + "name": { 1040 + "name": "name", 1041 + "type": "text(256)", 1042 + "primaryKey": false, 1043 + "notNull": true, 1044 + "autoincrement": false, 1045 + "default": "''" 1046 + }, 1047 + "external_name": { 1048 + "name": "external_name", 1049 + "type": "text", 1050 + "primaryKey": false, 1051 + "notNull": false, 1052 + "autoincrement": false 1053 + }, 1054 + "description": { 1055 + "name": "description", 1056 + "type": "text", 1057 + "primaryKey": false, 1058 + "notNull": true, 1059 + "autoincrement": false, 1060 + "default": "''" 1061 + }, 1062 + "headers": { 1063 + "name": "headers", 1064 + "type": "text", 1065 + "primaryKey": false, 1066 + "notNull": false, 1067 + "autoincrement": false, 1068 + "default": "''" 1069 + }, 1070 + "body": { 1071 + "name": "body", 1072 + "type": "text", 1073 + "primaryKey": false, 1074 + "notNull": false, 1075 + "autoincrement": false, 1076 + "default": "''" 1077 + }, 1078 + "method": { 1079 + "name": "method", 1080 + "type": "text", 1081 + "primaryKey": false, 1082 + "notNull": false, 1083 + "autoincrement": false, 1084 + "default": "'GET'" 1085 + }, 1086 + "workspace_id": { 1087 + "name": "workspace_id", 1088 + "type": "integer", 1089 + "primaryKey": false, 1090 + "notNull": false, 1091 + "autoincrement": false 1092 + }, 1093 + "timeout": { 1094 + "name": "timeout", 1095 + "type": "integer", 1096 + "primaryKey": false, 1097 + "notNull": true, 1098 + "autoincrement": false, 1099 + "default": 45000 1100 + }, 1101 + "degraded_after": { 1102 + "name": "degraded_after", 1103 + "type": "integer", 1104 + "primaryKey": false, 1105 + "notNull": false, 1106 + "autoincrement": false 1107 + }, 1108 + "assertions": { 1109 + "name": "assertions", 1110 + "type": "text", 1111 + "primaryKey": false, 1112 + "notNull": false, 1113 + "autoincrement": false 1114 + }, 1115 + "otel_endpoint": { 1116 + "name": "otel_endpoint", 1117 + "type": "text", 1118 + "primaryKey": false, 1119 + "notNull": false, 1120 + "autoincrement": false 1121 + }, 1122 + "otel_headers": { 1123 + "name": "otel_headers", 1124 + "type": "text", 1125 + "primaryKey": false, 1126 + "notNull": false, 1127 + "autoincrement": false 1128 + }, 1129 + "public": { 1130 + "name": "public", 1131 + "type": "integer", 1132 + "primaryKey": false, 1133 + "notNull": false, 1134 + "autoincrement": false, 1135 + "default": false 1136 + }, 1137 + "retry": { 1138 + "name": "retry", 1139 + "type": "integer", 1140 + "primaryKey": false, 1141 + "notNull": false, 1142 + "autoincrement": false, 1143 + "default": 3 1144 + }, 1145 + "follow_redirects": { 1146 + "name": "follow_redirects", 1147 + "type": "integer", 1148 + "primaryKey": false, 1149 + "notNull": false, 1150 + "autoincrement": false, 1151 + "default": true 1152 + }, 1153 + "created_at": { 1154 + "name": "created_at", 1155 + "type": "integer", 1156 + "primaryKey": false, 1157 + "notNull": false, 1158 + "autoincrement": false, 1159 + "default": "(strftime('%s', 'now'))" 1160 + }, 1161 + "updated_at": { 1162 + "name": "updated_at", 1163 + "type": "integer", 1164 + "primaryKey": false, 1165 + "notNull": false, 1166 + "autoincrement": false, 1167 + "default": "(strftime('%s', 'now'))" 1168 + }, 1169 + "deleted_at": { 1170 + "name": "deleted_at", 1171 + "type": "integer", 1172 + "primaryKey": false, 1173 + "notNull": false, 1174 + "autoincrement": false 1175 + } 1176 + }, 1177 + "indexes": {}, 1178 + "foreignKeys": { 1179 + "monitor_workspace_id_workspace_id_fk": { 1180 + "name": "monitor_workspace_id_workspace_id_fk", 1181 + "tableFrom": "monitor", 1182 + "tableTo": "workspace", 1183 + "columnsFrom": [ 1184 + "workspace_id" 1185 + ], 1186 + "columnsTo": [ 1187 + "id" 1188 + ], 1189 + "onDelete": "no action", 1190 + "onUpdate": "no action" 1191 + } 1192 + }, 1193 + "compositePrimaryKeys": {}, 1194 + "uniqueConstraints": {}, 1195 + "checkConstraints": {} 1196 + }, 1197 + "monitors_to_pages": { 1198 + "name": "monitors_to_pages", 1199 + "columns": { 1200 + "monitor_id": { 1201 + "name": "monitor_id", 1202 + "type": "integer", 1203 + "primaryKey": false, 1204 + "notNull": true, 1205 + "autoincrement": false 1206 + }, 1207 + "page_id": { 1208 + "name": "page_id", 1209 + "type": "integer", 1210 + "primaryKey": false, 1211 + "notNull": true, 1212 + "autoincrement": false 1213 + }, 1214 + "created_at": { 1215 + "name": "created_at", 1216 + "type": "integer", 1217 + "primaryKey": false, 1218 + "notNull": false, 1219 + "autoincrement": false, 1220 + "default": "(strftime('%s', 'now'))" 1221 + }, 1222 + "order": { 1223 + "name": "order", 1224 + "type": "integer", 1225 + "primaryKey": false, 1226 + "notNull": false, 1227 + "autoincrement": false, 1228 + "default": 0 1229 + }, 1230 + "monitor_group_id": { 1231 + "name": "monitor_group_id", 1232 + "type": "integer", 1233 + "primaryKey": false, 1234 + "notNull": false, 1235 + "autoincrement": false 1236 + }, 1237 + "group_order": { 1238 + "name": "group_order", 1239 + "type": "integer", 1240 + "primaryKey": false, 1241 + "notNull": false, 1242 + "autoincrement": false, 1243 + "default": 0 1244 + } 1245 + }, 1246 + "indexes": {}, 1247 + "foreignKeys": { 1248 + "monitors_to_pages_monitor_id_monitor_id_fk": { 1249 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 1250 + "tableFrom": "monitors_to_pages", 1251 + "tableTo": "monitor", 1252 + "columnsFrom": [ 1253 + "monitor_id" 1254 + ], 1255 + "columnsTo": [ 1256 + "id" 1257 + ], 1258 + "onDelete": "cascade", 1259 + "onUpdate": "no action" 1260 + }, 1261 + "monitors_to_pages_page_id_page_id_fk": { 1262 + "name": "monitors_to_pages_page_id_page_id_fk", 1263 + "tableFrom": "monitors_to_pages", 1264 + "tableTo": "page", 1265 + "columnsFrom": [ 1266 + "page_id" 1267 + ], 1268 + "columnsTo": [ 1269 + "id" 1270 + ], 1271 + "onDelete": "cascade", 1272 + "onUpdate": "no action" 1273 + }, 1274 + "monitors_to_pages_monitor_group_id_monitor_group_id_fk": { 1275 + "name": "monitors_to_pages_monitor_group_id_monitor_group_id_fk", 1276 + "tableFrom": "monitors_to_pages", 1277 + "tableTo": "monitor_group", 1278 + "columnsFrom": [ 1279 + "monitor_group_id" 1280 + ], 1281 + "columnsTo": [ 1282 + "id" 1283 + ], 1284 + "onDelete": "cascade", 1285 + "onUpdate": "no action" 1286 + } 1287 + }, 1288 + "compositePrimaryKeys": { 1289 + "monitors_to_pages_monitor_id_page_id_pk": { 1290 + "columns": [ 1291 + "monitor_id", 1292 + "page_id" 1293 + ], 1294 + "name": "monitors_to_pages_monitor_id_page_id_pk" 1295 + } 1296 + }, 1297 + "uniqueConstraints": {}, 1298 + "checkConstraints": {} 1299 + }, 1300 + "page_subscriber": { 1301 + "name": "page_subscriber", 1302 + "columns": { 1303 + "id": { 1304 + "name": "id", 1305 + "type": "integer", 1306 + "primaryKey": true, 1307 + "notNull": true, 1308 + "autoincrement": false 1309 + }, 1310 + "email": { 1311 + "name": "email", 1312 + "type": "text", 1313 + "primaryKey": false, 1314 + "notNull": true, 1315 + "autoincrement": false 1316 + }, 1317 + "page_id": { 1318 + "name": "page_id", 1319 + "type": "integer", 1320 + "primaryKey": false, 1321 + "notNull": true, 1322 + "autoincrement": false 1323 + }, 1324 + "token": { 1325 + "name": "token", 1326 + "type": "text", 1327 + "primaryKey": false, 1328 + "notNull": false, 1329 + "autoincrement": false 1330 + }, 1331 + "accepted_at": { 1332 + "name": "accepted_at", 1333 + "type": "integer", 1334 + "primaryKey": false, 1335 + "notNull": false, 1336 + "autoincrement": false 1337 + }, 1338 + "expires_at": { 1339 + "name": "expires_at", 1340 + "type": "integer", 1341 + "primaryKey": false, 1342 + "notNull": false, 1343 + "autoincrement": false 1344 + }, 1345 + "unsubscribed_at": { 1346 + "name": "unsubscribed_at", 1347 + "type": "integer", 1348 + "primaryKey": false, 1349 + "notNull": false, 1350 + "autoincrement": false 1351 + }, 1352 + "created_at": { 1353 + "name": "created_at", 1354 + "type": "integer", 1355 + "primaryKey": false, 1356 + "notNull": false, 1357 + "autoincrement": false, 1358 + "default": "(strftime('%s', 'now'))" 1359 + }, 1360 + "updated_at": { 1361 + "name": "updated_at", 1362 + "type": "integer", 1363 + "primaryKey": false, 1364 + "notNull": false, 1365 + "autoincrement": false, 1366 + "default": "(strftime('%s', 'now'))" 1367 + } 1368 + }, 1369 + "indexes": {}, 1370 + "foreignKeys": { 1371 + "page_subscriber_page_id_page_id_fk": { 1372 + "name": "page_subscriber_page_id_page_id_fk", 1373 + "tableFrom": "page_subscriber", 1374 + "tableTo": "page", 1375 + "columnsFrom": [ 1376 + "page_id" 1377 + ], 1378 + "columnsTo": [ 1379 + "id" 1380 + ], 1381 + "onDelete": "cascade", 1382 + "onUpdate": "no action" 1383 + } 1384 + }, 1385 + "compositePrimaryKeys": {}, 1386 + "uniqueConstraints": {}, 1387 + "checkConstraints": {} 1388 + }, 1389 + "notification": { 1390 + "name": "notification", 1391 + "columns": { 1392 + "id": { 1393 + "name": "id", 1394 + "type": "integer", 1395 + "primaryKey": true, 1396 + "notNull": true, 1397 + "autoincrement": false 1398 + }, 1399 + "name": { 1400 + "name": "name", 1401 + "type": "text", 1402 + "primaryKey": false, 1403 + "notNull": true, 1404 + "autoincrement": false 1405 + }, 1406 + "provider": { 1407 + "name": "provider", 1408 + "type": "text", 1409 + "primaryKey": false, 1410 + "notNull": true, 1411 + "autoincrement": false 1412 + }, 1413 + "data": { 1414 + "name": "data", 1415 + "type": "text", 1416 + "primaryKey": false, 1417 + "notNull": false, 1418 + "autoincrement": false, 1419 + "default": "'{}'" 1420 + }, 1421 + "workspace_id": { 1422 + "name": "workspace_id", 1423 + "type": "integer", 1424 + "primaryKey": false, 1425 + "notNull": false, 1426 + "autoincrement": false 1427 + }, 1428 + "created_at": { 1429 + "name": "created_at", 1430 + "type": "integer", 1431 + "primaryKey": false, 1432 + "notNull": false, 1433 + "autoincrement": false, 1434 + "default": "(strftime('%s', 'now'))" 1435 + }, 1436 + "updated_at": { 1437 + "name": "updated_at", 1438 + "type": "integer", 1439 + "primaryKey": false, 1440 + "notNull": false, 1441 + "autoincrement": false, 1442 + "default": "(strftime('%s', 'now'))" 1443 + } 1444 + }, 1445 + "indexes": {}, 1446 + "foreignKeys": { 1447 + "notification_workspace_id_workspace_id_fk": { 1448 + "name": "notification_workspace_id_workspace_id_fk", 1449 + "tableFrom": "notification", 1450 + "tableTo": "workspace", 1451 + "columnsFrom": [ 1452 + "workspace_id" 1453 + ], 1454 + "columnsTo": [ 1455 + "id" 1456 + ], 1457 + "onDelete": "no action", 1458 + "onUpdate": "no action" 1459 + } 1460 + }, 1461 + "compositePrimaryKeys": {}, 1462 + "uniqueConstraints": {}, 1463 + "checkConstraints": {} 1464 + }, 1465 + "notification_trigger": { 1466 + "name": "notification_trigger", 1467 + "columns": { 1468 + "id": { 1469 + "name": "id", 1470 + "type": "integer", 1471 + "primaryKey": true, 1472 + "notNull": true, 1473 + "autoincrement": false 1474 + }, 1475 + "monitor_id": { 1476 + "name": "monitor_id", 1477 + "type": "integer", 1478 + "primaryKey": false, 1479 + "notNull": false, 1480 + "autoincrement": false 1481 + }, 1482 + "notification_id": { 1483 + "name": "notification_id", 1484 + "type": "integer", 1485 + "primaryKey": false, 1486 + "notNull": false, 1487 + "autoincrement": false 1488 + }, 1489 + "cron_timestamp": { 1490 + "name": "cron_timestamp", 1491 + "type": "integer", 1492 + "primaryKey": false, 1493 + "notNull": true, 1494 + "autoincrement": false 1495 + } 1496 + }, 1497 + "indexes": { 1498 + "notification_id_monitor_id_crontimestampe": { 1499 + "name": "notification_id_monitor_id_crontimestampe", 1500 + "columns": [ 1501 + "notification_id", 1502 + "monitor_id", 1503 + "cron_timestamp" 1504 + ], 1505 + "isUnique": true 1506 + } 1507 + }, 1508 + "foreignKeys": { 1509 + "notification_trigger_monitor_id_monitor_id_fk": { 1510 + "name": "notification_trigger_monitor_id_monitor_id_fk", 1511 + "tableFrom": "notification_trigger", 1512 + "tableTo": "monitor", 1513 + "columnsFrom": [ 1514 + "monitor_id" 1515 + ], 1516 + "columnsTo": [ 1517 + "id" 1518 + ], 1519 + "onDelete": "cascade", 1520 + "onUpdate": "no action" 1521 + }, 1522 + "notification_trigger_notification_id_notification_id_fk": { 1523 + "name": "notification_trigger_notification_id_notification_id_fk", 1524 + "tableFrom": "notification_trigger", 1525 + "tableTo": "notification", 1526 + "columnsFrom": [ 1527 + "notification_id" 1528 + ], 1529 + "columnsTo": [ 1530 + "id" 1531 + ], 1532 + "onDelete": "cascade", 1533 + "onUpdate": "no action" 1534 + } 1535 + }, 1536 + "compositePrimaryKeys": {}, 1537 + "uniqueConstraints": {}, 1538 + "checkConstraints": {} 1539 + }, 1540 + "notifications_to_monitors": { 1541 + "name": "notifications_to_monitors", 1542 + "columns": { 1543 + "monitor_id": { 1544 + "name": "monitor_id", 1545 + "type": "integer", 1546 + "primaryKey": false, 1547 + "notNull": true, 1548 + "autoincrement": false 1549 + }, 1550 + "notification_id": { 1551 + "name": "notification_id", 1552 + "type": "integer", 1553 + "primaryKey": false, 1554 + "notNull": true, 1555 + "autoincrement": false 1556 + }, 1557 + "created_at": { 1558 + "name": "created_at", 1559 + "type": "integer", 1560 + "primaryKey": false, 1561 + "notNull": false, 1562 + "autoincrement": false, 1563 + "default": "(strftime('%s', 'now'))" 1564 + } 1565 + }, 1566 + "indexes": {}, 1567 + "foreignKeys": { 1568 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 1569 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 1570 + "tableFrom": "notifications_to_monitors", 1571 + "tableTo": "monitor", 1572 + "columnsFrom": [ 1573 + "monitor_id" 1574 + ], 1575 + "columnsTo": [ 1576 + "id" 1577 + ], 1578 + "onDelete": "cascade", 1579 + "onUpdate": "no action" 1580 + }, 1581 + "notifications_to_monitors_notification_id_notification_id_fk": { 1582 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 1583 + "tableFrom": "notifications_to_monitors", 1584 + "tableTo": "notification", 1585 + "columnsFrom": [ 1586 + "notification_id" 1587 + ], 1588 + "columnsTo": [ 1589 + "id" 1590 + ], 1591 + "onDelete": "cascade", 1592 + "onUpdate": "no action" 1593 + } 1594 + }, 1595 + "compositePrimaryKeys": { 1596 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1597 + "columns": [ 1598 + "monitor_id", 1599 + "notification_id" 1600 + ], 1601 + "name": "notifications_to_monitors_monitor_id_notification_id_pk" 1602 + } 1603 + }, 1604 + "uniqueConstraints": {}, 1605 + "checkConstraints": {} 1606 + }, 1607 + "monitor_status": { 1608 + "name": "monitor_status", 1609 + "columns": { 1610 + "monitor_id": { 1611 + "name": "monitor_id", 1612 + "type": "integer", 1613 + "primaryKey": false, 1614 + "notNull": true, 1615 + "autoincrement": false 1616 + }, 1617 + "region": { 1618 + "name": "region", 1619 + "type": "text", 1620 + "primaryKey": false, 1621 + "notNull": true, 1622 + "autoincrement": false, 1623 + "default": "''" 1624 + }, 1625 + "status": { 1626 + "name": "status", 1627 + "type": "text", 1628 + "primaryKey": false, 1629 + "notNull": true, 1630 + "autoincrement": false, 1631 + "default": "'active'" 1632 + }, 1633 + "created_at": { 1634 + "name": "created_at", 1635 + "type": "integer", 1636 + "primaryKey": false, 1637 + "notNull": false, 1638 + "autoincrement": false, 1639 + "default": "(strftime('%s', 'now'))" 1640 + }, 1641 + "updated_at": { 1642 + "name": "updated_at", 1643 + "type": "integer", 1644 + "primaryKey": false, 1645 + "notNull": false, 1646 + "autoincrement": false, 1647 + "default": "(strftime('%s', 'now'))" 1648 + } 1649 + }, 1650 + "indexes": { 1651 + "monitor_status_idx": { 1652 + "name": "monitor_status_idx", 1653 + "columns": [ 1654 + "monitor_id", 1655 + "region" 1656 + ], 1657 + "isUnique": false 1658 + } 1659 + }, 1660 + "foreignKeys": { 1661 + "monitor_status_monitor_id_monitor_id_fk": { 1662 + "name": "monitor_status_monitor_id_monitor_id_fk", 1663 + "tableFrom": "monitor_status", 1664 + "tableTo": "monitor", 1665 + "columnsFrom": [ 1666 + "monitor_id" 1667 + ], 1668 + "columnsTo": [ 1669 + "id" 1670 + ], 1671 + "onDelete": "cascade", 1672 + "onUpdate": "no action" 1673 + } 1674 + }, 1675 + "compositePrimaryKeys": { 1676 + "monitor_status_monitor_id_region_pk": { 1677 + "columns": [ 1678 + "monitor_id", 1679 + "region" 1680 + ], 1681 + "name": "monitor_status_monitor_id_region_pk" 1682 + } 1683 + }, 1684 + "uniqueConstraints": {}, 1685 + "checkConstraints": {} 1686 + }, 1687 + "invitation": { 1688 + "name": "invitation", 1689 + "columns": { 1690 + "id": { 1691 + "name": "id", 1692 + "type": "integer", 1693 + "primaryKey": true, 1694 + "notNull": true, 1695 + "autoincrement": false 1696 + }, 1697 + "email": { 1698 + "name": "email", 1699 + "type": "text", 1700 + "primaryKey": false, 1701 + "notNull": true, 1702 + "autoincrement": false 1703 + }, 1704 + "role": { 1705 + "name": "role", 1706 + "type": "text", 1707 + "primaryKey": false, 1708 + "notNull": true, 1709 + "autoincrement": false, 1710 + "default": "'member'" 1711 + }, 1712 + "workspace_id": { 1713 + "name": "workspace_id", 1714 + "type": "integer", 1715 + "primaryKey": false, 1716 + "notNull": true, 1717 + "autoincrement": false 1718 + }, 1719 + "token": { 1720 + "name": "token", 1721 + "type": "text", 1722 + "primaryKey": false, 1723 + "notNull": true, 1724 + "autoincrement": false 1725 + }, 1726 + "expires_at": { 1727 + "name": "expires_at", 1728 + "type": "integer", 1729 + "primaryKey": false, 1730 + "notNull": true, 1731 + "autoincrement": false 1732 + }, 1733 + "created_at": { 1734 + "name": "created_at", 1735 + "type": "integer", 1736 + "primaryKey": false, 1737 + "notNull": false, 1738 + "autoincrement": false, 1739 + "default": "(strftime('%s', 'now'))" 1740 + }, 1741 + "accepted_at": { 1742 + "name": "accepted_at", 1743 + "type": "integer", 1744 + "primaryKey": false, 1745 + "notNull": false, 1746 + "autoincrement": false 1747 + } 1748 + }, 1749 + "indexes": {}, 1750 + "foreignKeys": {}, 1751 + "compositePrimaryKeys": {}, 1752 + "uniqueConstraints": {}, 1753 + "checkConstraints": {} 1754 + }, 1755 + "incident": { 1756 + "name": "incident", 1757 + "columns": { 1758 + "id": { 1759 + "name": "id", 1760 + "type": "integer", 1761 + "primaryKey": true, 1762 + "notNull": true, 1763 + "autoincrement": false 1764 + }, 1765 + "title": { 1766 + "name": "title", 1767 + "type": "text", 1768 + "primaryKey": false, 1769 + "notNull": true, 1770 + "autoincrement": false, 1771 + "default": "''" 1772 + }, 1773 + "summary": { 1774 + "name": "summary", 1775 + "type": "text", 1776 + "primaryKey": false, 1777 + "notNull": true, 1778 + "autoincrement": false, 1779 + "default": "''" 1780 + }, 1781 + "status": { 1782 + "name": "status", 1783 + "type": "text", 1784 + "primaryKey": false, 1785 + "notNull": true, 1786 + "autoincrement": false, 1787 + "default": "'triage'" 1788 + }, 1789 + "monitor_id": { 1790 + "name": "monitor_id", 1791 + "type": "integer", 1792 + "primaryKey": false, 1793 + "notNull": false, 1794 + "autoincrement": false 1795 + }, 1796 + "workspace_id": { 1797 + "name": "workspace_id", 1798 + "type": "integer", 1799 + "primaryKey": false, 1800 + "notNull": false, 1801 + "autoincrement": false 1802 + }, 1803 + "started_at": { 1804 + "name": "started_at", 1805 + "type": "integer", 1806 + "primaryKey": false, 1807 + "notNull": true, 1808 + "autoincrement": false, 1809 + "default": "(strftime('%s', 'now'))" 1810 + }, 1811 + "acknowledged_at": { 1812 + "name": "acknowledged_at", 1813 + "type": "integer", 1814 + "primaryKey": false, 1815 + "notNull": false, 1816 + "autoincrement": false 1817 + }, 1818 + "acknowledged_by": { 1819 + "name": "acknowledged_by", 1820 + "type": "integer", 1821 + "primaryKey": false, 1822 + "notNull": false, 1823 + "autoincrement": false 1824 + }, 1825 + "resolved_at": { 1826 + "name": "resolved_at", 1827 + "type": "integer", 1828 + "primaryKey": false, 1829 + "notNull": false, 1830 + "autoincrement": false 1831 + }, 1832 + "resolved_by": { 1833 + "name": "resolved_by", 1834 + "type": "integer", 1835 + "primaryKey": false, 1836 + "notNull": false, 1837 + "autoincrement": false 1838 + }, 1839 + "incident_screenshot_url": { 1840 + "name": "incident_screenshot_url", 1841 + "type": "text", 1842 + "primaryKey": false, 1843 + "notNull": false, 1844 + "autoincrement": false 1845 + }, 1846 + "recovery_screenshot_url": { 1847 + "name": "recovery_screenshot_url", 1848 + "type": "text", 1849 + "primaryKey": false, 1850 + "notNull": false, 1851 + "autoincrement": false 1852 + }, 1853 + "auto_resolved": { 1854 + "name": "auto_resolved", 1855 + "type": "integer", 1856 + "primaryKey": false, 1857 + "notNull": false, 1858 + "autoincrement": false, 1859 + "default": false 1860 + }, 1861 + "created_at": { 1862 + "name": "created_at", 1863 + "type": "integer", 1864 + "primaryKey": false, 1865 + "notNull": false, 1866 + "autoincrement": false, 1867 + "default": "(strftime('%s', 'now'))" 1868 + }, 1869 + "updated_at": { 1870 + "name": "updated_at", 1871 + "type": "integer", 1872 + "primaryKey": false, 1873 + "notNull": false, 1874 + "autoincrement": false, 1875 + "default": "(strftime('%s', 'now'))" 1876 + } 1877 + }, 1878 + "indexes": { 1879 + "incident_monitor_id_started_at_unique": { 1880 + "name": "incident_monitor_id_started_at_unique", 1881 + "columns": [ 1882 + "monitor_id", 1883 + "started_at" 1884 + ], 1885 + "isUnique": true 1886 + } 1887 + }, 1888 + "foreignKeys": { 1889 + "incident_monitor_id_monitor_id_fk": { 1890 + "name": "incident_monitor_id_monitor_id_fk", 1891 + "tableFrom": "incident", 1892 + "tableTo": "monitor", 1893 + "columnsFrom": [ 1894 + "monitor_id" 1895 + ], 1896 + "columnsTo": [ 1897 + "id" 1898 + ], 1899 + "onDelete": "set default", 1900 + "onUpdate": "no action" 1901 + }, 1902 + "incident_workspace_id_workspace_id_fk": { 1903 + "name": "incident_workspace_id_workspace_id_fk", 1904 + "tableFrom": "incident", 1905 + "tableTo": "workspace", 1906 + "columnsFrom": [ 1907 + "workspace_id" 1908 + ], 1909 + "columnsTo": [ 1910 + "id" 1911 + ], 1912 + "onDelete": "no action", 1913 + "onUpdate": "no action" 1914 + }, 1915 + "incident_acknowledged_by_user_id_fk": { 1916 + "name": "incident_acknowledged_by_user_id_fk", 1917 + "tableFrom": "incident", 1918 + "tableTo": "user", 1919 + "columnsFrom": [ 1920 + "acknowledged_by" 1921 + ], 1922 + "columnsTo": [ 1923 + "id" 1924 + ], 1925 + "onDelete": "no action", 1926 + "onUpdate": "no action" 1927 + }, 1928 + "incident_resolved_by_user_id_fk": { 1929 + "name": "incident_resolved_by_user_id_fk", 1930 + "tableFrom": "incident", 1931 + "tableTo": "user", 1932 + "columnsFrom": [ 1933 + "resolved_by" 1934 + ], 1935 + "columnsTo": [ 1936 + "id" 1937 + ], 1938 + "onDelete": "no action", 1939 + "onUpdate": "no action" 1940 + } 1941 + }, 1942 + "compositePrimaryKeys": {}, 1943 + "uniqueConstraints": {}, 1944 + "checkConstraints": {} 1945 + }, 1946 + "monitor_tag": { 1947 + "name": "monitor_tag", 1948 + "columns": { 1949 + "id": { 1950 + "name": "id", 1951 + "type": "integer", 1952 + "primaryKey": true, 1953 + "notNull": true, 1954 + "autoincrement": false 1955 + }, 1956 + "workspace_id": { 1957 + "name": "workspace_id", 1958 + "type": "integer", 1959 + "primaryKey": false, 1960 + "notNull": true, 1961 + "autoincrement": false 1962 + }, 1963 + "name": { 1964 + "name": "name", 1965 + "type": "text", 1966 + "primaryKey": false, 1967 + "notNull": true, 1968 + "autoincrement": false 1969 + }, 1970 + "color": { 1971 + "name": "color", 1972 + "type": "text", 1973 + "primaryKey": false, 1974 + "notNull": true, 1975 + "autoincrement": false 1976 + }, 1977 + "created_at": { 1978 + "name": "created_at", 1979 + "type": "integer", 1980 + "primaryKey": false, 1981 + "notNull": false, 1982 + "autoincrement": false, 1983 + "default": "(strftime('%s', 'now'))" 1984 + }, 1985 + "updated_at": { 1986 + "name": "updated_at", 1987 + "type": "integer", 1988 + "primaryKey": false, 1989 + "notNull": false, 1990 + "autoincrement": false, 1991 + "default": "(strftime('%s', 'now'))" 1992 + } 1993 + }, 1994 + "indexes": {}, 1995 + "foreignKeys": { 1996 + "monitor_tag_workspace_id_workspace_id_fk": { 1997 + "name": "monitor_tag_workspace_id_workspace_id_fk", 1998 + "tableFrom": "monitor_tag", 1999 + "tableTo": "workspace", 2000 + "columnsFrom": [ 2001 + "workspace_id" 2002 + ], 2003 + "columnsTo": [ 2004 + "id" 2005 + ], 2006 + "onDelete": "cascade", 2007 + "onUpdate": "no action" 2008 + } 2009 + }, 2010 + "compositePrimaryKeys": {}, 2011 + "uniqueConstraints": {}, 2012 + "checkConstraints": {} 2013 + }, 2014 + "monitor_tag_to_monitor": { 2015 + "name": "monitor_tag_to_monitor", 2016 + "columns": { 2017 + "monitor_id": { 2018 + "name": "monitor_id", 2019 + "type": "integer", 2020 + "primaryKey": false, 2021 + "notNull": true, 2022 + "autoincrement": false 2023 + }, 2024 + "monitor_tag_id": { 2025 + "name": "monitor_tag_id", 2026 + "type": "integer", 2027 + "primaryKey": false, 2028 + "notNull": true, 2029 + "autoincrement": false 2030 + }, 2031 + "created_at": { 2032 + "name": "created_at", 2033 + "type": "integer", 2034 + "primaryKey": false, 2035 + "notNull": false, 2036 + "autoincrement": false, 2037 + "default": "(strftime('%s', 'now'))" 2038 + } 2039 + }, 2040 + "indexes": {}, 2041 + "foreignKeys": { 2042 + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { 2043 + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", 2044 + "tableFrom": "monitor_tag_to_monitor", 2045 + "tableTo": "monitor", 2046 + "columnsFrom": [ 2047 + "monitor_id" 2048 + ], 2049 + "columnsTo": [ 2050 + "id" 2051 + ], 2052 + "onDelete": "cascade", 2053 + "onUpdate": "no action" 2054 + }, 2055 + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { 2056 + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", 2057 + "tableFrom": "monitor_tag_to_monitor", 2058 + "tableTo": "monitor_tag", 2059 + "columnsFrom": [ 2060 + "monitor_tag_id" 2061 + ], 2062 + "columnsTo": [ 2063 + "id" 2064 + ], 2065 + "onDelete": "cascade", 2066 + "onUpdate": "no action" 2067 + } 2068 + }, 2069 + "compositePrimaryKeys": { 2070 + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { 2071 + "columns": [ 2072 + "monitor_id", 2073 + "monitor_tag_id" 2074 + ], 2075 + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" 2076 + } 2077 + }, 2078 + "uniqueConstraints": {}, 2079 + "checkConstraints": {} 2080 + }, 2081 + "application": { 2082 + "name": "application", 2083 + "columns": { 2084 + "id": { 2085 + "name": "id", 2086 + "type": "integer", 2087 + "primaryKey": true, 2088 + "notNull": true, 2089 + "autoincrement": false 2090 + }, 2091 + "name": { 2092 + "name": "name", 2093 + "type": "text", 2094 + "primaryKey": false, 2095 + "notNull": false, 2096 + "autoincrement": false 2097 + }, 2098 + "dsn": { 2099 + "name": "dsn", 2100 + "type": "text", 2101 + "primaryKey": false, 2102 + "notNull": false, 2103 + "autoincrement": false 2104 + }, 2105 + "workspace_id": { 2106 + "name": "workspace_id", 2107 + "type": "integer", 2108 + "primaryKey": false, 2109 + "notNull": false, 2110 + "autoincrement": false 2111 + }, 2112 + "created_at": { 2113 + "name": "created_at", 2114 + "type": "integer", 2115 + "primaryKey": false, 2116 + "notNull": false, 2117 + "autoincrement": false, 2118 + "default": "(strftime('%s', 'now'))" 2119 + }, 2120 + "updated_at": { 2121 + "name": "updated_at", 2122 + "type": "integer", 2123 + "primaryKey": false, 2124 + "notNull": false, 2125 + "autoincrement": false, 2126 + "default": "(strftime('%s', 'now'))" 2127 + } 2128 + }, 2129 + "indexes": { 2130 + "application_dsn_unique": { 2131 + "name": "application_dsn_unique", 2132 + "columns": [ 2133 + "dsn" 2134 + ], 2135 + "isUnique": true 2136 + } 2137 + }, 2138 + "foreignKeys": { 2139 + "application_workspace_id_workspace_id_fk": { 2140 + "name": "application_workspace_id_workspace_id_fk", 2141 + "tableFrom": "application", 2142 + "tableTo": "workspace", 2143 + "columnsFrom": [ 2144 + "workspace_id" 2145 + ], 2146 + "columnsTo": [ 2147 + "id" 2148 + ], 2149 + "onDelete": "no action", 2150 + "onUpdate": "no action" 2151 + } 2152 + }, 2153 + "compositePrimaryKeys": {}, 2154 + "uniqueConstraints": {}, 2155 + "checkConstraints": {} 2156 + }, 2157 + "maintenance": { 2158 + "name": "maintenance", 2159 + "columns": { 2160 + "id": { 2161 + "name": "id", 2162 + "type": "integer", 2163 + "primaryKey": true, 2164 + "notNull": true, 2165 + "autoincrement": false 2166 + }, 2167 + "title": { 2168 + "name": "title", 2169 + "type": "text(256)", 2170 + "primaryKey": false, 2171 + "notNull": true, 2172 + "autoincrement": false 2173 + }, 2174 + "message": { 2175 + "name": "message", 2176 + "type": "text", 2177 + "primaryKey": false, 2178 + "notNull": true, 2179 + "autoincrement": false 2180 + }, 2181 + "from": { 2182 + "name": "from", 2183 + "type": "integer", 2184 + "primaryKey": false, 2185 + "notNull": true, 2186 + "autoincrement": false 2187 + }, 2188 + "to": { 2189 + "name": "to", 2190 + "type": "integer", 2191 + "primaryKey": false, 2192 + "notNull": true, 2193 + "autoincrement": false 2194 + }, 2195 + "workspace_id": { 2196 + "name": "workspace_id", 2197 + "type": "integer", 2198 + "primaryKey": false, 2199 + "notNull": false, 2200 + "autoincrement": false 2201 + }, 2202 + "page_id": { 2203 + "name": "page_id", 2204 + "type": "integer", 2205 + "primaryKey": false, 2206 + "notNull": false, 2207 + "autoincrement": false 2208 + }, 2209 + "created_at": { 2210 + "name": "created_at", 2211 + "type": "integer", 2212 + "primaryKey": false, 2213 + "notNull": false, 2214 + "autoincrement": false, 2215 + "default": "(strftime('%s', 'now'))" 2216 + }, 2217 + "updated_at": { 2218 + "name": "updated_at", 2219 + "type": "integer", 2220 + "primaryKey": false, 2221 + "notNull": false, 2222 + "autoincrement": false, 2223 + "default": "(strftime('%s', 'now'))" 2224 + } 2225 + }, 2226 + "indexes": {}, 2227 + "foreignKeys": { 2228 + "maintenance_workspace_id_workspace_id_fk": { 2229 + "name": "maintenance_workspace_id_workspace_id_fk", 2230 + "tableFrom": "maintenance", 2231 + "tableTo": "workspace", 2232 + "columnsFrom": [ 2233 + "workspace_id" 2234 + ], 2235 + "columnsTo": [ 2236 + "id" 2237 + ], 2238 + "onDelete": "no action", 2239 + "onUpdate": "no action" 2240 + }, 2241 + "maintenance_page_id_page_id_fk": { 2242 + "name": "maintenance_page_id_page_id_fk", 2243 + "tableFrom": "maintenance", 2244 + "tableTo": "page", 2245 + "columnsFrom": [ 2246 + "page_id" 2247 + ], 2248 + "columnsTo": [ 2249 + "id" 2250 + ], 2251 + "onDelete": "cascade", 2252 + "onUpdate": "no action" 2253 + } 2254 + }, 2255 + "compositePrimaryKeys": {}, 2256 + "uniqueConstraints": {}, 2257 + "checkConstraints": {} 2258 + }, 2259 + "maintenance_to_monitor": { 2260 + "name": "maintenance_to_monitor", 2261 + "columns": { 2262 + "maintenance_id": { 2263 + "name": "maintenance_id", 2264 + "type": "integer", 2265 + "primaryKey": false, 2266 + "notNull": true, 2267 + "autoincrement": false 2268 + }, 2269 + "monitor_id": { 2270 + "name": "monitor_id", 2271 + "type": "integer", 2272 + "primaryKey": false, 2273 + "notNull": true, 2274 + "autoincrement": false 2275 + }, 2276 + "created_at": { 2277 + "name": "created_at", 2278 + "type": "integer", 2279 + "primaryKey": false, 2280 + "notNull": false, 2281 + "autoincrement": false, 2282 + "default": "(strftime('%s', 'now'))" 2283 + } 2284 + }, 2285 + "indexes": {}, 2286 + "foreignKeys": { 2287 + "maintenance_to_monitor_maintenance_id_maintenance_id_fk": { 2288 + "name": "maintenance_to_monitor_maintenance_id_maintenance_id_fk", 2289 + "tableFrom": "maintenance_to_monitor", 2290 + "tableTo": "maintenance", 2291 + "columnsFrom": [ 2292 + "maintenance_id" 2293 + ], 2294 + "columnsTo": [ 2295 + "id" 2296 + ], 2297 + "onDelete": "cascade", 2298 + "onUpdate": "no action" 2299 + }, 2300 + "maintenance_to_monitor_monitor_id_monitor_id_fk": { 2301 + "name": "maintenance_to_monitor_monitor_id_monitor_id_fk", 2302 + "tableFrom": "maintenance_to_monitor", 2303 + "tableTo": "monitor", 2304 + "columnsFrom": [ 2305 + "monitor_id" 2306 + ], 2307 + "columnsTo": [ 2308 + "id" 2309 + ], 2310 + "onDelete": "cascade", 2311 + "onUpdate": "no action" 2312 + } 2313 + }, 2314 + "compositePrimaryKeys": { 2315 + "maintenance_to_monitor_maintenance_id_monitor_id_pk": { 2316 + "columns": [ 2317 + "maintenance_id", 2318 + "monitor_id" 2319 + ], 2320 + "name": "maintenance_to_monitor_maintenance_id_monitor_id_pk" 2321 + } 2322 + }, 2323 + "uniqueConstraints": {}, 2324 + "checkConstraints": {} 2325 + }, 2326 + "check": { 2327 + "name": "check", 2328 + "columns": { 2329 + "id": { 2330 + "name": "id", 2331 + "type": "integer", 2332 + "primaryKey": true, 2333 + "notNull": true, 2334 + "autoincrement": true 2335 + }, 2336 + "regions": { 2337 + "name": "regions", 2338 + "type": "text", 2339 + "primaryKey": false, 2340 + "notNull": true, 2341 + "autoincrement": false, 2342 + "default": "''" 2343 + }, 2344 + "url": { 2345 + "name": "url", 2346 + "type": "text(4096)", 2347 + "primaryKey": false, 2348 + "notNull": true, 2349 + "autoincrement": false 2350 + }, 2351 + "headers": { 2352 + "name": "headers", 2353 + "type": "text", 2354 + "primaryKey": false, 2355 + "notNull": false, 2356 + "autoincrement": false, 2357 + "default": "''" 2358 + }, 2359 + "body": { 2360 + "name": "body", 2361 + "type": "text", 2362 + "primaryKey": false, 2363 + "notNull": false, 2364 + "autoincrement": false, 2365 + "default": "''" 2366 + }, 2367 + "method": { 2368 + "name": "method", 2369 + "type": "text", 2370 + "primaryKey": false, 2371 + "notNull": false, 2372 + "autoincrement": false, 2373 + "default": "'GET'" 2374 + }, 2375 + "count_requests": { 2376 + "name": "count_requests", 2377 + "type": "integer", 2378 + "primaryKey": false, 2379 + "notNull": false, 2380 + "autoincrement": false, 2381 + "default": 1 2382 + }, 2383 + "workspace_id": { 2384 + "name": "workspace_id", 2385 + "type": "integer", 2386 + "primaryKey": false, 2387 + "notNull": false, 2388 + "autoincrement": false 2389 + }, 2390 + "created_at": { 2391 + "name": "created_at", 2392 + "type": "integer", 2393 + "primaryKey": false, 2394 + "notNull": false, 2395 + "autoincrement": false, 2396 + "default": "(strftime('%s', 'now'))" 2397 + } 2398 + }, 2399 + "indexes": {}, 2400 + "foreignKeys": { 2401 + "check_workspace_id_workspace_id_fk": { 2402 + "name": "check_workspace_id_workspace_id_fk", 2403 + "tableFrom": "check", 2404 + "tableTo": "workspace", 2405 + "columnsFrom": [ 2406 + "workspace_id" 2407 + ], 2408 + "columnsTo": [ 2409 + "id" 2410 + ], 2411 + "onDelete": "no action", 2412 + "onUpdate": "no action" 2413 + } 2414 + }, 2415 + "compositePrimaryKeys": {}, 2416 + "uniqueConstraints": {}, 2417 + "checkConstraints": {} 2418 + }, 2419 + "monitor_run": { 2420 + "name": "monitor_run", 2421 + "columns": { 2422 + "id": { 2423 + "name": "id", 2424 + "type": "integer", 2425 + "primaryKey": true, 2426 + "notNull": true, 2427 + "autoincrement": false 2428 + }, 2429 + "workspace_id": { 2430 + "name": "workspace_id", 2431 + "type": "integer", 2432 + "primaryKey": false, 2433 + "notNull": false, 2434 + "autoincrement": false 2435 + }, 2436 + "monitor_id": { 2437 + "name": "monitor_id", 2438 + "type": "integer", 2439 + "primaryKey": false, 2440 + "notNull": false, 2441 + "autoincrement": false 2442 + }, 2443 + "runned_at": { 2444 + "name": "runned_at", 2445 + "type": "integer", 2446 + "primaryKey": false, 2447 + "notNull": false, 2448 + "autoincrement": false 2449 + }, 2450 + "created_at": { 2451 + "name": "created_at", 2452 + "type": "integer", 2453 + "primaryKey": false, 2454 + "notNull": false, 2455 + "autoincrement": false, 2456 + "default": "(strftime('%s', 'now'))" 2457 + } 2458 + }, 2459 + "indexes": {}, 2460 + "foreignKeys": { 2461 + "monitor_run_workspace_id_workspace_id_fk": { 2462 + "name": "monitor_run_workspace_id_workspace_id_fk", 2463 + "tableFrom": "monitor_run", 2464 + "tableTo": "workspace", 2465 + "columnsFrom": [ 2466 + "workspace_id" 2467 + ], 2468 + "columnsTo": [ 2469 + "id" 2470 + ], 2471 + "onDelete": "no action", 2472 + "onUpdate": "no action" 2473 + }, 2474 + "monitor_run_monitor_id_monitor_id_fk": { 2475 + "name": "monitor_run_monitor_id_monitor_id_fk", 2476 + "tableFrom": "monitor_run", 2477 + "tableTo": "monitor", 2478 + "columnsFrom": [ 2479 + "monitor_id" 2480 + ], 2481 + "columnsTo": [ 2482 + "id" 2483 + ], 2484 + "onDelete": "no action", 2485 + "onUpdate": "no action" 2486 + } 2487 + }, 2488 + "compositePrimaryKeys": {}, 2489 + "uniqueConstraints": {}, 2490 + "checkConstraints": {} 2491 + }, 2492 + "private_location": { 2493 + "name": "private_location", 2494 + "columns": { 2495 + "id": { 2496 + "name": "id", 2497 + "type": "integer", 2498 + "primaryKey": true, 2499 + "notNull": true, 2500 + "autoincrement": false 2501 + }, 2502 + "name": { 2503 + "name": "name", 2504 + "type": "text", 2505 + "primaryKey": false, 2506 + "notNull": true, 2507 + "autoincrement": false 2508 + }, 2509 + "token": { 2510 + "name": "token", 2511 + "type": "text", 2512 + "primaryKey": false, 2513 + "notNull": true, 2514 + "autoincrement": false 2515 + }, 2516 + "last_seen_at": { 2517 + "name": "last_seen_at", 2518 + "type": "integer", 2519 + "primaryKey": false, 2520 + "notNull": false, 2521 + "autoincrement": false 2522 + }, 2523 + "workspace_id": { 2524 + "name": "workspace_id", 2525 + "type": "integer", 2526 + "primaryKey": false, 2527 + "notNull": false, 2528 + "autoincrement": false 2529 + }, 2530 + "created_at": { 2531 + "name": "created_at", 2532 + "type": "integer", 2533 + "primaryKey": false, 2534 + "notNull": false, 2535 + "autoincrement": false, 2536 + "default": "(strftime('%s', 'now'))" 2537 + }, 2538 + "updated_at": { 2539 + "name": "updated_at", 2540 + "type": "integer", 2541 + "primaryKey": false, 2542 + "notNull": false, 2543 + "autoincrement": false, 2544 + "default": "(strftime('%s', 'now'))" 2545 + } 2546 + }, 2547 + "indexes": {}, 2548 + "foreignKeys": { 2549 + "private_location_workspace_id_workspace_id_fk": { 2550 + "name": "private_location_workspace_id_workspace_id_fk", 2551 + "tableFrom": "private_location", 2552 + "tableTo": "workspace", 2553 + "columnsFrom": [ 2554 + "workspace_id" 2555 + ], 2556 + "columnsTo": [ 2557 + "id" 2558 + ], 2559 + "onDelete": "no action", 2560 + "onUpdate": "no action" 2561 + } 2562 + }, 2563 + "compositePrimaryKeys": {}, 2564 + "uniqueConstraints": {}, 2565 + "checkConstraints": {} 2566 + }, 2567 + "private_location_to_monitor": { 2568 + "name": "private_location_to_monitor", 2569 + "columns": { 2570 + "private_location_id": { 2571 + "name": "private_location_id", 2572 + "type": "integer", 2573 + "primaryKey": false, 2574 + "notNull": false, 2575 + "autoincrement": false 2576 + }, 2577 + "monitor_id": { 2578 + "name": "monitor_id", 2579 + "type": "integer", 2580 + "primaryKey": false, 2581 + "notNull": false, 2582 + "autoincrement": false 2583 + }, 2584 + "created_at": { 2585 + "name": "created_at", 2586 + "type": "integer", 2587 + "primaryKey": false, 2588 + "notNull": false, 2589 + "autoincrement": false, 2590 + "default": "(strftime('%s', 'now'))" 2591 + }, 2592 + "deleted_at": { 2593 + "name": "deleted_at", 2594 + "type": "integer", 2595 + "primaryKey": false, 2596 + "notNull": false, 2597 + "autoincrement": false 2598 + } 2599 + }, 2600 + "indexes": {}, 2601 + "foreignKeys": { 2602 + "private_location_to_monitor_private_location_id_private_location_id_fk": { 2603 + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", 2604 + "tableFrom": "private_location_to_monitor", 2605 + "tableTo": "private_location", 2606 + "columnsFrom": [ 2607 + "private_location_id" 2608 + ], 2609 + "columnsTo": [ 2610 + "id" 2611 + ], 2612 + "onDelete": "cascade", 2613 + "onUpdate": "no action" 2614 + }, 2615 + "private_location_to_monitor_monitor_id_monitor_id_fk": { 2616 + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", 2617 + "tableFrom": "private_location_to_monitor", 2618 + "tableTo": "monitor", 2619 + "columnsFrom": [ 2620 + "monitor_id" 2621 + ], 2622 + "columnsTo": [ 2623 + "id" 2624 + ], 2625 + "onDelete": "cascade", 2626 + "onUpdate": "no action" 2627 + } 2628 + }, 2629 + "compositePrimaryKeys": {}, 2630 + "uniqueConstraints": {}, 2631 + "checkConstraints": {} 2632 + }, 2633 + "monitor_group": { 2634 + "name": "monitor_group", 2635 + "columns": { 2636 + "id": { 2637 + "name": "id", 2638 + "type": "integer", 2639 + "primaryKey": true, 2640 + "notNull": true, 2641 + "autoincrement": false 2642 + }, 2643 + "workspace_id": { 2644 + "name": "workspace_id", 2645 + "type": "integer", 2646 + "primaryKey": false, 2647 + "notNull": true, 2648 + "autoincrement": false 2649 + }, 2650 + "page_id": { 2651 + "name": "page_id", 2652 + "type": "integer", 2653 + "primaryKey": false, 2654 + "notNull": true, 2655 + "autoincrement": false 2656 + }, 2657 + "name": { 2658 + "name": "name", 2659 + "type": "text", 2660 + "primaryKey": false, 2661 + "notNull": true, 2662 + "autoincrement": false 2663 + }, 2664 + "created_at": { 2665 + "name": "created_at", 2666 + "type": "integer", 2667 + "primaryKey": false, 2668 + "notNull": false, 2669 + "autoincrement": false, 2670 + "default": "(strftime('%s', 'now'))" 2671 + }, 2672 + "updated_at": { 2673 + "name": "updated_at", 2674 + "type": "integer", 2675 + "primaryKey": false, 2676 + "notNull": false, 2677 + "autoincrement": false, 2678 + "default": "(strftime('%s', 'now'))" 2679 + } 2680 + }, 2681 + "indexes": {}, 2682 + "foreignKeys": { 2683 + "monitor_group_workspace_id_workspace_id_fk": { 2684 + "name": "monitor_group_workspace_id_workspace_id_fk", 2685 + "tableFrom": "monitor_group", 2686 + "tableTo": "workspace", 2687 + "columnsFrom": [ 2688 + "workspace_id" 2689 + ], 2690 + "columnsTo": [ 2691 + "id" 2692 + ], 2693 + "onDelete": "cascade", 2694 + "onUpdate": "no action" 2695 + }, 2696 + "monitor_group_page_id_page_id_fk": { 2697 + "name": "monitor_group_page_id_page_id_fk", 2698 + "tableFrom": "monitor_group", 2699 + "tableTo": "page", 2700 + "columnsFrom": [ 2701 + "page_id" 2702 + ], 2703 + "columnsTo": [ 2704 + "id" 2705 + ], 2706 + "onDelete": "cascade", 2707 + "onUpdate": "no action" 2708 + } 2709 + }, 2710 + "compositePrimaryKeys": {}, 2711 + "uniqueConstraints": {}, 2712 + "checkConstraints": {} 2713 + }, 2714 + "viewer": { 2715 + "name": "viewer", 2716 + "columns": { 2717 + "id": { 2718 + "name": "id", 2719 + "type": "integer", 2720 + "primaryKey": true, 2721 + "notNull": true, 2722 + "autoincrement": false 2723 + }, 2724 + "name": { 2725 + "name": "name", 2726 + "type": "text", 2727 + "primaryKey": false, 2728 + "notNull": false, 2729 + "autoincrement": false 2730 + }, 2731 + "email": { 2732 + "name": "email", 2733 + "type": "text", 2734 + "primaryKey": false, 2735 + "notNull": false, 2736 + "autoincrement": false 2737 + }, 2738 + "emailVerified": { 2739 + "name": "emailVerified", 2740 + "type": "integer", 2741 + "primaryKey": false, 2742 + "notNull": false, 2743 + "autoincrement": false 2744 + }, 2745 + "image": { 2746 + "name": "image", 2747 + "type": "text", 2748 + "primaryKey": false, 2749 + "notNull": false, 2750 + "autoincrement": false 2751 + }, 2752 + "created_at": { 2753 + "name": "created_at", 2754 + "type": "integer", 2755 + "primaryKey": false, 2756 + "notNull": false, 2757 + "autoincrement": false, 2758 + "default": "(strftime('%s', 'now'))" 2759 + }, 2760 + "updated_at": { 2761 + "name": "updated_at", 2762 + "type": "integer", 2763 + "primaryKey": false, 2764 + "notNull": false, 2765 + "autoincrement": false, 2766 + "default": "(strftime('%s', 'now'))" 2767 + } 2768 + }, 2769 + "indexes": { 2770 + "viewer_email_unique": { 2771 + "name": "viewer_email_unique", 2772 + "columns": [ 2773 + "email" 2774 + ], 2775 + "isUnique": true 2776 + } 2777 + }, 2778 + "foreignKeys": {}, 2779 + "compositePrimaryKeys": {}, 2780 + "uniqueConstraints": {}, 2781 + "checkConstraints": {} 2782 + }, 2783 + "viewer_accounts": { 2784 + "name": "viewer_accounts", 2785 + "columns": { 2786 + "user_id": { 2787 + "name": "user_id", 2788 + "type": "text", 2789 + "primaryKey": false, 2790 + "notNull": true, 2791 + "autoincrement": false 2792 + }, 2793 + "type": { 2794 + "name": "type", 2795 + "type": "text", 2796 + "primaryKey": false, 2797 + "notNull": true, 2798 + "autoincrement": false 2799 + }, 2800 + "provider": { 2801 + "name": "provider", 2802 + "type": "text", 2803 + "primaryKey": false, 2804 + "notNull": true, 2805 + "autoincrement": false 2806 + }, 2807 + "providerAccountId": { 2808 + "name": "providerAccountId", 2809 + "type": "text", 2810 + "primaryKey": false, 2811 + "notNull": true, 2812 + "autoincrement": false 2813 + }, 2814 + "refresh_token": { 2815 + "name": "refresh_token", 2816 + "type": "text", 2817 + "primaryKey": false, 2818 + "notNull": false, 2819 + "autoincrement": false 2820 + }, 2821 + "access_token": { 2822 + "name": "access_token", 2823 + "type": "text", 2824 + "primaryKey": false, 2825 + "notNull": false, 2826 + "autoincrement": false 2827 + }, 2828 + "expires_at": { 2829 + "name": "expires_at", 2830 + "type": "integer", 2831 + "primaryKey": false, 2832 + "notNull": false, 2833 + "autoincrement": false 2834 + }, 2835 + "token_type": { 2836 + "name": "token_type", 2837 + "type": "text", 2838 + "primaryKey": false, 2839 + "notNull": false, 2840 + "autoincrement": false 2841 + }, 2842 + "scope": { 2843 + "name": "scope", 2844 + "type": "text", 2845 + "primaryKey": false, 2846 + "notNull": false, 2847 + "autoincrement": false 2848 + }, 2849 + "id_token": { 2850 + "name": "id_token", 2851 + "type": "text", 2852 + "primaryKey": false, 2853 + "notNull": false, 2854 + "autoincrement": false 2855 + }, 2856 + "session_state": { 2857 + "name": "session_state", 2858 + "type": "text", 2859 + "primaryKey": false, 2860 + "notNull": false, 2861 + "autoincrement": false 2862 + } 2863 + }, 2864 + "indexes": {}, 2865 + "foreignKeys": { 2866 + "viewer_accounts_user_id_viewer_id_fk": { 2867 + "name": "viewer_accounts_user_id_viewer_id_fk", 2868 + "tableFrom": "viewer_accounts", 2869 + "tableTo": "viewer", 2870 + "columnsFrom": [ 2871 + "user_id" 2872 + ], 2873 + "columnsTo": [ 2874 + "id" 2875 + ], 2876 + "onDelete": "cascade", 2877 + "onUpdate": "no action" 2878 + } 2879 + }, 2880 + "compositePrimaryKeys": { 2881 + "viewer_accounts_provider_providerAccountId_pk": { 2882 + "columns": [ 2883 + "provider", 2884 + "providerAccountId" 2885 + ], 2886 + "name": "viewer_accounts_provider_providerAccountId_pk" 2887 + } 2888 + }, 2889 + "uniqueConstraints": {}, 2890 + "checkConstraints": {} 2891 + }, 2892 + "viewer_session": { 2893 + "name": "viewer_session", 2894 + "columns": { 2895 + "session_token": { 2896 + "name": "session_token", 2897 + "type": "text", 2898 + "primaryKey": true, 2899 + "notNull": true, 2900 + "autoincrement": false 2901 + }, 2902 + "user_id": { 2903 + "name": "user_id", 2904 + "type": "integer", 2905 + "primaryKey": false, 2906 + "notNull": true, 2907 + "autoincrement": false 2908 + }, 2909 + "expires": { 2910 + "name": "expires", 2911 + "type": "integer", 2912 + "primaryKey": false, 2913 + "notNull": true, 2914 + "autoincrement": false 2915 + } 2916 + }, 2917 + "indexes": {}, 2918 + "foreignKeys": { 2919 + "viewer_session_user_id_viewer_id_fk": { 2920 + "name": "viewer_session_user_id_viewer_id_fk", 2921 + "tableFrom": "viewer_session", 2922 + "tableTo": "viewer", 2923 + "columnsFrom": [ 2924 + "user_id" 2925 + ], 2926 + "columnsTo": [ 2927 + "id" 2928 + ], 2929 + "onDelete": "cascade", 2930 + "onUpdate": "no action" 2931 + } 2932 + }, 2933 + "compositePrimaryKeys": {}, 2934 + "uniqueConstraints": {}, 2935 + "checkConstraints": {} 2936 + }, 2937 + "api_key": { 2938 + "name": "api_key", 2939 + "columns": { 2940 + "id": { 2941 + "name": "id", 2942 + "type": "integer", 2943 + "primaryKey": true, 2944 + "notNull": true, 2945 + "autoincrement": true 2946 + }, 2947 + "name": { 2948 + "name": "name", 2949 + "type": "text", 2950 + "primaryKey": false, 2951 + "notNull": true, 2952 + "autoincrement": false 2953 + }, 2954 + "description": { 2955 + "name": "description", 2956 + "type": "text", 2957 + "primaryKey": false, 2958 + "notNull": false, 2959 + "autoincrement": false 2960 + }, 2961 + "prefix": { 2962 + "name": "prefix", 2963 + "type": "text", 2964 + "primaryKey": false, 2965 + "notNull": true, 2966 + "autoincrement": false 2967 + }, 2968 + "hashed_token": { 2969 + "name": "hashed_token", 2970 + "type": "text", 2971 + "primaryKey": false, 2972 + "notNull": true, 2973 + "autoincrement": false 2974 + }, 2975 + "workspace_id": { 2976 + "name": "workspace_id", 2977 + "type": "integer", 2978 + "primaryKey": false, 2979 + "notNull": true, 2980 + "autoincrement": false 2981 + }, 2982 + "created_by_id": { 2983 + "name": "created_by_id", 2984 + "type": "integer", 2985 + "primaryKey": false, 2986 + "notNull": true, 2987 + "autoincrement": false 2988 + }, 2989 + "created_at": { 2990 + "name": "created_at", 2991 + "type": "integer", 2992 + "primaryKey": false, 2993 + "notNull": false, 2994 + "autoincrement": false, 2995 + "default": "(strftime('%s', 'now'))" 2996 + }, 2997 + "expires_at": { 2998 + "name": "expires_at", 2999 + "type": "integer", 3000 + "primaryKey": false, 3001 + "notNull": false, 3002 + "autoincrement": false 3003 + }, 3004 + "last_used_at": { 3005 + "name": "last_used_at", 3006 + "type": "integer", 3007 + "primaryKey": false, 3008 + "notNull": false, 3009 + "autoincrement": false 3010 + } 3011 + }, 3012 + "indexes": { 3013 + "api_key_prefix_unique": { 3014 + "name": "api_key_prefix_unique", 3015 + "columns": [ 3016 + "prefix" 3017 + ], 3018 + "isUnique": true 3019 + }, 3020 + "api_key_hashed_token_unique": { 3021 + "name": "api_key_hashed_token_unique", 3022 + "columns": [ 3023 + "hashed_token" 3024 + ], 3025 + "isUnique": true 3026 + }, 3027 + "api_key_prefix_idx": { 3028 + "name": "api_key_prefix_idx", 3029 + "columns": [ 3030 + "prefix" 3031 + ], 3032 + "isUnique": false 3033 + } 3034 + }, 3035 + "foreignKeys": { 3036 + "api_key_workspace_id_workspace_id_fk": { 3037 + "name": "api_key_workspace_id_workspace_id_fk", 3038 + "tableFrom": "api_key", 3039 + "tableTo": "workspace", 3040 + "columnsFrom": [ 3041 + "workspace_id" 3042 + ], 3043 + "columnsTo": [ 3044 + "id" 3045 + ], 3046 + "onDelete": "cascade", 3047 + "onUpdate": "no action" 3048 + }, 3049 + "api_key_created_by_id_user_id_fk": { 3050 + "name": "api_key_created_by_id_user_id_fk", 3051 + "tableFrom": "api_key", 3052 + "tableTo": "user", 3053 + "columnsFrom": [ 3054 + "created_by_id" 3055 + ], 3056 + "columnsTo": [ 3057 + "id" 3058 + ], 3059 + "onDelete": "cascade", 3060 + "onUpdate": "no action" 3061 + } 3062 + }, 3063 + "compositePrimaryKeys": {}, 3064 + "uniqueConstraints": {}, 3065 + "checkConstraints": {} 3066 + }, 3067 + "maintenance_to_page_component": { 3068 + "name": "maintenance_to_page_component", 3069 + "columns": { 3070 + "maintenance_id": { 3071 + "name": "maintenance_id", 3072 + "type": "integer", 3073 + "primaryKey": false, 3074 + "notNull": true, 3075 + "autoincrement": false 3076 + }, 3077 + "page_component_id": { 3078 + "name": "page_component_id", 3079 + "type": "integer", 3080 + "primaryKey": false, 3081 + "notNull": true, 3082 + "autoincrement": false 3083 + }, 3084 + "created_at": { 3085 + "name": "created_at", 3086 + "type": "integer", 3087 + "primaryKey": false, 3088 + "notNull": false, 3089 + "autoincrement": false, 3090 + "default": "(strftime('%s', 'now'))" 3091 + } 3092 + }, 3093 + "indexes": {}, 3094 + "foreignKeys": { 3095 + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { 3096 + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", 3097 + "tableFrom": "maintenance_to_page_component", 3098 + "tableTo": "maintenance", 3099 + "columnsFrom": [ 3100 + "maintenance_id" 3101 + ], 3102 + "columnsTo": [ 3103 + "id" 3104 + ], 3105 + "onDelete": "cascade", 3106 + "onUpdate": "no action" 3107 + }, 3108 + "maintenance_to_page_component_page_component_id_page_component_id_fk": { 3109 + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", 3110 + "tableFrom": "maintenance_to_page_component", 3111 + "tableTo": "page_component", 3112 + "columnsFrom": [ 3113 + "page_component_id" 3114 + ], 3115 + "columnsTo": [ 3116 + "id" 3117 + ], 3118 + "onDelete": "cascade", 3119 + "onUpdate": "no action" 3120 + } 3121 + }, 3122 + "compositePrimaryKeys": { 3123 + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { 3124 + "columns": [ 3125 + "maintenance_id", 3126 + "page_component_id" 3127 + ], 3128 + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" 3129 + } 3130 + }, 3131 + "uniqueConstraints": {}, 3132 + "checkConstraints": {} 3133 + }, 3134 + "page_component": { 3135 + "name": "page_component", 3136 + "columns": { 3137 + "id": { 3138 + "name": "id", 3139 + "type": "integer", 3140 + "primaryKey": true, 3141 + "notNull": true, 3142 + "autoincrement": false 3143 + }, 3144 + "workspace_id": { 3145 + "name": "workspace_id", 3146 + "type": "integer", 3147 + "primaryKey": false, 3148 + "notNull": true, 3149 + "autoincrement": false 3150 + }, 3151 + "page_id": { 3152 + "name": "page_id", 3153 + "type": "integer", 3154 + "primaryKey": false, 3155 + "notNull": true, 3156 + "autoincrement": false 3157 + }, 3158 + "type": { 3159 + "name": "type", 3160 + "type": "text", 3161 + "primaryKey": false, 3162 + "notNull": true, 3163 + "autoincrement": false, 3164 + "default": "'monitor'" 3165 + }, 3166 + "monitor_id": { 3167 + "name": "monitor_id", 3168 + "type": "integer", 3169 + "primaryKey": false, 3170 + "notNull": false, 3171 + "autoincrement": false 3172 + }, 3173 + "name": { 3174 + "name": "name", 3175 + "type": "text", 3176 + "primaryKey": false, 3177 + "notNull": true, 3178 + "autoincrement": false 3179 + }, 3180 + "description": { 3181 + "name": "description", 3182 + "type": "text", 3183 + "primaryKey": false, 3184 + "notNull": false, 3185 + "autoincrement": false 3186 + }, 3187 + "order": { 3188 + "name": "order", 3189 + "type": "integer", 3190 + "primaryKey": false, 3191 + "notNull": false, 3192 + "autoincrement": false, 3193 + "default": 0 3194 + }, 3195 + "group_id": { 3196 + "name": "group_id", 3197 + "type": "integer", 3198 + "primaryKey": false, 3199 + "notNull": false, 3200 + "autoincrement": false 3201 + }, 3202 + "group_order": { 3203 + "name": "group_order", 3204 + "type": "integer", 3205 + "primaryKey": false, 3206 + "notNull": false, 3207 + "autoincrement": false, 3208 + "default": 0 3209 + }, 3210 + "created_at": { 3211 + "name": "created_at", 3212 + "type": "integer", 3213 + "primaryKey": false, 3214 + "notNull": false, 3215 + "autoincrement": false, 3216 + "default": "(strftime('%s', 'now'))" 3217 + }, 3218 + "updated_at": { 3219 + "name": "updated_at", 3220 + "type": "integer", 3221 + "primaryKey": false, 3222 + "notNull": false, 3223 + "autoincrement": false, 3224 + "default": "(strftime('%s', 'now'))" 3225 + } 3226 + }, 3227 + "indexes": { 3228 + "page_component_page_id_monitor_id_unique": { 3229 + "name": "page_component_page_id_monitor_id_unique", 3230 + "columns": [ 3231 + "page_id", 3232 + "monitor_id" 3233 + ], 3234 + "isUnique": true 3235 + } 3236 + }, 3237 + "foreignKeys": { 3238 + "page_component_workspace_id_workspace_id_fk": { 3239 + "name": "page_component_workspace_id_workspace_id_fk", 3240 + "tableFrom": "page_component", 3241 + "tableTo": "workspace", 3242 + "columnsFrom": [ 3243 + "workspace_id" 3244 + ], 3245 + "columnsTo": [ 3246 + "id" 3247 + ], 3248 + "onDelete": "cascade", 3249 + "onUpdate": "no action" 3250 + }, 3251 + "page_component_page_id_page_id_fk": { 3252 + "name": "page_component_page_id_page_id_fk", 3253 + "tableFrom": "page_component", 3254 + "tableTo": "page", 3255 + "columnsFrom": [ 3256 + "page_id" 3257 + ], 3258 + "columnsTo": [ 3259 + "id" 3260 + ], 3261 + "onDelete": "cascade", 3262 + "onUpdate": "no action" 3263 + }, 3264 + "page_component_monitor_id_monitor_id_fk": { 3265 + "name": "page_component_monitor_id_monitor_id_fk", 3266 + "tableFrom": "page_component", 3267 + "tableTo": "monitor", 3268 + "columnsFrom": [ 3269 + "monitor_id" 3270 + ], 3271 + "columnsTo": [ 3272 + "id" 3273 + ], 3274 + "onDelete": "cascade", 3275 + "onUpdate": "no action" 3276 + }, 3277 + "page_component_group_id_page_component_groups_id_fk": { 3278 + "name": "page_component_group_id_page_component_groups_id_fk", 3279 + "tableFrom": "page_component", 3280 + "tableTo": "page_component_groups", 3281 + "columnsFrom": [ 3282 + "group_id" 3283 + ], 3284 + "columnsTo": [ 3285 + "id" 3286 + ], 3287 + "onDelete": "set null", 3288 + "onUpdate": "no action" 3289 + } 3290 + }, 3291 + "compositePrimaryKeys": {}, 3292 + "uniqueConstraints": {}, 3293 + "checkConstraints": { 3294 + "page_component_type_check": { 3295 + "name": "page_component_type_check", 3296 + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" 3297 + } 3298 + } 3299 + }, 3300 + "status_report_to_page_component": { 3301 + "name": "status_report_to_page_component", 3302 + "columns": { 3303 + "status_report_id": { 3304 + "name": "status_report_id", 3305 + "type": "integer", 3306 + "primaryKey": false, 3307 + "notNull": true, 3308 + "autoincrement": false 3309 + }, 3310 + "page_component_id": { 3311 + "name": "page_component_id", 3312 + "type": "integer", 3313 + "primaryKey": false, 3314 + "notNull": true, 3315 + "autoincrement": false 3316 + }, 3317 + "created_at": { 3318 + "name": "created_at", 3319 + "type": "integer", 3320 + "primaryKey": false, 3321 + "notNull": false, 3322 + "autoincrement": false, 3323 + "default": "(strftime('%s', 'now'))" 3324 + } 3325 + }, 3326 + "indexes": {}, 3327 + "foreignKeys": { 3328 + "status_report_to_page_component_status_report_id_status_report_id_fk": { 3329 + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", 3330 + "tableFrom": "status_report_to_page_component", 3331 + "tableTo": "status_report", 3332 + "columnsFrom": [ 3333 + "status_report_id" 3334 + ], 3335 + "columnsTo": [ 3336 + "id" 3337 + ], 3338 + "onDelete": "cascade", 3339 + "onUpdate": "no action" 3340 + }, 3341 + "status_report_to_page_component_page_component_id_page_component_id_fk": { 3342 + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", 3343 + "tableFrom": "status_report_to_page_component", 3344 + "tableTo": "page_component", 3345 + "columnsFrom": [ 3346 + "page_component_id" 3347 + ], 3348 + "columnsTo": [ 3349 + "id" 3350 + ], 3351 + "onDelete": "cascade", 3352 + "onUpdate": "no action" 3353 + } 3354 + }, 3355 + "compositePrimaryKeys": { 3356 + "status_report_to_page_component_status_report_id_page_component_id_pk": { 3357 + "columns": [ 3358 + "status_report_id", 3359 + "page_component_id" 3360 + ], 3361 + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" 3362 + } 3363 + }, 3364 + "uniqueConstraints": {}, 3365 + "checkConstraints": {} 3366 + }, 3367 + "page_component_groups": { 3368 + "name": "page_component_groups", 3369 + "columns": { 3370 + "id": { 3371 + "name": "id", 3372 + "type": "integer", 3373 + "primaryKey": true, 3374 + "notNull": true, 3375 + "autoincrement": false 3376 + }, 3377 + "workspace_id": { 3378 + "name": "workspace_id", 3379 + "type": "integer", 3380 + "primaryKey": false, 3381 + "notNull": true, 3382 + "autoincrement": false 3383 + }, 3384 + "page_id": { 3385 + "name": "page_id", 3386 + "type": "integer", 3387 + "primaryKey": false, 3388 + "notNull": true, 3389 + "autoincrement": false 3390 + }, 3391 + "name": { 3392 + "name": "name", 3393 + "type": "text", 3394 + "primaryKey": false, 3395 + "notNull": true, 3396 + "autoincrement": false 3397 + }, 3398 + "created_at": { 3399 + "name": "created_at", 3400 + "type": "integer", 3401 + "primaryKey": false, 3402 + "notNull": false, 3403 + "autoincrement": false, 3404 + "default": "(strftime('%s', 'now'))" 3405 + }, 3406 + "updated_at": { 3407 + "name": "updated_at", 3408 + "type": "integer", 3409 + "primaryKey": false, 3410 + "notNull": false, 3411 + "autoincrement": false, 3412 + "default": "(strftime('%s', 'now'))" 3413 + } 3414 + }, 3415 + "indexes": {}, 3416 + "foreignKeys": { 3417 + "page_component_groups_workspace_id_workspace_id_fk": { 3418 + "name": "page_component_groups_workspace_id_workspace_id_fk", 3419 + "tableFrom": "page_component_groups", 3420 + "tableTo": "workspace", 3421 + "columnsFrom": [ 3422 + "workspace_id" 3423 + ], 3424 + "columnsTo": [ 3425 + "id" 3426 + ], 3427 + "onDelete": "cascade", 3428 + "onUpdate": "no action" 3429 + }, 3430 + "page_component_groups_page_id_page_id_fk": { 3431 + "name": "page_component_groups_page_id_page_id_fk", 3432 + "tableFrom": "page_component_groups", 3433 + "tableTo": "page", 3434 + "columnsFrom": [ 3435 + "page_id" 3436 + ], 3437 + "columnsTo": [ 3438 + "id" 3439 + ], 3440 + "onDelete": "cascade", 3441 + "onUpdate": "no action" 3442 + } 3443 + }, 3444 + "compositePrimaryKeys": {}, 3445 + "uniqueConstraints": {}, 3446 + "checkConstraints": {} 3447 + } 3448 + }, 3449 + "views": {}, 3450 + "enums": {}, 3451 + "_meta": { 3452 + "schemas": {}, 3453 + "tables": {}, 3454 + "columns": {} 3455 + }, 3456 + "internal": { 3457 + "indexes": {} 3458 + } 3459 + }
+7
packages/db/drizzle/meta/_journal.json
··· 386 386 "when": 1768564852930, 387 387 "tag": "0054_bitter_lilandra", 388 388 "breakpoints": true 389 + }, 390 + { 391 + "idx": 55, 392 + "version": "6", 393 + "when": 1769675142003, 394 + "tag": "0055_spicy_bastion", 395 + "breakpoints": true 389 396 } 390 397 ] 391 398 }
+3
packages/db/src/schema/page_components/constants.ts
··· 1 + export const pageComponentTypes = ["static", "monitor"] as const; 2 + 3 + export type PageComponentType = (typeof pageComponentTypes)[number];
+1
packages/db/src/schema/page_components/index.ts
··· 1 1 export * from "./page_components"; 2 2 export * from "./validation"; 3 + export * from "./constants";
+3 -4
packages/db/src/schema/page_components/page_components.ts
··· 14 14 import { page } from "../pages"; 15 15 import { statusReport } from "../status_reports"; 16 16 import { workspace } from "../workspaces"; 17 - 18 - export const pageComponentTypes = ["external", "monitor"] as const; 17 + import { pageComponentTypes } from "./constants"; 19 18 20 19 export const pageComponent = sqliteTable( 21 20 "page_component", ··· 55 54 ), 56 55 check( 57 56 "page_component_type_check", 58 - // NOTE: This check ensures that either the component is a monitor or an external component, but not both. 59 - sql`${t.type} = 'monitor' AND ${t.monitorId} IS NOT NULL OR ${t.type} = 'external' AND ${t.monitorId} IS NULL`, 57 + // NOTE: This check ensures that either the component is a monitor or a static component, but not both. 58 + sql`${t.type} = 'monitor' AND ${t.monitorId} IS NOT NULL OR ${t.type} = 'static' AND ${t.monitorId} IS NULL`, 60 59 ), 61 60 ], 62 61 );
+3 -4
packages/db/src/schema/page_components/validation.ts
··· 1 1 import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 2 import type { z } from "zod"; 3 - 4 3 import { pageComponent } from "./page_components"; 5 4 6 5 export const selectPageComponentSchema = createSelectSchema(pageComponent); ··· 13 12 if (data.type === "monitor" && !data.monitorId) { 14 13 return false; 15 14 } 16 - // monitorId must be null when type='external' 17 - if (data.type === "external" && data.monitorId) { 15 + // monitorId must be null when type='static' 16 + if (data.type === "static" && data.monitorId) { 18 17 return false; 19 18 } 20 19 return true; 21 20 }, 22 21 { 23 22 message: 24 - "monitorId must be set when type is 'monitor' and must be null when type is 'external'", 23 + "monitorId must be set when type is 'monitor' and must be null when type is 'static'", 25 24 }, 26 25 ); 27 26
+3
packages/db/src/schema/plan/config.ts
··· 38 38 "max-regions": 6, 39 39 "data-retention": "14 days", 40 40 "status-pages": 1, 41 + "page-components": 3, 41 42 maintenance: true, 42 43 "monitor-values-visibility": true, 43 44 "response-logs": false, ··· 110 111 "max-regions": 6, 111 112 "data-retention": "3 months", 112 113 "status-pages": 1, 114 + "page-components": 20, 113 115 maintenance: true, 114 116 "monitor-values-visibility": true, 115 117 "response-logs": true, ··· 182 184 "max-regions": AVAILABLE_REGIONS.length, 183 185 "data-retention": "12 months", 184 186 "status-pages": 5, 187 + "page-components": 50, 185 188 maintenance: true, 186 189 "monitor-values-visibility": true, 187 190 "response-logs": true,
+1
packages/db/src/schema/plan/schema.ts
··· 27 27 * Status page limits 28 28 */ 29 29 "status-pages": z.number().prefault(1), 30 + "page-components": z.number().prefault(3), 30 31 maintenance: z.boolean().prefault(true), 31 32 "monitor-values-visibility": z.boolean().prefault(true), 32 33 "status-subscribers": z.boolean().prefault(false),
+18 -32
packages/db/src/schema/shared.ts
··· 54 54 ) 55 55 .prefault([]), 56 56 }); 57 - // TODO: it would be nice to automatically add the monitor relation here 58 - // .refine((data) => ({ monitors: data.maintenancesToMonitors.map((m) => m.monitorId) })); 59 57 60 58 export const selectPageSchemaWithRelation = selectPageSchema.extend({ 61 59 monitors: z.array(selectMonitorSchema), 62 60 statusReports: z.array(selectStatusReportPageSchema), 63 - }); 64 - 65 - export const selectPageSchemaWithMonitorsRelation = selectPageSchema.extend({ 66 - monitorsToPages: z.array( 67 - z.object({ 68 - monitorId: z.number(), 69 - pageId: z.number(), 70 - order: z.number().prefault(0).optional(), 71 - monitor: selectMonitorSchema, 72 - }), 73 - ), 74 - maintenances: selectMaintenanceSchema.array().prefault([]), 75 - statusReports: selectStatusReportSchema 76 - .extend({ statusReportUpdates: selectStatusReportUpdateSchema.array() }) 77 - .array() 78 - .prefault([]), 79 61 }); 80 62 81 63 export const legacy_selectPublicPageSchemaWithRelation = selectPageSchema ··· 172 154 typeof selectPageComponentWithMonitorRelation 173 155 >; 174 156 157 + export const selectPublicPageLightSchemaWithRelation = selectPageSchema 158 + .extend({ 159 + monitors: z.array(selectPublicMonitorSchema).prefault([]), 160 + statusReports: z.array(selectStatusReportPageSchema).prefault([]), 161 + incidents: z.array(selectIncidentSchema).prefault([]), 162 + maintenances: z.array(selectMaintenancePageSchema).prefault([]), 163 + workspacePlan: workspacePlanSchema 164 + .nullable() 165 + .prefault("free") 166 + .transform((val) => val ?? "free"), 167 + // NEW: Include pageComponents for modern consumers 168 + pageComponents: selectPageComponentWithMonitorRelation.array().prefault([]), 169 + pageComponentGroups: selectPageComponentGroupSchema.array().prefault([]), 170 + }) 171 + .omit({ 172 + id: true, 173 + }); 174 + 175 175 export const selectPublicPageSchemaWithRelation = selectPageSchema.extend({ 176 176 monitorGroups: selectMonitorGroupSchema.array().prefault([]), 177 177 // TODO: include status of the monitor ··· 191 191 .transform((val) => val ?? "free"), 192 192 whiteLabel: z.boolean().prefault(false), 193 193 }); 194 - 195 - export const selectPublicStatusReportSchemaWithRelation = 196 - selectStatusReportSchema.extend({ 197 - monitorsToStatusReports: z 198 - .array( 199 - z.object({ 200 - monitorId: z.number(), 201 - statusReportId: z.number(), 202 - monitor: selectPublicMonitorSchema, 203 - }), 204 - ) 205 - .prefault([]), 206 - statusReportUpdates: z.array(selectStatusReportUpdateSchema), 207 - }); 208 194 209 195 export type StatusReportWithUpdates = z.infer< 210 196 typeof selectStatusReportPageSchema
+1
packages/db/src/schema/workspaces/validation.ts
··· 55 55 monitors: z.number().prefault(0), 56 56 notifications: z.number().prefault(0), 57 57 pages: z.number().prefault(0), 58 + pageComponents: z.number().prefault(0), 58 59 // checks: z.number().default(0), 59 60 }) 60 61 .nullish(),
+20 -2
packages/db/src/sync.ts
··· 259 259 } 260 260 261 261 /** 262 + * REVERSE SYNC: Syncs a page_component delete to monitors_to_pages 263 + * Used when pageComponent is the primary table and monitorsToPages is kept for backwards compatibility 264 + */ 265 + export async function syncPageComponentToMonitorsToPageDelete( 266 + db: DB | Transaction, 267 + data: { monitorId: number; pageId: number }, 268 + ) { 269 + await db 270 + .delete(monitorsToPages) 271 + .where( 272 + and( 273 + eq(monitorsToPages.monitorId, data.monitorId), 274 + eq(monitorsToPages.pageId, data.pageId), 275 + ), 276 + ); 277 + } 278 + 279 + /** 262 280 * Syncs monitors_to_pages deletes for a specific page to page_component 263 281 */ 264 282 export async function syncMonitorsToPageDeleteByPage( ··· 479 497 if (pageComponentIds.length === 0) return; 480 498 481 499 // Find monitor IDs from the page components 482 - // Only get components that have a monitorId (not external components) 500 + // Only get components that have a monitorId (not static components) 483 501 const components = await db 484 502 .select({ monitorId: pageComponent.monitorId }) 485 503 .from(pageComponent) ··· 691 709 if (pageComponentIds.length === 0) return; 692 710 693 711 // Find monitor IDs from the page components 694 - // Only get components that have a monitorId (not external components) 712 + // Only get components that have a monitorId (not static components) 695 713 const components = await db 696 714 .select({ monitorId: pageComponent.monitorId }) 697 715 .from(pageComponent)