Openstatus www.openstatus.dev

feat: page-components [dashboard] [Part 3] (#1754)

* feat: page-components dashboard page

* fix: description

* chore: disable external components

* fix: monitor description

* fix: upsert component instead of delete and recreate

* chore: update status-page note

* chore: reverse sync

* fix: sync only active monitors

* fix: devin review

* fix: typo

* fix: typo

authored by

Maximilian Kaske and committed by
GitHub
a4437f9e 3452a96a

+1996 -98
+9 -1
apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx
··· 3 3 import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; 4 4 import { useTRPC } from "@/lib/trpc/client"; 5 5 import { useQuery } from "@tanstack/react-query"; 6 - import { Cog, Hammer, Megaphone, PanelTop, Users } from "lucide-react"; 6 + import { 7 + Cog, 8 + Hammer, 9 + LayoutTemplate, 10 + Megaphone, 11 + PanelTop, 12 + Users, 13 + } from "lucide-react"; 7 14 import { useParams } from "next/navigation"; 8 15 9 16 export function Breadcrumb() { ··· 35 42 }, 36 43 { value: "maintenances", label: "Maintenances", icon: Hammer }, 37 44 { value: "subscribers", label: "Subscribers", icon: Users }, 45 + { value: "components", label: "Components", icon: LayoutTemplate }, 38 46 { value: "edit", label: "Settings", icon: Cog }, 39 47 ], 40 48 },
+35
apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/layout.tsx
··· 1 + import { SidebarProvider } from "@/components/ui/sidebar"; 2 + import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 3 + import { Sidebar } from "../sidebar"; 4 + 5 + export default async function Layout({ 6 + children, 7 + params, 8 + }: { 9 + children: React.ReactNode; 10 + params: Promise<{ id: string }>; 11 + }) { 12 + const { id } = await params; 13 + const queryClient = getQueryClient(); 14 + 15 + await Promise.all([ 16 + queryClient.prefetchQuery( 17 + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), 18 + ), 19 + queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), 20 + queryClient.prefetchQuery( 21 + trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), 22 + ), 23 + ]); 24 + 25 + return ( 26 + <HydrateClient> 27 + <SidebarProvider defaultOpen={false}> 28 + <div className="w-full flex-1">{children}</div> 29 + <div className="hidden lg:block"> 30 + <Sidebar /> 31 + </div> 32 + </SidebarProvider> 33 + </HydrateClient> 34 + ); 35 + }
+37
apps/dashboard/src/app/(dashboard)/status-pages/[id]/components/page.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + Section, 5 + SectionDescription, 6 + SectionGroup, 7 + SectionHeader, 8 + SectionTitle, 9 + } from "@/components/content/section"; 10 + import { FormComponentsUpdate } from "@/components/forms/components/update"; 11 + import { useTRPC } from "@/lib/trpc/client"; 12 + import { useQuery } from "@tanstack/react-query"; 13 + import { useParams } from "next/navigation"; 14 + 15 + export default function Page() { 16 + const { id } = useParams<{ id: string }>(); 17 + const trpc = useTRPC(); 18 + const { data: statusPage } = useQuery( 19 + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), 20 + ); 21 + 22 + if (!statusPage) return null; 23 + 24 + return ( 25 + <SectionGroup> 26 + <Section> 27 + <SectionHeader> 28 + <SectionTitle>{statusPage.title}</SectionTitle> 29 + <SectionDescription> 30 + Configure your page components. 31 + </SectionDescription> 32 + </SectionHeader> 33 + <FormComponentsUpdate /> 34 + </Section> 35 + </SectionGroup> 36 + ); 37 + }
+57
apps/dashboard/src/components/data-table/page-components/columns.tsx
··· 1 + "use client"; 2 + 3 + import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; 4 + import type { RouterOutputs } from "@openstatus/api"; 5 + import type { ColumnDef } from "@tanstack/react-table"; 6 + import { DataTableRowActions } from "./data-table-row-actions"; 7 + 8 + type PageComponent = RouterOutputs["pageComponent"]["list"][number]; 9 + 10 + export const columns: ColumnDef<PageComponent>[] = [ 11 + { 12 + accessorKey: "name", 13 + header: "Name", 14 + enableSorting: false, 15 + enableHiding: false, 16 + }, 17 + { 18 + accessorKey: "description", 19 + header: "Description", 20 + enableSorting: false, 21 + cell: ({ row }) => { 22 + const value = row.getValue("description"); 23 + return ( 24 + <span className="max-w-[200px] truncate text-muted-foreground"> 25 + {value ? String(value) : "-"} 26 + </span> 27 + ); 28 + }, 29 + }, 30 + { 31 + accessorKey: "type", 32 + header: ({ column }) => ( 33 + <DataTableColumnHeader column={column} title="Type" /> 34 + ), 35 + cell: ({ row }) => { 36 + const value = row.getValue("type"); 37 + return <span className="capitalize">{String(value)}</span>; 38 + }, 39 + }, 40 + { 41 + accessorKey: "order", 42 + header: ({ column }) => ( 43 + <DataTableColumnHeader column={column} title="Order" /> 44 + ), 45 + cell: ({ row }) => { 46 + const value = row.getValue("order"); 47 + return <span>{value != null ? String(value) : "-"}</span>; 48 + }, 49 + }, 50 + { 51 + id: "actions", 52 + cell: ({ row }) => <DataTableRowActions row={row} />, 53 + meta: { 54 + cellClassName: "w-8", 55 + }, 56 + }, 57 + ];
+46
apps/dashboard/src/components/data-table/page-components/data-table-row-actions.tsx
··· 1 + "use client"; 2 + 3 + import { QuickActions } from "@/components/dropdowns/quick-actions"; 4 + import { getActions } from "@/data/page-components.client"; 5 + import { useTRPC } from "@/lib/trpc/client"; 6 + import type { RouterOutputs } from "@openstatus/api"; 7 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 8 + import type { Row } from "@tanstack/react-table"; 9 + 10 + type PageComponent = RouterOutputs["pageComponent"]["list"][number]; 11 + 12 + interface DataTableRowActionsProps { 13 + row: Row<PageComponent>; 14 + } 15 + 16 + export function DataTableRowActions({ row }: DataTableRowActionsProps) { 17 + const trpc = useTRPC(); 18 + const actions = getActions({}); 19 + const queryClient = useQueryClient(); 20 + 21 + const deletePageComponentMutation = useMutation( 22 + trpc.pageComponent.delete.mutationOptions({ 23 + onSuccess: () => { 24 + queryClient.refetchQueries({ 25 + queryKey: trpc.pageComponent.list.queryKey({ 26 + pageId: row.original.pageId ?? undefined, 27 + }), 28 + }); 29 + }, 30 + }), 31 + ); 32 + 33 + return ( 34 + <QuickActions 35 + actions={actions} 36 + deleteAction={{ 37 + confirmationValue: row.original.name ?? "component", 38 + submitAction: async () => { 39 + await deletePageComponentMutation.mutateAsync({ 40 + id: row.original.id, 41 + }); 42 + }, 43 + }} 44 + /> 45 + ); 46 + }
+1175
apps/dashboard/src/components/forms/components/form-components.tsx
··· 1 + "use client"; 2 + 3 + import { Link } from "@/components/common/link"; 4 + import { 5 + EmptyStateContainer, 6 + EmptyStateTitle, 7 + } from "@/components/content/empty-state"; 8 + import { 9 + FormCard, 10 + FormCardContent, 11 + FormCardDescription, 12 + FormCardFooter, 13 + FormCardFooterInfo, 14 + FormCardHeader, 15 + FormCardSeparator, 16 + FormCardTitle, 17 + } from "@/components/forms/form-card"; 18 + import { STATUS } from "@/components/nav/nav-monitors"; 19 + import { 20 + AlertDialog, 21 + AlertDialogAction, 22 + AlertDialogCancel, 23 + AlertDialogContent, 24 + AlertDialogDescription, 25 + AlertDialogFooter, 26 + AlertDialogHeader, 27 + AlertDialogTitle, 28 + AlertDialogTrigger, 29 + } from "@/components/ui/alert-dialog"; 30 + import { Button } from "@/components/ui/button"; 31 + import { 32 + Command, 33 + CommandEmpty, 34 + CommandGroup, 35 + CommandInput, 36 + CommandItem, 37 + CommandList, 38 + } from "@/components/ui/command"; 39 + import { 40 + DropdownMenu, 41 + DropdownMenuContent, 42 + DropdownMenuGroup, 43 + DropdownMenuItem, 44 + DropdownMenuSub, 45 + DropdownMenuSubContent, 46 + DropdownMenuSubTrigger, 47 + DropdownMenuTrigger, 48 + } from "@/components/ui/dropdown-menu"; 49 + import { 50 + Form, 51 + FormControl, 52 + FormField, 53 + FormItem, 54 + FormLabel, 55 + FormMessage, 56 + } from "@/components/ui/form"; 57 + import { Input } from "@/components/ui/input"; 58 + import { 59 + Sortable, 60 + SortableContent, 61 + SortableItem, 62 + SortableItemHandle, 63 + SortableOverlay, 64 + } from "@/components/ui/sortable"; 65 + import { 66 + Tooltip, 67 + TooltipContent, 68 + TooltipProvider, 69 + TooltipTrigger, 70 + } from "@/components/ui/tooltip"; 71 + import { cn } from "@/lib/utils"; 72 + import type { UniqueIdentifier } from "@dnd-kit/core"; 73 + import { zodResolver } from "@hookform/resolvers/zod"; 74 + import type { RouterOutputs } from "@openstatus/api"; 75 + import { isTRPCClientError } from "@trpc/client"; 76 + import { 77 + Check, 78 + Eye, 79 + EyeOff, 80 + GripVertical, 81 + Link2, 82 + Link2Off, 83 + Plus, 84 + Trash2, 85 + } from "lucide-react"; 86 + import { useCallback, useEffect, useState, useTransition } from "react"; 87 + import { type UseFormReturn, useForm } from "react-hook-form"; 88 + import { toast } from "sonner"; 89 + import { z } from "zod"; 90 + 91 + type PageComponent = RouterOutputs["pageComponent"]["list"][number]; 92 + type Monitor = RouterOutputs["monitor"]["list"][number]; 93 + 94 + type ComponentGroup = { 95 + id: number; 96 + name: string; 97 + components: PageComponent[]; 98 + }; 99 + 100 + const componentSchema = z.object({ 101 + id: z.number(), 102 + monitorId: z.number().nullish(), 103 + order: z.number(), 104 + name: z.string().min(1, { message: "Name is required" }), 105 + description: z.string().optional(), 106 + type: z.enum(["monitor", "external"]), 107 + }); 108 + 109 + const schema = z.object({ 110 + components: z.array(componentSchema), 111 + groups: z.array( 112 + z.object({ 113 + id: z.number(), 114 + order: z.number(), 115 + name: z.string(), 116 + components: z.array(componentSchema).min(1, { 117 + message: "At least one component is required", 118 + }), 119 + }), 120 + ), 121 + }); 122 + 123 + const getSortedComponents = ( 124 + components: PageComponent[], 125 + componentData: { 126 + id: number; 127 + order: number; 128 + name?: string; 129 + type?: "monitor" | "external"; 130 + monitorId?: number | null; 131 + }[], 132 + monitors: Monitor[], 133 + ) => { 134 + const orderMap = new Map(componentData?.map((c) => [c.id, c.order]) ?? []); 135 + 136 + // Create a map of existing components 137 + const componentMap = new Map(components.map((c) => [c.id, c])); 138 + 139 + // Create a map of monitors for lookup 140 + const monitorMap = new Map(monitors.map((m) => [m.id, m])); 141 + 142 + // Create synthetic components for any in componentData that don't exist in components 143 + componentData.forEach((c) => { 144 + if (!componentMap.has(c.id)) { 145 + // Look up monitor data if this is a monitor component 146 + const monitor = c.monitorId ? monitorMap.get(c.monitorId) : null; 147 + 148 + // Create synthetic PageComponent 149 + componentMap.set(c.id, { 150 + id: c.id, 151 + name: c.name ?? "", 152 + type: c.type ?? "external", 153 + monitorId: c.monitorId ?? null, 154 + monitor: monitor ?? null, 155 + groupId: null, 156 + groupOrder: null, 157 + order: c.order, 158 + } as PageComponent); 159 + } 160 + }); 161 + 162 + return Array.from(componentMap.values()) 163 + .filter((component) => orderMap.has(component.id)) 164 + .sort((a, b) => { 165 + const aOrder = orderMap.get(a.id) ?? 0; 166 + const bOrder = orderMap.get(b.id) ?? 0; 167 + return aOrder - bOrder; 168 + }); 169 + }; 170 + 171 + const getSortedItems = ( 172 + components: PageComponent[], 173 + componentData: { 174 + id: number; 175 + order: number; 176 + name?: string; 177 + type?: "monitor" | "external"; 178 + monitorId?: number | null; 179 + }[], 180 + groups: Array<{ 181 + id: number; 182 + order: number; 183 + name: string; 184 + components: Array<{ 185 + id: number; 186 + order: number; 187 + name?: string; 188 + type?: "monitor" | "external"; 189 + monitorId?: number | null; 190 + }>; 191 + }>, 192 + monitors: Monitor[], 193 + ): (PageComponent | ComponentGroup)[] => { 194 + // Create map of component orders 195 + const componentOrderMap = new Map(componentData.map((c) => [c.id, c.order])); 196 + 197 + // Create a map of existing components 198 + const componentMap = new Map(components.map((c) => [c.id, c])); 199 + 200 + // Create a map of monitors for lookup 201 + const monitorMap = new Map(monitors.map((m) => [m.id, m])); 202 + 203 + // Create synthetic components for any in componentData that don't exist in components 204 + componentData.forEach((c) => { 205 + if (!componentMap.has(c.id)) { 206 + // Look up monitor data if this is a monitor component 207 + const monitor = c.monitorId ? monitorMap.get(c.monitorId) : null; 208 + 209 + // Create synthetic PageComponent 210 + componentMap.set(c.id, { 211 + id: c.id, 212 + name: c.name ?? "", 213 + type: c.type ?? "external", 214 + monitorId: c.monitorId ?? null, 215 + monitor: monitor ?? null, 216 + groupId: null, 217 + groupOrder: null, 218 + order: c.order, 219 + } as PageComponent); 220 + } 221 + }); 222 + 223 + // Get all enhanced components (including synthetic ones) 224 + const enhancedComponents = Array.from(componentMap.values()); 225 + 226 + // Create array of components with their orders 227 + const componentsWithOrder = enhancedComponents 228 + .filter((component) => componentOrderMap.has(component.id)) 229 + .map((component) => ({ 230 + item: component, 231 + order: componentOrderMap.get(component.id) ?? 0, 232 + })); 233 + 234 + // Create array of groups with their orders 235 + const groupsWithOrder = groups.map((group) => ({ 236 + item: { 237 + id: group.id, 238 + name: group.name, 239 + components: getSortedComponents( 240 + enhancedComponents, 241 + group.components, 242 + monitors, 243 + ), 244 + } as ComponentGroup, 245 + order: group.order, 246 + })); 247 + 248 + // Combine and sort by order 249 + return [...componentsWithOrder, ...groupsWithOrder] 250 + .sort((a, b) => a.order - b.order) 251 + .map((entry) => entry.item); 252 + }; 253 + 254 + type FormValues = z.infer<typeof schema>; 255 + 256 + export function FormComponents({ 257 + defaultValues, 258 + onSubmit, 259 + pageComponents, 260 + allPageComponents, 261 + monitors, 262 + legacy, 263 + ...props 264 + }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 265 + defaultValues?: FormValues; 266 + /** Page components available for selection (standalone, not in groups) */ 267 + pageComponents: PageComponent[]; 268 + /** All page components for the page (including those in groups) */ 269 + allPageComponents: PageComponent[]; 270 + /** Monitors available for selection */ 271 + monitors: Monitor[]; 272 + /** 273 + * Whether the status page is legacy or new 274 + */ 275 + legacy: boolean; 276 + onSubmit: (values: FormValues) => Promise<void>; 277 + }) { 278 + const form = useForm<FormValues>({ 279 + resolver: zodResolver(schema), 280 + defaultValues: defaultValues ?? { components: [], groups: [] }, 281 + }); 282 + const [isPending, startTransition] = useTransition(); 283 + const watchComponents = form.watch("components"); 284 + const watchGroups = form.watch("groups"); 285 + const [data, setData] = useState<(PageComponent | ComponentGroup)[]>( 286 + getSortedItems( 287 + allPageComponents, 288 + defaultValues?.components ?? [], 289 + defaultValues?.groups ?? [], 290 + monitors, 291 + ), 292 + ); 293 + 294 + // Get all monitor IDs that are already used (in standalone components or groups) 295 + const usedMonitorIds = new Set([ 296 + ...(watchComponents ?? []) 297 + .filter((c) => c.monitorId) 298 + .map((c) => c.monitorId), 299 + ...(watchGroups ?? []) 300 + .flatMap((g) => g.components) 301 + .filter((c) => c.monitorId) 302 + .map((c) => c.monitorId), 303 + ]); 304 + 305 + useEffect(() => { 306 + const sortedItems = getSortedItems( 307 + allPageComponents, 308 + watchComponents, 309 + watchGroups ?? [], 310 + monitors, 311 + ); 312 + setData(sortedItems); 313 + }, [watchComponents, watchGroups, allPageComponents, monitors]); 314 + 315 + const onValueChange = useCallback( 316 + (newItems: (PageComponent | ComponentGroup)[]) => { 317 + setData(newItems); 318 + 319 + // Update components with their position in the overall list 320 + const existingComponents = form.getValues("components") ?? []; 321 + const components = newItems 322 + .map((item, index) => ({ item, index })) 323 + .filter( 324 + (entry): entry is { item: PageComponent; index: number } => 325 + "type" in entry.item, 326 + ) 327 + .map(({ item, index }) => { 328 + const existingComponent = existingComponents.find( 329 + (c) => c.id === item.id, 330 + ); 331 + return { 332 + id: item.id, 333 + monitorId: item.monitorId, 334 + order: index, 335 + name: existingComponent?.name ?? item.name, 336 + description: existingComponent?.description ?? "", 337 + type: item.type, 338 + }; 339 + }); 340 + form.setValue("components", components); 341 + 342 + // Update groups with their position in the overall list 343 + const existingGroups = form.getValues("groups") ?? []; 344 + const groups = newItems 345 + .map((item, index) => ({ item, index })) 346 + .filter( 347 + (entry): entry is { item: ComponentGroup; index: number } => 348 + "components" in entry.item && !("type" in entry.item), 349 + ) 350 + .map(({ item, index }) => { 351 + const existingGroup = existingGroups.find((g) => g.id === item.id); 352 + return existingGroup 353 + ? { 354 + ...existingGroup, 355 + order: index, 356 + } 357 + : { 358 + id: item.id, 359 + order: index, 360 + name: item.name, 361 + components: [], 362 + }; 363 + }); 364 + form.setValue("groups", groups); 365 + }, 366 + [form], 367 + ); 368 + 369 + const getItemValue = useCallback( 370 + (item: PageComponent | ComponentGroup) => item.id, 371 + [], 372 + ); 373 + 374 + const handleAddGroup = useCallback(() => { 375 + const newGroupId = Date.now(); 376 + const existingGroups = form.getValues("groups") ?? []; 377 + const existingComponents = form.getValues("components") ?? []; 378 + const order = existingGroups.length + existingComponents.length; 379 + const newGroups = [ 380 + ...existingGroups, 381 + { id: newGroupId, order, name: "", components: [] }, 382 + ]; 383 + form.setValue("groups", newGroups); 384 + setData((prev) => [...prev, { id: newGroupId, name: "", components: [] }]); 385 + }, [form]); 386 + 387 + const handleDeleteGroup = useCallback( 388 + (groupId: number) => { 389 + const existingGroups = form.getValues("groups") ?? []; 390 + form.setValue( 391 + "groups", 392 + existingGroups.filter((g) => g.id !== groupId), 393 + ); 394 + setData((prev) => prev.filter((item) => item.id !== groupId)); 395 + }, 396 + [form], 397 + ); 398 + 399 + const handleDeleteComponent = useCallback( 400 + (componentId: number) => { 401 + const existingComponents = form.getValues("components") ?? []; 402 + form.setValue( 403 + "components", 404 + existingComponents.filter((c) => c.id !== componentId), 405 + ); 406 + setData((prev) => prev.filter((item) => item.id !== componentId)); 407 + }, 408 + [form], 409 + ); 410 + 411 + const renderOverlay = useCallback( 412 + ({ value }: { value: UniqueIdentifier }) => { 413 + const index = data.findIndex((item) => item.id === value); 414 + if (index === -1) return null; 415 + const item = data[index]; 416 + 417 + if ("type" in item) { 418 + return ( 419 + <ComponentRow 420 + component={item} 421 + form={form} 422 + className="border-transparent border-x px-2" 423 + onDelete={handleDeleteComponent} 424 + // FIXME: this is used to show an input instead of the name when dragging a component 425 + // fieldNamePrefix={`components.${index}`} 426 + /> 427 + ); 428 + } 429 + 430 + const groups = form.getValues("groups") ?? []; 431 + const groupIndex = groups.findIndex((g) => g.id === item.id); 432 + return ( 433 + <ComponentGroupRow 434 + group={item} 435 + groupIndex={groupIndex} 436 + onDeleteGroup={handleDeleteGroup} 437 + form={form} 438 + allPageComponents={allPageComponents} 439 + monitors={monitors} 440 + /> 441 + ); 442 + }, 443 + [ 444 + data, 445 + handleDeleteGroup, 446 + form, 447 + allPageComponents, 448 + monitors, 449 + handleDeleteComponent, 450 + ], 451 + ); 452 + 453 + function submitAction(values: FormValues) { 454 + if (isPending) return; 455 + 456 + startTransition(async () => { 457 + try { 458 + const promise = onSubmit(values); 459 + toast.promise(promise, { 460 + loading: "Saving...", 461 + success: "Saved", 462 + error: (error) => { 463 + if (isTRPCClientError(error)) { 464 + return error.message; 465 + } 466 + return "Failed to save"; 467 + }, 468 + }); 469 + await promise; 470 + } catch (error) { 471 + console.error(error); 472 + } 473 + }); 474 + } 475 + 476 + 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, 624 + ); 625 + return ( 626 + <ComponentRow 627 + key={`${item.id}-component`} 628 + className="border-transparent border-x px-2" 629 + component={item} 630 + form={form} 631 + onDelete={handleDeleteComponent} 632 + fieldNamePrefix={ 633 + componentIndex >= 0 634 + ? `components.${componentIndex}` 635 + : undefined 636 + } 637 + /> 638 + ); 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> 680 + ); 681 + } 682 + 683 + interface ComponentRowProps 684 + extends Omit<React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { 685 + component: PageComponent; 686 + form: UseFormReturn<FormValues>; 687 + onDelete: (componentId: number) => void; 688 + /** The form field name prefix, e.g. "components.0" or "groups.0.components.1" */ 689 + fieldNamePrefix?: string; 690 + } 691 + 692 + function ComponentRow({ 693 + component, 694 + className, 695 + onDelete, 696 + form, 697 + fieldNamePrefix, 698 + ...props 699 + }: ComponentRowProps) { 700 + return ( 701 + <SortableItem 702 + value={component.id} 703 + asChild 704 + className={cn("rounded-md", className)} 705 + {...props} 706 + > 707 + <div className="grid h-9 grid-cols-4 gap-2"> 708 + <div className="flex flex-row items-center gap-1 self-center"> 709 + <SortableItemHandle> 710 + <GripVertical 711 + size={16} 712 + aria-hidden="true" 713 + className="text-muted-foreground" 714 + /> 715 + </SortableItemHandle> 716 + {fieldNamePrefix ? ( 717 + <FormField 718 + control={form.control} 719 + name={`${fieldNamePrefix}.name` as "components.0.name"} 720 + render={({ field }) => ( 721 + <FormItem className="w-full"> 722 + <FormLabel className="sr-only">Component name</FormLabel> 723 + <FormControl> 724 + <Input 725 + placeholder="Name" 726 + className="w-full bg-background" 727 + {...field} 728 + /> 729 + </FormControl> 730 + <FormMessage /> 731 + </FormItem> 732 + )} 733 + /> 734 + ) : ( 735 + <span className="truncate rounded-md border border-transparent px-3 py-1 text-sm"> 736 + {component.name} 737 + </span> 738 + )} 739 + </div> 740 + <div className="flex flex-row items-center gap-1 self-center"> 741 + {fieldNamePrefix ? ( 742 + <FormField 743 + control={form.control} 744 + name={ 745 + `${fieldNamePrefix}.description` as "components.0.description" 746 + } 747 + render={({ field }) => ( 748 + <FormItem className="w-full"> 749 + <FormLabel className="sr-only"> 750 + Component description 751 + </FormLabel> 752 + <FormControl> 753 + <Input 754 + placeholder="Description" 755 + className="w-full bg-background" 756 + {...field} 757 + /> 758 + </FormControl> 759 + <FormMessage /> 760 + </FormItem> 761 + )} 762 + /> 763 + ) : ( 764 + <span className="truncate rounded-md border border-transparent px-3 py-1 text-sm"> 765 + {component.description} 766 + </span> 767 + )} 768 + </div> 769 + <div className="flex items-center gap-2 self-center text-muted-foreground text-sm"> 770 + {component.monitor && component.type === "monitor" ? ( 771 + <Link 772 + href={`/monitors/${component.monitorId}/overview`} 773 + onClick={(e) => e.stopPropagation()} 774 + className="flex w-full items-center gap-2 truncate text-sm" 775 + > 776 + <Link2 className="size-4 shrink-0" />{" "} 777 + <span className="truncate">{component.monitor.name}</span> 778 + </Link> 779 + ) : ( 780 + <span className="flex items-center gap-2 text-muted-foreground text-sm"> 781 + <Link2Off className="size-4 shrink-0" />{" "} 782 + <span className="truncate">External Component</span> 783 + </span> 784 + )} 785 + </div> 786 + <div className="flex justify-between"> 787 + <div className="flex flex-1 items-center gap-2.5"> 788 + {component.monitor && component.type === "monitor" ? ( 789 + <div className="flex items-center gap-2"> 790 + <TooltipProvider delayDuration={0}> 791 + {component.monitor.public ? ( 792 + <Tooltip> 793 + <TooltipTrigger> 794 + <Eye className="size-4 text-muted-foreground" /> 795 + </TooltipTrigger> 796 + <TooltipContent>Public</TooltipContent> 797 + </Tooltip> 798 + ) : ( 799 + <Tooltip> 800 + <TooltipTrigger> 801 + <EyeOff className="size-4 text-muted-foreground" /> 802 + </TooltipTrigger> 803 + <TooltipContent>Private</TooltipContent> 804 + </Tooltip> 805 + )} 806 + </TooltipProvider> 807 + </div> 808 + ) : null} 809 + {component.monitor && component.type === "monitor" ? ( 810 + <div 811 + className={cn( 812 + "size-2 rounded-full", 813 + STATUS[ 814 + component.monitor.active 815 + ? component.monitor.status 816 + : "inactive" 817 + ], 818 + )} 819 + /> 820 + ) : null} 821 + </div> 822 + <AlertDialog> 823 + <AlertDialogTrigger asChild> 824 + <Button 825 + type="button" 826 + variant="ghost" 827 + size="icon" 828 + className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 [&_svg]:size-4 [&_svg]:text-destructive" 829 + > 830 + <Trash2 /> 831 + </Button> 832 + </AlertDialogTrigger> 833 + <AlertDialogContent> 834 + <AlertDialogHeader> 835 + <AlertDialogTitle>Are you sure?</AlertDialogTitle> 836 + <AlertDialogDescription> 837 + You are about to delete this component. 838 + </AlertDialogDescription> 839 + </AlertDialogHeader> 840 + <AlertDialogFooter> 841 + <AlertDialogCancel>Cancel</AlertDialogCancel> 842 + <AlertDialogAction 843 + 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" 844 + onClick={() => onDelete(component.id)} 845 + > 846 + Delete 847 + </AlertDialogAction> 848 + </AlertDialogFooter> 849 + </AlertDialogContent> 850 + </AlertDialog> 851 + </div> 852 + </div> 853 + </SortableItem> 854 + ); 855 + } 856 + 857 + interface ComponentGroupRowProps 858 + extends Omit<React.ComponentPropsWithoutRef<typeof SortableItem>, "value"> { 859 + group: ComponentGroup; 860 + groupIndex: number; 861 + onDeleteGroup: (groupId: number) => void; 862 + form: UseFormReturn<FormValues>; 863 + allPageComponents: PageComponent[]; 864 + monitors: Monitor[]; 865 + } 866 + 867 + function ComponentGroupRow({ 868 + group, 869 + groupIndex, 870 + onDeleteGroup, 871 + form, 872 + allPageComponents, 873 + monitors, 874 + }: ComponentGroupRowProps) { 875 + const watchGroup = form.watch(`groups.${groupIndex}`); 876 + const watchComponents = form.watch("components"); 877 + const watchGroups = form.watch("groups"); 878 + const [data, setData] = useState<PageComponent[]>(group.components); 879 + 880 + // Calculate taken monitor IDs (in main list or other groups) 881 + const takenMonitorIds = new Set([ 882 + ...watchComponents.filter((c) => c.monitorId).map((c) => c.monitorId), 883 + ...watchGroups 884 + .filter((g) => g.id !== group.id) 885 + .flatMap((g) => 886 + g.components.filter((c) => c.monitorId).map((c) => c.monitorId), 887 + ), 888 + ]); 889 + 890 + // FIXME: order is not being updated in the form 891 + const onValueChange = useCallback( 892 + (newComponents: PageComponent[]) => { 893 + setData(newComponents); 894 + // Update the form with the new component order 895 + const existingComponents = 896 + form.getValues(`groups.${groupIndex}.components`) ?? []; 897 + form.setValue( 898 + `groups.${groupIndex}.components`, 899 + newComponents.map((c, index) => { 900 + const existingComponent = existingComponents.find( 901 + (ec) => ec.id === c.id, 902 + ); 903 + return { 904 + id: c.id, 905 + monitorId: c.monitorId, 906 + order: index, 907 + name: existingComponent?.name ?? c.name, 908 + description: existingComponent?.description ?? "", 909 + type: c.type, 910 + }; 911 + }), 912 + ); 913 + }, 914 + [form, groupIndex], 915 + ); 916 + 917 + useEffect(() => { 918 + setData( 919 + getSortedComponents(allPageComponents, watchGroup.components, monitors), 920 + ); 921 + }, [watchGroup.components, allPageComponents, monitors]); 922 + 923 + const getItemValue = useCallback((item: PageComponent) => item.id, []); 924 + 925 + const handleDeleteComponent = useCallback( 926 + (componentId: number) => { 927 + const existingComponents = 928 + form.getValues(`groups.${groupIndex}.components`) ?? []; 929 + form.setValue( 930 + `groups.${groupIndex}.components`, 931 + existingComponents.filter((c) => c.id !== componentId), 932 + ); 933 + setData((prev) => prev.filter((item) => item.id !== componentId)); 934 + }, 935 + [form, groupIndex], 936 + ); 937 + 938 + const renderOverlay = useCallback( 939 + ({ value }: { value: UniqueIdentifier }) => { 940 + const component = data.find((item) => item.id === value); 941 + if (!component) return null; 942 + 943 + return ( 944 + <ComponentRow 945 + component={component} 946 + form={form} 947 + onDelete={handleDeleteComponent} 948 + /> 949 + ); 950 + }, 951 + [data, form, handleDeleteComponent], 952 + ); 953 + 954 + return ( 955 + <SortableItem value={group.id} className="rounded-md border bg-muted"> 956 + <div className="grid grid-cols-4 gap-2 px-2 pt-2"> 957 + <div className="flex flex-row items-center gap-1 self-center"> 958 + <SortableItemHandle> 959 + <GripVertical 960 + size={16} 961 + aria-hidden="true" 962 + className="text-muted-foreground" 963 + /> 964 + </SortableItemHandle> 965 + <FormField 966 + key={`${group.id}-name-${groupIndex}`} 967 + control={form.control} 968 + name={`groups.${groupIndex}.name` as const} 969 + render={({ field }) => ( 970 + <FormItem className="w-full"> 971 + <FormLabel className="sr-only">Group name</FormLabel> 972 + <FormControl> 973 + <Input 974 + placeholder="Group Name" 975 + className="w-full bg-background" 976 + {...field} 977 + /> 978 + </FormControl> 979 + <FormMessage /> 980 + </FormItem> 981 + )} 982 + /> 983 + </div> 984 + <FormField 985 + key={`${group.id}-components-${groupIndex}`} 986 + control={form.control} 987 + name={`groups.${groupIndex}.components` as const} 988 + render={({ field }) => ( 989 + <FormItem className="flex flex-col"> 990 + <FormLabel className="sr-only">Components</FormLabel> 991 + <DropdownMenu> 992 + <DropdownMenuTrigger asChild> 993 + <Button variant="outline" className="w-full"> 994 + <Plus /> 995 + Add Component 996 + </Button> 997 + </DropdownMenuTrigger> 998 + <DropdownMenuContent 999 + align="end" 1000 + className="w-[var(--radix-dropdown-menu-trigger-width)]" 1001 + > 1002 + <DropdownMenuGroup> 1003 + <DropdownMenuItem 1004 + disabled 1005 + onClick={() => { 1006 + const current = field.value ?? []; 1007 + form.setValue(`groups.${groupIndex}.components`, [ 1008 + ...current, 1009 + { 1010 + id: Date.now(), 1011 + monitorId: null, 1012 + order: current.length, 1013 + name: "", 1014 + description: "", 1015 + type: "external" as const, 1016 + }, 1017 + ]); 1018 + }} 1019 + > 1020 + <Link2Off className="text-muted-foreground" /> 1021 + Add External (soon) 1022 + </DropdownMenuItem> 1023 + <DropdownMenuSub> 1024 + <DropdownMenuSubTrigger className="gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0"> 1025 + <Link2 className="text-muted-foreground" /> 1026 + Add Monitor 1027 + </DropdownMenuSubTrigger> 1028 + <DropdownMenuSubContent className="p-0"> 1029 + <Command> 1030 + <CommandInput 1031 + placeholder="Search monitors..." 1032 + className="h-9" 1033 + /> 1034 + <CommandList> 1035 + <CommandEmpty>No monitors found.</CommandEmpty> 1036 + <CommandGroup> 1037 + {monitors.map((monitor) => { 1038 + const current = field.value ?? []; 1039 + const isTaken = takenMonitorIds.has(monitor.id); 1040 + const isSelected = current.some( 1041 + (c) => c.monitorId === monitor.id, 1042 + ); 1043 + return ( 1044 + <CommandItem 1045 + value={monitor.name} 1046 + key={monitor.id} 1047 + disabled={isTaken} 1048 + onSelect={() => { 1049 + if (isSelected) { 1050 + form.setValue( 1051 + `groups.${groupIndex}.components`, 1052 + current.filter( 1053 + (c) => c.monitorId !== monitor.id, 1054 + ), 1055 + ); 1056 + } else { 1057 + form.setValue( 1058 + `groups.${groupIndex}.components`, 1059 + [ 1060 + ...current, 1061 + { 1062 + id: Date.now(), 1063 + monitorId: monitor.id, 1064 + order: current.length, 1065 + name: monitor.name, 1066 + description: monitor.description, 1067 + type: "monitor" as const, 1068 + }, 1069 + ], 1070 + ); 1071 + } 1072 + }} 1073 + > 1074 + {monitor.name} 1075 + <Check 1076 + className={cn( 1077 + "ml-auto", 1078 + isSelected 1079 + ? "opacity-100" 1080 + : "opacity-0", 1081 + )} 1082 + /> 1083 + </CommandItem> 1084 + ); 1085 + })} 1086 + </CommandGroup> 1087 + </CommandList> 1088 + </Command> 1089 + </DropdownMenuSubContent> 1090 + </DropdownMenuSub> 1091 + </DropdownMenuGroup> 1092 + </DropdownMenuContent> 1093 + </DropdownMenu> 1094 + <FormMessage /> 1095 + </FormItem> 1096 + )} 1097 + /> 1098 + <div /> 1099 + <div className="flex justify-end"> 1100 + <AlertDialog> 1101 + <AlertDialogTrigger asChild> 1102 + <Button 1103 + type="button" 1104 + variant="ghost" 1105 + size="icon" 1106 + className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20 [&_svg]:size-4 [&_svg]:text-destructive" 1107 + // NOTE: delete directly if no components are in the group 1108 + {...(data.length === 0 1109 + ? { onClick: () => onDeleteGroup(group.id) } 1110 + : {})} 1111 + > 1112 + <Trash2 /> 1113 + </Button> 1114 + </AlertDialogTrigger> 1115 + <AlertDialogContent> 1116 + <AlertDialogHeader> 1117 + <AlertDialogTitle>Are you sure?</AlertDialogTitle> 1118 + <AlertDialogDescription> 1119 + You are about to delete this group and all its components. 1120 + </AlertDialogDescription> 1121 + </AlertDialogHeader> 1122 + <AlertDialogFooter> 1123 + <AlertDialogCancel>Cancel</AlertDialogCancel> 1124 + <AlertDialogAction 1125 + 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" 1126 + onClick={() => onDeleteGroup(group.id)} 1127 + > 1128 + Delete 1129 + </AlertDialogAction> 1130 + </AlertDialogFooter> 1131 + </AlertDialogContent> 1132 + </AlertDialog> 1133 + </div> 1134 + </div> 1135 + <div className="mt-2 border-t px-2 pt-2 pb-2"> 1136 + <Sortable 1137 + value={data} 1138 + onValueChange={onValueChange} 1139 + getItemValue={getItemValue} 1140 + orientation="vertical" 1141 + > 1142 + {data.length ? ( 1143 + <SortableContent className="grid gap-2"> 1144 + {data.map((item, _index) => { 1145 + const groupComponents = 1146 + form.getValues(`groups.${groupIndex}.components`) ?? []; 1147 + const componentIndex = groupComponents.findIndex( 1148 + (c) => c.id === item.id, 1149 + ); 1150 + return ( 1151 + <ComponentRow 1152 + key={`${item.id}-component`} 1153 + component={item} 1154 + form={form} 1155 + onDelete={handleDeleteComponent} 1156 + fieldNamePrefix={ 1157 + componentIndex >= 0 1158 + ? `groups.${groupIndex}.components.${componentIndex}` 1159 + : undefined 1160 + } 1161 + /> 1162 + ); 1163 + })} 1164 + <SortableOverlay>{renderOverlay}</SortableOverlay> 1165 + </SortableContent> 1166 + ) : ( 1167 + <EmptyStateContainer> 1168 + <EmptyStateTitle>No components selected</EmptyStateTitle> 1169 + </EmptyStateContainer> 1170 + )} 1171 + </Sortable> 1172 + </div> 1173 + </SortableItem> 1174 + ); 1175 + }
+91
apps/dashboard/src/components/forms/components/update.tsx
··· 1 + "use client"; 2 + 3 + import { FormComponents } from "@/components/forms/components/form-components"; 4 + import { useTRPC } from "@/lib/trpc/client"; 5 + import { useMutation, useQuery } from "@tanstack/react-query"; 6 + import { useParams } from "next/navigation"; 7 + import { FormCardGroup } from "../form-card"; 8 + 9 + export function FormComponentsUpdate() { 10 + const { id } = useParams<{ id: string }>(); 11 + const trpc = useTRPC(); 12 + const { data: statusPage, refetch } = useQuery( 13 + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), 14 + ); 15 + const { data: pageComponents, refetch: refetchComponents } = useQuery( 16 + trpc.pageComponent.list.queryOptions({ pageId: Number.parseInt(id) }), 17 + ); 18 + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); 19 + 20 + const updateComponentsMutation = useMutation( 21 + trpc.pageComponent.updateOrder.mutationOptions({ 22 + onSuccess: () => { 23 + refetch(); 24 + refetchComponents(); 25 + }, 26 + }), 27 + ); 28 + 29 + if (!statusPage || !pageComponents || !monitors) return null; 30 + 31 + // Separate standalone components from grouped components 32 + const standaloneComponents = pageComponents.filter((c) => !c.groupId); 33 + const groupedComponents = pageComponents.filter((c) => c.groupId); 34 + 35 + // Build groups from pageComponentGroups 36 + const groups = statusPage.pageComponentGroups.map((group) => { 37 + const componentsInGroup = groupedComponents.filter( 38 + (c) => c.groupId === group.id, 39 + ); 40 + // Find the order of the group (use the first component's order) 41 + const firstComponent = componentsInGroup[0]; 42 + return { 43 + id: group.id, 44 + order: firstComponent?.order ?? 0, 45 + name: group.name, 46 + components: componentsInGroup.map((c) => ({ 47 + id: c.id, 48 + monitorId: c.monitorId, 49 + order: c.groupOrder ?? 0, 50 + name: c.name, 51 + description: c.description ?? "", 52 + type: c.type, 53 + })), 54 + }; 55 + }); 56 + 57 + // Build default values for the form 58 + const defaultValues = { 59 + components: standaloneComponents.map((c) => ({ 60 + id: c.id, 61 + monitorId: c.monitorId, 62 + order: c.order ?? 0, 63 + name: c.name, 64 + description: c.description ?? "", 65 + type: c.type, 66 + })), 67 + groups, 68 + }; 69 + 70 + return ( 71 + <FormCardGroup> 72 + <FormComponents 73 + pageComponents={standaloneComponents} 74 + monitors={monitors} 75 + allPageComponents={pageComponents} 76 + defaultValues={defaultValues} 77 + legacy={statusPage.legacyPage} 78 + onSubmit={async (values) => { 79 + await updateComponentsMutation.mutateAsync({ 80 + pageId: Number.parseInt(id), 81 + components: values.components.map(({ id, ...rest }) => rest), 82 + groups: values.groups.map(({ id, components, ...rest }) => ({ 83 + ...rest, 84 + components: components.map(({ id, ...c }) => c), 85 + })), 86 + }); 87 + }} 88 + /> 89 + </FormCardGroup> 90 + ); 91 + }
-23
apps/dashboard/src/components/forms/monitor/update.tsx
··· 16 16 import { FormResponseTime } from "./form-response-time"; 17 17 import { FormRetry, RETRY_DEFAULT } from "./form-retry"; 18 18 import { FormSchedulingRegions } from "./form-scheduling-regions"; 19 - import { FormStatusPages } from "./form-status-pages"; 20 19 import { FormTags } from "./form-tags"; 21 20 import { FormVisibility } from "./form-visibility"; 22 21 ··· 85 84 // TODO: open dialog 86 85 console.error(err); 87 86 }, 88 - }), 89 - ); 90 - 91 - const updateStatusPagesMutation = useMutation( 92 - trpc.monitor.updateStatusPages.mutationOptions({ 93 - onSuccess: () => refetch(), 94 87 }), 95 88 ); 96 89 ··· 191 184 regions: values.regions, 192 185 periodicity: values.periodicity, 193 186 privateLocations: values.privateLocations, 194 - }); 195 - }} 196 - /> 197 - <FormStatusPages 198 - statusPages={statusPages} 199 - defaultValues={{ 200 - statusPages: monitor.pages.map(({ id }) => id), 201 - description: monitor.description, 202 - externalName: monitor.externalName ?? "", 203 - }} 204 - onSubmit={async (values) => { 205 - await updateStatusPagesMutation.mutateAsync({ 206 - id: Number.parseInt(id), 207 - statusPages: values.statusPages, 208 - description: values.description, 209 - externalName: values.externalName, 210 187 }); 211 188 }} 212 189 />
+4 -54
apps/dashboard/src/components/forms/status-page/update.tsx
··· 11 11 import { FormDangerZone } from "./form-danger-zone"; 12 12 import { FormGeneral } from "./form-general"; 13 13 import { FormLinks } from "./form-links"; 14 - import { FormMonitors } from "./form-monitors"; 15 14 import { FormPageAccess } from "./form-page-access"; 16 15 17 16 export function FormStatusPageUpdate() { ··· 38 37 39 38 const updatePasswordProtectionMutation = useMutation( 40 39 trpc.page.updatePasswordProtection.mutationOptions({ 41 - onSuccess: () => refetch(), 42 - }), 43 - ); 44 - 45 - const updateMonitorsMutation = useMutation( 46 - trpc.page.updateMonitors.mutationOptions({ 47 40 onSuccess: () => refetch(), 48 41 }), 49 42 ); ··· 96 89 97 90 return ( 98 91 <FormCardGroup> 99 - <Note color="info"> 92 + <Note color="warning"> 100 93 <Info /> 101 94 <p className="text-sm"> 102 - We've enabled the new version of the status page. Read more about the{" "} 103 - <Link 104 - href="https://docs.openstatus.dev/tutorial/how-to-configure-status-page/" 105 - rel="noreferrer" 106 - target="_blank" 107 - > 108 - configuration 109 - </Link> 110 - . 95 + Looking to connect monitors to your status page? The setup now has a 96 + separate components page{" "} 97 + <Link href={`/status-pages/${id}/components`}>components</Link>. 111 98 </p> 112 99 </Note> 113 100 <FormGeneral ··· 124 111 slug: values.slug, 125 112 description: values.description ?? "", 126 113 icon: values.icon ?? "", 127 - }); 128 - }} 129 - /> 130 - <FormMonitors 131 - monitors={monitors ?? []} 132 - defaultValues={{ 133 - monitors: statusPage.monitors 134 - .filter((m) => !m.groupId) 135 - .map((monitor) => ({ 136 - id: monitor.id, 137 - order: monitor.order, 138 - active: monitor.active ?? null, 139 - })), 140 - groups: statusPage.monitorGroups.map((group) => { 141 - const order = 142 - statusPage.monitors.find((m) => m.groupId === group.id)?.order ?? 143 - 0; 144 - return { 145 - id: -1 * group.id, // negative id to avoid conflicts with monitors 146 - order, 147 - name: group.name, 148 - monitors: statusPage.monitors 149 - .filter((m) => m.groupId === group.id) 150 - .map((monitor) => ({ 151 - id: monitor.id, 152 - order: monitor.groupOrder, 153 - active: monitor.active ?? null, 154 - })), 155 - }; 156 - }), 157 - }} 158 - legacy={statusPage.legacyPage} 159 - onSubmit={async (values) => { 160 - await updateMonitorsMutation.mutateAsync({ 161 - id: Number.parseInt(id), 162 - monitors: values.monitors, 163 - groups: values.groups, 164 114 }); 165 115 }} 166 116 />
+1 -1
apps/dashboard/src/components/nav/nav-monitors.tsx
··· 33 33 import { usePathname, useRouter } from "next/navigation"; 34 34 import { toast } from "sonner"; 35 35 36 - const STATUS = { 36 + export const STATUS = { 37 37 degraded: "bg-warning border border-warning", 38 38 error: "bg-destructive border border-destructive", 39 39 inactive: "bg-muted-foreground/70 border border-muted-foreground/70",
+21
apps/dashboard/src/data/page-components.client.ts
··· 1 + import { Trash2 } from "lucide-react"; 2 + 3 + export const actions = [ 4 + { 5 + id: "delete", 6 + label: "Delete", 7 + icon: Trash2, 8 + variant: "destructive" as const, 9 + }, 10 + ] as const; 11 + 12 + export type PageComponentAction = (typeof actions)[number]; 13 + 14 + export const getActions = ( 15 + props: Partial<Record<PageComponentAction["id"], () => Promise<void> | void>>, 16 + ): (PageComponentAction & { onClick?: () => Promise<void> | void })[] => { 17 + return actions.map((action) => ({ 18 + ...action, 19 + onClick: props[action.id as keyof typeof props], 20 + })); 21 + };
+2
packages/api/src/edge.ts
··· 11 11 import { monitorTagRouter } from "./router/monitorTag"; 12 12 import { notificationRouter } from "./router/notification"; 13 13 import { pageRouter } from "./router/page"; 14 + import { pageComponentRouter } from "./router/pageComponent"; 14 15 import { pageSubscriberRouter } from "./router/pageSubscriber"; 15 16 import { privateLocationRouter } from "./router/privateLocation"; 16 17 import { statusPageRouter } from "./router/statusPage"; ··· 25 26 workspace: workspaceRouter, 26 27 monitor: monitorRouter, 27 28 page: pageRouter, 29 + pageComponent: pageComponentRouter, 28 30 statusReport: statusReportRouter, 29 31 domain: domainRouter, 30 32 integration: integrationRouter,
+46 -7
packages/api/src/router/page.ts
··· 12 12 syncMonitorGroupDeleteMany, 13 13 syncMonitorGroupInsert, 14 14 syncMonitorsToPageDelete, 15 - syncMonitorsToPageInsertMany, 16 15 syncMonitorsToPageUpsertMany, 16 + syncPageComponentToMonitorsToPageInsertMany, 17 17 } from "@openstatus/db"; 18 18 import { 19 19 incidentTable, ··· 25 25 monitorsToPages, 26 26 page, 27 27 pageAccessTypes, 28 + pageComponent, 28 29 selectMaintenanceSchema, 29 30 selectMonitorGroupSchema, 30 31 selectMonitorSchema, 32 + selectPageComponentGroupSchema, 31 33 selectPageComponentSchema, 32 34 selectPageSchema, 33 35 statusReport, ··· 122 124 .get(); 123 125 124 126 if (monitorIds.length) { 125 - // We should make sure the user has access to the monitors 127 + // We should make sure the user has access to the monitors AND they are active 126 128 const allMonitors = await opts.ctx.db.query.monitor.findMany({ 127 129 where: and( 128 130 inArray(monitor.id, monitorIds), 129 131 eq(monitor.workspaceId, opts.ctx.workspace.id), 132 + eq(monitor.active, true), // Only allow active monitors 130 133 isNull(monitor.deletedAt), 131 134 ), 132 135 }); ··· 134 137 if (allMonitors.length !== monitorIds.length) { 135 138 throw new TRPCError({ 136 139 code: "FORBIDDEN", 137 - message: "You don't have access to all the monitors.", 140 + message: 141 + "You don't have access to all the monitors or some monitors are inactive.", 138 142 }); 139 143 } 140 144 141 - const values = monitors.map(({ monitorId }, index) => ({ 145 + // Build a map for quick lookup 146 + const monitorMap = new Map(allMonitors.map((m) => [m.id, m])); 147 + 148 + // Build pageComponent values (primary table) 149 + const pageComponentValues = monitors 150 + .map(({ monitorId }, index) => { 151 + const m = monitorMap.get(monitorId); 152 + if (!m || !m.workspaceId) return null; 153 + return { 154 + workspaceId: m.workspaceId, 155 + pageId: newPage.id, 156 + type: "monitor" as const, 157 + monitorId, 158 + name: m.externalName || m.name, 159 + order: index, 160 + groupId: null, 161 + groupOrder: 0, 162 + }; 163 + }) 164 + .filter((v): v is NonNullable<typeof v> => v !== null); 165 + 166 + // Insert into pageComponents (primary table) 167 + await opts.ctx.db 168 + .insert(pageComponent) 169 + .values(pageComponentValues) 170 + .run(); 171 + 172 + // Build values for reverse sync to monitorsToPages 173 + const monitorsToPageValues = monitors.map(({ monitorId }, index) => ({ 142 174 pageId: newPage.id, 143 175 order: index, 144 176 monitorId, 145 177 })); 146 178 147 - await opts.ctx.db.insert(monitorsToPages).values(values).run(); 148 - // Sync to page components 149 - await syncMonitorsToPageInsertMany(opts.ctx.db, values); 179 + // Reverse sync to monitorsToPages (for backwards compatibility) 180 + await syncPageComponentToMonitorsToPageInsertMany( 181 + opts.ctx.db, 182 + monitorsToPageValues, 183 + ); 150 184 } 151 185 152 186 return newPage; ··· 353 387 monitorsToPages: { with: { monitor: true, monitorGroup: true } }, 354 388 maintenances: true, 355 389 pageComponents: true, 390 + pageComponentGroups: true, 356 391 }, 357 392 }); 358 393 ··· 368 403 ) 369 404 .prefault([]), 370 405 monitorGroups: z.array(selectMonitorGroupSchema).prefault([]), 406 + pageComponentGroups: z 407 + .array(selectPageComponentGroupSchema) 408 + .prefault([]), 371 409 maintenances: z.array(selectMaintenanceSchema).prefault([]), 372 410 pageComponents: z.array(selectPageComponentSchema).prefault([]), 373 411 }) ··· 386 424 .map((m) => [m.monitorGroup?.id, m.monitorGroup]), 387 425 ).values(), 388 426 ), 427 + pageComponentGroups: data?.pageComponentGroups ?? [], 389 428 maintenances: data?.maintenances, 390 429 pageComponents: data?.pageComponents, 391 430 });
+423
packages/api/src/router/pageComponent.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { type SQL, and, asc, desc, eq, inArray, sql } from "@openstatus/db"; 4 + import { 5 + page, 6 + pageComponent, 7 + pageComponentGroup, 8 + } from "@openstatus/db/src/schema"; 9 + 10 + import { TRPCError } from "@trpc/server"; 11 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 12 + 13 + export const pageComponentRouter = createTRPCRouter({ 14 + list: protectedProcedure 15 + .input( 16 + z 17 + .object({ 18 + pageId: z.number().optional(), 19 + order: z.enum(["asc", "desc"]).optional(), 20 + }) 21 + .optional(), 22 + ) 23 + .query(async (opts) => { 24 + const whereConditions: SQL[] = [ 25 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 26 + ]; 27 + 28 + if (opts.input?.pageId) { 29 + whereConditions.push(eq(pageComponent.pageId, opts.input.pageId)); 30 + } 31 + 32 + const query = opts.ctx.db.query.pageComponent.findMany({ 33 + where: and(...whereConditions), 34 + orderBy: 35 + opts.input?.order === "desc" 36 + ? desc(pageComponent.order) 37 + : asc(pageComponent.order), 38 + with: { 39 + monitor: true, 40 + group: true, 41 + }, 42 + }); 43 + 44 + const result = await query; 45 + 46 + return result; 47 + }), 48 + 49 + delete: protectedProcedure 50 + .input(z.object({ id: z.number() })) 51 + .mutation(async (opts) => { 52 + return await opts.ctx.db 53 + .delete(pageComponent) 54 + .where( 55 + and( 56 + eq(pageComponent.id, opts.input.id), 57 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 58 + ), 59 + ) 60 + .returning(); 61 + }), 62 + 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 + updateOrder: protectedProcedure 142 + .input( 143 + z.object({ 144 + pageId: z.number(), 145 + components: z.array( 146 + z.object({ 147 + id: z.number().optional(), // Optional for new components 148 + monitorId: z.number().nullish(), 149 + order: z.number(), 150 + name: z.string(), 151 + description: z.string().nullish(), 152 + type: z.enum(["monitor", "external"]), 153 + }), 154 + ), 155 + groups: z.array( 156 + z.object({ 157 + order: z.number(), 158 + name: z.string(), 159 + components: z.array( 160 + z.object({ 161 + id: z.number().optional(), // Optional for new components 162 + monitorId: z.number().nullish(), 163 + order: z.number(), 164 + name: z.string(), 165 + description: z.string().nullish(), 166 + type: z.enum(["monitor", "external"]), 167 + }), 168 + ), 169 + }), 170 + ), 171 + }), 172 + ) 173 + .mutation(async (opts) => { 174 + await opts.ctx.db.transaction(async (tx) => { 175 + // Get existing state 176 + const existingComponents = await tx 177 + .select() 178 + .from(pageComponent) 179 + .where( 180 + and( 181 + eq(pageComponent.pageId, opts.input.pageId), 182 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 183 + ), 184 + ) 185 + .all(); 186 + 187 + const existingGroups = await tx 188 + .select() 189 + .from(pageComponentGroup) 190 + .where( 191 + and( 192 + eq(pageComponentGroup.pageId, opts.input.pageId), 193 + eq(pageComponentGroup.workspaceId, opts.ctx.workspace.id), 194 + ), 195 + ) 196 + .all(); 197 + 198 + const existingGroupIds = existingGroups.map((g) => g.id); 199 + 200 + // Collect all monitorIds from input (for monitor-type components) 201 + const inputMonitorIds = [ 202 + ...opts.input.components 203 + .filter((c) => c.type === "monitor" && c.monitorId) 204 + .map((c) => c.monitorId), 205 + ...opts.input.groups.flatMap((g) => 206 + g.components 207 + .filter((c) => c.type === "monitor" && c.monitorId) 208 + .map((c) => c.monitorId), 209 + ), 210 + ] as number[]; 211 + 212 + // Collect IDs for external components that have IDs in input 213 + const inputExternalComponentIds = [ 214 + ...opts.input.components 215 + .filter((c) => c.type === "external" && c.id) 216 + .map((c) => c.id), 217 + ...opts.input.groups.flatMap((g) => 218 + g.components 219 + .filter((c) => c.type === "external" && c.id) 220 + .map((c) => c.id), 221 + ), 222 + ] as number[]; 223 + 224 + // Find components that are being removed 225 + // 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 228 + const removedMonitorComponents = existingComponents.filter( 229 + (c) => 230 + c.type === "monitor" && 231 + c.monitorId && 232 + !inputMonitorIds.includes(c.monitorId), 233 + ); 234 + 235 + const hasExternalComponentsInInput = 236 + opts.input.components.some((c) => c.type === "external") || 237 + opts.input.groups.some((g) => 238 + g.components.some((c) => c.type === "external"), 239 + ); 240 + 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) { 247 + // If the input has IDs, only remove those not in the list 248 + if (inputExternalComponentIds.length > 0) { 249 + return !inputExternalComponentIds.includes(c.id); 250 + } 251 + // If input doesn't have IDs, remove all existing external components 252 + return true; 253 + } 254 + // If no external components in input at all, remove existing ones 255 + return true; 256 + }); 257 + 258 + const removedComponentIds = [ 259 + ...removedMonitorComponents.map((c) => c.id), 260 + ...removedExternalComponents.map((c) => c.id), 261 + ]; 262 + 263 + // Delete removed components 264 + if (removedComponentIds.length > 0) { 265 + await tx 266 + .delete(pageComponent) 267 + .where( 268 + and( 269 + eq(pageComponent.pageId, opts.input.pageId), 270 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 271 + inArray(pageComponent.id, removedComponentIds), 272 + ), 273 + ); 274 + } 275 + 276 + // Clear groupId from all components before deleting groups 277 + // This prevents foreign key constraint errors 278 + if (existingGroupIds.length > 0) { 279 + await tx 280 + .update(pageComponent) 281 + .set({ groupId: null }) 282 + .where( 283 + and( 284 + eq(pageComponent.pageId, opts.input.pageId), 285 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 286 + inArray(pageComponent.groupId, existingGroupIds), 287 + ), 288 + ); 289 + } 290 + 291 + // Delete old groups and create new ones 292 + if (existingGroupIds.length > 0) { 293 + await tx 294 + .delete(pageComponentGroup) 295 + .where( 296 + and( 297 + eq(pageComponentGroup.pageId, opts.input.pageId), 298 + eq(pageComponentGroup.workspaceId, opts.ctx.workspace.id), 299 + ), 300 + ); 301 + } 302 + 303 + // Create new groups 304 + const newGroups: Array<{ id: number; name: string }> = []; 305 + if (opts.input.groups.length > 0) { 306 + const createdGroups = await tx 307 + .insert(pageComponentGroup) 308 + .values( 309 + opts.input.groups.map((g) => ({ 310 + pageId: opts.input.pageId, 311 + workspaceId: opts.ctx.workspace.id, 312 + name: g.name, 313 + })), 314 + ) 315 + .returning(); 316 + newGroups.push(...createdGroups); 317 + } 318 + 319 + // Prepare values for upsert - both grouped and ungrouped components 320 + const groupComponentValues = opts.input.groups.flatMap((g, i) => 321 + g.components.map((c) => ({ 322 + id: c.id, // Will be undefined for new components 323 + pageId: opts.input.pageId, 324 + workspaceId: opts.ctx.workspace.id, 325 + name: c.name, 326 + description: c.description, 327 + type: c.type, 328 + monitorId: c.monitorId, 329 + order: g.order, 330 + groupId: newGroups[i].id, 331 + groupOrder: c.order, 332 + })), 333 + ); 334 + 335 + const standaloneComponentValues = opts.input.components.map((c) => ({ 336 + id: c.id, // Will be undefined for new components 337 + pageId: opts.input.pageId, 338 + workspaceId: opts.ctx.workspace.id, 339 + name: c.name, 340 + description: c.description, 341 + type: c.type, 342 + monitorId: c.monitorId, 343 + order: c.order, 344 + groupId: null as number | null, 345 + groupOrder: null as number | null, 346 + })); 347 + 348 + const allComponentValues = [ 349 + ...groupComponentValues, 350 + ...standaloneComponentValues, 351 + ]; 352 + 353 + // Separate monitor and external components for different upsert strategies 354 + const monitorComponents = allComponentValues.filter( 355 + (c) => c.type === "monitor" && c.monitorId, 356 + ); 357 + const externalComponents = allComponentValues.filter( 358 + (c) => c.type === "external", 359 + ); 360 + 361 + // Upsert monitor components using SQL-level conflict resolution 362 + // This uses the (pageId, monitorId) unique constraint to preserve component IDs 363 + if (monitorComponents.length > 0) { 364 + await tx 365 + .insert(pageComponent) 366 + .values(monitorComponents) 367 + .onConflictDoUpdate({ 368 + target: [pageComponent.pageId, pageComponent.monitorId], 369 + set: { 370 + name: sql.raw("excluded.`name`"), 371 + description: sql.raw("excluded.`description`"), 372 + order: sql.raw("excluded.`order`"), 373 + groupId: sql.raw("excluded.`group_id`"), 374 + groupOrder: sql.raw("excluded.`group_order`"), 375 + updatedAt: sql`(strftime('%s', 'now'))`, 376 + }, 377 + }); 378 + } 379 + 380 + // Handle external components 381 + // If they have IDs, update them; otherwise insert new ones 382 + for (const componentValue of externalComponents) { 383 + if (componentValue.id) { 384 + // Update existing external component (preserves ID and relationships) 385 + await tx 386 + .update(pageComponent) 387 + .set({ 388 + name: componentValue.name, 389 + description: componentValue.description, 390 + type: componentValue.type, 391 + monitorId: componentValue.monitorId, 392 + order: componentValue.order, 393 + groupId: componentValue.groupId, 394 + groupOrder: componentValue.groupOrder, 395 + updatedAt: new Date(), 396 + }) 397 + .where( 398 + and( 399 + eq(pageComponent.id, componentValue.id), 400 + eq(pageComponent.pageId, opts.input.pageId), 401 + eq(pageComponent.workspaceId, opts.ctx.workspace.id), 402 + ), 403 + ); 404 + } else { 405 + // Insert new external component 406 + await tx.insert(pageComponent).values({ 407 + pageId: componentValue.pageId, 408 + workspaceId: componentValue.workspaceId, 409 + name: componentValue.name, 410 + description: componentValue.description, 411 + type: componentValue.type, 412 + monitorId: componentValue.monitorId, 413 + order: componentValue.order, 414 + groupId: componentValue.groupId, 415 + groupOrder: componentValue.groupOrder, 416 + }); 417 + } 418 + } 419 + }); 420 + 421 + return { success: true }; 422 + }), 423 + });
+4 -4
packages/api/src/router/sync.test.ts
··· 105 105 .get(); 106 106 testPageId = testPage.id; 107 107 108 - // Create test monitor using tRPC 108 + // Create test monitor using tRPC (must be active for sync to work) 109 109 const ctx = getTestContext(); 110 110 const caller = appRouter.createCaller(ctx); 111 111 const createdMonitor = await caller.monitor.new({ ··· 115 115 method: monitorData.method, 116 116 headers: [], 117 117 assertions: [], 118 - active: false, 118 + active: true, // Changed to true - sync functions only sync active monitors 119 119 skipCheck: true, 120 120 }); 121 121 testMonitorId = createdMonitor.id; ··· 594 594 const ctx = getTestContext(); 595 595 const caller = appRouter.createCaller(ctx); 596 596 597 - // Create a monitor specifically for deletion tests 597 + // Create a monitor specifically for deletion tests (must be active for sync) 598 598 const deletableMonitor = await caller.monitor.new({ 599 599 name: `${TEST_PREFIX}-deletable-monitor`, 600 600 url: "https://delete-test.example.com", ··· 602 602 method: "GET" as const, 603 603 headers: [], 604 604 assertions: [], 605 - active: false, 605 + active: true, // Changed to true - sync functions only sync active monitors 606 606 skipCheck: true, 607 607 }); 608 608 deletableMonitorId = deletableMonitor.id;
+45 -8
packages/db/src/sync.ts
··· 6 6 maintenancesToMonitors, 7 7 maintenancesToPageComponents, 8 8 monitor, 9 + monitorsToPages, 9 10 monitorsToStatusReport, 10 11 pageComponent, 11 12 pageComponentGroup, ··· 90 91 groupOrder?: number; 91 92 }, 92 93 ) { 93 - // Get monitor data for name and workspace_id 94 + // Get monitor data for name and workspace_id (only active monitors) 94 95 const monitorData = await db 95 96 .select({ 96 97 id: monitor.id, 97 98 name: monitor.name, 98 99 externalName: monitor.externalName, 99 100 workspaceId: monitor.workspaceId, 101 + active: monitor.active, 100 102 }) 101 103 .from(monitor) 102 104 .where(eq(monitor.id, data.monitorId)) 103 105 .get(); 104 106 105 - if (!monitorData || !monitorData.workspaceId) return; 107 + // Skip if monitor doesn't exist, has no workspace, or is inactive 108 + if (!monitorData || !monitorData.workspaceId || !monitorData.active) return; 106 109 107 110 await db 108 111 .insert(pageComponent) ··· 134 137 ) { 135 138 if (items.length === 0) return; 136 139 137 - // Get all monitor data in one query 140 + // Get all monitor data in one query (only active monitors) 138 141 const monitorIds = [...new Set(items.map((item) => item.monitorId))]; 139 142 const monitors = await db 140 143 .select({ ··· 142 145 name: monitor.name, 143 146 externalName: monitor.externalName, 144 147 workspaceId: monitor.workspaceId, 148 + active: monitor.active, 145 149 }) 146 150 .from(monitor) 147 - .where(inArray(monitor.id, monitorIds)); 151 + .where(and(inArray(monitor.id, monitorIds), eq(monitor.active, true))); 148 152 149 153 const monitorMap = new Map(monitors.map((m) => [m.id, m])); 150 154 151 155 const values = items 152 156 .map((item) => { 153 157 const m = monitorMap.get(item.monitorId); 154 - if (!m || !m.workspaceId) return null; 158 + // Skip if monitor doesn't exist, has no workspace, or is inactive 159 + if (!m || !m.workspaceId || !m.active) return null; 155 160 return { 156 161 workspaceId: m.workspaceId, 157 162 pageId: item.pageId, ··· 186 191 ) { 187 192 if (items.length === 0) return; 188 193 189 - // Get all monitor data in one query 194 + // Get all monitor data in one query (only active monitors) 190 195 const monitorIds = [...new Set(items.map((item) => item.monitorId))]; 191 196 const monitors = await db 192 197 .select({ ··· 194 199 name: monitor.name, 195 200 externalName: monitor.externalName, 196 201 workspaceId: monitor.workspaceId, 202 + active: monitor.active, 197 203 }) 198 204 .from(monitor) 199 - .where(inArray(monitor.id, monitorIds)); 205 + .where(and(inArray(monitor.id, monitorIds), eq(monitor.active, true))); 200 206 201 207 const monitorMap = new Map(monitors.map((m) => [m.id, m])); 202 208 203 209 const values = items 204 210 .map((item) => { 205 211 const m = monitorMap.get(item.monitorId); 206 - if (!m || !m.workspaceId) return null; 212 + // Skip if monitor doesn't exist, has no workspace, or is inactive 213 + if (!m || !m.workspaceId || !m.active) return null; 207 214 return { 208 215 workspaceId: m.workspaceId, 209 216 pageId: item.pageId, ··· 276 283 await db 277 284 .delete(pageComponent) 278 285 .where(inArray(pageComponent.monitorId, monitorIds)); 286 + } 287 + 288 + /** 289 + * REVERSE SYNC: Syncs page_component inserts to monitors_to_pages 290 + * Used when pageComponents is the primary table and monitorsToPages is kept for backwards compatibility 291 + */ 292 + export async function syncPageComponentToMonitorsToPageInsertMany( 293 + db: DB | Transaction, 294 + items: Array<{ 295 + monitorId: number; 296 + pageId: number; 297 + order?: number; 298 + monitorGroupId?: number | null; 299 + groupOrder?: number; 300 + }>, 301 + ) { 302 + if (items.length === 0) return; 303 + 304 + await db 305 + .insert(monitorsToPages) 306 + .values( 307 + items.map((item) => ({ 308 + monitorId: item.monitorId, 309 + pageId: item.pageId, 310 + order: item.order ?? 0, 311 + monitorGroupId: item.monitorGroupId ?? null, 312 + groupOrder: item.groupOrder ?? 0, 313 + })), 314 + ) 315 + .onConflictDoNothing(); 279 316 } 280 317 281 318 // ============================================================================