Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

i see dead code (#1578)

authored by

Thibault Le Ouay and committed by
GitHub
58935eba affd1c7a

-10963
-72
apps/web/src/components/billing/pro-banner.tsx
··· 1 - "use client"; 2 - 3 - import { ArrowRight, ChevronRight, Rocket, X } from "lucide-react"; 4 - import Link from "next/link"; 5 - import { useParams } from "next/navigation"; 6 - import { useEffect, useState } from "react"; 7 - 8 - import { Button } from "@openstatus/ui/src/components/button"; 9 - 10 - import { api } from "@/trpc/client"; 11 - 12 - export function ProBanner() { 13 - const [hidden, setHidden] = useState(true); 14 - const params = useParams(); 15 - const workspaceSlug = params?.workspaceSlug; 16 - 17 - function onClick() { 18 - if (document) { 19 - const expires = new Date(); 20 - expires.setDate(expires.getDate() + 7); 21 - // store the cookie for 7 days and only for a specific workspace 22 - document.cookie = `hide-pro-banner=true; expires=${expires}; path=/app/${workspaceSlug}`; 23 - } 24 - setHidden(true); 25 - } 26 - 27 - useEffect(() => { 28 - async function configureProBanner() { 29 - const workspace = await api.workspace.getWorkspace.query(); 30 - // make sure to display the banner only for free plans 31 - if (document && workspace?.plan === "free") { 32 - const cookie = document.cookie 33 - .split("; ") 34 - .find((row) => row.startsWith("hide-pro-banner")); 35 - if (!cookie) { 36 - setHidden(false); 37 - } 38 - } 39 - } 40 - configureProBanner(); 41 - }, []); 42 - 43 - if (hidden) return null; 44 - 45 - return ( 46 - <div className="grid gap-2 rounded-md border border-border p-3"> 47 - <div className="flex items-center justify-between"> 48 - <p className="inline-flex items-center font-medium text-sm"> 49 - OpenStatus Subscription <Rocket className="ml-2 h-4 w-4" /> 50 - </p> 51 - <Button 52 - variant="ghost" 53 - size="icon" 54 - onClick={onClick} 55 - className="h-7 w-7" 56 - > 57 - <X className="h-4 w-4" /> 58 - </Button> 59 - </div> 60 - <p className="text-muted-foreground text-sm"> 61 - Unlock custom domains, teams, 1 min. checks and more. 62 - </p> 63 - <Button variant="secondary" size="sm" asChild> 64 - <Link href={`/app/${workspaceSlug}/settings/billing`} className="group"> 65 - <span className="mr-0.5">Upgrade</span>{" "} 66 - <ArrowRight className="relative inline h-4 w-0 transition-all group-hover:w-4" /> 67 - <ChevronRight className="relative inline h-4 w-4 transition-all group-hover:w-0" /> 68 - </Link> 69 - </Button> 70 - </div> 71 - ); 72 - }
-38
apps/web/src/components/billing/pro-feature-alert.tsx
··· 1 - "use client"; 2 - 3 - import { AlertTriangle } from "lucide-react"; 4 - import Link from "next/link"; 5 - import { useParams } from "next/navigation"; 6 - 7 - import type { WorkspacePlan } from "@openstatus/db/src/schema/workspaces/validation"; 8 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 9 - 10 - interface Props { 11 - feature: string; 12 - workspacePlan?: WorkspacePlan; 13 - } 14 - 15 - export function ProFeatureAlert({ feature, workspacePlan = "team" }: Props) { 16 - const params = useParams<{ workspaceSlug: string }>(); 17 - return ( 18 - <Alert> 19 - <AlertTriangle className="h-4 w-4" /> 20 - <AlertTitle> 21 - {feature} is a <span className="capitalize">{workspacePlan}</span>{" "} 22 - feature. 23 - </AlertTitle> 24 - <AlertDescription> 25 - If you want to use{" "} 26 - <span className="lowercase underline decoration-dotted">{feature}</span> 27 - , please upgrade your plan. Go to{" "} 28 - <Link 29 - href={`/app/${params.workspaceSlug}/settings/billing`} 30 - className="inline-flex items-center font-medium text-foreground underline underline-offset-4 hover:no-underline" 31 - > 32 - settings 33 - </Link> 34 - . 35 - </AlertDescription> 36 - </Alert> 37 - ); 38 - }
-17
apps/web/src/components/billing/pro-feature-badge.tsx
··· 1 - import type { WorkspacePlan } from "@openstatus/db/src/schema"; 2 - import { Badge } from "@openstatus/ui"; 3 - import { upgradePlan } from "./utils"; 4 - 5 - export function ProFeatureBadge({ 6 - plan, 7 - minRequiredPlan, 8 - }: { 9 - plan: WorkspacePlan; 10 - minRequiredPlan: WorkspacePlan; 11 - }) { 12 - const shouldUpgrade = upgradePlan(plan, minRequiredPlan); 13 - 14 - if (!shouldUpgrade) return null; 15 - 16 - return <Badge>{minRequiredPlan}</Badge>; 17 - }
-59
apps/web/src/components/billing/pro-feature-hover-card.tsx
··· 1 - "use client"; 2 - 3 - import { useState } from "react"; 4 - 5 - import type { WorkspacePlan } from "@openstatus/db/src/schema/workspaces/validation"; 6 - import { HoverCard, HoverCardContent, HoverCardTrigger } from "@openstatus/ui"; 7 - import { ArrowUpRight } from "lucide-react"; 8 - import Link from "next/link"; 9 - import { upgradePlan } from "./utils"; 10 - 11 - // TBD: we could useParams() to access workspaceSlug 12 - 13 - export function ProFeatureHoverCard({ 14 - children, 15 - plan, 16 - minRequiredPlan, 17 - workspaceSlug, 18 - }: { 19 - children: React.ReactNode; 20 - plan: WorkspacePlan; 21 - minRequiredPlan: WorkspacePlan; 22 - workspaceSlug: string; 23 - }) { 24 - const [open, setOpen] = useState(false); 25 - const shouldUpgrade = upgradePlan(plan, minRequiredPlan); 26 - 27 - if (!shouldUpgrade) return children; 28 - 29 - // TODO: add a <Badge /> component to display the plan name 30 - 31 - return ( 32 - <HoverCard openDelay={0} open={open} onOpenChange={setOpen}> 33 - <HoverCardTrigger 34 - onClick={() => setOpen(true)} 35 - className="relative cursor-not-allowed opacity-70" 36 - asChild 37 - > 38 - {children} 39 - </HoverCardTrigger> 40 - <HoverCardContent side="top" className="grid gap-2"> 41 - <p className="text-muted-foreground text-sm"> 42 - This feature is only available starting from the{" "} 43 - <span className="font-semibold capitalize">{minRequiredPlan}</span>{" "} 44 - plan. 45 - </p> 46 - <p className="text-sm"> 47 - <Link 48 - href={`/app/${workspaceSlug}/settings/billing`} 49 - target="_blank" 50 - className="group inline-flex items-center font-medium text-foreground underline underline-offset-4 hover:no-underline" 51 - > 52 - Upgrade now 53 - <ArrowUpRight className="ml-1 h-4 w-4 shrink-0 text-muted-foreground group-hover:text-foreground" /> 54 - </Link> 55 - </p> 56 - </HoverCardContent> 57 - </HoverCard> 58 - ); 59 - }
-6
apps/web/src/components/billing/utils.ts
··· 1 - import { workspacePlanHierarchy } from "@openstatus/db/src/schema"; 2 - import type { WorkspacePlan } from "@openstatus/db/src/schema/workspaces/validation"; 3 - 4 - export function upgradePlan(current: WorkspacePlan, required: WorkspacePlan) { 5 - return workspacePlanHierarchy[current] < workspacePlanHierarchy[required]; 6 - }
-31
apps/web/src/components/dashboard/help-callout.tsx
··· 1 - import { HelpCircle } from "lucide-react"; 2 - 3 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 4 - 5 - export function HelpCallout() { 6 - return ( 7 - <Alert className="max-w-md"> 8 - <HelpCircle className="h-4 w-4" /> 9 - <AlertTitle className="">Need help?</AlertTitle> 10 - <AlertDescription className="text-muted-foreground"> 11 - Let us know at{" "} 12 - <a 13 - href="mailto:ping@openstatus.dev" 14 - className="font-medium text-foreground underline hover:no-underline" 15 - > 16 - ping@openstatus.dev 17 - </a>{" "} 18 - or join our{" "} 19 - <a 20 - href="/discord" 21 - target="_blank" 22 - className="font-medium text-foreground underline hover:no-underline" 23 - rel="noreferrer" 24 - > 25 - Discord 26 - </a> 27 - . 28 - </AlertDescription> 29 - </Alert> 30 - ); 31 - }
-72
apps/web/src/components/dashboard/info-alert-dialog.tsx
··· 1 - "use client"; 2 - import { useEffect, useState } from "react"; 3 - 4 - import { 5 - AlertDialog, 6 - AlertDialogAction, 7 - AlertDialogContent, 8 - AlertDialogDescription, 9 - AlertDialogFooter, 10 - AlertDialogHeader, 11 - AlertDialogTitle, 12 - } from "@openstatus/ui"; 13 - 14 - interface InfoAlertDialogProps { 15 - workspaceSlug: string; 16 - /** 17 - * Default expiration time in days 18 - */ 19 - expires?: number; 20 - 21 - id: string; 22 - title: React.ReactNode; 23 - description: React.ReactNode; 24 - } 25 - 26 - export function InfoAlertDialog({ 27 - workspaceSlug, 28 - expires = 7, 29 - id, 30 - title, 31 - description, 32 - }: InfoAlertDialogProps) { 33 - const [open, setOpen] = useState(false); 34 - 35 - function onClick() { 36 - if (document) { 37 - const expiresAt = new Date(); 38 - expiresAt.setDate(expiresAt.getDate() + expires); 39 - // store the cookie for 7 days and only for a specific workspace 40 - document.cookie = `${id}=true; expires=${expiresAt}; path=/app/${workspaceSlug}`; 41 - } 42 - setOpen(false); 43 - } 44 - 45 - useEffect(() => { 46 - async function configureProBanner() { 47 - if (document) { 48 - const cookie = document.cookie 49 - .split("; ") 50 - .find((row) => row.startsWith(id)); 51 - if (!cookie) { 52 - setOpen(true); 53 - } 54 - } 55 - } 56 - configureProBanner(); 57 - }, [id]); 58 - 59 - return ( 60 - <AlertDialog open={open} onOpenChange={setOpen}> 61 - <AlertDialogContent> 62 - <AlertDialogHeader> 63 - <AlertDialogTitle>{title}</AlertDialogTitle> 64 - <AlertDialogDescription>{description}</AlertDialogDescription> 65 - </AlertDialogHeader> 66 - <AlertDialogFooter> 67 - <AlertDialogAction onClick={onClick}>Continue</AlertDialogAction> 68 - </AlertDialogFooter> 69 - </AlertDialogContent> 70 - </AlertDialog> 71 - ); 72 - }
-18
apps/web/src/components/dashboard/limit.tsx
··· 1 - import Link from "next/link"; 2 - 3 - export function Limit() { 4 - return ( 5 - <div className="col-span-full text-center"> 6 - <p className="text-muted-foreground text-sm"> 7 - You have reached the account limits. Please{" "} 8 - <Link 9 - href={"./settings/billing"} 10 - className="text-foreground underline underline-offset-4 hover:no-underline" 11 - > 12 - upgrade 13 - </Link>{" "} 14 - your account. 15 - </p> 16 - </div> 17 - ); 18 - }
-135
apps/web/src/components/data-table/incident/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { formatDistanceStrict } from "date-fns"; 5 - import Image from "next/image"; 6 - import Link from "next/link"; 7 - 8 - import type { Incident } from "@openstatus/db/src/schema"; 9 - 10 - import { formatDateTime } from "@/lib/utils"; 11 - import { DataTableRowActions } from "./data-table-row-actions"; 12 - 13 - export const columns: ColumnDef<Incident>[] = [ 14 - { 15 - accessorKey: "monitorId", 16 - header: "Monitor", 17 - cell: ({ row }) => { 18 - return ( 19 - <Link 20 - href={`./monitors/${row.original.monitorId}/overview`} 21 - className="group flex items-center gap-2" 22 - > 23 - <span className="max-w-[125px] truncate group-hover:underline"> 24 - {row.original.monitorName} 25 - </span> 26 - </Link> 27 - ); 28 - }, 29 - }, 30 - { 31 - accessorKey: "startedAt", 32 - header: "Started At", 33 - cell: ({ row }) => { 34 - const { startedAt, incidentScreenshotUrl } = row.original; 35 - const date = startedAt ? formatDateTime(startedAt) : "-"; 36 - return ( 37 - <div className="flex gap-2"> 38 - <span className="max-w-[150px] truncate text-muted-foreground sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> 39 - {date} 40 - </span> 41 - {incidentScreenshotUrl ? ( 42 - <a 43 - href={incidentScreenshotUrl} 44 - target="_blank" 45 - rel="noreferrer" 46 - className="relative relative h-5 w-5 overflow-hidden rounded border" 47 - > 48 - <Image 49 - src={incidentScreenshotUrl} 50 - alt="incident screenshot" 51 - fill={true} 52 - /> 53 - </a> 54 - ) : null} 55 - </div> 56 - ); 57 - }, 58 - }, 59 - { 60 - accessorKey: "acknowledgetAt", 61 - header: "Acknowledged At", 62 - cell: ({ row }) => { 63 - const { acknowledgedAt } = row.original; 64 - const date = acknowledgedAt ? formatDateTime(acknowledgedAt) : "-"; 65 - return ( 66 - <div className="flex"> 67 - <span className="max-w-[150px] truncate text-muted-foreground sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> 68 - {date} 69 - </span> 70 - </div> 71 - ); 72 - }, 73 - }, 74 - { 75 - accessorKey: "resolvedAt", 76 - header: "Resolved At", 77 - cell: ({ row }) => { 78 - const { resolvedAt, recoveryScreenshotUrl } = row.original; 79 - const date = resolvedAt ? formatDateTime(resolvedAt) : "-"; 80 - return ( 81 - <div className="flex gap-2"> 82 - <span className="max-w-[150px] truncate text-muted-foreground sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> 83 - {date} 84 - </span> 85 - {recoveryScreenshotUrl ? ( 86 - <a 87 - href={recoveryScreenshotUrl} 88 - target="_blank" 89 - rel="noreferrer" 90 - className="relative h-5 w-5 overflow-hidden rounded border" 91 - > 92 - <Image 93 - src={recoveryScreenshotUrl} 94 - alt="recovery screenshot" 95 - fill={true} 96 - /> 97 - </a> 98 - ) : null} 99 - </div> 100 - ); 101 - }, 102 - }, 103 - { 104 - header: "Duration", 105 - cell: ({ row }) => { 106 - const { startedAt, resolvedAt } = row.original; 107 - 108 - if (!resolvedAt) { 109 - return <span className="text-muted-foreground">-</span>; 110 - } 111 - 112 - const duration = formatDistanceStrict( 113 - new Date(startedAt), 114 - new Date(resolvedAt), 115 - ); 116 - return ( 117 - <div className="flex"> 118 - <span className="max-w-[150px] truncate text-muted-foreground sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> 119 - {duration} 120 - </span> 121 - </div> 122 - ); 123 - }, 124 - }, 125 - { 126 - id: "actions", 127 - cell: ({ row }) => { 128 - return ( 129 - <div className="text-right"> 130 - <DataTableRowActions row={row} /> 131 - </div> 132 - ); 133 - }, 134 - }, 135 - ];
-147
apps/web/src/components/data-table/incident/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import Link from "next/link"; 6 - import { useRouter } from "next/navigation"; 7 - import * as React from "react"; 8 - 9 - import { selectIncidentSchema } from "@openstatus/db/src/schema"; 10 - import { 11 - AlertDialog, 12 - AlertDialogAction, 13 - AlertDialogCancel, 14 - AlertDialogContent, 15 - AlertDialogDescription, 16 - AlertDialogFooter, 17 - AlertDialogHeader, 18 - AlertDialogTitle, 19 - AlertDialogTrigger, 20 - Button, 21 - DropdownMenu, 22 - DropdownMenuContent, 23 - DropdownMenuItem, 24 - DropdownMenuSeparator, 25 - DropdownMenuTrigger, 26 - } from "@openstatus/ui"; 27 - 28 - import { LoadingAnimation } from "@/components/loading-animation"; 29 - import { toastAction } from "@/lib/toast"; 30 - import { api } from "@/trpc/client"; 31 - 32 - interface DataTableRowActionsProps<TData> { 33 - row: Row<TData>; 34 - } 35 - 36 - export function DataTableRowActions<TData>({ 37 - row, 38 - }: DataTableRowActionsProps<TData>) { 39 - const incident = selectIncidentSchema.parse(row.original); 40 - const router = useRouter(); 41 - const [isPending, startTransition] = React.useTransition(); 42 - const [alertOpen, setAlertOpen] = React.useState(false); 43 - 44 - async function resolved() { 45 - startTransition(async () => { 46 - try { 47 - if (!incident.id) return; 48 - await api.incident.resolvedIncident.mutate({ id: incident.id }); 49 - toastAction("success"); 50 - router.refresh(); 51 - } catch { 52 - toastAction("error"); 53 - } 54 - }); 55 - } 56 - 57 - async function acknowledge() { 58 - startTransition(async () => { 59 - try { 60 - if (!incident.id) return; 61 - await api.incident.acknowledgeIncident.mutate({ id: incident.id }); 62 - toastAction("success"); 63 - router.refresh(); 64 - } catch { 65 - toastAction("error"); 66 - } 67 - }); 68 - } 69 - 70 - async function onDelete() { 71 - startTransition(async () => { 72 - try { 73 - if (!incident.id) return; 74 - await api.incident.delete.mutate({ id: incident.id }); 75 - toastAction("success"); 76 - setAlertOpen(false); 77 - router.refresh(); 78 - } catch { 79 - toastAction("error"); 80 - } 81 - }); 82 - } 83 - 84 - return ( 85 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 86 - <DropdownMenu> 87 - <DropdownMenuTrigger asChild> 88 - <Button 89 - variant="ghost" 90 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 91 - > 92 - <span className="sr-only">Open menu</span> 93 - <MoreHorizontal className="h-4 w-4" /> 94 - </Button> 95 - </DropdownMenuTrigger> 96 - <DropdownMenuContent align="end"> 97 - <DropdownMenuItem 98 - disabled={incident.acknowledgedAt !== null} 99 - onClick={acknowledge} 100 - > 101 - Acknowledge 102 - </DropdownMenuItem> 103 - <DropdownMenuItem 104 - disabled={ 105 - incident.resolvedAt !== null || incident.acknowledgedAt === null 106 - } 107 - onClick={resolved} 108 - > 109 - Resolved 110 - </DropdownMenuItem> 111 - <DropdownMenuSeparator /> 112 - <Link href={`./incidents/${incident.id}/overview`}> 113 - <DropdownMenuItem>Details</DropdownMenuItem> 114 - </Link> 115 - <DropdownMenuSeparator /> 116 - <AlertDialogTrigger asChild> 117 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 118 - Delete 119 - </DropdownMenuItem> 120 - </AlertDialogTrigger> 121 - </DropdownMenuContent> 122 - </DropdownMenu> 123 - <AlertDialogContent> 124 - <AlertDialogHeader> 125 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 126 - <AlertDialogDescription> 127 - This action cannot be undone. This will permanently delete the 128 - monitor. 129 - </AlertDialogDescription> 130 - </AlertDialogHeader> 131 - <AlertDialogFooter> 132 - <AlertDialogCancel>Cancel</AlertDialogCancel> 133 - <AlertDialogAction 134 - onClick={(e) => { 135 - e.preventDefault(); 136 - onDelete(); 137 - }} 138 - disabled={isPending} 139 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 140 - > 141 - {!isPending ? "Delete" : <LoadingAnimation />} 142 - </AlertDialogAction> 143 - </AlertDialogFooter> 144 - </AlertDialogContent> 145 - </AlertDialog> 146 - ); 147 - }
-83
apps/web/src/components/data-table/incident/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - // FIXME: right now, the mobile layout is messed up 25 - // https://github.com/TanStack/table/discussions/3192#discussioncomment-6458134 26 - 27 - export function DataTable<TData, TValue>({ 28 - columns, 29 - data, 30 - }: DataTableProps<TData, TValue>) { 31 - const table = useReactTable({ 32 - data, 33 - columns, 34 - getCoreRowModel: getCoreRowModel(), 35 - }); 36 - 37 - return ( 38 - <div className="rounded-md border"> 39 - <Table> 40 - <TableHeader className="bg-muted/50"> 41 - {table.getHeaderGroups().map((headerGroup) => ( 42 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 43 - {headerGroup.headers.map((header) => { 44 - return ( 45 - <TableHead key={header.id}> 46 - {header.isPlaceholder 47 - ? null 48 - : flexRender( 49 - header.column.columnDef.header, 50 - header.getContext(), 51 - )} 52 - </TableHead> 53 - ); 54 - })} 55 - </TableRow> 56 - ))} 57 - </TableHeader> 58 - <TableBody> 59 - {table.getRowModel().rows?.length ? ( 60 - table.getRowModel().rows.map((row) => ( 61 - <TableRow 62 - key={row.id} 63 - data-state={row.getIsSelected() && "selected"} 64 - > 65 - {row.getVisibleCells().map((cell) => ( 66 - <TableCell key={cell.id}> 67 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 68 - </TableCell> 69 - ))} 70 - </TableRow> 71 - )) 72 - ) : ( 73 - <TableRow> 74 - <TableCell colSpan={columns.length} className="h-24 text-center"> 75 - No results. 76 - </TableCell> 77 - </TableRow> 78 - )} 79 - </TableBody> 80 - </Table> 81 - </div> 82 - ); 83 - }
-47
apps/web/src/components/data-table/invitation/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - 5 - import type { Invitation, WorkspaceRole } from "@openstatus/db/src/schema"; 6 - import { Badge } from "@openstatus/ui/src/components/badge"; 7 - 8 - import { formatDate } from "@/lib/utils"; 9 - import { DataTableRowActions } from "./data-table-row-actions"; 10 - 11 - // TODO: add total number of monitors 12 - 13 - export const columns: ColumnDef<Invitation>[] = [ 14 - { 15 - accessorKey: "email", 16 - header: "Email", 17 - }, 18 - { 19 - accessorKey: "role", 20 - header: "Role", 21 - cell: ({ row }) => { 22 - const role = row.getValue("role") as WorkspaceRole; 23 - return ( 24 - <Badge variant={role === "member" ? "outline" : "default"}> 25 - {row.getValue("role")} 26 - </Badge> 27 - ); 28 - }, 29 - }, 30 - { 31 - accessorKey: "expiresAt", 32 - header: "Expires at", 33 - cell: ({ row }) => { 34 - return <span>{formatDate(row.getValue("expiresAt"))}</span>; 35 - }, 36 - }, 37 - { 38 - id: "actions", 39 - cell: ({ row }) => { 40 - return ( 41 - <div className="text-right"> 42 - <DataTableRowActions row={row} /> 43 - </div> 44 - ); 45 - }, 46 - }, 47 - ];
-82
apps/web/src/components/data-table/invitation/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { useRouter } from "next/navigation"; 5 - import * as React from "react"; 6 - 7 - import { selectInvitationSchema } from "@openstatus/db/src/schema"; 8 - import { 9 - AlertDialog, 10 - AlertDialogAction, 11 - AlertDialogCancel, 12 - AlertDialogContent, 13 - AlertDialogDescription, 14 - AlertDialogFooter, 15 - AlertDialogHeader, 16 - AlertDialogTitle, 17 - AlertDialogTrigger, 18 - Button, 19 - } from "@openstatus/ui"; 20 - 21 - import { LoadingAnimation } from "@/components/loading-animation"; 22 - import { toastAction } from "@/lib/toast"; 23 - import { api } from "@/trpc/client"; 24 - 25 - interface DataTableRowActionsProps<TData> { 26 - row: Row<TData>; 27 - } 28 - 29 - export function DataTableRowActions<TData>({ 30 - row, 31 - }: DataTableRowActionsProps<TData>) { 32 - const invitation = selectInvitationSchema.parse(row.original); 33 - const router = useRouter(); 34 - const [alertOpen, setAlertOpen] = React.useState(false); 35 - const [isPending, startTransition] = React.useTransition(); 36 - 37 - async function onRevoke() { 38 - startTransition(async () => { 39 - try { 40 - if (!invitation.id) return; 41 - await api.invitation.delete.mutate({ id: invitation.id }); 42 - toastAction("deleted"); 43 - router.refresh(); 44 - setAlertOpen(false); 45 - } catch { 46 - toastAction("error"); 47 - } 48 - }); 49 - } 50 - 51 - return ( 52 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 53 - <AlertDialogTrigger asChild> 54 - <Button variant="outline" size="sm"> 55 - Revoke 56 - </Button> 57 - </AlertDialogTrigger> 58 - <AlertDialogContent> 59 - <AlertDialogHeader> 60 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 61 - <AlertDialogDescription> 62 - The invitation will be revoked and the user will no longer be able 63 - to join the workspace. 64 - </AlertDialogDescription> 65 - </AlertDialogHeader> 66 - <AlertDialogFooter> 67 - <AlertDialogCancel>Cancel</AlertDialogCancel> 68 - <AlertDialogAction 69 - onClick={(e) => { 70 - e.preventDefault(); 71 - onRevoke(); 72 - }} 73 - disabled={isPending} 74 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 75 - > 76 - {!isPending ? "Revoke" : <LoadingAnimation />} 77 - </AlertDialogAction> 78 - </AlertDialogFooter> 79 - </AlertDialogContent> 80 - </AlertDialog> 81 - ); 82 - }
-80
apps/web/src/components/data-table/invitation/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - export function DataTable<TData, TValue>({ 25 - columns, 26 - data, 27 - }: DataTableProps<TData, TValue>) { 28 - const table = useReactTable({ 29 - data, 30 - columns, 31 - getCoreRowModel: getCoreRowModel(), 32 - }); 33 - 34 - return ( 35 - <div className="rounded-md border"> 36 - <Table> 37 - <TableHeader className="bg-muted/50"> 38 - {table.getHeaderGroups().map((headerGroup) => ( 39 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 40 - {headerGroup.headers.map((header) => { 41 - return ( 42 - <TableHead key={header.id}> 43 - {header.isPlaceholder 44 - ? null 45 - : flexRender( 46 - header.column.columnDef.header, 47 - header.getContext(), 48 - )} 49 - </TableHead> 50 - ); 51 - })} 52 - </TableRow> 53 - ))} 54 - </TableHeader> 55 - <TableBody> 56 - {table.getRowModel().rows?.length ? ( 57 - table.getRowModel().rows.map((row) => ( 58 - <TableRow 59 - key={row.id} 60 - data-state={row.getIsSelected() && "selected"} 61 - > 62 - {row.getVisibleCells().map((cell) => ( 63 - <TableCell key={cell.id}> 64 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 - </TableCell> 66 - ))} 67 - </TableRow> 68 - )) 69 - ) : ( 70 - <TableRow> 71 - <TableCell colSpan={columns.length} className="h-24 text-center"> 72 - No results. 73 - </TableCell> 74 - </TableRow> 75 - )} 76 - </TableBody> 77 - </Table> 78 - </div> 79 - ); 80 - }
-58
apps/web/src/components/data-table/maintenance/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - 5 - import type { Maintenance } from "@openstatus/db/src/schema"; 6 - import { format } from "date-fns"; 7 - import { DataTableRowActions } from "./data-table-row-actions"; 8 - 9 - export const columns: ColumnDef<Maintenance>[] = [ 10 - { 11 - accessorKey: "title", 12 - header: "Title", 13 - }, 14 - { 15 - accessorKey: "message", 16 - header: "Message", 17 - cell: ({ row }) => { 18 - return ( 19 - <p className="flex max-w-[125px] lg:max-w-[250px] xl:max-w-[350px]"> 20 - <span className="truncate">{row.getValue("message")}</span> 21 - </p> 22 - ); 23 - }, 24 - }, 25 - { 26 - accessorKey: "from", 27 - header: "Start", 28 - cell: ({ row }) => { 29 - return ( 30 - <p className="text-muted-foreground"> 31 - {format(row.getValue("from"), "LLL dd, y HH:mm zzzz")} 32 - </p> 33 - ); 34 - }, 35 - }, 36 - { 37 - accessorKey: "to", 38 - header: "End", 39 - cell: ({ row }) => { 40 - return ( 41 - <p className="text-muted-foreground"> 42 - {format(row.getValue("to"), "LLL dd, y HH:mm zzzz")} 43 - </p> 44 - ); 45 - }, 46 - }, 47 - // missing: from, to 48 - { 49 - id: "actions", 50 - cell: ({ row }) => { 51 - return ( 52 - <div className="text-right"> 53 - <DataTableRowActions row={row} /> 54 - </div> 55 - ); 56 - }, 57 - }, 58 - ];
-115
apps/web/src/components/data-table/maintenance/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import Link from "next/link"; 6 - import { useRouter } from "next/navigation"; 7 - import * as React from "react"; 8 - 9 - import { selectMaintenanceSchema } from "@openstatus/db/src/schema"; 10 - import { 11 - AlertDialog, 12 - AlertDialogAction, 13 - AlertDialogCancel, 14 - AlertDialogContent, 15 - AlertDialogDescription, 16 - AlertDialogFooter, 17 - AlertDialogHeader, 18 - AlertDialogTitle, 19 - AlertDialogTrigger, 20 - Button, 21 - DropdownMenu, 22 - DropdownMenuContent, 23 - DropdownMenuItem, 24 - DropdownMenuTrigger, 25 - } from "@openstatus/ui"; 26 - 27 - import { LoadingAnimation } from "@/components/loading-animation"; 28 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 29 - import { toastAction } from "@/lib/toast"; 30 - import { api } from "@/trpc/client"; 31 - 32 - interface DataTableRowActionsProps<TData> { 33 - row: Row<TData>; 34 - } 35 - 36 - export function DataTableRowActions<TData>({ 37 - row, 38 - }: DataTableRowActionsProps<TData>) { 39 - const maintenance = selectMaintenanceSchema.parse(row.original); 40 - const router = useRouter(); 41 - const [alertOpen, setAlertOpen] = React.useState(false); 42 - const [isPending, startTransition] = React.useTransition(); 43 - const { copy } = useCopyToClipboard(); 44 - 45 - async function onDelete() { 46 - startTransition(async () => { 47 - try { 48 - if (!maintenance.id) return; 49 - await api.maintenance.delete.mutate({ id: maintenance.id }); 50 - toastAction("deleted"); 51 - router.refresh(); 52 - setAlertOpen(false); 53 - } catch { 54 - toastAction("error"); 55 - } 56 - }); 57 - } 58 - 59 - return ( 60 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 61 - <DropdownMenu> 62 - <DropdownMenuTrigger asChild> 63 - <Button 64 - variant="ghost" 65 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 66 - > 67 - <span className="sr-only">Open menu</span> 68 - <MoreHorizontal className="h-4 w-4" /> 69 - </Button> 70 - </DropdownMenuTrigger> 71 - <DropdownMenuContent align="end"> 72 - <Link href={`./maintenances/${maintenance.id}/edit`}> 73 - <DropdownMenuItem>Edit</DropdownMenuItem> 74 - </Link> 75 - <DropdownMenuItem 76 - onClick={() => 77 - copy(`${maintenance.id}`, { 78 - withToast: `Copied ID '${maintenance.id}'`, 79 - }) 80 - } 81 - > 82 - Copy ID 83 - </DropdownMenuItem> 84 - <AlertDialogTrigger asChild> 85 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 86 - Delete 87 - </DropdownMenuItem> 88 - </AlertDialogTrigger> 89 - </DropdownMenuContent> 90 - </DropdownMenu> 91 - <AlertDialogContent> 92 - <AlertDialogHeader> 93 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 94 - <AlertDialogDescription> 95 - This action cannot be undone. This will permanently delete the 96 - monitor. 97 - </AlertDialogDescription> 98 - </AlertDialogHeader> 99 - <AlertDialogFooter> 100 - <AlertDialogCancel>Cancel</AlertDialogCancel> 101 - <AlertDialogAction 102 - onClick={(e) => { 103 - e.preventDefault(); 104 - onDelete(); 105 - }} 106 - disabled={isPending} 107 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 108 - > 109 - {!isPending ? "Delete" : <LoadingAnimation />} 110 - </AlertDialogAction> 111 - </AlertDialogFooter> 112 - </AlertDialogContent> 113 - </AlertDialog> 114 - ); 115 - }
-80
apps/web/src/components/data-table/maintenance/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - export function DataTable<TData, TValue>({ 25 - columns, 26 - data, 27 - }: DataTableProps<TData, TValue>) { 28 - const table = useReactTable({ 29 - data, 30 - columns, 31 - getCoreRowModel: getCoreRowModel(), 32 - }); 33 - 34 - return ( 35 - <div className="rounded-md border"> 36 - <Table> 37 - <TableHeader className="bg-muted/50"> 38 - {table.getHeaderGroups().map((headerGroup) => ( 39 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 40 - {headerGroup.headers.map((header) => { 41 - return ( 42 - <TableHead key={header.id}> 43 - {header.isPlaceholder 44 - ? null 45 - : flexRender( 46 - header.column.columnDef.header, 47 - header.getContext(), 48 - )} 49 - </TableHead> 50 - ); 51 - })} 52 - </TableRow> 53 - ))} 54 - </TableHeader> 55 - <TableBody> 56 - {table.getRowModel().rows?.length ? ( 57 - table.getRowModel().rows.map((row) => ( 58 - <TableRow 59 - key={row.id} 60 - data-state={row.getIsSelected() && "selected"} 61 - > 62 - {row.getVisibleCells().map((cell) => ( 63 - <TableCell key={cell.id}> 64 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 - </TableCell> 66 - ))} 67 - </TableRow> 68 - )) 69 - ) : ( 70 - <TableRow> 71 - <TableCell colSpan={columns.length} className="h-24 text-center"> 72 - No results. 73 - </TableCell> 74 - </TableRow> 75 - )} 76 - </TableBody> 77 - </Table> 78 - </div> 79 - ); 80 - }
-349
apps/web/src/components/data-table/monitor/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { formatDistanceToNowStrict } from "date-fns"; 5 - import Link from "next/link"; 6 - 7 - import type { 8 - Incident, 9 - Maintenance, 10 - Monitor, 11 - MonitorTag, 12 - } from "@openstatus/db/src/schema"; 13 - import { Tracker } from "@openstatus/tracker"; 14 - import { 15 - Badge, 16 - Checkbox, 17 - Tooltip, 18 - TooltipContent, 19 - TooltipProvider, 20 - TooltipTrigger, 21 - } from "@openstatus/ui"; 22 - 23 - import { StatusDotWithTooltip } from "@/components/monitor/status-dot-with-tooltip"; 24 - import { TagBadgeWithTooltip } from "@/components/monitor/tag-badge-with-tooltip"; 25 - import { Bar } from "@/components/tracker/tracker"; 26 - import { isActiveMaintenance } from "@/lib/maintenances/utils"; 27 - 28 - import type { ResponseStatusTracker, ResponseTimeMetrics } from "@/lib/tb"; 29 - import { Eye, EyeOff, Radio, View } from "lucide-react"; 30 - import type { ReactNode } from "react"; 31 - import { DataTableColumnHeader } from "./data-table-column-header"; 32 - import { DataTableRowActions } from "./data-table-row-actions"; 33 - 34 - // EXAMPLE: get the type of the response of the endpoint 35 - 36 - export const columns: ColumnDef<{ 37 - monitor: Monitor; 38 - metrics?: ResponseTimeMetrics; 39 - data?: ResponseStatusTracker[]; 40 - incidents?: Incident[]; 41 - maintenances?: Maintenance[]; 42 - tags?: MonitorTag[]; 43 - }>[] = [ 44 - { 45 - id: "id", 46 - accessorKey: "id", 47 - accessorFn: (row) => row.monitor.id, 48 - }, 49 - { 50 - id: "jobType", 51 - accessorKey: "jobType", 52 - accessorFn: (row) => row.monitor.jobType, 53 - filterFn: (row, _id, value) => { 54 - if (!Array.isArray(value)) return true; 55 - return value.includes(row.original.monitor.jobType); 56 - }, 57 - }, 58 - { 59 - id: "select", 60 - header: ({ table }) => ( 61 - <Checkbox 62 - checked={ 63 - table.getIsAllPageRowsSelected() || 64 - (table.getIsSomePageRowsSelected() && "indeterminate") 65 - } 66 - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} 67 - aria-label="Select all" 68 - className="translate-y-[2px]" 69 - /> 70 - ), 71 - cell: ({ row }) => ( 72 - <Checkbox 73 - checked={row.getIsSelected()} 74 - onCheckedChange={(value) => row.toggleSelected(!!value)} 75 - aria-label="Select row" 76 - className="translate-y-[2px]" 77 - /> 78 - ), 79 - }, 80 - { 81 - accessorKey: "active", 82 - accessorFn: (row) => row.monitor.active, 83 - header: () => <Radio className="h-4 w-4" />, 84 - cell: ({ row }) => { 85 - const { active, status } = row.original.monitor; 86 - const maintenance = isActiveMaintenance(row.original.maintenances); 87 - return ( 88 - <div className="flex w-4 items-center justify-center"> 89 - <StatusDotWithTooltip 90 - active={active} 91 - status={status} 92 - maintenance={maintenance} 93 - /> 94 - </div> 95 - ); 96 - }, 97 - filterFn: (row, _id, value) => { 98 - if (!Array.isArray(value)) return true; 99 - return value.includes(row.original.monitor.active); 100 - }, 101 - meta: { 102 - headerClassName: "w-4", 103 - }, 104 - }, 105 - { 106 - accessorKey: "name", 107 - accessorFn: (row) => row.monitor.name, // used for filtering as name is nested within the monitor object 108 - header: ({ column }) => ( 109 - <DataTableColumnHeader column={column} title="Name" /> 110 - ), 111 - cell: ({ row }) => { 112 - const { name, public: _public } = row.original.monitor; 113 - return ( 114 - <div className="flex gap-2"> 115 - <Link 116 - href={`./monitors/${row.original.monitor.id}/overview`} 117 - className="group flex max-w-full items-center gap-2" 118 - prefetch={false} 119 - > 120 - <span className="truncate group-hover:underline">{name}</span> 121 - </Link> 122 - {_public ? <Badge variant="secondary">public</Badge> : null} 123 - </div> 124 - ); 125 - }, 126 - }, 127 - { 128 - accessorKey: "tags", 129 - header: "Tags", 130 - cell: ({ row }) => { 131 - const { tags } = row.original; 132 - if (!tags?.length) 133 - return <span className="text-muted-foreground">-</span>; 134 - return <TagBadgeWithTooltip tags={tags} />; 135 - }, 136 - filterFn: (row, _id, value) => { 137 - if (!Array.isArray(value)) return true; 138 - // REMINDER: if one value is found, return true 139 - // we could consider restricting it to all the values have to be found 140 - return value.some((item) => 141 - row.original.tags?.some((tag) => tag.name === item), 142 - ); 143 - }, 144 - }, 145 - { 146 - accessorKey: "public", 147 - accessorFn: (row) => row.monitor.public, 148 - header: () => ( 149 - <div className="w-4"> 150 - <View className="h-4 w-4" /> 151 - </div> 152 - ), 153 - cell: ({ row }) => { 154 - const { public: _public } = row.original.monitor; 155 - return ( 156 - <> 157 - {_public ? ( 158 - <Eye className="h-4 w-4" /> 159 - ) : ( 160 - <EyeOff className="h-4 w-4" /> 161 - )} 162 - </> 163 - ); 164 - }, 165 - filterFn: (row, _id, value) => { 166 - if (!Array.isArray(value)) return true; 167 - return value.includes(row.original.monitor.public); 168 - }, 169 - }, 170 - { 171 - accessorKey: "tracker", 172 - header: () => ( 173 - <HeaderTooltip text="UTC time period"> 174 - <span className="underline decoration-dotted">Last 7 days</span> 175 - </HeaderTooltip> 176 - ), 177 - cell: ({ row }) => { 178 - const tracker = new Tracker({ 179 - data: row.original.data?.slice(0, 7).reverse(), 180 - incidents: row.original.incidents, 181 - maintenances: row.original.maintenances, 182 - }); 183 - return ( 184 - <div className="flex w-24 gap-1"> 185 - {tracker.days?.map((tracker) => ( 186 - <Bar 187 - key={tracker.day} 188 - className="h-5" 189 - showValues={true} 190 - {...tracker} 191 - /> 192 - ))} 193 - </div> 194 - ); 195 - }, 196 - }, 197 - { 198 - accessorKey: "lastTimestamp", 199 - header: "Last ping", 200 - cell: ({ row }) => { 201 - const timestamp = row.original.metrics?.lastTimestamp; 202 - if (timestamp) { 203 - const distance = formatDistanceToNowStrict(new Date(timestamp), { 204 - addSuffix: true, 205 - }); 206 - return ( 207 - <div className="flex max-w-[84px] text-muted-foreground sm:max-w-none"> 208 - <span className="truncate">{distance}</span> 209 - </div> 210 - ); 211 - } 212 - return <span className="text-muted-foreground">-</span>; 213 - }, 214 - }, 215 - { 216 - accessorKey: "uptime", 217 - header: ({ column }) => ( 218 - <DataTableColumnHeader column={column} title="Uptime" /> 219 - ), 220 - cell: ({ row }) => { 221 - const { count, ok } = row.original?.metrics || {}; 222 - if (!count || !ok) 223 - return <span className="text-muted-foreground">-</span>; 224 - const rounded = Math.round((ok / count) * 10_000) / 100; 225 - return <DisplayNumber value={rounded} suffix="%" />; 226 - }, 227 - sortingFn: (rowA, rowB, columnId) => { 228 - const valueA = rowA.getValue(columnId) as number | undefined; 229 - const valueB = rowB.getValue(columnId) as number | undefined; 230 - if (!valueB) return valueA || 1; 231 - if (!valueA) return -valueB; 232 - return valueA - valueB; 233 - }, 234 - }, 235 - { 236 - accessorKey: "p50Latency", 237 - accessorFn: (row) => row.metrics?.p50Latency, 238 - header: ({ column }) => ( 239 - <DataTableColumnHeader column={column} title="P50" /> 240 - ), 241 - cell: ({ row }) => { 242 - const latency = row.original.metrics?.p50Latency; 243 - if (latency) return <DisplayNumber value={latency} suffix="ms" />; 244 - return <span className="text-muted-foreground">-</span>; 245 - }, 246 - sortingFn: (rowA, rowB, columnId) => { 247 - const valueA = rowA.getValue(columnId) as number | undefined; 248 - const valueB = rowB.getValue(columnId) as number | undefined; 249 - if (!valueB) return valueA || 1; 250 - if (!valueA) return -valueB; 251 - return valueA - valueB; 252 - }, 253 - }, 254 - { 255 - accessorKey: "p75Latency", 256 - accessorFn: (row) => row.metrics?.p75Latency, 257 - header: ({ column }) => ( 258 - <DataTableColumnHeader column={column} title="P75" /> 259 - ), 260 - cell: ({ row }) => { 261 - const latency = row.original.metrics?.p75Latency; 262 - if (latency) return <DisplayNumber value={latency} suffix="ms" />; 263 - return <span className="text-muted-foreground">-</span>; 264 - }, 265 - sortingFn: (rowA, rowB, columnId) => { 266 - const valueA = rowA.getValue(columnId) as number | undefined; 267 - const valueB = rowB.getValue(columnId) as number | undefined; 268 - if (!valueB) return valueA || 1; 269 - if (!valueA) return -valueB; 270 - return valueA - valueB; 271 - }, 272 - }, 273 - { 274 - accessorKey: "p95Latency", 275 - accessorFn: (row) => row.metrics?.p95Latency, 276 - header: ({ column }) => ( 277 - <DataTableColumnHeader column={column} title="P95" /> 278 - ), 279 - cell: ({ row }) => { 280 - const latency = row.original.metrics?.p95Latency; 281 - if (latency) return <DisplayNumber value={latency} suffix="ms" />; 282 - return <span className="text-muted-foreground">-</span>; 283 - }, 284 - sortingFn: (rowA, rowB, columnId) => { 285 - const valueA = rowA.getValue(columnId) as number | undefined; 286 - const valueB = rowB.getValue(columnId) as number | undefined; 287 - if (!valueB) return valueA || 1; 288 - if (!valueA) return -valueB; 289 - return valueA - valueB; 290 - }, 291 - }, 292 - { 293 - accessorKey: "p99Latency", 294 - accessorFn: (row) => row.metrics?.p99Latency, 295 - header: ({ column }) => ( 296 - <DataTableColumnHeader column={column} title="P99" /> 297 - ), 298 - cell: ({ row }) => { 299 - const latency = row.original.metrics?.p99Latency; 300 - if (latency) return <DisplayNumber value={latency} suffix="ms" />; 301 - return <span className="text-muted-foreground">-</span>; 302 - }, 303 - sortingFn: (rowA, rowB, columnId) => { 304 - const valueA = rowA.getValue(columnId) as number | undefined; 305 - const valueB = rowB.getValue(columnId) as number | undefined; 306 - if (!valueB) return valueA || 1; 307 - if (!valueA) return -valueB; 308 - return valueA - valueB; 309 - }, 310 - }, 311 - { 312 - id: "actions", 313 - cell: ({ row }) => { 314 - return ( 315 - <div className="text-right"> 316 - <DataTableRowActions row={row} /> 317 - </div> 318 - ); 319 - }, 320 - }, 321 - ]; 322 - 323 - function HeaderTooltip({ 324 - text, 325 - children, 326 - }: { 327 - text: string; 328 - children: ReactNode; 329 - }) { 330 - return ( 331 - <TooltipProvider> 332 - <Tooltip> 333 - <TooltipTrigger suppressHydrationWarning>{children}</TooltipTrigger> 334 - <TooltipContent>{text}</TooltipContent> 335 - </Tooltip> 336 - </TooltipProvider> 337 - ); 338 - } 339 - 340 - function DisplayNumber({ value, suffix }: { value: number; suffix: string }) { 341 - return ( 342 - <span className="font-mono"> 343 - {new Intl.NumberFormat("us").format(value).toString()} 344 - <span className="font-normal text-muted-foreground text-xs"> 345 - {suffix} 346 - </span> 347 - </span> 348 - ); 349 - }
-66
apps/web/src/components/data-table/monitor/data-table-column-header.tsx
··· 1 - import type { Column } from "@tanstack/react-table"; 2 - import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"; 3 - 4 - import { 5 - Button, 6 - DropdownMenu, 7 - DropdownMenuContent, 8 - DropdownMenuItem, 9 - DropdownMenuTrigger, 10 - } from "@openstatus/ui"; 11 - 12 - import { cn } from "@/lib/utils"; 13 - 14 - interface DataTableColumnHeaderProps<TData, TValue> 15 - extends React.HTMLAttributes<HTMLDivElement> { 16 - column: Column<TData, TValue>; 17 - title: string; 18 - } 19 - 20 - export function DataTableColumnHeader<TData, TValue>({ 21 - column, 22 - title, 23 - className, 24 - }: DataTableColumnHeaderProps<TData, TValue>) { 25 - if (!column.getCanSort()) { 26 - return <div className={cn(className)}>{title}</div>; 27 - } 28 - 29 - return ( 30 - <div className={cn("flex items-center space-x-2", className)}> 31 - <DropdownMenu> 32 - <DropdownMenuTrigger asChild> 33 - <Button 34 - variant="ghost" 35 - size="sm" 36 - className="-ml-3 h-8 data-[state=open]:bg-accent" 37 - > 38 - <span>{title}</span> 39 - {column.getIsSorted() === "desc" ? ( 40 - <ArrowUp className="ml-2 h-4 w-4" /> 41 - ) : column.getIsSorted() === "asc" ? ( 42 - <ArrowDown className="ml-2 h-4 w-4" /> 43 - ) : ( 44 - <ChevronsUpDown className="ml-2 h-4 w-4" /> 45 - )} 46 - </Button> 47 - </DropdownMenuTrigger> 48 - <DropdownMenuContent align="start"> 49 - <DropdownMenuItem onClick={() => column.toggleSorting(false)}> 50 - <ArrowDown className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> 51 - Asc 52 - </DropdownMenuItem> 53 - <DropdownMenuItem onClick={() => column.toggleSorting(true)}> 54 - <ArrowUp className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" /> 55 - Desc 56 - </DropdownMenuItem> 57 - {/* <DropdownMenuSeparator /> 58 - <DropdownMenuItem onClick={() => column.toggleVisibility(false)}> 59 - <EyeOff className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" /> 60 - Hide 61 - </DropdownMenuItem> */} 62 - </DropdownMenuContent> 63 - </DropdownMenu> 64 - </div> 65 - ); 66 - }
-363
apps/web/src/components/data-table/monitor/data-table-floating-actions.tsx
··· 1 - "use client"; 2 - 3 - import * as Portal from "@radix-ui/react-portal"; 4 - import type { Table } from "@tanstack/react-table"; 5 - import { useEffect, useState, useTransition } from "react"; 6 - 7 - import { Kbd } from "@/components/kbd"; 8 - import { LoadingAnimation } from "@/components/loading-animation"; 9 - import { toast, toastAction } from "@/lib/toast"; 10 - import { cn } from "@/lib/utils"; 11 - import { api } from "@/trpc/client"; 12 - import type { Monitor, MonitorTag } from "@openstatus/db/src/schema"; 13 - import { 14 - AlertDialog, 15 - AlertDialogAction, 16 - AlertDialogCancel, 17 - AlertDialogContent, 18 - AlertDialogDescription, 19 - AlertDialogFooter, 20 - AlertDialogHeader, 21 - AlertDialogTitle, 22 - AlertDialogTrigger, 23 - Button, 24 - Command, 25 - CommandEmpty, 26 - CommandGroup, 27 - CommandInput, 28 - CommandItem, 29 - CommandList, 30 - Popover, 31 - PopoverContent, 32 - PopoverTrigger, 33 - Select, 34 - SelectContent, 35 - SelectGroup, 36 - SelectItem, 37 - SelectLabel, 38 - SelectTrigger, 39 - SelectValue, 40 - Tooltip, 41 - TooltipContent, 42 - TooltipProvider, 43 - TooltipTrigger, 44 - } from "@openstatus/ui"; 45 - import { Check, X } from "lucide-react"; 46 - import { useRouter } from "next/navigation"; 47 - 48 - interface DataTableFloatingActions<TData> { 49 - table: Table<TData>; 50 - actions?: []; 51 - tags?: MonitorTag[]; 52 - } 53 - 54 - export function DataTableFloatingActions<TData>({ 55 - table, 56 - tags, 57 - }: DataTableFloatingActions<TData>) { 58 - const router = useRouter(); 59 - const [alertOpen, setAlertOpen] = useState(false); 60 - const [isPending, startTransition] = useTransition(); 61 - const [method, setMethod] = useState< 62 - "delete" | "active" | "public" | "tag" | null 63 - >(null); 64 - const rows = table.getFilteredSelectedRowModel().rows; 65 - 66 - // clear selection on escape key 67 - useEffect(() => { 68 - function handleKeyDown(event: KeyboardEvent) { 69 - if (event.key === "Escape") { 70 - table.toggleAllRowsSelected(false); 71 - } 72 - } 73 - 74 - window.addEventListener("keydown", handleKeyDown); 75 - return () => window.removeEventListener("keydown", handleKeyDown); 76 - }, [table]); 77 - 78 - if (table.getFilteredSelectedRowModel().rows.length === 0) { 79 - return null; 80 - } 81 - 82 - function handleUpdates(props: Partial<Pick<Monitor, "active" | "public">>) { 83 - startTransition(async () => { 84 - toast.promise( 85 - async () => { 86 - await api.monitor.updateMonitors.mutate({ 87 - ids: rows.map((row) => row.getValue("id")), 88 - ...props, 89 - }); 90 - router.refresh(); 91 - }, 92 - { 93 - loading: "Updating monitor(s)...", 94 - success: "Monitor(s) updated!", 95 - error: "Something went wrong!", 96 - finally: () => {}, 97 - }, 98 - ); 99 - }); 100 - } 101 - 102 - function handleTagUpdates(props: { 103 - tagId: number; 104 - action: "add" | "remove"; 105 - }) { 106 - startTransition(async () => { 107 - toast.promise( 108 - async () => { 109 - await api.monitor.updateMonitorsTag.mutate({ 110 - ids: rows.map((row) => row.getValue("id")), 111 - ...props, 112 - }); 113 - router.refresh(); 114 - }, 115 - { 116 - loading: 117 - props.action === "add" 118 - ? "Adding tag to monitor(s)..." 119 - : "Removing tag from monitor(s)...", 120 - success: 121 - props.action === "add" 122 - ? "Tag added to monitor(s)!" 123 - : "Tag removed from monitor(s)", 124 - error: "Something went wrong!", 125 - finally: () => {}, 126 - }, 127 - ); 128 - }); 129 - } 130 - 131 - function handleDeletes() { 132 - startTransition(async () => { 133 - try { 134 - await api.monitor.deleteMonitors.mutate({ 135 - ids: rows.map((row) => row.getValue("id")), 136 - }); 137 - setAlertOpen(false); 138 - table.toggleAllRowsSelected(false); 139 - router.refresh(); 140 - } catch (error) { 141 - console.error(error); 142 - toastAction("error"); 143 - } 144 - }); 145 - } 146 - 147 - // TODO: can we make it smarter! Its ugly as hell 148 - 149 - const statusValue = rows.every((row) => row.getValue("active") === true) 150 - ? "true" 151 - : rows.every((row) => row.getValue("active") === false) 152 - ? "false" 153 - : undefined; 154 - 155 - const visibilityValue = rows.every((row) => row.getValue("public") === true) 156 - ? "true" 157 - : rows.every((row) => row.getValue("public") === false) 158 - ? "false" 159 - : undefined; 160 - 161 - const everyTagValue = 162 - tags?.filter((tag) => { 163 - return rows.every((row) => { 164 - const _tags = row.getValue("tags"); 165 - if (Array.isArray(_tags)) { 166 - return _tags.map(({ id }) => id)?.includes(tag.id); 167 - } 168 - return false; 169 - }); 170 - }) || []; 171 - 172 - const someTagValue = 173 - tags?.filter((tag) => { 174 - return rows.some((row) => { 175 - const _tags = row.getValue("tags"); 176 - if (Array.isArray(_tags)) { 177 - return _tags.map(({ id }) => id)?.includes(tag.id); 178 - } 179 - return false; 180 - }); 181 - }) || []; 182 - 183 - return ( 184 - <Portal.Root> 185 - <div className="fixed inset-x-0 bottom-4 z-50 mx-auto w-fit px-4"> 186 - <div className="flex flex-wrap items-center gap-2 rounded-md border bg-background px-4 py-2 shadow-sm"> 187 - <TooltipProvider> 188 - <Tooltip> 189 - <TooltipTrigger asChild> 190 - <Button 191 - variant="ghost" 192 - onClick={() => table.toggleAllRowsSelected(false)} 193 - className="border border-dashed" 194 - > 195 - <span className="whitespace-nowrap"> 196 - {rows.length} selected 197 - </span> 198 - <X className="ml-1.5 size-4 shrink-0" /> 199 - </Button> 200 - </TooltipTrigger> 201 - <TooltipContent className="flex items-center"> 202 - <p className="mr-2">Clear selection</p> 203 - <Kbd abbrTitle="Escape" variant="outline"> 204 - Esc 205 - </Kbd> 206 - </TooltipContent> 207 - </Tooltip> 208 - </TooltipProvider> 209 - <AlertDialog 210 - open={alertOpen} 211 - onOpenChange={(value) => setAlertOpen(value)} 212 - > 213 - <AlertDialogTrigger asChild> 214 - <Button 215 - variant="outline" 216 - className="border-destructive text-destructive hover:bg-destructive hover:text-background" 217 - > 218 - Delete 219 - </Button> 220 - </AlertDialogTrigger> 221 - <AlertDialogContent> 222 - <AlertDialogHeader> 223 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 224 - <AlertDialogDescription> 225 - This action cannot be undone. This will permanently delete the 226 - selected monitor(s). 227 - </AlertDialogDescription> 228 - </AlertDialogHeader> 229 - <AlertDialogFooter> 230 - <AlertDialogCancel>Cancel</AlertDialogCancel> 231 - <AlertDialogAction 232 - onClick={() => { 233 - setMethod("delete"); 234 - handleDeletes(); 235 - }} 236 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 237 - > 238 - {isPending && method === "delete" ? ( 239 - <LoadingAnimation /> 240 - ) : ( 241 - "Delete" 242 - )} 243 - </AlertDialogAction> 244 - </AlertDialogFooter> 245 - </AlertDialogContent> 246 - </AlertDialog> 247 - <Select 248 - disabled={isPending && method === "active"} 249 - value={statusValue} 250 - onValueChange={(value) => { 251 - setMethod("active"); 252 - handleUpdates({ active: value === "true" }); 253 - }} 254 - > 255 - <SelectTrigger className="h-9 max-w-fit"> 256 - <SelectValue placeholder="Status" /> 257 - </SelectTrigger> 258 - <SelectContent> 259 - <SelectGroup> 260 - <SelectLabel>Status</SelectLabel> 261 - <SelectItem value="true">Active</SelectItem> 262 - <SelectItem value="false">Inactive</SelectItem> 263 - </SelectGroup> 264 - </SelectContent> 265 - </Select> 266 - <Popover> 267 - <PopoverTrigger disabled={isPending && method === "tag"} asChild> 268 - <Button variant="outline" className="flex items-center gap-2"> 269 - <span>Tags</span> 270 - {everyTagValue.length ? ( 271 - <div className="relative flex overflow-hidden"> 272 - {everyTagValue.map((tag) => ( 273 - <div 274 - key={tag.id} 275 - style={{ backgroundColor: tag.color }} 276 - className="h-2.5 w-2.5 rounded-full ring-2 ring-background" 277 - /> 278 - ))} 279 - </div> 280 - ) : null} 281 - </Button> 282 - </PopoverTrigger> 283 - <PopoverContent className="w-[200px] p-0" align="start"> 284 - <Command> 285 - <CommandInput placeholder={"Tags"} /> 286 - <CommandList> 287 - <CommandEmpty>No results found.</CommandEmpty> 288 - <CommandGroup> 289 - {tags?.map((tag) => { 290 - const isSelected = everyTagValue 291 - .map((tag) => tag.id) 292 - ?.includes(tag.id); 293 - const isIndeterminated = !isSelected 294 - ? someTagValue.map((tag) => tag.id)?.includes(tag.id) 295 - : false; 296 - return ( 297 - <CommandItem 298 - key={String(tag.name)} 299 - onSelect={() => { 300 - setMethod("tag"); 301 - handleTagUpdates({ 302 - tagId: tag.id, 303 - action: 304 - !isSelected || isIndeterminated 305 - ? "add" 306 - : "remove", 307 - }); 308 - }} 309 - > 310 - <div 311 - className={cn( 312 - "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", 313 - { 314 - "bg-primary text-primary-foreground": 315 - isSelected, 316 - "text-muted-foreground": isIndeterminated, 317 - "opacity-50 [&_svg]:invisible": 318 - !isSelected && !isIndeterminated, 319 - }, 320 - )} 321 - > 322 - <Check className={cn("h-4 w-4")} /> 323 - </div> 324 - <div className="flex w-full items-center justify-between"> 325 - <span>{tag.name}</span> 326 - <div 327 - key={tag.id} 328 - style={{ backgroundColor: tag.color }} 329 - className="h-2.5 w-2.5 rounded-full" 330 - /> 331 - </div> 332 - </CommandItem> 333 - ); 334 - })} 335 - </CommandGroup> 336 - </CommandList> 337 - </Command> 338 - </PopoverContent> 339 - </Popover> 340 - <Select 341 - disabled={isPending && method === "public"} 342 - value={visibilityValue} 343 - onValueChange={(value) => { 344 - setMethod("public"); 345 - handleUpdates({ public: value === "true" }); 346 - }} 347 - > 348 - <SelectTrigger className="h-9 max-w-fit"> 349 - <SelectValue placeholder="Visibility" /> 350 - </SelectTrigger> 351 - <SelectContent> 352 - <SelectGroup> 353 - <SelectLabel>Visibility</SelectLabel> 354 - <SelectItem value="true">Public</SelectItem> 355 - <SelectItem value="false">Private</SelectItem> 356 - </SelectGroup> 357 - </SelectContent> 358 - </Select> 359 - </div> 360 - </div> 361 - </Portal.Root> 362 - ); 363 - }
-119
apps/web/src/components/data-table/monitor/data-table-pagination.tsx
··· 1 - "use client"; 2 - 3 - import type { Table } from "@tanstack/react-table"; 4 - import { 5 - ChevronLeft, 6 - ChevronRight, 7 - ChevronsLeft, 8 - ChevronsRight, 9 - } from "lucide-react"; 10 - 11 - import { 12 - Button, 13 - Select, 14 - SelectContent, 15 - SelectItem, 16 - SelectTrigger, 17 - SelectValue, 18 - } from "@openstatus/ui"; 19 - 20 - import { parseAsInteger, useQueryStates } from "nuqs"; 21 - 22 - // REMINDER: pageIndex pagination issue - jumping back to 0 after change 23 - 24 - interface DataTablePaginationProps<TData> { 25 - table: Table<TData>; 26 - } 27 - 28 - export function DataTablePagination<TData>({ 29 - table, 30 - }: DataTablePaginationProps<TData>) { 31 - const [_, setSearchParams] = useQueryStates({ 32 - pageSize: parseAsInteger.withDefault(10), 33 - // pageIndex: parseAsInteger.withDefault(0), 34 - }); 35 - 36 - return ( 37 - <div className="flex flex-wrap-reverse items-center justify-between gap-4 px-2"> 38 - <div> 39 - <p className="text-muted-foreground text-sm"> 40 - {table.getFilteredSelectedRowModel().rows.length} of{" "} 41 - {table.getFilteredRowModel().rows.length} row(s) selected. 42 - </p> 43 - </div> 44 - <div className="flex items-center space-x-6 lg:space-x-8"> 45 - <div className="flex items-center space-x-2"> 46 - <p className="font-medium text-sm">Rows per page</p> 47 - <Select 48 - value={`${table.getState().pagination.pageSize}`} 49 - onValueChange={(value) => { 50 - table.setPageSize(Number(value)); 51 - setSearchParams({ pageSize: Number(value) }); 52 - }} 53 - > 54 - <SelectTrigger className="h-8 w-[70px]"> 55 - <SelectValue placeholder={table.getState().pagination.pageSize} /> 56 - </SelectTrigger> 57 - <SelectContent side="top"> 58 - {[10, 20, 30, 40, 50].map((pageSize) => ( 59 - <SelectItem key={pageSize} value={`${pageSize}`}> 60 - {pageSize} 61 - </SelectItem> 62 - ))} 63 - </SelectContent> 64 - </Select> 65 - </div> 66 - <div className="flex w-[100px] items-center justify-center font-medium text-sm"> 67 - Page {table.getState().pagination.pageIndex + 1} of{" "} 68 - {table.getPageCount()} 69 - </div> 70 - <div className="flex items-center space-x-2"> 71 - <Button 72 - variant="outline" 73 - className="hidden h-8 w-8 p-0 lg:flex" 74 - onClick={() => { 75 - table.setPageIndex(0); 76 - }} 77 - disabled={!table.getCanPreviousPage()} 78 - > 79 - <span className="sr-only">Go to first page</span> 80 - <ChevronsLeft className="h-4 w-4" /> 81 - </Button> 82 - <Button 83 - variant="outline" 84 - className="h-8 w-8 p-0" 85 - onClick={() => { 86 - table.previousPage(); 87 - }} 88 - disabled={!table.getCanPreviousPage()} 89 - > 90 - <span className="sr-only">Go to previous page</span> 91 - <ChevronLeft className="h-4 w-4" /> 92 - </Button> 93 - <Button 94 - variant="outline" 95 - className="h-8 w-8 p-0" 96 - onClick={() => { 97 - table.nextPage(); 98 - }} 99 - disabled={!table.getCanNextPage()} 100 - > 101 - <span className="sr-only">Go to next page</span> 102 - <ChevronRight className="h-4 w-4" /> 103 - </Button> 104 - <Button 105 - variant="outline" 106 - className="hidden h-8 w-8 p-0 lg:flex" 107 - onClick={() => { 108 - table.setPageIndex(table.getPageCount() - 1); 109 - }} 110 - disabled={!table.getCanNextPage()} 111 - > 112 - <span className="sr-only">Go to last page</span> 113 - <ChevronsRight className="h-4 w-4" /> 114 - </Button> 115 - </div> 116 - </div> 117 - </div> 118 - ); 119 - }
-202
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import Link from "next/link"; 6 - import { useRouter } from "next/navigation"; 7 - import { useState, useTransition } from "react"; 8 - import { z } from "zod"; 9 - 10 - import { selectMonitorSchema } from "@openstatus/db/src/schema"; 11 - import { 12 - AlertDialog, 13 - AlertDialogAction, 14 - AlertDialogCancel, 15 - AlertDialogContent, 16 - AlertDialogDescription, 17 - AlertDialogFooter, 18 - AlertDialogHeader, 19 - AlertDialogTitle, 20 - AlertDialogTrigger, 21 - Button, 22 - DropdownMenu, 23 - DropdownMenuContent, 24 - DropdownMenuItem, 25 - DropdownMenuSeparator, 26 - DropdownMenuTrigger, 27 - } from "@openstatus/ui"; 28 - 29 - import { LoadingAnimation } from "@/components/loading-animation"; 30 - import type { RegionChecker } from "@/components/ping-response-analysis/utils"; 31 - import { toast, toastAction } from "@/lib/toast"; 32 - import { api } from "@/trpc/client"; 33 - 34 - import type { TCPResponseTest } from "@/app/api/checker/test/tcp/schema"; 35 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 36 - 37 - interface DataTableRowActionsProps<TData> { 38 - row: Row<TData>; 39 - } 40 - 41 - export function DataTableRowActions<TData>({ 42 - row, 43 - }: DataTableRowActionsProps<TData>) { 44 - const { monitor, isLimitReached } = z 45 - .object({ monitor: selectMonitorSchema, isLimitReached: z.boolean() }) 46 - .parse(row.original); 47 - const router = useRouter(); 48 - const [alertOpen, setAlertOpen] = useState(false); 49 - const [isPending, startTransition] = useTransition(); 50 - const { copy } = useCopyToClipboard(); 51 - 52 - async function onDelete() { 53 - startTransition(async () => { 54 - try { 55 - if (!monitor.id) return; 56 - await api.monitor.delete.mutate({ id: monitor.id }); 57 - toastAction("deleted"); 58 - router.refresh(); 59 - setAlertOpen(false); 60 - } catch { 61 - toastAction("error"); 62 - } 63 - }); 64 - } 65 - 66 - // FIXME: the test doenst take the assertions into account! 67 - // FIXME: improve (similar to the one in the edit form - also include toast.promise + better error message!) 68 - async function onTest() { 69 - startTransition(async () => { 70 - const { url, body, method, headers, jobType } = monitor; 71 - 72 - try { 73 - const res = await fetch(`/api/checker/test/${jobType}`, { 74 - method: "POST", 75 - headers: new Headers({ 76 - "Content-Type": "application/json", 77 - }), 78 - body: JSON.stringify({ url, body, method, headers }), 79 - }); 80 - const data = (await res.json()) as 81 - | RegionChecker 82 - | z.infer<typeof TCPResponseTest>; 83 - 84 - // FIXME: assertions 85 - // it's getting 😭 86 - const success = 87 - data.state === "success" && data.type === "http" 88 - ? data.status >= 200 && data.status < 300 89 - : data.state === "success" && data.type === "tcp" && !data.error; 90 - 91 - if (success) { 92 - toastAction("test-success"); 93 - } else { 94 - toastAction("test-error"); 95 - } 96 - } catch { 97 - toastAction("error"); 98 - } 99 - }); 100 - } 101 - async function onClone() { 102 - startTransition(async () => { 103 - try { 104 - const id = monitor.id; 105 - if (!id) return; 106 - 107 - const selectedMonitorData = await api.monitor.getMonitorById.query({ 108 - id, 109 - }); 110 - 111 - const { notificationIds, pageIds, monitorTagIds } = 112 - await api.monitor.getMonitorRelationsById.query({ id }); 113 - 114 - const cloneMonitorData = { 115 - ...selectedMonitorData, 116 - name: `${selectedMonitorData.name} - copy`, 117 - tags: monitorTagIds, 118 - notifications: notificationIds, 119 - pages: pageIds, 120 - active: false, 121 - id: undefined, 122 - updatedAt: undefined, 123 - createdAt: undefined, 124 - }; 125 - 126 - // Create a clone function in the api 127 - await api.monitor.create.mutate(cloneMonitorData); 128 - 129 - toast.success("Monitor cloned!"); 130 - router.refresh(); 131 - } catch (error) { 132 - console.log("error", error); 133 - toastAction("error"); 134 - } 135 - }); 136 - } 137 - 138 - return ( 139 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 140 - <DropdownMenu> 141 - <DropdownMenuTrigger asChild> 142 - <Button 143 - variant="ghost" 144 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 145 - > 146 - <span className="sr-only">Open menu</span> 147 - <MoreHorizontal className="h-4 w-4" /> 148 - </Button> 149 - </DropdownMenuTrigger> 150 - <DropdownMenuContent align="end"> 151 - <Link href={`./monitors/${monitor.id}/edit`}> 152 - <DropdownMenuItem>Edit</DropdownMenuItem> 153 - </Link> 154 - <Link href={`./monitors/${monitor.id}/overview`}> 155 - <DropdownMenuItem>Details</DropdownMenuItem> 156 - </Link> 157 - <DropdownMenuItem onClick={onClone} disabled={isLimitReached}> 158 - Clone 159 - </DropdownMenuItem> 160 - <DropdownMenuItem onClick={onTest}>Test</DropdownMenuItem> 161 - <DropdownMenuItem 162 - onClick={() => 163 - copy(`${monitor.id}`, { 164 - withToast: `Copied ID '${monitor.id}'`, 165 - }) 166 - } 167 - > 168 - Copy ID 169 - </DropdownMenuItem> 170 - <DropdownMenuSeparator /> 171 - <AlertDialogTrigger asChild> 172 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 173 - Delete 174 - </DropdownMenuItem> 175 - </AlertDialogTrigger> 176 - </DropdownMenuContent> 177 - </DropdownMenu> 178 - <AlertDialogContent> 179 - <AlertDialogHeader> 180 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 181 - <AlertDialogDescription> 182 - This action cannot be undone. This will permanently delete the 183 - monitor. 184 - </AlertDialogDescription> 185 - </AlertDialogHeader> 186 - <AlertDialogFooter> 187 - <AlertDialogCancel>Cancel</AlertDialogCancel> 188 - <AlertDialogAction 189 - onClick={(e) => { 190 - e.preventDefault(); 191 - onDelete(); 192 - }} 193 - disabled={isPending} 194 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 195 - > 196 - {!isPending ? "Delete" : <LoadingAnimation />} 197 - </AlertDialogAction> 198 - </AlertDialogFooter> 199 - </AlertDialogContent> 200 - </AlertDialog> 201 - ); 202 - }
-92
apps/web/src/components/data-table/monitor/data-table-toolbar.tsx
··· 1 - "use client"; 2 - 3 - import type { Table } from "@tanstack/react-table"; 4 - import { X } from "lucide-react"; 5 - 6 - import type { MonitorTag } from "@openstatus/db/src/schema"; 7 - import { Button, Input } from "@openstatus/ui"; 8 - 9 - import { DataTableFacetedFilter } from "../data-table-faceted-filter"; 10 - 11 - interface DataTableToolbarProps<TData> { 12 - table: Table<TData>; 13 - tags?: MonitorTag[]; 14 - } 15 - 16 - export function DataTableToolbar<TData>({ 17 - table, 18 - tags, 19 - }: DataTableToolbarProps<TData>) { 20 - const isFiltered = table.getState().columnFilters.length > 0; 21 - 22 - return ( 23 - <div className="flex flex-wrap items-center justify-between gap-3"> 24 - <div className="flex flex-1 flex-wrap items-center gap-2"> 25 - <Input 26 - placeholder="Filter names..." 27 - value={(table.getColumn("name")?.getFilterValue() as string) ?? ""} 28 - onChange={(event) => 29 - table.getColumn("name")?.setFilterValue(event.target.value) 30 - } 31 - className="h-8 w-[150px] lg:w-[250px]" 32 - /> 33 - {table.getColumn("tags") && tags && ( 34 - <DataTableFacetedFilter 35 - column={table.getColumn("tags")} 36 - title="Tags" 37 - options={tags?.map((tag) => ({ 38 - label: tag.name, 39 - value: tag.name, 40 - }))} 41 - /> 42 - )} 43 - {table.getColumn("public") && ( 44 - <DataTableFacetedFilter 45 - column={table.getColumn("public")} 46 - title="Visibility" 47 - options={[ 48 - { label: "Public", value: true }, 49 - { label: "Private", value: false }, 50 - ]} 51 - /> 52 - )} 53 - {table.getColumn("active") && ( 54 - <DataTableFacetedFilter 55 - column={table.getColumn("active")} 56 - title="Active" 57 - options={[ 58 - { label: "True", value: true }, 59 - { label: "False", value: false }, 60 - ]} 61 - /> 62 - )} 63 - {table.getColumn("jobType") && ( 64 - <DataTableFacetedFilter 65 - column={table.getColumn("jobType")} 66 - title="Type" 67 - options={[ 68 - { label: "HTTP", value: "http" }, 69 - { label: "TCP", value: "tcp" }, 70 - ]} 71 - /> 72 - )} 73 - {isFiltered && ( 74 - <Button 75 - variant="ghost" 76 - onClick={() => table.resetColumnFilters()} 77 - className="h-8 px-2 lg:px-3" 78 - > 79 - Reset 80 - <X className="ml-2 h-4 w-4" /> 81 - </Button> 82 - )} 83 - </div> 84 - <div className="flex items-center self-end rounded-lg border border-dashed bg-muted/50 px-3 py-2"> 85 - <p className="text-muted-foreground text-xs"> 86 - Quantiles and Uptime are aggregated data from the{" "} 87 - <span className="text-foreground">last 24h</span>. 88 - </p> 89 - </div> 90 - </div> 91 - ); 92 - }
-176
apps/web/src/components/data-table/monitor/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { 4 - ColumnDef, 5 - ColumnFiltersState, 6 - PaginationState, 7 - SortingState, 8 - Table as TTable, 9 - VisibilityState, 10 - } from "@tanstack/react-table"; 11 - import { 12 - flexRender, 13 - getCoreRowModel, 14 - getFacetedRowModel, 15 - getFacetedUniqueValues, 16 - getFilteredRowModel, 17 - getPaginationRowModel, 18 - getSortedRowModel, 19 - useReactTable, 20 - } from "@tanstack/react-table"; 21 - import * as React from "react"; 22 - import { z } from "zod"; 23 - 24 - import { selectMonitorTagSchema } from "@openstatus/db/src/schema"; 25 - import type { MonitorTag } from "@openstatus/db/src/schema"; 26 - import { 27 - Table, 28 - TableBody, 29 - TableCell, 30 - TableHead, 31 - TableHeader, 32 - TableRow, 33 - } from "@openstatus/ui"; 34 - 35 - import { DataTableFloatingActions } from "./data-table-floating-actions"; 36 - import { DataTablePagination } from "./data-table-pagination"; 37 - import { DataTableToolbar } from "./data-table-toolbar"; 38 - 39 - interface DataTableProps<TData, TValue> { 40 - columns: ColumnDef<TData, TValue>[]; 41 - data: TData[]; 42 - tags?: MonitorTag[]; 43 - defaultColumnFilters?: ColumnFiltersState; 44 - defaultPagination?: PaginationState; 45 - } 46 - 47 - export function DataTable<TData, TValue>({ 48 - columns, 49 - data, 50 - tags, 51 - defaultColumnFilters = [], 52 - defaultPagination = { pageIndex: 0, pageSize: 10 }, 53 - }: DataTableProps<TData, TValue>) { 54 - const [sorting, setSorting] = React.useState<SortingState>([]); 55 - const [columnFilters, setColumnFilters] = 56 - React.useState<ColumnFiltersState>(defaultColumnFilters); 57 - const [columnVisibility, setColumnVisibility] = 58 - React.useState<VisibilityState>({ 59 - public: false, 60 - id: false, 61 - jobType: false, 62 - }); 63 - 64 - const [pagination, setPagination] = 65 - React.useState<PaginationState>(defaultPagination); 66 - 67 - const table = useReactTable({ 68 - data, 69 - columns, 70 - state: { 71 - columnFilters, 72 - columnVisibility, 73 - pagination, 74 - sorting, 75 - }, 76 - // @ts-expect-error - REMINDER: unfortunately we cannot pass a function from a RSC to a client component 77 - getRowId: (row, index) => row.monitor?.id?.toString() ?? index, 78 - onPaginationChange: setPagination, 79 - getPaginationRowModel: getPaginationRowModel(), 80 - onColumnFiltersChange: setColumnFilters, 81 - onColumnVisibilityChange: setColumnVisibility, 82 - getFilteredRowModel: getFilteredRowModel(), 83 - getCoreRowModel: getCoreRowModel(), 84 - onSortingChange: setSorting, 85 - getSortedRowModel: getSortedRowModel(), 86 - getFacetedRowModel: getFacetedRowModel(), 87 - // TODO: check if we can optimize it - because it gets bigger and bigger with every new filter 88 - // getFacetedUniqueValues: getFacetedUniqueValues(), 89 - // REMINDER: We cannot use the default getFacetedUniqueValues as it doesnt support Array of Objects 90 - getFacetedUniqueValues: (_table: TTable<TData>, columnId: string) => () => { 91 - const map = getFacetedUniqueValues<TData>()(_table, columnId)(); 92 - if (columnId === "tags") { 93 - if (tags) { 94 - for (const tag of tags) { 95 - const tagsNumber = data.reduce((prev, curr) => { 96 - const values = z 97 - .object({ tags: z.array(selectMonitorTagSchema) }) 98 - .safeParse(curr); 99 - if (!values.success) return prev; 100 - if (values.data.tags?.find((t) => t.name === tag.name)) 101 - return prev + 1; 102 - return prev; 103 - }, 0); 104 - map.set(tag.name, tagsNumber); 105 - } 106 - } 107 - } 108 - return map; 109 - }, 110 - }); 111 - 112 - return ( 113 - <div className="space-y-4"> 114 - <DataTableToolbar table={table} tags={tags} /> 115 - <div className="rounded-md border"> 116 - <Table> 117 - <TableHeader className="bg-muted/50"> 118 - {table.getHeaderGroups().map((headerGroup) => ( 119 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 120 - {headerGroup.headers.map((header) => { 121 - return ( 122 - // FIXME: className="[&:has(svg)]:w-4" takes the svg of the button > checkbox into account 123 - <TableHead 124 - key={header.id} 125 - className={header.column.columnDef.meta?.headerClassName} 126 - > 127 - {header.isPlaceholder 128 - ? null 129 - : flexRender( 130 - header.column.columnDef.header, 131 - header.getContext(), 132 - )} 133 - </TableHead> 134 - ); 135 - })} 136 - </TableRow> 137 - ))} 138 - </TableHeader> 139 - <TableBody> 140 - {table.getRowModel().rows?.length ? ( 141 - table.getRowModel().rows.map((row) => ( 142 - <TableRow 143 - key={row.id} 144 - data-state={row.getIsSelected() && "selected"} 145 - > 146 - {row.getVisibleCells().map((cell) => ( 147 - <TableCell 148 - key={cell.id} 149 - className={cell.column.columnDef.meta?.cellClassName} 150 - > 151 - {flexRender( 152 - cell.column.columnDef.cell, 153 - cell.getContext(), 154 - )} 155 - </TableCell> 156 - ))} 157 - </TableRow> 158 - )) 159 - ) : ( 160 - <TableRow> 161 - <TableCell 162 - colSpan={columns.length} 163 - className="h-24 text-center" 164 - > 165 - No results. 166 - </TableCell> 167 - </TableRow> 168 - )} 169 - </TableBody> 170 - </Table> 171 - </div> 172 - <DataTablePagination table={table} /> 173 - <DataTableFloatingActions table={table} tags={tags} /> 174 - </div> 175 - ); 176 - }
-73
apps/web/src/components/data-table/notification/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - 5 - import type { Monitor, Notification } from "@openstatus/db/src/schema"; 6 - import { Badge } from "@openstatus/ui/src/components/badge"; 7 - 8 - import Link from "next/link"; 9 - import { z } from "zod"; 10 - import { DataTableBadges } from "../data-table-badges"; 11 - import { DataTableRowActions } from "./data-table-row-actions"; 12 - 13 - // TODO: use the getProviderMetaData function from the notification form to access the data 14 - 15 - export const columns: ColumnDef< 16 - Notification & { monitor: { monitor: Monitor }[] } 17 - >[] = [ 18 - { 19 - accessorKey: "name", 20 - header: "Name", 21 - cell: ({ row }) => { 22 - const { name } = row.original; 23 - return ( 24 - <div className="flex gap-2"> 25 - <Link 26 - href={`./notifications/${row.original.id}/edit`} 27 - className="group flex max-w-full items-center gap-2" 28 - prefetch={false} 29 - > 30 - <span className="truncate group-hover:underline">{name}</span> 31 - </Link> 32 - </div> 33 - ); 34 - }, 35 - }, 36 - { 37 - accessorKey: "provider", 38 - header: "Provider", 39 - cell: ({ row }) => { 40 - return ( 41 - <Badge variant="secondary" className="capitalize"> 42 - {row.getValue("provider")} 43 - </Badge> 44 - ); 45 - }, 46 - }, 47 - { 48 - accessorKey: "monitor", 49 - header: "Monitors", 50 - cell: ({ row }) => { 51 - const monitor = row.getValue("monitor"); 52 - const monitors = z 53 - .object({ monitor: z.object({ name: z.string() }) }) 54 - .array() 55 - .parse(monitor); 56 - return ( 57 - <DataTableBadges 58 - names={monitors.map((monitor) => monitor.monitor.name)} 59 - /> 60 - ); 61 - }, 62 - }, 63 - { 64 - id: "actions", 65 - cell: ({ row }) => { 66 - return ( 67 - <div className="text-right"> 68 - <DataTableRowActions row={row} /> 69 - </div> 70 - ); 71 - }, 72 - }, 73 - ];
-119
apps/web/src/components/data-table/notification/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import Link from "next/link"; 6 - import { useRouter } from "next/navigation"; 7 - import * as React from "react"; 8 - 9 - import { selectNotificationSchema } from "@openstatus/db/src/schema"; 10 - import { 11 - AlertDialog, 12 - AlertDialogAction, 13 - AlertDialogCancel, 14 - AlertDialogContent, 15 - AlertDialogDescription, 16 - AlertDialogFooter, 17 - AlertDialogHeader, 18 - AlertDialogTitle, 19 - AlertDialogTrigger, 20 - Button, 21 - DropdownMenu, 22 - DropdownMenuContent, 23 - DropdownMenuItem, 24 - DropdownMenuSeparator, 25 - DropdownMenuTrigger, 26 - } from "@openstatus/ui"; 27 - 28 - import { LoadingAnimation } from "@/components/loading-animation"; 29 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 30 - import { toastAction } from "@/lib/toast"; 31 - import { api } from "@/trpc/client"; 32 - 33 - interface DataTableRowActionsProps<TData> { 34 - row: Row<TData>; 35 - } 36 - 37 - export function DataTableRowActions<TData>({ 38 - row, 39 - }: DataTableRowActionsProps<TData>) { 40 - const notification = selectNotificationSchema.parse(row.original); 41 - const router = useRouter(); 42 - const [alertOpen, setAlertOpen] = React.useState(false); 43 - const [isPending, startTransition] = React.useTransition(); 44 - const { copy } = useCopyToClipboard(); 45 - 46 - async function onDelete() { 47 - startTransition(async () => { 48 - try { 49 - if (!notification.id) return; 50 - await api.notification.deleteNotification.mutate({ 51 - id: notification.id, 52 - }); 53 - toastAction("deleted"); 54 - router.refresh(); 55 - setAlertOpen(false); 56 - } catch { 57 - toastAction("error"); 58 - } 59 - }); 60 - } 61 - 62 - return ( 63 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 64 - <DropdownMenu> 65 - <DropdownMenuTrigger asChild> 66 - <Button 67 - variant="ghost" 68 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 69 - > 70 - <span className="sr-only">Open menu</span> 71 - <MoreHorizontal className="h-4 w-4" /> 72 - </Button> 73 - </DropdownMenuTrigger> 74 - <DropdownMenuContent align="end"> 75 - <Link href={`./notifications/${notification.id}/edit`}> 76 - <DropdownMenuItem>Edit</DropdownMenuItem> 77 - </Link> 78 - <DropdownMenuItem 79 - onClick={() => 80 - copy(`${notification.id}`, { 81 - withToast: `Copied ID '${notification.id}'`, 82 - }) 83 - } 84 - > 85 - Copy ID 86 - </DropdownMenuItem> 87 - <DropdownMenuSeparator /> 88 - <AlertDialogTrigger asChild> 89 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 90 - Delete 91 - </DropdownMenuItem> 92 - </AlertDialogTrigger> 93 - </DropdownMenuContent> 94 - </DropdownMenu> 95 - <AlertDialogContent> 96 - <AlertDialogHeader> 97 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 98 - <AlertDialogDescription> 99 - This action cannot be undone. This will permanently delete the 100 - notification. 101 - </AlertDialogDescription> 102 - </AlertDialogHeader> 103 - <AlertDialogFooter> 104 - <AlertDialogCancel>Cancel</AlertDialogCancel> 105 - <AlertDialogAction 106 - onClick={(e) => { 107 - e.preventDefault(); 108 - onDelete(); 109 - }} 110 - disabled={isPending} 111 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 112 - > 113 - {!isPending ? "Delete" : <LoadingAnimation />} 114 - </AlertDialogAction> 115 - </AlertDialogFooter> 116 - </AlertDialogContent> 117 - </AlertDialog> 118 - ); 119 - }
-80
apps/web/src/components/data-table/notification/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - export function DataTable<TData, TValue>({ 25 - columns, 26 - data, 27 - }: DataTableProps<TData, TValue>) { 28 - const table = useReactTable({ 29 - data, 30 - columns, 31 - getCoreRowModel: getCoreRowModel(), 32 - }); 33 - 34 - return ( 35 - <div className="rounded-md border"> 36 - <Table> 37 - <TableHeader className="bg-muted/50"> 38 - {table.getHeaderGroups().map((headerGroup) => ( 39 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 40 - {headerGroup.headers.map((header) => { 41 - return ( 42 - <TableHead key={header.id}> 43 - {header.isPlaceholder 44 - ? null 45 - : flexRender( 46 - header.column.columnDef.header, 47 - header.getContext(), 48 - )} 49 - </TableHead> 50 - ); 51 - })} 52 - </TableRow> 53 - ))} 54 - </TableHeader> 55 - <TableBody> 56 - {table.getRowModel().rows?.length ? ( 57 - table.getRowModel().rows.map((row) => ( 58 - <TableRow 59 - key={row.id} 60 - data-state={row.getIsSelected() && "selected"} 61 - > 62 - {row.getVisibleCells().map((cell) => ( 63 - <TableCell key={cell.id}> 64 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 - </TableCell> 66 - ))} 67 - </TableRow> 68 - )) 69 - ) : ( 70 - <TableRow> 71 - <TableCell colSpan={columns.length} className="h-24 text-center"> 72 - No results. 73 - </TableCell> 74 - </TableRow> 75 - )} 76 - </TableBody> 77 - </Table> 78 - </div> 79 - ); 80 - }
-46
apps/web/src/components/data-table/page-subscriber/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - 5 - import type { PageSubscriber } from "@openstatus/db/src/schema"; 6 - 7 - import { formatDateTime } from "@/lib/utils"; 8 - import { DataTableRowActions } from "./data-table-row-actions"; 9 - 10 - export const columns: ColumnDef<PageSubscriber>[] = [ 11 - { 12 - accessorKey: "email", 13 - header: "Email", 14 - cell: ({ row }) => { 15 - return <span>{row.getValue("email")}</span>; 16 - }, 17 - }, 18 - { 19 - accessorKey: "acceptedAt", 20 - header: "Accepted", 21 - cell: ({ row }) => { 22 - const { acceptedAt } = row.original; 23 - const date = acceptedAt ? formatDateTime(acceptedAt) : "-"; 24 - return <span className="text-muted-foreground">{date}</span>; 25 - }, 26 - }, 27 - { 28 - accessorKey: "createdAt", 29 - header: "Created", 30 - cell: ({ row }) => { 31 - const { createdAt } = row.original; 32 - const date = createdAt ? formatDateTime(createdAt) : "-"; 33 - return <span className="text-muted-foreground">{date}</span>; 34 - }, 35 - }, 36 - { 37 - id: "actions", 38 - cell: ({ row }) => { 39 - return ( 40 - <div className="text-right"> 41 - <DataTableRowActions row={row} /> 42 - </div> 43 - ); 44 - }, 45 - }, 46 - ];
-120
apps/web/src/components/data-table/page-subscriber/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import { useRouter } from "next/navigation"; 6 - import * as React from "react"; 7 - 8 - import { selectPageSubscriberSchema } from "@openstatus/db/src/schema"; 9 - import { 10 - AlertDialog, 11 - AlertDialogAction, 12 - AlertDialogCancel, 13 - AlertDialogContent, 14 - AlertDialogDescription, 15 - AlertDialogFooter, 16 - AlertDialogHeader, 17 - AlertDialogTitle, 18 - AlertDialogTrigger, 19 - Button, 20 - DropdownMenu, 21 - DropdownMenuContent, 22 - DropdownMenuItem, 23 - DropdownMenuTrigger, 24 - } from "@openstatus/ui"; 25 - 26 - import { LoadingAnimation } from "@/components/loading-animation"; 27 - import { toastAction } from "@/lib/toast"; 28 - import { api } from "@/trpc/client"; 29 - 30 - interface DataTableRowActionsProps<TData> { 31 - row: Row<TData>; 32 - } 33 - 34 - export function DataTableRowActions<TData>({ 35 - row, 36 - }: DataTableRowActionsProps<TData>) { 37 - const subscriber = selectPageSubscriberSchema.parse(row.original); 38 - const router = useRouter(); 39 - const [alertOpen, setAlertOpen] = React.useState(false); 40 - const [isPending, startTransition] = React.useTransition(); 41 - 42 - async function onDelete() { 43 - startTransition(async () => { 44 - try { 45 - if (!subscriber.id) return; 46 - await api.pageSubscriber.unsubscribeById.mutate({ 47 - id: subscriber.id, 48 - }); 49 - toastAction("deleted"); 50 - router.refresh(); 51 - setAlertOpen(false); 52 - } catch { 53 - toastAction("error"); 54 - } 55 - }); 56 - } 57 - 58 - async function onAccept() { 59 - startTransition(async () => { 60 - try { 61 - if (!subscriber.id) return; 62 - await api.pageSubscriber.acceptSubscriberById.mutate({ 63 - id: subscriber.id, 64 - }); 65 - toastAction("success"); 66 - router.refresh(); 67 - } catch { 68 - toastAction("error"); 69 - } 70 - }); 71 - } 72 - 73 - return ( 74 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 75 - <DropdownMenu> 76 - <DropdownMenuTrigger asChild> 77 - <Button 78 - variant="ghost" 79 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 80 - > 81 - <span className="sr-only">Open menu</span> 82 - <MoreHorizontal className="h-4 w-4" /> 83 - </Button> 84 - </DropdownMenuTrigger> 85 - <DropdownMenuContent align="end"> 86 - {!subscriber.acceptedAt ? ( 87 - <DropdownMenuItem onClick={onAccept}>Accept</DropdownMenuItem> 88 - ) : null} 89 - <AlertDialogTrigger asChild> 90 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 91 - Delete 92 - </DropdownMenuItem> 93 - </AlertDialogTrigger> 94 - </DropdownMenuContent> 95 - </DropdownMenu> 96 - <AlertDialogContent> 97 - <AlertDialogHeader> 98 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 99 - <AlertDialogDescription> 100 - The user will not be able to receive the latest status reports. But 101 - will be able to subscribe again. 102 - </AlertDialogDescription> 103 - </AlertDialogHeader> 104 - <AlertDialogFooter> 105 - <AlertDialogCancel>Cancel</AlertDialogCancel> 106 - <AlertDialogAction 107 - onClick={(e) => { 108 - e.preventDefault(); 109 - onDelete(); 110 - }} 111 - disabled={isPending} 112 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 113 - > 114 - {!isPending ? "Delete" : <LoadingAnimation />} 115 - </AlertDialogAction> 116 - </AlertDialogFooter> 117 - </AlertDialogContent> 118 - </AlertDialog> 119 - ); 120 - }
-80
apps/web/src/components/data-table/page-subscriber/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - export function DataTable<TData, TValue>({ 25 - columns, 26 - data, 27 - }: DataTableProps<TData, TValue>) { 28 - const table = useReactTable({ 29 - data, 30 - columns, 31 - getCoreRowModel: getCoreRowModel(), 32 - }); 33 - 34 - return ( 35 - <div className="rounded-md border"> 36 - <Table> 37 - <TableHeader className="bg-muted/50"> 38 - {table.getHeaderGroups().map((headerGroup) => ( 39 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 40 - {headerGroup.headers.map((header) => { 41 - return ( 42 - <TableHead key={header.id}> 43 - {header.isPlaceholder 44 - ? null 45 - : flexRender( 46 - header.column.columnDef.header, 47 - header.getContext(), 48 - )} 49 - </TableHead> 50 - ); 51 - })} 52 - </TableRow> 53 - ))} 54 - </TableHeader> 55 - <TableBody> 56 - {table.getRowModel().rows?.length ? ( 57 - table.getRowModel().rows.map((row) => ( 58 - <TableRow 59 - key={row.id} 60 - data-state={row.getIsSelected() && "selected"} 61 - > 62 - {row.getVisibleCells().map((cell) => ( 63 - <TableCell key={cell.id}> 64 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 - </TableCell> 66 - ))} 67 - </TableRow> 68 - )) 69 - ) : ( 70 - <TableRow> 71 - <TableCell colSpan={columns.length} className="h-24 text-center"> 72 - No results. 73 - </TableCell> 74 - </TableRow> 75 - )} 76 - </TableBody> 77 - </Table> 78 - </div> 79 - ); 80 - }
-173
apps/web/src/components/data-table/status-page/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import Image from "next/image"; 5 - import Link from "next/link"; 6 - import * as z from "zod"; 7 - 8 - import type { 9 - Maintenance, 10 - Page, 11 - StatusReport, 12 - StatusReportUpdate, 13 - } from "@openstatus/db/src/schema"; 14 - import { 15 - Tooltip, 16 - TooltipContent, 17 - TooltipProvider, 18 - TooltipTrigger, 19 - } from "@openstatus/ui"; 20 - 21 - import { formatDate } from "@/lib/utils"; 22 - import { ArrowUpRight, Check } from "lucide-react"; 23 - import { DataTableBadges } from "../data-table-badges"; 24 - import { DataTableRowActions } from "./data-table-row-actions"; 25 - 26 - export const columns: ColumnDef< 27 - Page & { 28 - monitorsToPages: { monitor: { name: string } }[]; 29 - maintenances: Maintenance[]; // we get only the active maintenances! 30 - statusReports: (StatusReport & { 31 - statusReportUpdates: StatusReportUpdate[]; 32 - })[]; 33 - } 34 - >[] = [ 35 - { 36 - accessorKey: "title", 37 - header: "Title", 38 - cell: ({ row }) => { 39 - return ( 40 - <Link 41 - href={`./status-pages/${row.original.id}/reports`} 42 - className="group flex items-center gap-2" 43 - > 44 - <span className="max-w-[125px] truncate group-hover:underline"> 45 - {row.getValue("title")} 46 - </span> 47 - </Link> 48 - ); 49 - }, 50 - }, 51 - { 52 - accessorKey: "slug", 53 - header: "Slug", 54 - cell: ({ row }) => { 55 - return ( 56 - <TooltipProvider> 57 - <Tooltip delayDuration={100}> 58 - <TooltipTrigger> 59 - <a 60 - href={ 61 - process.env.NODE_ENV === "production" 62 - ? `https://${row.original.slug}.openstatus.dev` 63 - : `/status-page/${row.original.slug}` 64 - } 65 - target="_blank" 66 - rel="noreferrer" 67 - className="group flex items-center gap-1" 68 - > 69 - <span className="max-w-[125px] truncate font-mono group-hover:underline"> 70 - {row.getValue("slug")} 71 - </span> 72 - <ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground group-hover:text-foreground" /> 73 - </a> 74 - </TooltipTrigger> 75 - <TooltipContent>Visit page</TooltipContent> 76 - </Tooltip> 77 - </TooltipProvider> 78 - ); 79 - }, 80 - }, 81 - { 82 - accessorKey: "monitorsToPages", 83 - header: "Monitors", 84 - cell: ({ row }) => { 85 - const monitorsToPages = row.getValue("monitorsToPages"); 86 - const monitors = z 87 - .object({ monitor: z.object({ name: z.string() }) }) 88 - .array() 89 - .parse(monitorsToPages); 90 - return ( 91 - <DataTableBadges 92 - names={monitors.map((monitor) => monitor.monitor.name)} 93 - /> 94 - ); 95 - }, 96 - }, 97 - { 98 - accessorKey: "statusReports", 99 - header: "Last Report", 100 - cell: ({ row }) => { 101 - const lastReport = row.original.statusReports?.[0]; 102 - 103 - if (!lastReport) { 104 - return <span className="text-muted-foreground/50">-</span>; 105 - } 106 - 107 - const date = 108 - lastReport.statusReportUpdates?.[0].date || lastReport.updatedAt; 109 - 110 - return ( 111 - <div className="group relative"> 112 - <span className="group-hover:text-muted-foreground/70"> 113 - {formatDate(date)} 114 - </span> 115 - <div className="-inset-x-2 -inset-y-1 invisible absolute flex items-center px-2 py-1 backdrop-blur-xs group-hover:visible"> 116 - <Link 117 - href={`./status-pages/${row.original.id}/reports/${lastReport.id}`} 118 - className="hover:underline" 119 - > 120 - Go to report 121 - </Link> 122 - </div> 123 - </div> 124 - ); 125 - }, 126 - }, 127 - { 128 - accessorKey: "icon", 129 - header: "Favicon", 130 - cell: ({ row }) => { 131 - if (!row.getValue("icon")) { 132 - return <span className="text-muted-foreground/50">-</span>; 133 - } 134 - return ( 135 - <Image 136 - src={row.getValue("icon")} 137 - alt="" 138 - className="rounded-sm border border-border" 139 - width={20} 140 - height={20} 141 - /> 142 - ); 143 - }, 144 - }, 145 - { 146 - accessorKey: "passwordProtected", 147 - header: "Protected", 148 - cell: ({ row }) => { 149 - const passwordProtected = Boolean(row.getValue("passwordProtected")); 150 - if (passwordProtected) { 151 - return <Check className="h-4 w-4 text-foreground" />; 152 - } 153 - return <span className="text-muted-foreground/50">-</span>; 154 - }, 155 - }, 156 - // { 157 - // accessorKey: "createdAt", 158 - // header: "Created", 159 - // cell: ({ row }) => { 160 - // return <span>{formatDate(row.getValue("createdAt"))}</span>; 161 - // }, 162 - // }, 163 - { 164 - id: "actions", 165 - cell: ({ row }) => { 166 - return ( 167 - <div className="text-right"> 168 - <DataTableRowActions row={row} /> 169 - </div> 170 - ); 171 - }, 172 - }, 173 - ];
-224
apps/web/src/components/data-table/status-page/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import Link from "next/link"; 6 - import { useRouter } from "next/navigation"; 7 - import * as React from "react"; 8 - 9 - import { selectPageSchema } from "@openstatus/db/src/schema"; 10 - import { 11 - AlertDialog, 12 - AlertDialogAction, 13 - AlertDialogCancel, 14 - AlertDialogContent, 15 - AlertDialogDescription, 16 - AlertDialogFooter, 17 - AlertDialogHeader, 18 - AlertDialogTitle, 19 - AlertDialogTrigger, 20 - Button, 21 - Dialog, 22 - DialogContent, 23 - DialogDescription, 24 - DialogHeader, 25 - DialogTitle, 26 - DialogTrigger, 27 - DropdownMenu, 28 - DropdownMenuContent, 29 - DropdownMenuItem, 30 - DropdownMenuSeparator, 31 - DropdownMenuTrigger, 32 - Label, 33 - RadioGroup, 34 - RadioGroupItem, 35 - } from "@openstatus/ui"; 36 - 37 - import { LoadingAnimation } from "@/components/loading-animation"; 38 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 39 - import { toastAction } from "@/lib/toast"; 40 - import { api } from "@/trpc/client"; 41 - 42 - export const SIZE: Record<string, { width: number; height: number }> = { 43 - sm: { width: 120, height: 34 }, 44 - md: { width: 160, height: 46 }, 45 - lg: { width: 200, height: 56 }, 46 - xl: { width: 240, height: 68 }, 47 - }; 48 - 49 - interface DataTableRowActionsProps<TData> { 50 - row: Row<TData>; 51 - } 52 - 53 - export function DataTableRowActions<TData>({ 54 - row, 55 - }: DataTableRowActionsProps<TData>) { 56 - const page = selectPageSchema.parse(row.original); 57 - const router = useRouter(); 58 - const [alertOpen, setAlertOpen] = React.useState(false); 59 - const [isPending, startTransition] = React.useTransition(); 60 - const [size, setSize] = React.useState<"sm" | "md" | "lg" | "xl">("sm"); 61 - const [theme, setTheme] = React.useState<"light" | "dark">("light"); 62 - const { copy } = useCopyToClipboard(); 63 - 64 - async function onDelete() { 65 - startTransition(async () => { 66 - try { 67 - if (!page.id) return; 68 - await api.page.delete.mutate({ id: page.id }); 69 - toastAction("deleted"); 70 - router.refresh(); 71 - setAlertOpen(false); 72 - } catch { 73 - toastAction("error"); 74 - } 75 - }); 76 - } 77 - 78 - return ( 79 - <Dialog> 80 - <AlertDialog 81 - open={alertOpen} 82 - onOpenChange={(value) => setAlertOpen(value)} 83 - > 84 - <DropdownMenu> 85 - <DropdownMenuTrigger asChild> 86 - <Button 87 - variant="ghost" 88 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 89 - > 90 - <span className="sr-only">Open menu</span> 91 - <MoreHorizontal className="h-4 w-4" /> 92 - </Button> 93 - </DropdownMenuTrigger> 94 - <DropdownMenuContent align="end"> 95 - <Link href={`./status-pages/${page.id}/edit`}> 96 - <DropdownMenuItem>Edit</DropdownMenuItem> 97 - </Link> 98 - <Link 99 - href={ 100 - process.env.NODE_ENV === "production" 101 - ? `https://${page.slug}.openstatus.dev` 102 - : `/status-page/${page.slug}` 103 - } 104 - target="_blank" 105 - > 106 - <DropdownMenuItem>Visit</DropdownMenuItem> 107 - </Link> 108 - <DropdownMenuItem 109 - onClick={() => 110 - copy(`${page.id}`, { 111 - withToast: `Copied ID '${page.id}'`, 112 - }) 113 - } 114 - > 115 - Copy ID 116 - </DropdownMenuItem> 117 - <DropdownMenuSeparator /> 118 - <Link href={`./status-pages/${page.id}/reports/new`}> 119 - <DropdownMenuItem>Create Report</DropdownMenuItem> 120 - </Link> 121 - <DialogTrigger asChild> 122 - <DropdownMenuItem>Create Badge</DropdownMenuItem> 123 - </DialogTrigger> 124 - <DropdownMenuSeparator /> 125 - <AlertDialogTrigger asChild> 126 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 127 - Delete 128 - </DropdownMenuItem> 129 - </AlertDialogTrigger> 130 - </DropdownMenuContent> 131 - </DropdownMenu> 132 - <AlertDialogContent> 133 - <AlertDialogHeader> 134 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 135 - <AlertDialogDescription> 136 - This action cannot be undone. This will permanently delete the 137 - monitor. 138 - </AlertDialogDescription> 139 - </AlertDialogHeader> 140 - <AlertDialogFooter> 141 - <AlertDialogCancel>Cancel</AlertDialogCancel> 142 - <AlertDialogAction 143 - onClick={(e) => { 144 - e.preventDefault(); 145 - onDelete(); 146 - }} 147 - disabled={isPending} 148 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 149 - > 150 - {!isPending ? "Delete" : <LoadingAnimation />} 151 - </AlertDialogAction> 152 - </AlertDialogFooter> 153 - </AlertDialogContent> 154 - <DialogContent> 155 - <DialogHeader> 156 - <DialogTitle>Create Badge</DialogTitle> 157 - </DialogHeader> 158 - <DialogDescription> 159 - Create an uptime badge for your status page. 160 - </DialogDescription> 161 - <div className="flex items-center justify-center"> 162 - <div className="flex w-full items-center justify-center rounded-md border p-4"> 163 - <img 164 - src={`/status-page/${page.slug}/badge?size=${size}&theme=${theme}`} 165 - alt="Badge" 166 - width={SIZE[size].width} 167 - height={SIZE[size].height} 168 - /> 169 - </div> 170 - </div> 171 - <div className="flex items-center justify-between"> 172 - <RadioGroup 173 - className="flex" 174 - onValueChange={(value) => 175 - setSize(value as "sm" | "md" | "lg" | "xl") 176 - } 177 - value={size} 178 - > 179 - {Object.keys(SIZE).map((size) => ( 180 - <div className="flex items-center space-x-2" key={size}> 181 - <RadioGroupItem value={size} id={size} /> 182 - <Label htmlFor={size}>{size}</Label> 183 - </div> 184 - ))} 185 - </RadioGroup> 186 - <div> 187 - <span className="font-mono text-muted-foreground text-sm"> 188 - {SIZE[size].width}x{SIZE[size].height} 189 - </span> 190 - </div> 191 - </div> 192 - <RadioGroup 193 - className="flex" 194 - onValueChange={(value) => setTheme(value as "light" | "dark")} 195 - value={theme} 196 - > 197 - {["light", "dark"].map((theme) => ( 198 - <div className="flex items-center space-x-2" key={theme}> 199 - <RadioGroupItem value={theme} id={theme} /> 200 - <Label htmlFor={theme}>{theme}</Label> 201 - </div> 202 - ))} 203 - </RadioGroup> 204 - <div className="flex items-center justify-center"> 205 - <Button 206 - variant="outline" 207 - className="w-full" 208 - size="sm" 209 - onClick={() => 210 - copy( 211 - `https://openstatus.dev/status-page/${page.slug}/badge?size=${size}&theme=${theme}`, 212 - { withToast: true }, 213 - ) 214 - } 215 - > 216 - https://openstatus.dev/status-page/{page.slug}/badge?size={size} 217 - &theme={theme} 218 - </Button> 219 - </div> 220 - </DialogContent> 221 - </AlertDialog> 222 - </Dialog> 223 - ); 224 - }
-80
apps/web/src/components/data-table/status-page/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - export function DataTable<TData, TValue>({ 25 - columns, 26 - data, 27 - }: DataTableProps<TData, TValue>) { 28 - const table = useReactTable({ 29 - data, 30 - columns, 31 - getCoreRowModel: getCoreRowModel(), 32 - }); 33 - 34 - return ( 35 - <div className="rounded-md border"> 36 - <Table> 37 - <TableHeader className="bg-muted/50"> 38 - {table.getHeaderGroups().map((headerGroup) => ( 39 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 40 - {headerGroup.headers.map((header) => { 41 - return ( 42 - <TableHead key={header.id}> 43 - {header.isPlaceholder 44 - ? null 45 - : flexRender( 46 - header.column.columnDef.header, 47 - header.getContext(), 48 - )} 49 - </TableHead> 50 - ); 51 - })} 52 - </TableRow> 53 - ))} 54 - </TableHeader> 55 - <TableBody> 56 - {table.getRowModel().rows?.length ? ( 57 - table.getRowModel().rows.map((row) => ( 58 - <TableRow 59 - key={row.id} 60 - data-state={row.getIsSelected() && "selected"} 61 - > 62 - {row.getVisibleCells().map((cell) => ( 63 - <TableCell key={cell.id}> 64 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 - </TableCell> 66 - ))} 67 - </TableRow> 68 - )) 69 - ) : ( 70 - <TableRow> 71 - <TableCell colSpan={columns.length} className="h-24 text-center"> 72 - No results. 73 - </TableCell> 74 - </TableRow> 75 - )} 76 - </TableBody> 77 - </Table> 78 - </div> 79 - ); 80 - }
-63
apps/web/src/components/data-table/status-report/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import Link from "next/link"; 5 - 6 - import type { 7 - StatusReport, 8 - StatusReportUpdate, 9 - } from "@openstatus/db/src/schema"; 10 - 11 - import { StatusBadge } from "@/components/status-update/status-badge"; 12 - import { formatDate } from "@/lib/utils"; 13 - import { DataTableRowActions } from "./data-table-row-actions"; 14 - 15 - export const columns: ColumnDef< 16 - StatusReport & { statusReportUpdates: StatusReportUpdate[] } 17 - >[] = [ 18 - { 19 - accessorKey: "title", 20 - header: "Title", 21 - cell: ({ row }) => { 22 - const id = row.original.id; 23 - return ( 24 - <Link href={`./reports/${id}/overview`} className="hover:underline"> 25 - <span className="truncate">{row.getValue("title")}</span> 26 - </Link> 27 - ); 28 - }, 29 - }, 30 - { 31 - accessorKey: "status", 32 - header: "Status", 33 - cell: ({ row }) => { 34 - const status = row.original.status; 35 - return <StatusBadge status={status} />; 36 - }, 37 - }, 38 - { 39 - accessorKey: "statusReportUpdates", 40 - header: "Updates", 41 - cell: ({ row }) => { 42 - const statusReportUpdates = row.original.statusReportUpdates; 43 - return <code>{statusReportUpdates.length}</code>; 44 - }, 45 - }, 46 - { 47 - accessorKey: "updatedAt", 48 - header: "Last Updated", 49 - cell: ({ row }) => { 50 - return <span>{formatDate(row.getValue("updatedAt"))}</span>; 51 - }, 52 - }, 53 - { 54 - id: "actions", 55 - cell: ({ row }) => { 56 - return ( 57 - <div className="text-right"> 58 - <DataTableRowActions row={row} /> 59 - </div> 60 - ); 61 - }, 62 - }, 63 - ];
-120
apps/web/src/components/data-table/status-report/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import Link from "next/link"; 6 - import { useRouter } from "next/navigation"; 7 - import * as React from "react"; 8 - 9 - import { selectStatusReportSchema } from "@openstatus/db/src/schema"; 10 - import { 11 - AlertDialog, 12 - AlertDialogAction, 13 - AlertDialogCancel, 14 - AlertDialogContent, 15 - AlertDialogDescription, 16 - AlertDialogFooter, 17 - AlertDialogHeader, 18 - AlertDialogTitle, 19 - AlertDialogTrigger, 20 - Button, 21 - DropdownMenu, 22 - DropdownMenuContent, 23 - DropdownMenuItem, 24 - DropdownMenuTrigger, 25 - } from "@openstatus/ui"; 26 - 27 - import { LoadingAnimation } from "@/components/loading-animation"; 28 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 29 - import { toastAction } from "@/lib/toast"; 30 - import { api } from "@/trpc/client"; 31 - 32 - interface DataTableRowActionsProps<TData> { 33 - row: Row<TData>; 34 - } 35 - 36 - export function DataTableRowActions<TData>({ 37 - row, 38 - }: DataTableRowActionsProps<TData>) { 39 - const statusReport = selectStatusReportSchema.parse(row.original); 40 - const router = useRouter(); 41 - const [alertOpen, setAlertOpen] = React.useState(false); 42 - const [isPending, startTransition] = React.useTransition(); 43 - const { copy } = useCopyToClipboard(); 44 - 45 - async function onDelete() { 46 - startTransition(async () => { 47 - try { 48 - if (!statusReport.id) return; 49 - await api.statusReport.deleteStatusReport.mutate({ 50 - id: statusReport.id, 51 - }); 52 - toastAction("deleted"); 53 - router.refresh(); 54 - setAlertOpen(false); 55 - } catch { 56 - toastAction("error"); 57 - } 58 - }); 59 - } 60 - 61 - return ( 62 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 63 - <DropdownMenu> 64 - <DropdownMenuTrigger asChild> 65 - <Button 66 - variant="ghost" 67 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 68 - > 69 - <span className="sr-only">Open menu</span> 70 - <MoreHorizontal className="h-4 w-4" /> 71 - </Button> 72 - </DropdownMenuTrigger> 73 - <DropdownMenuContent align="end"> 74 - <Link href={`./reports/${statusReport.id}/edit`}> 75 - <DropdownMenuItem>Edit</DropdownMenuItem> 76 - </Link> 77 - <Link href={`./reports/${statusReport.id}/overview`}> 78 - <DropdownMenuItem>View</DropdownMenuItem> 79 - </Link> 80 - <DropdownMenuItem 81 - onClick={() => 82 - copy(`${statusReport.id}`, { 83 - withToast: `Copied ID '${statusReport.id}'`, 84 - }) 85 - } 86 - > 87 - Copy ID 88 - </DropdownMenuItem> 89 - <AlertDialogTrigger asChild> 90 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 91 - Delete 92 - </DropdownMenuItem> 93 - </AlertDialogTrigger> 94 - </DropdownMenuContent> 95 - </DropdownMenu> 96 - <AlertDialogContent> 97 - <AlertDialogHeader> 98 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 99 - <AlertDialogDescription> 100 - This action cannot be undone. This will permanently delete the 101 - monitor. 102 - </AlertDialogDescription> 103 - </AlertDialogHeader> 104 - <AlertDialogFooter> 105 - <AlertDialogCancel>Cancel</AlertDialogCancel> 106 - <AlertDialogAction 107 - onClick={(e) => { 108 - e.preventDefault(); 109 - onDelete(); 110 - }} 111 - disabled={isPending} 112 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 113 - > 114 - {!isPending ? "Delete" : <LoadingAnimation />} 115 - </AlertDialogAction> 116 - </AlertDialogFooter> 117 - </AlertDialogContent> 118 - </AlertDialog> 119 - ); 120 - }
-80
apps/web/src/components/data-table/status-report/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - export function DataTable<TData, TValue>({ 25 - columns, 26 - data, 27 - }: DataTableProps<TData, TValue>) { 28 - const table = useReactTable({ 29 - data, 30 - columns, 31 - getCoreRowModel: getCoreRowModel(), 32 - }); 33 - 34 - return ( 35 - <div className="rounded-md border"> 36 - <Table> 37 - <TableHeader className="bg-muted/50"> 38 - {table.getHeaderGroups().map((headerGroup) => ( 39 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 40 - {headerGroup.headers.map((header) => { 41 - return ( 42 - <TableHead key={header.id}> 43 - {header.isPlaceholder 44 - ? null 45 - : flexRender( 46 - header.column.columnDef.header, 47 - header.getContext(), 48 - )} 49 - </TableHead> 50 - ); 51 - })} 52 - </TableRow> 53 - ))} 54 - </TableHeader> 55 - <TableBody> 56 - {table.getRowModel().rows?.length ? ( 57 - table.getRowModel().rows.map((row) => ( 58 - <TableRow 59 - key={row.id} 60 - data-state={row.getIsSelected() && "selected"} 61 - > 62 - {row.getVisibleCells().map((cell) => ( 63 - <TableCell key={cell.id}> 64 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 - </TableCell> 66 - ))} 67 - </TableRow> 68 - )) 69 - ) : ( 70 - <TableRow> 71 - <TableCell colSpan={columns.length} className="h-24 text-center"> 72 - No results. 73 - </TableCell> 74 - </TableRow> 75 - )} 76 - </TableBody> 77 - </Table> 78 - </div> 79 - ); 80 - }
-59
apps/web/src/components/data-table/user/columns.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - 5 - import type { User, WorkspaceRole } from "@openstatus/db/src/schema"; 6 - import { Badge } from "@openstatus/ui/src/components/badge"; 7 - 8 - import { formatDate } from "@/lib/utils"; 9 - import { DataTableRowActions } from "./data-table-row-actions"; 10 - 11 - // TODO: add total number of monitors 12 - 13 - export const columns: ColumnDef<User & { role: WorkspaceRole }>[] = [ 14 - { 15 - accessorKey: "email", 16 - header: "Email", 17 - cell: ({ row }) => { 18 - return ( 19 - <div> 20 - <p> 21 - {row.original.firstName} {row.original.lastName} 22 - </p> 23 - <p className="text-muted-foreground">{row.getValue("email")}</p> 24 - </div> 25 - ); 26 - }, 27 - }, 28 - { 29 - accessorKey: "role", 30 - header: "Role", 31 - cell: ({ row }) => { 32 - const role = row.getValue("role") as WorkspaceRole; 33 - return ( 34 - <Badge variant={role === "member" ? "outline" : "default"}> 35 - {row.getValue("role")} 36 - </Badge> 37 - ); 38 - }, 39 - }, 40 - { 41 - accessorKey: "createdAt", 42 - header: "Created", 43 - cell: ({ row }) => { 44 - return <span>{formatDate(row.getValue("createdAt"))}</span>; 45 - }, 46 - }, 47 - { 48 - id: "actions", 49 - cell: ({ row }) => { 50 - const role = row.original.role; 51 - if (role === "owner") return null; 52 - return ( 53 - <div className="text-right"> 54 - <DataTableRowActions row={row} /> 55 - </div> 56 - ); 57 - }, 58 - }, 59 - ];
-105
apps/web/src/components/data-table/user/data-table-row-actions.tsx
··· 1 - "use client"; 2 - 3 - import type { Row } from "@tanstack/react-table"; 4 - import { MoreHorizontal } from "lucide-react"; 5 - import { useRouter } from "next/navigation"; 6 - import * as React from "react"; 7 - 8 - import { selectUserSchema } from "@openstatus/db/src/schema"; 9 - import { 10 - AlertDialog, 11 - AlertDialogAction, 12 - AlertDialogCancel, 13 - AlertDialogContent, 14 - AlertDialogDescription, 15 - AlertDialogFooter, 16 - AlertDialogHeader, 17 - AlertDialogTitle, 18 - AlertDialogTrigger, 19 - Button, 20 - DropdownMenu, 21 - DropdownMenuContent, 22 - DropdownMenuItem, 23 - DropdownMenuTrigger, 24 - } from "@openstatus/ui"; 25 - 26 - import { LoadingAnimation } from "@/components/loading-animation"; 27 - import { toast, toastAction } from "@/lib/toast"; 28 - import { api } from "@/trpc/client"; 29 - 30 - interface DataTableRowActionsProps<TData> { 31 - row: Row<TData>; 32 - } 33 - 34 - export function DataTableRowActions<TData>({ 35 - row, 36 - }: DataTableRowActionsProps<TData>) { 37 - const user = selectUserSchema.parse(row.original); 38 - const router = useRouter(); 39 - const [alertOpen, setAlertOpen] = React.useState(false); 40 - const [isPending, startTransition] = React.useTransition(); 41 - 42 - async function onDelete() { 43 - startTransition(async () => { 44 - try { 45 - if (!user.id) return; 46 - await api.workspace.removeWorkspaceUser.mutate({ id: user.id }); 47 - toastAction("removed"); 48 - router.refresh(); 49 - setAlertOpen(false); 50 - } catch (error) { 51 - if (error instanceof Error) { 52 - toast.error(error.message); 53 - } else { 54 - toastAction("error"); 55 - } 56 - setAlertOpen(false); 57 - } 58 - }); 59 - } 60 - 61 - return ( 62 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 63 - <DropdownMenu> 64 - <DropdownMenuTrigger asChild> 65 - <Button 66 - variant="ghost" 67 - className="h-8 w-8 p-0 data-[state=open]:bg-accent" 68 - > 69 - <span className="sr-only">Open menu</span> 70 - <MoreHorizontal className="h-4 w-4" /> 71 - </Button> 72 - </DropdownMenuTrigger> 73 - <DropdownMenuContent align="end"> 74 - <AlertDialogTrigger asChild> 75 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 76 - Remove 77 - </DropdownMenuItem> 78 - </AlertDialogTrigger> 79 - </DropdownMenuContent> 80 - </DropdownMenu> 81 - <AlertDialogContent> 82 - <AlertDialogHeader> 83 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 84 - <AlertDialogDescription> 85 - This action cannot be undone. This will remove the user from your 86 - team. 87 - </AlertDialogDescription> 88 - </AlertDialogHeader> 89 - <AlertDialogFooter> 90 - <AlertDialogCancel>Cancel</AlertDialogCancel> 91 - <AlertDialogAction 92 - onClick={(e) => { 93 - e.preventDefault(); 94 - onDelete(); 95 - }} 96 - disabled={isPending} 97 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 98 - > 99 - {!isPending ? "Remove" : <LoadingAnimation />} 100 - </AlertDialogAction> 101 - </AlertDialogFooter> 102 - </AlertDialogContent> 103 - </AlertDialog> 104 - ); 105 - }
-80
apps/web/src/components/data-table/user/data-table.tsx
··· 1 - "use client"; 2 - 3 - import type { ColumnDef } from "@tanstack/react-table"; 4 - import { 5 - flexRender, 6 - getCoreRowModel, 7 - useReactTable, 8 - } from "@tanstack/react-table"; 9 - 10 - import { 11 - Table, 12 - TableBody, 13 - TableCell, 14 - TableHead, 15 - TableHeader, 16 - TableRow, 17 - } from "@openstatus/ui"; 18 - 19 - interface DataTableProps<TData, TValue> { 20 - columns: ColumnDef<TData, TValue>[]; 21 - data: TData[]; 22 - } 23 - 24 - export function DataTable<TData, TValue>({ 25 - columns, 26 - data, 27 - }: DataTableProps<TData, TValue>) { 28 - const table = useReactTable({ 29 - data, 30 - columns, 31 - getCoreRowModel: getCoreRowModel(), 32 - }); 33 - 34 - return ( 35 - <div className="rounded-md border"> 36 - <Table> 37 - <TableHeader className="bg-muted/50"> 38 - {table.getHeaderGroups().map((headerGroup) => ( 39 - <TableRow key={headerGroup.id} className="hover:bg-transparent"> 40 - {headerGroup.headers.map((header) => { 41 - return ( 42 - <TableHead key={header.id}> 43 - {header.isPlaceholder 44 - ? null 45 - : flexRender( 46 - header.column.columnDef.header, 47 - header.getContext(), 48 - )} 49 - </TableHead> 50 - ); 51 - })} 52 - </TableRow> 53 - ))} 54 - </TableHeader> 55 - <TableBody> 56 - {table.getRowModel().rows?.length ? ( 57 - table.getRowModel().rows.map((row) => ( 58 - <TableRow 59 - key={row.id} 60 - data-state={row.getIsSelected() && "selected"} 61 - > 62 - {row.getVisibleCells().map((cell) => ( 63 - <TableCell key={cell.id}> 64 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 65 - </TableCell> 66 - ))} 67 - </TableRow> 68 - )) 69 - ) : ( 70 - <TableRow> 71 - <TableCell colSpan={columns.length} className="h-24 text-center"> 72 - No results. 73 - </TableCell> 74 - </TableRow> 75 - )} 76 - </TableBody> 77 - </Table> 78 - </div> 79 - ); 80 - }
-119
apps/web/src/components/forms/maintenance/form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { usePathname, useRouter } from "next/navigation"; 5 - import { useTransition } from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import { insertMaintenanceSchema } from "@openstatus/db/src/schema"; 9 - import type { InsertMaintenance, Monitor } from "@openstatus/db/src/schema"; 10 - import { Form } from "@openstatus/ui"; 11 - 12 - import { toastAction } from "@/lib/toast"; 13 - import { api } from "@/trpc/client"; 14 - 15 - import { 16 - Tabs, 17 - TabsContent, 18 - TabsList, 19 - TabsTrigger, 20 - } from "@/components/dashboard/tabs"; 21 - 22 - import { SaveButton } from "../shared/save-button"; 23 - import { General } from "./general"; 24 - import { SectionConnect } from "./section-connect"; 25 - 26 - interface Props { 27 - defaultSection?: string; 28 - defaultValues?: InsertMaintenance; 29 - monitors?: Monitor[]; 30 - nextUrl?: string; 31 - pageId: number; 32 - } 33 - 34 - export function MaintenanceForm({ 35 - defaultSection, 36 - defaultValues, 37 - monitors, 38 - nextUrl, 39 - pageId, 40 - }: Props) { 41 - const form = useForm<InsertMaintenance>({ 42 - resolver: zodResolver(insertMaintenanceSchema), 43 - defaultValues: { 44 - ...defaultValues, 45 - title: defaultValues?.title || "", 46 - message: defaultValues?.message || "", 47 - from: defaultValues?.from ? new Date(defaultValues.from) : new Date(), 48 - to: defaultValues?.to 49 - ? new Date(defaultValues.to) 50 - : new Date(Date.now() + 1000 * 60 * 60), 51 - pageId, 52 - }, 53 - }); 54 - const router = useRouter(); 55 - const pathname = usePathname(); 56 - const [isPending, startTransition] = useTransition(); 57 - 58 - const onSubmit = async ({ ...props }: InsertMaintenance) => { 59 - startTransition(async () => { 60 - try { 61 - if (defaultValues) { 62 - await api.maintenance.updateLegacy.mutate(props); 63 - } else { 64 - await api.maintenance.create.mutate(props); 65 - } 66 - toastAction("saved"); 67 - // otherwise, the form will stay dirty - keepValues is used to keep the current values in the form 68 - form.reset({}, { keepValues: true }); 69 - if (nextUrl) { 70 - router.push(nextUrl); 71 - } 72 - router.refresh(); 73 - } catch { 74 - toastAction("error"); 75 - } 76 - }); 77 - }; 78 - 79 - function onValueChange(value: string) { 80 - // REMINDER: we are not merging the searchParams here 81 - // we are just setting the section to allow refreshing the page 82 - const params = new URLSearchParams(); 83 - params.set("section", value); 84 - router.push(`${pathname}?${params.toString()}`); 85 - } 86 - 87 - console.log(form.formState.errors); 88 - 89 - return ( 90 - <Form {...form}> 91 - <form 92 - onSubmit={async (e) => { 93 - e.preventDefault(); 94 - form.handleSubmit(onSubmit)(e); 95 - }} 96 - className="grid w-full gap-6" 97 - > 98 - <General form={form} /> 99 - <Tabs 100 - defaultValue={defaultSection} 101 - className="w-full" 102 - onValueChange={onValueChange} 103 - > 104 - <TabsList> 105 - <TabsTrigger value="connect">Connect</TabsTrigger> 106 - </TabsList> 107 - <TabsContent value="connect"> 108 - <SectionConnect form={form} monitors={monitors} /> 109 - </TabsContent> 110 - </Tabs> 111 - <SaveButton 112 - isPending={isPending} 113 - isDirty={form.formState.isDirty} 114 - onSubmit={form.handleSubmit(onSubmit)} 115 - /> 116 - </form> 117 - </Form> 118 - ); 119 - }
-113
apps/web/src/components/forms/maintenance/general.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { InsertMaintenance } from "@openstatus/db/src/schema"; 5 - import { 6 - DateTimePickerPopover, 7 - FormControl, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - Input, 14 - Textarea, 15 - } from "@openstatus/ui"; 16 - 17 - import { format } from "date-fns"; 18 - import { SectionHeader } from "../shared/section-header"; 19 - 20 - interface Props { 21 - form: UseFormReturn<InsertMaintenance>; 22 - } 23 - 24 - export function General({ form }: Props) { 25 - return ( 26 - <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 27 - <SectionHeader 28 - title="Maintenance Information" 29 - description="Give your users a heads up when you're doing maintenance." 30 - /> 31 - <div className="flex w-full flex-col gap-4 sm:col-span-2"> 32 - <FormField 33 - control={form.control} 34 - name="title" 35 - render={({ field }) => ( 36 - <FormItem className="w-full"> 37 - <FormLabel>Title</FormLabel> 38 - <FormControl> 39 - <Input placeholder="Database migration" {...field} /> 40 - </FormControl> 41 - <FormDescription>Displayed on the status page.</FormDescription> 42 - <FormMessage /> 43 - </FormItem> 44 - )} 45 - /> 46 - <FormField 47 - control={form.control} 48 - name="message" 49 - render={({ field }) => ( 50 - <FormItem className="col-span-full"> 51 - <FormLabel>Message</FormLabel> 52 - <FormControl> 53 - <Textarea 54 - placeholder="We're doing some maintenance. We'll be back soon!" 55 - {...field} 56 - /> 57 - </FormControl> 58 - <FormDescription>Give your users some context.</FormDescription> 59 - <FormMessage /> 60 - </FormItem> 61 - )} 62 - /> 63 - <div className="grid grid-cols-2 gap-4"> 64 - <FormField 65 - control={form.control} 66 - name="from" 67 - render={({ field }) => ( 68 - <FormItem className="flex w-full flex-col"> 69 - <FormLabel>From</FormLabel> 70 - <DateTimePickerPopover 71 - date={field.value ? new Date(field.value) : new Date()} 72 - setDate={(date) => { 73 - field.onChange(date); 74 - }} 75 - className="w-full" 76 - /> 77 - <FormMessage /> 78 - </FormItem> 79 - )} 80 - /> 81 - <FormField 82 - control={form.control} 83 - name="to" 84 - render={({ field }) => ( 85 - <FormItem className="flex w-full flex-col"> 86 - <FormLabel>To</FormLabel> 87 - <DateTimePickerPopover 88 - date={ 89 - field.value 90 - ? new Date(field.value) 91 - : new Date(Date.now() + 1000 * 60 * 60) 92 - } 93 - setDate={(date) => { 94 - field.onChange(date); 95 - }} 96 - className="w-full" 97 - /> 98 - <FormMessage /> 99 - </FormItem> 100 - )} 101 - /> 102 - <FormDescription className="sm:-mt-2 col-span-full"> 103 - The period{" "} 104 - <span className="font-medium"> 105 - in local time {format(new Date(), "z")} 106 - </span>{" "} 107 - when the maintenance takes place. 108 - </FormDescription> 109 - </div> 110 - </div> 111 - </div> 112 - ); 113 - }
-85
apps/web/src/components/forms/maintenance/section-connect.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { InsertMaintenance, Monitor } from "@openstatus/db/src/schema"; 5 - import { 6 - FormControl, 7 - FormDescription, 8 - FormField, 9 - FormItem, 10 - FormLabel, 11 - FormMessage, 12 - } from "@openstatus/ui"; 13 - 14 - import { CheckboxLabel } from "../shared/checkbox-label"; 15 - 16 - interface Props { 17 - form: UseFormReturn<InsertMaintenance>; 18 - monitors?: Monitor[]; 19 - } 20 - 21 - export function SectionConnect({ form, monitors }: Props) { 22 - return ( 23 - <div className="grid w-full gap-4"> 24 - <div className="flex flex-col gap-3"> 25 - <FormField 26 - control={form.control} 27 - name="monitors" 28 - render={() => ( 29 - <FormItem> 30 - <div className="mb-4"> 31 - <FormLabel>Monitors</FormLabel> 32 - <FormDescription> 33 - Select the monitors that are affected by this maintenance.{" "} 34 - <span className="font-medium"> 35 - We will not trigger any ping during that period. 36 - </span> 37 - </FormDescription> 38 - </div> 39 - <div className="grid grid-cols-1 grid-rows-1 gap-6 sm:grid-cols-2 md:grid-cols-3"> 40 - {monitors?.map((item) => ( 41 - <FormField 42 - key={item.id} 43 - control={form.control} 44 - name="monitors" 45 - render={({ field }) => { 46 - return ( 47 - <FormItem key={item.id} className="h-full w-full"> 48 - <FormControl className="w-full"> 49 - <CheckboxLabel 50 - id={String(item.id)} 51 - name="monitor" 52 - checked={field.value?.includes(item.id)} 53 - onCheckedChange={(checked) => { 54 - return checked 55 - ? field.onChange([ 56 - ...(field.value || []), 57 - item.id, 58 - ]) 59 - : field.onChange( 60 - field.value?.filter( 61 - (value) => value !== item.id, 62 - ), 63 - ); 64 - }} 65 - > 66 - {item.name} 67 - </CheckboxLabel> 68 - </FormControl> 69 - </FormItem> 70 - ); 71 - }} 72 - /> 73 - ))} 74 - </div> 75 - {!monitors || monitors.length === 0 ? ( 76 - <FormDescription>Missing monitors.</FormDescription> 77 - ) : null} 78 - <FormMessage /> 79 - </FormItem> 80 - )} 81 - /> 82 - </div> 83 - </div> 84 - ); 85 - }
-97
apps/web/src/components/forms/monitor-tag/form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import * as React from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import { insertMonitorTagSchema } from "@openstatus/db/src/schema"; 9 - import type { InsertMonitorTag, MonitorTag } from "@openstatus/db/src/schema"; 10 - import { 11 - Form, 12 - FormControl, 13 - FormField, 14 - FormItem, 15 - FormLabel, 16 - FormMessage, 17 - Input, 18 - } from "@openstatus/ui"; 19 - 20 - import { toastAction } from "@/lib/toast"; 21 - import { api } from "@/trpc/client"; 22 - import { SaveButton } from "../shared/save-button"; 23 - 24 - interface Props { 25 - defaultValues?: MonitorTag; 26 - } 27 - 28 - export function MonitorTagForm({ defaultValues }: Props) { 29 - const form = useForm<InsertMonitorTag>({ 30 - resolver: zodResolver(insertMonitorTagSchema), 31 - defaultValues, 32 - }); 33 - const router = useRouter(); 34 - const [isPending, startTransition] = React.useTransition(); 35 - 36 - const onSubmit = ({ ...props }: InsertMonitorTag) => { 37 - startTransition(async () => { 38 - try { 39 - if (defaultValues) { 40 - await api.monitorTag.update.mutate({ ...props }); 41 - } else { 42 - await api.monitorTag.create.mutate({ ...props }); 43 - } 44 - router.refresh(); 45 - toastAction("saved"); 46 - } catch { 47 - toastAction("error"); 48 - } 49 - }); 50 - }; 51 - 52 - return ( 53 - <Form {...form}> 54 - <form 55 - onSubmit={async (e) => { 56 - e.preventDefault(); 57 - form.handleSubmit(onSubmit)(e); 58 - }} 59 - className="grid w-full gap-6" 60 - > 61 - <div className="flex gap-6"> 62 - <FormField 63 - control={form.control} 64 - name="name" 65 - render={({ field }) => ( 66 - <FormItem> 67 - <FormLabel>Name</FormLabel> 68 - <FormControl> 69 - <Input placeholder="API" {...field} /> 70 - </FormControl> 71 - <FormMessage /> 72 - </FormItem> 73 - )} 74 - /> 75 - <FormField 76 - control={form.control} 77 - name="color" 78 - render={({ field }) => ( 79 - <FormItem className="w-12"> 80 - <FormLabel>Color</FormLabel> 81 - <FormControl> 82 - <Input type="color" {...field} /> 83 - </FormControl> 84 - <FormMessage /> 85 - </FormItem> 86 - )} 87 - /> 88 - </div> 89 - <SaveButton 90 - isPending={isPending} 91 - isDirty={form.formState.isDirty} 92 - onSubmit={form.handleSubmit(onSubmit)} 93 - /> 94 - </form> 95 - </Form> 96 - ); 97 - }
-370
apps/web/src/components/forms/monitor/form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 5 - import { usePathname, useRouter } from "next/navigation"; 6 - import * as React from "react"; 7 - import { useForm } from "react-hook-form"; 8 - 9 - import * as assertions from "@openstatus/assertions"; 10 - import type { 11 - InsertMonitor, 12 - MonitorTag, 13 - Notification, 14 - Page, 15 - WorkspacePlan, 16 - } from "@openstatus/db/src/schema"; 17 - import { insertMonitorSchema } from "@openstatus/db/src/schema"; 18 - import { Badge, Form } from "@openstatus/ui"; 19 - 20 - import { 21 - Tabs, 22 - TabsContent, 23 - TabsList, 24 - TabsTrigger, 25 - } from "@/components/dashboard/tabs"; 26 - import { FailedPingAlertConfirmation } from "@/components/modals/failed-ping-alert-confirmation"; 27 - import type { RegionChecker } from "@/components/ping-response-analysis/utils"; 28 - import { toast, toastAction } from "@/lib/toast"; 29 - import { formatDuration } from "@/lib/utils"; 30 - import { api } from "@/trpc/client"; 31 - import type { Region } from "@openstatus/db/src/schema/constants"; 32 - import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 33 - import { SaveButton } from "../shared/save-button"; 34 - import { General } from "./general"; 35 - import { RequestTestButton } from "./request-test-button"; 36 - import { SectionAssertions } from "./section-assertions"; 37 - import { SectionDanger } from "./section-danger"; 38 - import { SectionNotifications } from "./section-notifications"; 39 - import { SectionOtel } from "./section-otel"; 40 - import { SectionRequests } from "./section-requests"; 41 - import { SectionScheduling } from "./section-scheduling"; 42 - import { SectionStatusPage } from "./section-status-page"; 43 - 44 - interface Props { 45 - defaultSection?: string; 46 - limits: Limits; 47 - plan: WorkspacePlan; 48 - defaultValues?: InsertMonitor; 49 - notifications?: Notification[]; 50 - tags?: MonitorTag[]; 51 - pages?: Page[]; 52 - nextUrl?: string; 53 - withTestButton?: boolean; 54 - } 55 - 56 - const ABORT_TIMEOUT = 7_000; // in ms 57 - 58 - export function MonitorForm({ 59 - defaultSection, 60 - defaultValues, 61 - notifications, 62 - pages, 63 - tags, 64 - nextUrl, 65 - limits, 66 - plan, 67 - withTestButton = true, 68 - }: Props) { 69 - const _assertions = defaultValues?.assertions 70 - ? assertions.deserialize(defaultValues?.assertions).map((a) => a.schema) 71 - : []; 72 - 73 - const form = useForm<InsertMonitor>({ 74 - resolver: zodResolver(insertMonitorSchema), 75 - defaultValues: { 76 - url: defaultValues?.url || "", 77 - name: defaultValues?.name || "", 78 - description: defaultValues?.description || "", 79 - periodicity: defaultValues?.periodicity || "30m", 80 - active: defaultValues?.active ?? true, 81 - id: defaultValues?.id || 0, 82 - regions: defaultValues?.regions || getLimit(limits, "regions"), 83 - headers: defaultValues?.headers?.length 84 - ? defaultValues?.headers 85 - : [{ key: "", value: "" }], 86 - body: defaultValues?.body ?? "", 87 - method: defaultValues?.method ?? "GET", 88 - notifications: defaultValues?.notifications ?? [], 89 - pages: defaultValues?.pages ?? [], 90 - tags: defaultValues?.tags ?? [], 91 - public: defaultValues?.public ?? false, 92 - // biome-ignore lint/suspicious/noExplicitAny: <explanation> 93 - statusAssertions: _assertions.filter((a) => a.type === "status") as any, // TS considers a.type === "header" 94 - // biome-ignore lint/suspicious/noExplicitAny: <explanation> 95 - headerAssertions: _assertions.filter((a) => a.type === "header") as any, // TS considers a.type === "status" 96 - textBodyAssertions: _assertions.filter( 97 - (a) => a.type === "textBody", 98 - // biome-ignore lint/suspicious/noExplicitAny: <explanation> 99 - ) as any, // TS considers a.type === "textBody" 100 - degradedAfter: defaultValues?.degradedAfter, 101 - timeout: defaultValues?.timeout || 45000, 102 - jobType: defaultValues?.jobType || "http", 103 - otelEndpoint: defaultValues?.otelEndpoint ?? "", 104 - otelHeaders: defaultValues?.otelHeaders ?? [], 105 - }, 106 - }); 107 - const router = useRouter(); 108 - const pathname = usePathname(); 109 - const [isPending, setPending] = React.useState(false); 110 - const [pingFailed, setPingFailed] = React.useState(false); 111 - const type = React.useMemo( 112 - () => (defaultValues ? "update" : "create"), 113 - [defaultValues], 114 - ); 115 - 116 - const handleDataUpdateOrInsertion = async (props: InsertMonitor) => { 117 - if (defaultValues) { 118 - await api.monitor.update.mutate(props); 119 - } else { 120 - await api.monitor.create.mutate(props); 121 - } 122 - if (nextUrl) { 123 - router.push(nextUrl); 124 - } 125 - // to reset the `isDirty` state of them form while keeping the values for optimistic UI 126 - form.reset(undefined, { keepValues: true }); 127 - router.refresh(); 128 - }; 129 - 130 - const handleForceDataUpdateOrInsertion = async (props: InsertMonitor) => { 131 - try { 132 - handleDataUpdateOrInsertion(props); 133 - toastAction("saved"); 134 - } catch (_error) { 135 - toastAction("error"); 136 - } 137 - }; 138 - 139 - const onSubmit = ({ ...props }: InsertMonitor) => { 140 - toast.promise( 141 - async () => { 142 - setPending(true); 143 - const { error } = await pingEndpoint(); 144 - if (error) { 145 - setPingFailed(true); 146 - throw new Error(error); 147 - } 148 - await handleDataUpdateOrInsertion(props); 149 - }, 150 - { 151 - loading: "Checking the endpoint before saving...", 152 - success: () => "Endpoint is working fine. Saved!", 153 - error: (error: Error) => { 154 - if (error instanceof Error) return error.message; 155 - return "Endpoint is not working."; 156 - }, 157 - finally: () => { 158 - setPending(false); 159 - }, 160 - }, 161 - ); 162 - }; 163 - 164 - const validateJSON = (value?: string) => { 165 - if (!value) return; 166 - try { 167 - const obj = JSON.parse(value) as Record<string, unknown>; 168 - form.clearErrors("body"); 169 - return obj; 170 - } catch (_e) { 171 - form.setError("body", { 172 - message: "Not a valid JSON object", 173 - }); 174 - return false; 175 - } 176 - }; 177 - 178 - const pingEndpoint = async (region?: Region) => { 179 - try { 180 - const { 181 - url, 182 - body, 183 - method, 184 - headers, 185 - statusAssertions, 186 - headerAssertions, 187 - textBodyAssertions, 188 - jobType, 189 - } = form.getValues(); 190 - 191 - if ( 192 - body && 193 - body !== "" && 194 - headers?.some( 195 - (h) => h.key === "Content-Type" && h.value === "application/json", 196 - ) 197 - ) { 198 - const validJSON = validateJSON(body); 199 - if (!validJSON) { 200 - return { error: "Not a valid JSON object.", data: undefined }; 201 - } 202 - } 203 - 204 - const res = await fetch(`/api/checker/test/${jobType}`, { 205 - method: "POST", 206 - headers: new Headers({ 207 - "Content-Type": "application/json", 208 - }), 209 - body: JSON.stringify({ url, body, method, headers, region }), 210 - signal: AbortSignal.timeout(ABORT_TIMEOUT), 211 - }); 212 - 213 - if (!res.ok) { 214 - return { 215 - error: "Something went wrong. Please try again.", 216 - }; 217 - } 218 - 219 - const as = assertions.deserialize( 220 - JSON.stringify([ 221 - ...(statusAssertions || []), 222 - ...(headerAssertions || []), 223 - ...(textBodyAssertions || []), 224 - ]), 225 - ); 226 - 227 - const data = (await res.json()) as RegionChecker; 228 - 229 - const _headers: Record<string, string> = {}; 230 - // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> 231 - res.headers.forEach((value, key) => (_headers[key] = value)); 232 - if (data.state === "success") { 233 - if (as.length > 0) { 234 - for (const a of as) { 235 - const { success, message } = a.assert({ 236 - body: data.body ?? "", 237 - header: data.headers ?? {}, 238 - status: data.status, 239 - }); 240 - if (!success) { 241 - return { data, error: `Assertion error: ${message}` }; 242 - } 243 - } 244 - } else { 245 - // default assertion if no assertions are provided 246 - if (res.status < 200 || res.status >= 300) { 247 - return { 248 - data, 249 - error: `Assertion error: The response status was not 2XX: ${data.status}.`, 250 - }; 251 - } 252 - } 253 - } else { 254 - return { data, error: `Request error: ${data}` }; 255 - } 256 - 257 - return { data, error: undefined }; 258 - } catch (error) { 259 - console.error(error); 260 - if (error instanceof Error && error.name === "AbortError") { 261 - return { 262 - error: `Abort error: request takes more then ${formatDuration( 263 - ABORT_TIMEOUT, 264 - )}.`, 265 - }; 266 - } 267 - return { 268 - error: "Something went wrong. Please try again.", 269 - }; 270 - } 271 - }; 272 - 273 - function onValueChange(value: string) { 274 - // REMINDER: we are not merging the searchParams here 275 - // we are just setting the section to allow refreshing the page 276 - const params = new URLSearchParams(); 277 - params.set("section", value); 278 - router.push(`${pathname}?${params.toString()}`); 279 - } 280 - 281 - return ( 282 - <> 283 - <Form {...form}> 284 - <form 285 - onSubmit={form.handleSubmit(onSubmit)} 286 - onKeyDown={(e) => e.key === "Enter" && e.preventDefault()} 287 - className="flex w-full flex-col gap-6" 288 - > 289 - <General {...{ form, tags }} /> 290 - <Tabs 291 - defaultValue={defaultSection} 292 - className="w-full" 293 - onValueChange={onValueChange} 294 - > 295 - <TabsList> 296 - <TabsTrigger value="request">Request</TabsTrigger> 297 - <TabsTrigger value="scheduling">Scheduling & Regions</TabsTrigger> 298 - <TabsTrigger value="assertions"> 299 - Timing & Assertions{" "} 300 - {_assertions.length ? ( 301 - <Badge variant="secondary" className="ml-1"> 302 - {_assertions.length} 303 - </Badge> 304 - ) : null} 305 - </TabsTrigger> 306 - <TabsTrigger value="notifications"> 307 - Notifications{" "} 308 - {defaultValues?.notifications?.length ? ( 309 - <Badge variant="secondary" className="ml-1"> 310 - {defaultValues.notifications.length} 311 - </Badge> 312 - ) : null} 313 - </TabsTrigger> 314 - <TabsTrigger value="status-page"> 315 - Status Page{" "} 316 - {defaultValues?.pages?.length ? ( 317 - <Badge variant="secondary" className="ml-1"> 318 - {defaultValues.pages.length} 319 - </Badge> 320 - ) : null} 321 - </TabsTrigger> 322 - <TabsTrigger value="otel">OTel</TabsTrigger> 323 - {defaultValues?.id ? ( 324 - <TabsTrigger value="danger">Danger</TabsTrigger> 325 - ) : null} 326 - </TabsList> 327 - <TabsContent value="request"> 328 - <SectionRequests {...{ form, pingEndpoint, type }} /> 329 - </TabsContent> 330 - <TabsContent value="assertions"> 331 - <SectionAssertions {...{ form }} /> 332 - </TabsContent> 333 - <TabsContent value="scheduling"> 334 - <SectionScheduling {...{ form, limits, plan }} /> 335 - </TabsContent> 336 - <TabsContent value="otel"> 337 - <SectionOtel {...{ form, limits }} /> 338 - </TabsContent> 339 - <TabsContent value="notifications"> 340 - <SectionNotifications {...{ form, notifications }} /> 341 - </TabsContent> 342 - <TabsContent value="status-page"> 343 - <SectionStatusPage {...{ form, pages }} /> 344 - </TabsContent> 345 - {defaultValues?.id ? ( 346 - <TabsContent value="danger"> 347 - <SectionDanger monitorId={defaultValues.id} {...{ form }} /> 348 - </TabsContent> 349 - ) : null} 350 - </Tabs> 351 - <div className="grid gap-4 sm:flex sm:items-start sm:justify-end"> 352 - {withTestButton ? ( 353 - <RequestTestButton {...{ form, limits, pingEndpoint }} /> 354 - ) : null} 355 - <SaveButton 356 - isPending={isPending} 357 - isDirty={form.formState.isDirty} 358 - onSubmit={form.handleSubmit(onSubmit)} 359 - /> 360 - </div> 361 - </form> 362 - </Form> 363 - <FailedPingAlertConfirmation 364 - monitor={form.getValues()} 365 - {...{ pingFailed, setPingFailed }} 366 - onConfirm={handleForceDataUpdateOrInsertion} 367 - /> 368 - </> 369 - ); 370 - }
-90
apps/web/src/components/forms/monitor/general.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { InsertMonitor, MonitorTag } from "@openstatus/db/src/schema"; 5 - import { 6 - FormControl, 7 - FormDescription, 8 - FormField, 9 - FormItem, 10 - FormLabel, 11 - FormMessage, 12 - Input, 13 - Switch, 14 - } from "@openstatus/ui"; 15 - 16 - import { SectionHeader } from "../shared/section-header"; 17 - import { TagsMultiBox } from "./tags-multi-box"; 18 - 19 - interface Props { 20 - form: UseFormReturn<InsertMonitor>; 21 - tags?: MonitorTag[]; 22 - } 23 - 24 - export function General({ form, tags }: Props) { 25 - return ( 26 - <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 27 - <SectionHeader 28 - title="Basic Information" 29 - description="Be able to find your monitor easily." 30 - /> 31 - <div className="flex w-full flex-col gap-4 sm:col-span-2"> 32 - <div className="flex w-full gap-4"> 33 - <FormField 34 - control={form.control} 35 - name="name" 36 - render={({ field }) => ( 37 - <FormItem className="w-full"> 38 - <FormLabel>Name</FormLabel> 39 - <FormControl> 40 - <Input placeholder="Documenso" {...field} /> 41 - </FormControl> 42 - <FormDescription>Displayed on the status page.</FormDescription> 43 - <FormMessage /> 44 - </FormItem> 45 - )} 46 - /> 47 - <FormField 48 - control={form.control} 49 - name="active" 50 - render={({ field }) => ( 51 - <FormItem className="flex flex-row items-center justify-between space-x-1 space-y-0"> 52 - <FormLabel>Active</FormLabel> 53 - <FormControl> 54 - <Switch 55 - checked={field.value || false} 56 - onCheckedChange={(value) => field.onChange(value)} 57 - /> 58 - </FormControl> 59 - <FormMessage /> 60 - </FormItem> 61 - )} 62 - /> 63 - </div> 64 - <div className="grid gap-4 sm:grid-cols-3"> 65 - <FormField 66 - control={form.control} 67 - name="tags" 68 - render={({ field }) => ( 69 - <FormItem className="sm:col-span-2"> 70 - <FormLabel>Tags</FormLabel> 71 - <FormControl> 72 - <TagsMultiBox 73 - tags={tags} 74 - onChange={field.onChange} 75 - values={field.value} 76 - // {...field} 77 - /> 78 - </FormControl> 79 - <FormDescription> 80 - Categorize your monitors. Create new tags by typing a name. 81 - </FormDescription> 82 - <FormMessage /> 83 - </FormItem> 84 - )} 85 - /> 86 - </div> 87 - </div> 88 - </div> 89 - ); 90 - }
-159
apps/web/src/components/forms/monitor/request-test-button.tsx
··· 1 - "use client"; 2 - 3 - import { Send } from "lucide-react"; 4 - import React from "react"; 5 - import type { UseFormReturn } from "react-hook-form"; 6 - 7 - import { deserialize } from "@openstatus/assertions"; 8 - import type { InsertMonitor } from "@openstatus/db/src/schema"; 9 - import { 10 - type Region, 11 - monitorRegions, 12 - } from "@openstatus/db/src/schema/constants"; 13 - import { regionDict } from "@openstatus/regions"; 14 - import { 15 - Button, 16 - Dialog, 17 - DialogContent, 18 - DialogHeader, 19 - DialogTitle, 20 - Select, 21 - SelectContent, 22 - SelectItem, 23 - SelectTrigger, 24 - SelectValue, 25 - Tooltip, 26 - TooltipContent, 27 - TooltipProvider, 28 - TooltipTrigger, 29 - } from "@openstatus/ui"; 30 - 31 - import { LoadingAnimation } from "@/components/loading-animation"; 32 - import { RegionInfo } from "@/components/ping-response-analysis/region-info"; 33 - import { ResponseDetailTabs } from "@/components/ping-response-analysis/response-detail-tabs"; 34 - import type { RegionChecker } from "@/components/ping-response-analysis/utils"; 35 - import { toast, toastAction } from "@/lib/toast"; 36 - import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 37 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 38 - 39 - interface Props { 40 - form: UseFormReturn<InsertMonitor>; 41 - limits: Limits; 42 - pingEndpoint( 43 - region?: Region, 44 - ): Promise<{ data?: RegionChecker; error?: string }>; 45 - } 46 - 47 - export function RequestTestButton({ form, pingEndpoint, limits }: Props) { 48 - const [check, setCheck] = React.useState< 49 - { data: RegionChecker; error?: string } | undefined 50 - >(); 51 - const [value, setValue] = React.useState<Region>(monitorRegions[0]); 52 - const [isPending, startTransition] = React.useTransition(); 53 - 54 - const onClick = () => { 55 - if (isPending) return; 56 - 57 - const { url } = form.getValues(); 58 - 59 - if (!url) { 60 - toastAction("test-warning-empty-url"); 61 - return; 62 - } 63 - 64 - startTransition(async () => { 65 - try { 66 - const { data, error } = await pingEndpoint(value); 67 - if (data) setCheck({ data, error }); 68 - const isOk = !error; 69 - if (isOk) { 70 - toastAction("test-success"); 71 - } else { 72 - toast.error(error); 73 - } 74 - } catch { 75 - toastAction("error"); 76 - } 77 - }); 78 - }; 79 - 80 - const { flag } = regionDict[value as keyof typeof regionDict]; 81 - 82 - const { statusAssertions, headerAssertions } = form.getValues(); 83 - 84 - const regions = getLimit(limits, "regions"); 85 - 86 - return ( 87 - <Dialog open={!!check} onOpenChange={() => setCheck(undefined)}> 88 - <div className="group flex h-10 items-center rounded-md bg-transparent text-sm ring-offset-background focus-within:outline-hidden focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"> 89 - <Select 90 - value={value} 91 - onValueChange={(value: Region) => setValue(value)} 92 - > 93 - <SelectTrigger 94 - className="flex-1 rounded-r-none border-accent focus:ring-0" 95 - aria-label={value} 96 - > 97 - <SelectValue>{flag}</SelectValue> 98 - </SelectTrigger> 99 - <SelectContent> 100 - {regions.map((region) => { 101 - const { flag } = regionDict[region]; 102 - return ( 103 - <SelectItem key={region} value={region}> 104 - {flag} <span className="ml-1 font-mono">{region}</span> 105 - </SelectItem> 106 - ); 107 - })} 108 - </SelectContent> 109 - </Select> 110 - <TooltipProvider> 111 - <Tooltip> 112 - <TooltipTrigger asChild> 113 - <Button 114 - onClick={onClick} 115 - disabled={isPending} 116 - className="h-full flex-1 rounded-l-none focus:ring-0" 117 - variant="secondary" 118 - > 119 - {isPending ? ( 120 - <LoadingAnimation variant="inverse" /> 121 - ) : ( 122 - <> 123 - Test <Send className="ml-2 h-4 w-4" /> 124 - </> 125 - )} 126 - </Button> 127 - </TooltipTrigger> 128 - <TooltipContent> 129 - <p>Ping your endpoint</p> 130 - </TooltipContent> 131 - </Tooltip> 132 - </TooltipProvider> 133 - </div> 134 - <DialogContent className="max-h-screen w-full overflow-auto sm:max-w-3xl sm:p-8"> 135 - <DialogHeader> 136 - <DialogTitle>Response</DialogTitle> 137 - </DialogHeader> 138 - {check?.data.state === "success" ? ( 139 - <div className="grid gap-8"> 140 - <RegionInfo check={check.data} error={check.error} /> 141 - {check.data.state === "success" && check.data.type === "http" ? ( 142 - <ResponseDetailTabs 143 - timing={check.data.timing} 144 - headers={check.data.headers} 145 - status={check.data.status} 146 - assertions={deserialize( 147 - JSON.stringify([ 148 - ...(statusAssertions || []), 149 - ...(headerAssertions || []), 150 - ]), 151 - )} 152 - /> 153 - ) : null} 154 - </div> 155 - ) : null} 156 - </DialogContent> 157 - </Dialog> 158 - ); 159 - }
-126
apps/web/src/components/forms/monitor/section-danger.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - import type { UseFormReturn } from "react-hook-form"; 5 - 6 - import type { InsertMonitor } from "@openstatus/db/src/schema"; 7 - import { 8 - AlertDialog, 9 - AlertDialogAction, 10 - AlertDialogCancel, 11 - AlertDialogContent, 12 - AlertDialogDescription, 13 - AlertDialogFooter, 14 - AlertDialogHeader, 15 - AlertDialogTitle, 16 - AlertDialogTrigger, 17 - Button, 18 - Checkbox, 19 - FormControl, 20 - FormDescription, 21 - FormField, 22 - FormItem, 23 - FormLabel, 24 - } from "@openstatus/ui"; 25 - 26 - import { LoadingAnimation } from "@/components/loading-animation"; 27 - import { toastAction } from "@/lib/toast"; 28 - import { api } from "@/trpc/client"; 29 - import React from "react"; 30 - import { SectionHeader } from "../shared/section-header"; 31 - 32 - interface Props { 33 - monitorId: number; 34 - form: UseFormReturn<InsertMonitor>; 35 - } 36 - 37 - export function SectionDanger({ monitorId, form }: Props) { 38 - const router = useRouter(); 39 - const [open, setOpen] = React.useState(false); 40 - const [isPending, startTransition] = React.useTransition(); 41 - 42 - async function onDelete() { 43 - startTransition(async () => { 44 - try { 45 - await api.monitor.delete.mutate({ id: monitorId }); 46 - toastAction("deleted"); 47 - setOpen(false); 48 - router.push("/app"); 49 - } catch { 50 - toastAction("error"); 51 - } 52 - }); 53 - } 54 - 55 - return ( 56 - <div className="grid w-full gap-4"> 57 - <SectionHeader 58 - title="Danger Zone" 59 - description="Be aware of the changes you are about to make." 60 - /> 61 - <div className="grid gap-4 sm:grid-cols-3"> 62 - <FormField 63 - control={form.control} 64 - name="public" 65 - render={({ field }) => ( 66 - <FormItem className="flex flex-row items-start space-x-3 space-y-0 sm:col-span-2"> 67 - <FormControl> 68 - <Checkbox 69 - checked={field.value ?? false} 70 - onCheckedChange={field.onChange} 71 - /> 72 - </FormControl> 73 - <div className="space-y-1 leading-none"> 74 - <FormLabel>Allow public monitor</FormLabel> 75 - <FormDescription> 76 - Change monitor visibility. When checked, the monitor stats 77 - from the overview page will be public. You will be able to 78 - share it via a connected status page or{" "} 79 - <code className="underline underline-offset-4"> 80 - openstatus.dev/public/monitors/{form.getValues("id")} 81 - </code> 82 - . 83 - </FormDescription> 84 - </div> 85 - </FormItem> 86 - )} 87 - /> 88 - <div className="col-start-1 flex flex-col items-center gap-4 sm:col-span-2 sm:flex-row"> 89 - <AlertDialog open={open} onOpenChange={setOpen}> 90 - <AlertDialogTrigger asChild> 91 - <Button variant="destructive" className="w-full sm:w-auto"> 92 - Delete 93 - </Button> 94 - </AlertDialogTrigger> 95 - <AlertDialogContent> 96 - <AlertDialogHeader> 97 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 98 - <AlertDialogDescription> 99 - This action cannot be undone. This will permanently delete the 100 - monitor. 101 - </AlertDialogDescription> 102 - </AlertDialogHeader> 103 - <AlertDialogFooter> 104 - <AlertDialogCancel>Cancel</AlertDialogCancel> 105 - <AlertDialogAction 106 - onClick={(e) => { 107 - e.preventDefault(); 108 - onDelete(); 109 - }} 110 - disabled={isPending} 111 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 112 - > 113 - {!isPending ? "Delete" : <LoadingAnimation />} 114 - </AlertDialogAction> 115 - </AlertDialogFooter> 116 - </AlertDialogContent> 117 - </AlertDialog> 118 - <FormDescription className="order-1 text-red-500 sm:order-2"> 119 - This action cannot be undone. This will permanently delete the 120 - monitor. 121 - </FormDescription> 122 - </div> 123 - </div> 124 - </div> 125 - ); 126 - }
-103
apps/web/src/components/forms/monitor/section-limits.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import type { InsertMonitor } from "@openstatus/db/src/schema"; 6 - import { 7 - FormControl, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - Select, 14 - SelectContent, 15 - SelectItem, 16 - SelectTrigger, 17 - SelectValue, 18 - } from "@openstatus/ui"; 19 - 20 - import { formatDuration } from "@/lib/utils"; 21 - import { SectionHeader } from "../shared/section-header"; 22 - 23 - interface Props { 24 - form: UseFormReturn<InsertMonitor>; 25 - } 26 - 27 - // FIXME: replace with enum 28 - // TODO: can be also replaced by a custom number input with max value (+ validation) 29 - const limits = [100, 250, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 40_000]; 30 - 31 - export function SectionLimits({ form }: Props) { 32 - return ( 33 - <div className="grid w-full gap-4"> 34 - <SectionHeader 35 - title="Response Time Limits" 36 - description="Check when your endpoint is taking too long to respond. And set the limit to be considered degraded." 37 - /> 38 - <FormField 39 - control={form.control} 40 - name="id" // FIXME: should be something like 'limitDegraded' 41 - render={({ field }) => ( 42 - <FormItem> 43 - <FormLabel>Degraded limit</FormLabel> 44 - <Select 45 - onValueChange={(value) => field.onChange(Number.parseInt(value))} 46 - defaultValue={String(field.value)} 47 - > 48 - <FormControl> 49 - <SelectTrigger className="sm:w-[200px]"> 50 - <SelectValue placeholder="Select" /> 51 - </SelectTrigger> 52 - </FormControl> 53 - <SelectContent> 54 - {limits.map((limit) => ( 55 - <SelectItem key={limit} value={String(limit)}> 56 - {formatDuration(limit)} 57 - </SelectItem> 58 - ))} 59 - </SelectContent> 60 - </Select> 61 - <FormDescription> 62 - When the response time exceeds this limit, the monitor is will be 63 - considered as{" "} 64 - <span className="text-status-degraded">degraded</span>. 65 - </FormDescription> 66 - <FormMessage /> 67 - </FormItem> 68 - )} 69 - /> 70 - <FormField 71 - control={form.control} 72 - name="id" // FIXME: should be something like 'limitFailed' 73 - render={({ field }) => ( 74 - <FormItem> 75 - <FormLabel>Failed limit</FormLabel> 76 - <Select 77 - onValueChange={(value) => field.onChange(Number.parseInt(value))} 78 - defaultValue={String(field.value)} 79 - > 80 - <FormControl> 81 - <SelectTrigger className="sm:w-[200px]"> 82 - <SelectValue placeholder="Select" /> 83 - </SelectTrigger> 84 - </FormControl> 85 - <SelectContent> 86 - {limits.map((limit) => ( 87 - <SelectItem key={limit} value={String(limit)}> 88 - {formatDuration(limit)} 89 - </SelectItem> 90 - ))} 91 - </SelectContent> 92 - </Select> 93 - <FormDescription> 94 - When the response time exceeds this limit, the monitor is will be 95 - considered as <span className="text-status-down">failed</span>. 96 - </FormDescription> 97 - <FormMessage /> 98 - </FormItem> 99 - )} 100 - /> 101 - </div> 102 - ); 103 - }
-117
apps/web/src/components/forms/monitor/section-otel.tsx
··· 1 - "use client"; 2 - 3 - import { X } from "lucide-react"; 4 - import { type UseFormReturn, useFieldArray } from "react-hook-form"; 5 - 6 - import type { InsertMonitor } from "@openstatus/db/src/schema"; 7 - import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 8 - 9 - import { 10 - Button, 11 - FormControl, 12 - FormDescription, 13 - FormField, 14 - FormItem, 15 - FormLabel, 16 - FormMessage, 17 - Input, 18 - } from "@openstatus/ui"; 19 - 20 - import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 21 - import { SectionHeader } from "../shared/section-header"; 22 - 23 - interface Props { 24 - form: UseFormReturn<InsertMonitor>; 25 - limits: Limits; 26 - } 27 - 28 - export function SectionOtel({ form, limits }: Props) { 29 - const { fields, append, remove } = useFieldArray({ 30 - name: "otelHeaders", 31 - control: form.control, 32 - }); 33 - 34 - if (!limits.otel) { 35 - return <ProFeatureAlert feature="OpenTelemetry" workspacePlan="team" />; 36 - } 37 - 38 - return ( 39 - <div className="grid w-full gap-4"> 40 - <SectionHeader 41 - title="OpenTelemetry" 42 - description="Configure your OpenTelemetry Exporter." 43 - /> 44 - <div className="grid sm:grid-cols-2 md:grid-cols-3"> 45 - <FormField 46 - control={form.control} 47 - name="otelEndpoint" 48 - render={({ field }) => ( 49 - <FormItem className="col-span-full"> 50 - <FormLabel>Endpoint</FormLabel> 51 - <FormControl> 52 - <Input 53 - type="text" 54 - placeholder="https://otel.openstatus.dev/api/v1/metrics" 55 - {...field} 56 - value={field.value ?? ""} 57 - /> 58 - </FormControl> 59 - <FormDescription> 60 - The endpoint to send the metrics to. 61 - </FormDescription> 62 - <FormMessage /> 63 - </FormItem> 64 - )} 65 - /> 66 - </div> 67 - <div className="space-y-2 sm:col-span-full"> 68 - <FormLabel>Request Header</FormLabel> 69 - {fields.map((field, index) => ( 70 - <div key={field.id} className="grid grid-cols-6 gap-4"> 71 - <FormField 72 - control={form.control} 73 - name={`otelHeaders.${index}.key`} 74 - render={({ field }) => ( 75 - <FormItem className="col-span-2"> 76 - <FormControl> 77 - <Input placeholder="key" {...field} /> 78 - </FormControl> 79 - </FormItem> 80 - )} 81 - /> 82 - <div className="col-span-4 flex items-center space-x-2"> 83 - <FormField 84 - control={form.control} 85 - name={`otelHeaders.${index}.value`} 86 - render={({ field }) => ( 87 - <FormItem className="w-full"> 88 - <FormControl> 89 - <Input placeholder="value" {...field} /> 90 - </FormControl> 91 - </FormItem> 92 - )} 93 - /> 94 - <Button 95 - size="icon" 96 - variant="ghost" 97 - type="button" 98 - onClick={() => remove(index)} 99 - > 100 - <X className="h-4 w-4" /> 101 - </Button> 102 - </div> 103 - </div> 104 - ))} 105 - <div> 106 - <Button 107 - type="button" 108 - variant="outline" 109 - onClick={() => append({ key: "", value: "" })} 110 - > 111 - Add Custom Header 112 - </Button> 113 - </div> 114 - </div> 115 - </div> 116 - ); 117 - }
-334
apps/web/src/components/forms/monitor/section-request-http.tsx
··· 1 - "use client"; 2 - 3 - import { Wand2, X } from "lucide-react"; 4 - import type * as React from "react"; 5 - import { useFieldArray } from "react-hook-form"; 6 - import type { UseFormReturn } from "react-hook-form"; 7 - 8 - import { 9 - monitorMethods, 10 - monitorMethodsSchema, 11 - } from "@openstatus/db/src/schema"; 12 - import type { InsertMonitor } from "@openstatus/db/src/schema"; 13 - import { 14 - Button, 15 - FormControl, 16 - FormDescription, 17 - FormField, 18 - FormItem, 19 - FormLabel, 20 - FormMessage, 21 - Input, 22 - Select, 23 - SelectContent, 24 - SelectItem, 25 - SelectTrigger, 26 - SelectValue, 27 - Textarea, 28 - Tooltip, 29 - TooltipContent, 30 - TooltipProvider, 31 - TooltipTrigger, 32 - } from "@openstatus/ui"; 33 - 34 - import { toast } from "@/lib/toast"; 35 - import { useRef, useState } from "react"; 36 - 37 - const contentTypes = [ 38 - { value: "application/octet-stream", label: "Binary File" }, 39 - { value: "application/json", label: "JSON" }, 40 - { value: "application/xml", label: "XML" }, 41 - { value: "application/yaml", label: "YAML" }, 42 - { value: "application/edn", label: "EDN" }, 43 - { value: "application/other", label: "Other" }, 44 - { value: "none", label: "None" }, 45 - ]; 46 - 47 - interface Props { 48 - form: UseFormReturn<InsertMonitor>; 49 - } 50 - 51 - // TODO: add Dialog with response informations when pingEndpoint! 52 - 53 - export function SectionRequestHTTP({ form }: Props) { 54 - const { fields, append, prepend, remove, update } = useFieldArray({ 55 - name: "headers", 56 - control: form.control, 57 - }); 58 - const inputRef = useRef<HTMLInputElement>(null); 59 - const watchMethod = form.watch("method"); 60 - const [file, setFile] = useState<string | undefined>(undefined); 61 - const [content, setContent] = useState<string | undefined>( 62 - fields.find((field) => field.key === "Content-Type")?.value, 63 - ); 64 - 65 - const validateJSON = (value?: string) => { 66 - if (!value) return; 67 - try { 68 - const obj = JSON.parse(value) as Record<string, unknown>; 69 - form.clearErrors("body"); 70 - return obj; 71 - } catch (_e) { 72 - form.setError("body", { 73 - message: "Not a valid JSON object", 74 - }); 75 - return false; 76 - } 77 - }; 78 - 79 - const uploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => { 80 - if (event.target.files?.[0]) { 81 - const file = event.target.files[0]; 82 - 83 - // File too big, return error 84 - const fileSize = file.size / 1024 / 1024; // in MiB 85 - if (fileSize > 10) { 86 - // Display error message 87 - toast.error("File size is too big. Max 10MB allowed."); 88 - return; 89 - } 90 - 91 - const reader = new FileReader(); 92 - reader.onload = (event) => { 93 - if (event.target?.result && typeof event.target.result === "string") { 94 - form.setValue("body", event.target?.result); 95 - setFile(file.name); 96 - } 97 - }; 98 - 99 - reader.readAsDataURL(file); 100 - } 101 - }; 102 - 103 - const onPrettifyJSON = () => { 104 - const body = form.getValues("body"); 105 - const obj = validateJSON(body); 106 - if (obj) { 107 - const pretty = JSON.stringify(obj, undefined, 4); 108 - form.setValue("body", pretty); 109 - } 110 - }; 111 - 112 - return ( 113 - <div className="grid w-full gap-4"> 114 - <div className="grid gap-4 sm:grid-cols-7"> 115 - <FormField 116 - control={form.control} 117 - name="method" 118 - render={({ field }) => ( 119 - <FormItem className="sm:col-span-1"> 120 - <FormLabel>Method</FormLabel> 121 - <Select 122 - onValueChange={(value) => { 123 - field.onChange(monitorMethodsSchema.parse(value)); 124 - form.resetField("body", { defaultValue: "" }); 125 - setContent(undefined); 126 - }} 127 - defaultValue={field.value} 128 - > 129 - <FormControl> 130 - <SelectTrigger> 131 - <SelectValue placeholder="Select" /> 132 - </SelectTrigger> 133 - </FormControl> 134 - <SelectContent> 135 - {monitorMethods.map((method) => ( 136 - <SelectItem key={method} value={method}> 137 - {method} 138 - </SelectItem> 139 - ))} 140 - </SelectContent> 141 - </Select> 142 - <FormMessage /> 143 - </FormItem> 144 - )} 145 - /> 146 - <FormField 147 - control={form.control} 148 - name="url" 149 - render={({ field }) => ( 150 - <FormItem className="sm:col-span-6"> 151 - <FormLabel>URL</FormLabel> 152 - <FormControl> 153 - <Input 154 - className="bg-muted" 155 - placeholder="https://documenso.com/api/health" 156 - {...field} 157 - /> 158 - </FormControl> 159 - </FormItem> 160 - )} 161 - /> 162 - </div> 163 - <div className="space-y-2 sm:col-span-full"> 164 - <FormLabel>Request Header</FormLabel> 165 - {fields.map((field, index) => ( 166 - <div key={field.id} className="grid grid-cols-6 gap-4"> 167 - <FormField 168 - control={form.control} 169 - name={`headers.${index}.key`} 170 - render={({ field }) => ( 171 - <FormItem className="col-span-2"> 172 - <FormControl> 173 - <Input placeholder="key" {...field} /> 174 - </FormControl> 175 - </FormItem> 176 - )} 177 - /> 178 - <div className="col-span-4 flex items-center space-x-2"> 179 - <FormField 180 - control={form.control} 181 - name={`headers.${index}.value`} 182 - render={({ field }) => ( 183 - <FormItem className="w-full"> 184 - <FormControl> 185 - <Input placeholder="value" {...field} /> 186 - </FormControl> 187 - </FormItem> 188 - )} 189 - /> 190 - <Button 191 - size="icon" 192 - variant="ghost" 193 - type="button" 194 - onClick={() => remove(index)} 195 - > 196 - <X className="h-4 w-4" /> 197 - </Button> 198 - </div> 199 - </div> 200 - ))} 201 - <div> 202 - <Button 203 - type="button" 204 - variant="outline" 205 - onClick={() => append({ key: "", value: "" })} 206 - > 207 - Add Custom Header 208 - </Button> 209 - </div> 210 - </div> 211 - {(watchMethod === "POST" || 212 - watchMethod === "PUT" || 213 - watchMethod === "PATCH" || 214 - watchMethod === "DELETE") && ( 215 - <div className="sm:col-span-full"> 216 - <FormField 217 - control={form.control} 218 - name="body" 219 - render={({ field }) => ( 220 - <FormItem className="space-y-1.5"> 221 - <div className="flex items-end justify-between"> 222 - <FormLabel className="flex items-center space-x-2"> 223 - Body 224 - <Select 225 - defaultValue={content} 226 - onValueChange={(value) => { 227 - setContent(value); 228 - 229 - if (content === "application/octet-stream") { 230 - form.setValue("body", ""); 231 - setFile(undefined); 232 - } 233 - 234 - const contentIndex = fields.findIndex( 235 - (field) => field.key === "Content-Type", 236 - ); 237 - 238 - if (contentIndex >= 0) { 239 - if (value === "none") { 240 - remove(contentIndex); 241 - } else { 242 - update(contentIndex, { 243 - key: "Content-Type", 244 - value, 245 - }); 246 - } 247 - } else { 248 - prepend({ key: "Content-Type", value }); 249 - } 250 - }} 251 - > 252 - <SelectTrigger 253 - variant={"ghost"} 254 - className="ml-1 h-7 text-muted-foreground text-xs" 255 - > 256 - <SelectValue placeholder="Content-Type" /> 257 - </SelectTrigger> 258 - <SelectContent> 259 - {contentTypes.map((type) => ( 260 - <SelectItem key={type.value} value={type.value}> 261 - {type.label} 262 - </SelectItem> 263 - ))} 264 - </SelectContent> 265 - </Select> 266 - </FormLabel> 267 - {watchMethod === "POST" && 268 - fields.some( 269 - (field) => 270 - field.key === "Content-Type" && 271 - field.value === "application/json", 272 - ) && ( 273 - <TooltipProvider> 274 - <Tooltip> 275 - <TooltipTrigger asChild> 276 - <Button 277 - type="button" 278 - variant="ghost" 279 - size="icon" 280 - className="h-7 w-7" 281 - onClick={onPrettifyJSON} 282 - > 283 - <Wand2 className="h-3 w-3" /> 284 - </Button> 285 - </TooltipTrigger> 286 - <TooltipContent> 287 - <p>Prettify JSON</p> 288 - </TooltipContent> 289 - </Tooltip> 290 - </TooltipProvider> 291 - )} 292 - </div> 293 - <div className="space-y-2"> 294 - <FormControl> 295 - {content === "application/octet-stream" ? ( 296 - <> 297 - <Button 298 - type="button" 299 - variant="outline" 300 - onClick={() => inputRef.current?.click()} 301 - className="max-w-56" 302 - > 303 - <span className="truncate"> 304 - {file || form.getValues("body") || "Upload file"} 305 - </span> 306 - </Button> 307 - <input 308 - type="file" 309 - onChange={uploadFile} 310 - ref={inputRef} 311 - hidden 312 - /> 313 - </> 314 - ) : ( 315 - <> 316 - <Textarea 317 - rows={8} 318 - placeholder='{ "hello": "world" }' 319 - {...field} 320 - /> 321 - <FormDescription>Write your payload.</FormDescription> 322 - </> 323 - )} 324 - </FormControl> 325 - <FormMessage /> 326 - </div> 327 - </FormItem> 328 - )} 329 - /> 330 - </div> 331 - )} 332 - </div> 333 - ); 334 - }
-65
apps/web/src/components/forms/monitor/section-request-tcp.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { InsertMonitor } from "@openstatus/db/src/schema"; 5 - import { 6 - FormControl, 7 - FormDescription, 8 - FormField, 9 - FormItem, 10 - FormLabel, 11 - FormMessage, 12 - Input, 13 - } from "@openstatus/ui"; 14 - 15 - // TODO: add `port` and `host` field instead! 16 - 17 - interface Props { 18 - form: UseFormReturn<InsertMonitor>; 19 - } 20 - 21 - export function SectionRequestTCP({ form }: Props) { 22 - return ( 23 - <div className="grid w-full gap-4"> 24 - <div className="grid gap-4 sm:grid-cols-7"> 25 - <FormField 26 - control={form.control} 27 - name="url" 28 - render={({ field }) => ( 29 - <FormItem className="sm:col-span-5"> 30 - <FormLabel>Host:Port</FormLabel> 31 - <FormControl> 32 - <Input 33 - className="bg-muted" 34 - placeholder="192.168.1.1:80" 35 - {...field} 36 - /> 37 - </FormControl> 38 - <FormMessage /> 39 - <FormDescription> 40 - The input supports both IPv4 addresses and IPv6 addresses. 41 - </FormDescription> 42 - </FormItem> 43 - )} 44 - /> 45 - </div> 46 - <div className="text-sm"> 47 - <p>Examples:</p> 48 - <ul className="list-inside list-disc text-muted-foreground"> 49 - <li> 50 - Domain: <code className="text-foreground">openstatus.dev:443</code> 51 - </li> 52 - <li> 53 - IPv4: <code className="text-foreground">192.168.1.1:443</code> 54 - </li> 55 - <li> 56 - IPv6:{" "} 57 - <code className="text-foreground"> 58 - [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443 59 - </code> 60 - </li> 61 - </ul> 62 - </div> 63 - </div> 64 - ); 65 - }
-126
apps/web/src/components/forms/monitor/section-requests.tsx
··· 1 - "use client"; 2 - 3 - import * as React from "react"; 4 - import type { UseFormReturn } from "react-hook-form"; 5 - 6 - import { monitorJobTypesSchema } from "@openstatus/db/src/schema"; 7 - import type { InsertMonitor } from "@openstatus/db/src/schema"; 8 - 9 - import { 10 - Tabs, 11 - TabsContent, 12 - TabsList, 13 - TabsTrigger, 14 - } from "@/components/dashboard/tabs"; 15 - 16 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 17 - import { SectionHeader } from "../shared/section-header"; 18 - import { SectionRequestHTTP } from "./section-request-http"; 19 - import { SectionRequestTCP } from "./section-request-tcp"; 20 - 21 - interface Props { 22 - form: UseFormReturn<InsertMonitor>; 23 - type: "create" | "update"; 24 - } 25 - 26 - // TODO: add Dialog with response informations when pingEndpoint! 27 - 28 - export function SectionRequests({ form, type }: Props) { 29 - const jobType = form.getValues("jobType"); 30 - const [prevJobType, setPrevJobType] = React.useState(jobType); 31 - 32 - if (prevJobType !== jobType) { 33 - setPrevJobType(jobType); 34 - if (type === "create") { 35 - form.resetField("url"); 36 - form.resetField("method"); 37 - form.resetField("body"); 38 - form.resetField("headers"); 39 - form.resetField("headerAssertions"); 40 - form.resetField("statusAssertions"); 41 - form.resetField("textBodyAssertions"); 42 - } 43 - } 44 - 45 - return ( 46 - <div className="grid w-full gap-4"> 47 - <SectionHeader 48 - title="Request Settings" 49 - description={ 50 - type === "create" ? ( 51 - <> 52 - Create your{" "} 53 - <span className="font-medium font-mono text-foreground"> 54 - HTTP 55 - </span>{" "} 56 - or{" "} 57 - <span className="font-medium font-mono text-foreground">TCP</span>{" "} 58 - request type. Add custom headers, payload and test your endpoint 59 - before submitting.{" "} 60 - <span className="font-medium"> 61 - You will not be able to switch type after saving. 62 - </span> 63 - </> 64 - ) : ( 65 - <> 66 - Update your{" "} 67 - <span className="font-medium font-mono text-foreground"> 68 - {jobType?.toUpperCase()} 69 - </span>{" "} 70 - request. Add custom headers, payload and test your endpoint before 71 - submitting. 72 - </> 73 - ) 74 - } 75 - /> 76 - {type === "create" ? ( 77 - <Tabs 78 - value={jobType} 79 - onValueChange={(value) => { 80 - const validate = monitorJobTypesSchema.safeParse(value); 81 - if (!validate.success) return; 82 - form.setValue("jobType", validate.data, { 83 - shouldDirty: true, 84 - shouldTouch: true, 85 - shouldValidate: true, 86 - }); 87 - }} 88 - > 89 - <TabsList> 90 - <TabsTrigger value="http">HTTP</TabsTrigger> 91 - <TabsTrigger value="tcp">TCP</TabsTrigger> 92 - </TabsList> 93 - <TabsContent value="http"> 94 - <SectionRequestHTTP {...{ form }} /> 95 - </TabsContent> 96 - <TabsContent value="tcp"> 97 - <SectionRequestTCP {...{ form }} /> 98 - </TabsContent> 99 - </Tabs> 100 - ) : null} 101 - {type === "update" 102 - ? (() => { 103 - switch (jobType) { 104 - case "http": 105 - return <SectionRequestHTTP {...{ form }} />; 106 - case "tcp": 107 - return <SectionRequestTCP {...{ form }} />; 108 - default: 109 - return ( 110 - <Alert> 111 - <AlertTitle>Missing Type</AlertTitle> 112 - <AlertDescription> 113 - The job type{" "} 114 - <span className="font-mono text-foreground uppercase"> 115 - {jobType} 116 - </span>{" "} 117 - is missing. Please select a valid job type. 118 - </AlertDescription> 119 - </Alert> 120 - ); 121 - } 122 - })() 123 - : null} 124 - </div> 125 - ); 126 - }
-228
apps/web/src/components/forms/monitor/section-scheduling.tsx
··· 1 - "use client"; 2 - 3 - import { Info } from "lucide-react"; 4 - import type { UseFormReturn } from "react-hook-form"; 5 - 6 - import type { InsertMonitor, WorkspacePlan } from "@openstatus/db/src/schema"; 7 - import { monitorPeriodicitySchema } from "@openstatus/db/src/schema/constants"; 8 - import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 9 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 10 - 11 - import { cn } from "@/lib/utils"; 12 - import { groupByContinent } from "@openstatus/regions"; 13 - import { 14 - FormControl, 15 - FormDescription, 16 - FormField, 17 - FormItem, 18 - FormLabel, 19 - FormMessage, 20 - Select, 21 - SelectContent, 22 - SelectItem, 23 - SelectTrigger, 24 - SelectValue, 25 - } from "@openstatus/ui"; 26 - 27 - import { CheckboxLabel } from "../shared/checkbox-label"; 28 - import { SectionHeader } from "../shared/section-header"; 29 - import { SelectRegion } from "./select-region"; 30 - 31 - // TODO: centralize in a shared file! 32 - const cronJobs = [ 33 - { value: "30s", label: "30 seconds" }, 34 - { value: "1m", label: "1 minute" }, 35 - { value: "5m", label: "5 minutes" }, 36 - { value: "10m", label: "10 minutes" }, 37 - { value: "30m", label: "30 minutes" }, 38 - { value: "1h", label: "1 hour" }, 39 - ] as const; 40 - 41 - interface Props { 42 - form: UseFormReturn<InsertMonitor>; 43 - limits: Limits; 44 - plan: WorkspacePlan; 45 - } 46 - 47 - export function SectionScheduling({ form, limits, plan }: Props) { 48 - const periodicityLimit = getLimit(limits, "periodicity"); 49 - const regionsLimit = getLimit(limits, "regions"); 50 - return ( 51 - <div className="grid w-full gap-4"> 52 - <SectionHeader 53 - title="Schedule and Regions" 54 - description="Customize the period of time and the regions where your endpoint will be monitored." 55 - /> 56 - <div className="grid sm:grid-cols-2 md:grid-cols-3"> 57 - <FormField 58 - control={form.control} 59 - name="periodicity" 60 - render={({ field }) => ( 61 - <FormItem> 62 - <FormLabel>Frequency</FormLabel> 63 - <Select 64 - onValueChange={(value) => 65 - field.onChange(monitorPeriodicitySchema.parse(value)) 66 - } 67 - defaultValue={field.value} 68 - > 69 - <FormControl> 70 - <SelectTrigger className="w-full"> 71 - <SelectValue placeholder="How often should it check your endpoint?" /> 72 - </SelectTrigger> 73 - </FormControl> 74 - <SelectContent> 75 - {cronJobs.map(({ label, value }) => ( 76 - <SelectItem 77 - key={value} 78 - value={value} 79 - disabled={!periodicityLimit.includes(value)} 80 - > 81 - {label} 82 - </SelectItem> 83 - ))} 84 - </SelectContent> 85 - </Select> 86 - <FormDescription> 87 - Frequency of how often your endpoint will be pinged. 88 - </FormDescription> 89 - <FormMessage /> 90 - </FormItem> 91 - )} 92 - /> 93 - </div> 94 - <FormField 95 - control={form.control} 96 - name="regions" 97 - render={({ field }) => { 98 - return ( 99 - <FormItem> 100 - <FormLabel className="text-base">Regions</FormLabel> 101 - <div> 102 - <FormControl> 103 - <SelectRegion 104 - value={field.value} 105 - allowedRegions={regionsLimit} 106 - onChange={field.onChange} 107 - /> 108 - </FormControl> 109 - </div> 110 - <FormDescription> 111 - Select the regions you want to monitor your endpoint from.{" "} 112 - <br /> 113 - <span className="text-xs"> 114 - {plan === "free" 115 - ? "Only a few regions are available in the free plan. Upgrade to access all regions." 116 - : ""} 117 - </span> 118 - </FormDescription> 119 - <FewRegionsBanner form={form} className="max-w-md" /> 120 - <div> 121 - {Object.entries(groupByContinent) 122 - .sort((a, b) => a[0].localeCompare(b[0])) 123 - .map(([continent, regions]) => { 124 - return { continent, regions }; 125 - }) 126 - .map((current) => { 127 - return ( 128 - <div key={current.continent} className="py-2"> 129 - <p className="font-medium text-muted-foreground text-sm"> 130 - {current.continent} 131 - </p> 132 - <div className="grid grid-cols-3 gap-2"> 133 - {current.regions 134 - .sort((a, b) => 135 - a.location.localeCompare(b.location), 136 - ) 137 - .map((item) => { 138 - return ( 139 - <FormField 140 - key={item.code} 141 - control={form.control} 142 - name="regions" 143 - render={({ field }) => { 144 - const { flag, location } = item; 145 - return ( 146 - <FormItem 147 - key={item.code} 148 - className="h-full w-full" 149 - > 150 - <FormControl className="h-full"> 151 - <CheckboxLabel 152 - disabled={ 153 - !regionsLimit.includes(item.code) 154 - } 155 - id={item.code} 156 - name="region" 157 - checked={field.value?.includes( 158 - item.code, 159 - )} 160 - onCheckedChange={(checked) => { 161 - return checked 162 - ? field.onChange([ 163 - ...(field.value 164 - ? field.value 165 - : []), 166 - item.code, 167 - ]) 168 - : field.onChange( 169 - field.value?.filter( 170 - (value) => 171 - value !== item.code, 172 - ), 173 - ); 174 - }} 175 - className="p-3" 176 - > 177 - {location} {flag} 178 - </CheckboxLabel> 179 - </FormControl> 180 - </FormItem> 181 - ); 182 - }} 183 - /> 184 - ); 185 - })} 186 - </div> 187 - </div> 188 - ); 189 - })} 190 - </div> 191 - <FormMessage /> 192 - </FormItem> 193 - ); 194 - }} 195 - /> 196 - </div> 197 - ); 198 - } 199 - 200 - // REMINDER: only watch the regions in a new component to avoid re-renders 201 - function FewRegionsBanner({ 202 - form, 203 - className, 204 - }: Pick<Props, "form"> & { className?: string }) { 205 - const watchRegions = form.watch("regions"); 206 - 207 - if (watchRegions?.length && watchRegions.length > 2) return null; 208 - 209 - return ( 210 - <div 211 - className={cn( 212 - "flex items-center gap-3 rounded-lg border border-border px-3 py-2", 213 - className, 214 - )} 215 - > 216 - <Info 217 - className="-mt-0.5 shrink-0 text-status-monitoring" 218 - size={16} 219 - strokeWidth={2} 220 - aria-hidden="true" 221 - /> 222 - <p className="text-sm"> 223 - To minimize false positives, we recommend monitoring your endpoint in at 224 - least 3 regions. 225 - </p> 226 - </div> 227 - ); 228 - }
-111
apps/web/src/components/forms/monitor/section-status-page.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import type { InsertMonitor, Page } from "@openstatus/db/src/schema"; 6 - import { 7 - FormControl, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - Input, 14 - } from "@openstatus/ui"; 15 - 16 - import { CheckboxLabel } from "../shared/checkbox-label"; 17 - import { SectionHeader } from "../shared/section-header"; 18 - 19 - interface Props { 20 - form: UseFormReturn<InsertMonitor>; 21 - pages?: Page[]; 22 - } 23 - 24 - // FIXME: the `CheckLabel` doesn't take the full space 25 - 26 - export function SectionStatusPage({ form, pages }: Props) { 27 - return ( 28 - <div className="flex w-full flex-col gap-4"> 29 - <SectionHeader 30 - title="Status Page" 31 - description="Customize the informations about your monitor on the corresponding status page." 32 - /> 33 - <FormField 34 - control={form.control} 35 - name="description" 36 - render={({ field }) => ( 37 - <FormItem> 38 - <FormLabel>Description</FormLabel> 39 - <FormControl> 40 - <Input 41 - placeholder="Determines the api health of our services." 42 - {...field} 43 - /> 44 - </FormControl> 45 - <FormDescription> 46 - Provide your users with information about it. 47 - </FormDescription> 48 - <FormMessage /> 49 - </FormItem> 50 - )} 51 - /> 52 - <FormField 53 - control={form.control} 54 - name="pages" 55 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 56 - render={({ field }) => { 57 - return ( 58 - <FormItem> 59 - <div className="mb-4"> 60 - <FormLabel className="text-base">Status Pages</FormLabel> 61 - <FormDescription> 62 - Select the pages where you want to display the monitor. 63 - </FormDescription> 64 - </div> 65 - <div className="grid grid-cols-1 grid-rows-1 gap-4 sm:grid-cols-2 md:grid-cols-3"> 66 - {pages?.map((item) => ( 67 - <FormField 68 - key={item.id} 69 - control={form.control} 70 - name="pages" 71 - render={({ field }) => { 72 - return ( 73 - <FormItem key={item.id} className="h-full w-full"> 74 - <FormControl className="h-full"> 75 - <CheckboxLabel 76 - id={String(item.id)} 77 - name="page" 78 - checked={field.value?.includes(item.id)} 79 - onCheckedChange={(checked) => { 80 - return checked 81 - ? field.onChange([ 82 - ...(field.value ? field.value : []), 83 - item.id, 84 - ]) 85 - : field.onChange( 86 - field.value?.filter( 87 - (value) => value !== item.id, 88 - ), 89 - ); 90 - }} 91 - > 92 - <span>{item.title}</span> 93 - </CheckboxLabel> 94 - </FormControl> 95 - </FormItem> 96 - ); 97 - }} 98 - /> 99 - ))} 100 - </div> 101 - {!pages || pages.length === 0 ? ( 102 - <FormDescription>Missing status pages.</FormDescription> 103 - ) : null} 104 - <FormMessage /> 105 - </FormItem> 106 - ); 107 - }} 108 - /> 109 - </div> 110 - ); 111 - }
-144
apps/web/src/components/forms/monitor/select-region.tsx
··· 1 - "use client"; 2 - 3 - import { Check, ChevronsUpDown, Globe2 } from "lucide-react"; 4 - 5 - import { 6 - type Continent, 7 - type RegionInfo, 8 - formatRegionCode, 9 - regionDict, 10 - } from "@openstatus/regions"; 11 - import { Button, type ButtonProps } from "@openstatus/ui/src/components/button"; 12 - import { 13 - Command, 14 - CommandEmpty, 15 - CommandGroup, 16 - CommandInput, 17 - CommandItem, 18 - CommandList, 19 - CommandSeparator, 20 - } from "@openstatus/ui/src/components/command"; 21 - import { 22 - Popover, 23 - PopoverContent, 24 - PopoverTrigger, 25 - } from "@openstatus/ui/src/components/popover"; 26 - 27 - import { cn } from "@/lib/utils"; 28 - import { 29 - type Region, 30 - monitorRegions, 31 - } from "@openstatus/db/src/schema/constants"; 32 - 33 - interface SelectRegionProps extends Omit<ButtonProps, "onChange"> { 34 - allowedRegions: Region[]; 35 - value?: Region[]; 36 - onChange?: (value: Region[]) => void; 37 - } 38 - 39 - export function SelectRegion({ 40 - value = [], 41 - onChange, 42 - allowedRegions, 43 - className, 44 - ...props 45 - }: SelectRegionProps) { 46 - const regionsByContinent = monitorRegions.reduce( 47 - (prev, curr) => { 48 - const region = regionDict[curr]; 49 - 50 - const item = prev.find((r) => r.continent === region.continent); 51 - 52 - if (item) { 53 - item.data.push(region); 54 - } else { 55 - prev.push({ 56 - continent: region.continent, 57 - data: [region], 58 - }); 59 - } 60 - 61 - return prev; 62 - }, 63 - [] as { continent: Continent; data: RegionInfo[] }[], 64 - ); 65 - 66 - return ( 67 - <Popover> 68 - <PopoverTrigger asChild> 69 - <Button 70 - size="lg" 71 - variant="outline" 72 - className={cn("px-3 shadow-none", className)} 73 - {...props} 74 - > 75 - <Globe2 className="mr-2 h-4 w-4" /> 76 - <span className="whitespace-nowrap"> 77 - <code>{value.length}</code> Regions 78 - </span> 79 - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 80 - </Button> 81 - </PopoverTrigger> 82 - <PopoverContent className="p-0" align="start"> 83 - <Command> 84 - <CommandInput placeholder="Search regions..." /> 85 - <CommandList className="max-h-64"> 86 - <CommandEmpty>No results found.</CommandEmpty> 87 - <CommandGroup> 88 - <CommandItem 89 - onSelect={() => onChange?.(value.length ? [] : allowedRegions)} 90 - > 91 - {value.length ? "Clear all" : "Select all"} 92 - </CommandItem> 93 - </CommandGroup> 94 - <CommandSeparator /> 95 - {regionsByContinent.map(({ continent, data }) => { 96 - return ( 97 - <CommandGroup key={continent} heading={continent}> 98 - {data.map((region) => { 99 - const { code, flag, location, continent } = region; 100 - const isSelected = value.includes(code); 101 - return ( 102 - <CommandItem 103 - key={code} 104 - value={code} 105 - keywords={[code, location, continent]} 106 - disabled={!allowedRegions.includes(code)} 107 - onSelect={(checked) => { 108 - const newValue = !value.includes(checked as Region) 109 - ? [...value, code] 110 - : value.filter((r) => r !== code); 111 - onChange?.(newValue); 112 - }} 113 - > 114 - <div 115 - className={cn( 116 - "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", 117 - isSelected 118 - ? "bg-primary text-primary-foreground" 119 - : "opacity-50 [&_svg]:invisible", 120 - )} 121 - > 122 - <Check className={cn("h-4 w-4")} /> 123 - </div> 124 - <div className="flex w-full justify-between"> 125 - <span> 126 - {formatRegionCode(code)}{" "} 127 - <span className="truncate text-muted-foreground"> 128 - {location} 129 - </span> 130 - </span> 131 - <span>{flag}</span> 132 - </div> 133 - </CommandItem> 134 - ); 135 - })} 136 - </CommandGroup> 137 - ); 138 - })} 139 - </CommandList> 140 - </Command> 141 - </PopoverContent> 142 - </Popover> 143 - ); 144 - }
-397
apps/web/src/components/forms/monitor/tags-multi-box.tsx
··· 1 - "use client"; 2 - 3 - import { Check, ChevronsUpDown, Edit2 } from "lucide-react"; 4 - import * as React from "react"; 5 - 6 - import type { MonitorTag } from "@openstatus/db/src/schema"; 7 - import { 8 - Accordion, 9 - AccordionContent, 10 - AccordionItem, 11 - AccordionTrigger, 12 - AlertDialog, 13 - AlertDialogAction, 14 - AlertDialogCancel, 15 - AlertDialogContent, 16 - AlertDialogDescription, 17 - AlertDialogFooter, 18 - AlertDialogHeader, 19 - AlertDialogTitle, 20 - AlertDialogTrigger, 21 - Button, 22 - Command, 23 - CommandGroup, 24 - CommandInput, 25 - CommandItem, 26 - CommandList, 27 - CommandSeparator, 28 - Dialog, 29 - DialogContent, 30 - DialogDescription, 31 - DialogHeader, 32 - DialogTitle, 33 - Input, 34 - Label, 35 - Popover, 36 - PopoverContent, 37 - PopoverTrigger, 38 - } from "@openstatus/ui"; 39 - 40 - import { Icons } from "@/components/icons"; 41 - import { LoadingAnimation } from "@/components/loading-animation"; 42 - import { TagBadge } from "@/components/monitor/tag-badge"; 43 - import { toastAction } from "@/lib/toast"; 44 - import { cn } from "@/lib/utils"; 45 - import { api } from "@/trpc/client"; 46 - 47 - const colors = [ 48 - "#ff5c5c", // Red 49 - "#6fcf97", // Green 50 - "#70a1ff", // Blue 51 - "#ffb74d", // Orange 52 - "#b19cd9", // Violet 53 - "#7986cb", // Indigo 54 - "#64b5f6", // Turquoise 55 - "#ffee58", // Yellow 56 - "#f06292", // Pink 57 - "#ff77ff", // Fuchsia 58 - "#808080", // Gray 59 - ]; 60 - 61 - interface TagsMultiBoxProps { 62 - tags?: MonitorTag[]; 63 - values: number[]; // values from the form 64 - onChange: (values: number[]) => void; 65 - } 66 - 67 - export function TagsMultiBox({ 68 - tags: defaultTags = [], 69 - values, 70 - onChange, 71 - }: TagsMultiBoxProps) { 72 - const inputRef = React.useRef<HTMLInputElement>(null); 73 - const [tags, setTags] = React.useState<MonitorTag[]>(defaultTags); 74 - const [openCombobox, setOpenCombobox] = React.useState(false); 75 - const [openDialog, setOpenDialog] = React.useState(false); 76 - const [inputValue, setInputValue] = React.useState<string>(""); 77 - 78 - const create = async (name: string) => { 79 - try { 80 - const randomIndex = Math.floor(Math.random() * colors.length); 81 - const newTag = await api.monitorTag.create.mutate({ 82 - name: name.trim(), 83 - color: colors[randomIndex] || "#808080", // gray as default 84 - }); 85 - toastAction("saved"); 86 - setTags((prev) => [...prev, newTag]); 87 - // TODO: seems like the new value is not taken into account.... 88 - // That's mainly because we only update the id, and not the name! Same is for the update() function 89 - onChange([...values, newTag.id]); 90 - } catch { 91 - toastAction("error"); 92 - } 93 - }; 94 - 95 - const toggle = (item: MonitorTag) => { 96 - onChange( 97 - values?.includes(item.id) 98 - ? values.filter((v) => v !== item.id) 99 - : [...values, item.id], 100 - ); 101 - inputRef?.current?.focus(); 102 - }; 103 - 104 - const update = async (tag: MonitorTag) => { 105 - try { 106 - const updateTag = await api.monitorTag.update.mutate(tag); 107 - if (!updateTag) return; 108 - setTags((prev) => prev.map((f) => (f.id === tag.id ? updateTag : f))); 109 - toastAction("saved"); 110 - } catch { 111 - toastAction("error"); 112 - } 113 - }; 114 - 115 - const _delete = async (item: MonitorTag) => { 116 - try { 117 - await api.monitorTag.delete.mutate({ id: item.id }); 118 - setTags((prev) => prev.filter((f) => f.id !== item.id)); 119 - onChange(values?.filter((v) => v !== item.id)); 120 - toastAction("deleted"); 121 - } catch { 122 - toastAction("error"); 123 - } 124 - }; 125 - 126 - const onComboboxOpenChange = (value: boolean) => { 127 - inputRef.current?.blur(); // HACK: otherwise, would scroll automatically to the bottom of page 128 - setOpenCombobox(value); 129 - }; 130 - 131 - return ( 132 - <div className="w-full"> 133 - <Popover open={openCombobox} onOpenChange={onComboboxOpenChange}> 134 - <PopoverTrigger asChild> 135 - <Button 136 - variant="outline" 137 - role="combobox" 138 - aria-expanded={openCombobox} 139 - className="h-auto w-full justify-between text-foreground" 140 - > 141 - <span className="flex flex-wrap gap-2 truncate"> 142 - {values.length > 0 143 - ? values.map((id) => { 144 - const tag = tags.find((tag) => tag.id === id); 145 - return tag ? <TagBadge key={tag.id} {...tag} /> : null; 146 - }) 147 - : "Select tags"} 148 - </span> 149 - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 150 - </Button> 151 - </PopoverTrigger> 152 - <PopoverContent className="w-full p-0"> 153 - <Command className="w-(--radix-popover-trigger-width)" loop> 154 - <CommandInput 155 - // biome-ignore lint/suspicious/noExplicitAny: <explanation> 156 - ref={inputRef as any} 157 - placeholder="Search tag..." 158 - value={inputValue} 159 - onValueChange={setInputValue} 160 - /> 161 - <CommandList> 162 - <CommandGroup className="max-h-[145px] overflow-auto"> 163 - {tags.map((item) => { 164 - const isActive = values?.includes(item.id); 165 - return ( 166 - <CommandItem 167 - key={item.id} 168 - value={item.name} 169 - onSelect={() => toggle(item)} 170 - > 171 - <Check 172 - className={cn( 173 - "mr-2 h-4 w-4", 174 - isActive ? "opacity-100" : "opacity-0", 175 - )} 176 - /> 177 - <div className="flex-1">{item.name}</div> 178 - <div 179 - className="h-4 w-4 rounded-full" 180 - style={{ backgroundColor: item.color }} 181 - /> 182 - </CommandItem> 183 - ); 184 - })} 185 - <CommandItemCreate 186 - onSelect={() => create(inputValue)} 187 - {...{ inputValue, tags }} 188 - /> 189 - </CommandGroup> 190 - <CommandSeparator alwaysRender /> 191 - <CommandGroup> 192 - <CommandItem 193 - value={`:${inputValue}:`} // HACK: that way, the edit button will always be shown 194 - className="text-muted-foreground text-xs" 195 - onSelect={() => setOpenDialog(true)} 196 - > 197 - <div className={cn("mr-2 h-4 w-4")} /> 198 - <Edit2 className="mr-2 h-2.5 w-2.5" /> 199 - Edit tags 200 - </CommandItem> 201 - </CommandGroup> 202 - </CommandList> 203 - </Command> 204 - </PopoverContent> 205 - </Popover> 206 - <Dialog 207 - open={openDialog} 208 - onOpenChange={(open) => { 209 - if (!open) setOpenCombobox(true); 210 - setOpenDialog(open); 211 - }} 212 - > 213 - <DialogContent className="flex max-h-[90vh] flex-col"> 214 - <DialogHeader> 215 - <DialogTitle>Edit Tags</DialogTitle> 216 - <DialogDescription> 217 - Update or delete tags. Create a tag through the combobox. 218 - </DialogDescription> 219 - </DialogHeader> 220 - <div className="flex-1 overflow-scroll"> 221 - {tags.map((item) => { 222 - return ( 223 - <DialogListItem 224 - key={item.id} 225 - onDelete={() => _delete(item)} 226 - onSubmit={async (values) => { 227 - await update({ 228 - ...item, 229 - ...values, 230 - }); 231 - }} 232 - {...item} 233 - /> 234 - ); 235 - })} 236 - </div> 237 - </DialogContent> 238 - </Dialog> 239 - </div> 240 - ); 241 - } 242 - 243 - const CommandItemCreate = ({ 244 - inputValue, 245 - tags, 246 - onSelect, 247 - }: { 248 - inputValue: string; 249 - tags: MonitorTag[]; 250 - onSelect: () => Promise<void>; 251 - }) => { 252 - const [isPending, startTransition] = React.useTransition(); 253 - const hasNoTag = !tags 254 - .map(({ name }) => name.toLowerCase()) 255 - .includes(`${inputValue.toLowerCase()}`); 256 - 257 - const render = inputValue !== "" && hasNoTag; 258 - 259 - if (!render) return null; 260 - 261 - // BUG: whenever a space is appended, the Create-Button will not be shown. 262 - return ( 263 - <CommandItem 264 - key={`${inputValue}`} 265 - value={`${inputValue}`} 266 - className="text-muted-foreground text-xs" 267 - onSelect={() => { 268 - startTransition(async () => { 269 - await onSelect(); 270 - }); 271 - }} 272 - disabled={isPending} 273 - > 274 - <div className={cn("mr-2 h-4 w-4")} /> 275 - {isPending ? "Creating" : "Create"} new label &quot;{inputValue}&quot; 276 - </CommandItem> 277 - ); 278 - }; 279 - 280 - const DialogListItem = ({ 281 - id, 282 - name, 283 - color, 284 - onSubmit, 285 - onDelete, 286 - }: MonitorTag & { 287 - onSubmit: (values: { name: string; color: string }) => Promise<void>; 288 - onDelete: () => Promise<void>; 289 - }) => { 290 - const inputRef = React.useRef<HTMLInputElement>(null); 291 - const [accordionValue, setAccordionValue] = React.useState<string>(""); 292 - const [inputValue, setInputValue] = React.useState<string>(name); 293 - const [colorValue, setColorValue] = React.useState<string>(color); 294 - const [isPending, startTransition] = React.useTransition(); 295 - const disabled = name === inputValue && color === colorValue; 296 - 297 - React.useEffect(() => { 298 - if (accordionValue !== "") { 299 - inputRef.current?.focus(); 300 - } 301 - }, [accordionValue]); 302 - 303 - // const handleSubmit = () => {} 304 - // const handleDelete = () => {} 305 - 306 - return ( 307 - <Accordion 308 - key={id} 309 - type="single" // will never work as we have only one accordion for each tag 310 - collapsible 311 - value={accordionValue} 312 - onValueChange={setAccordionValue} 313 - > 314 - <AccordionItem value={`item-${id}`}> 315 - <AccordionTrigger className="w-full hover:no-underline"> 316 - <TagBadge color={color} name={name} /> 317 - </AccordionTrigger> 318 - <AccordionContent> 319 - {/* REMINDER: cannot nest form within form! HOTFIX: no form */} 320 - <div className="flex items-end gap-4 px-1"> 321 - <div className="grid w-full gap-3"> 322 - <Label htmlFor="name">Label name</Label> 323 - <Input 324 - ref={inputRef} 325 - id="name" 326 - value={inputValue} 327 - onChange={(e) => setInputValue(e.target.value)} 328 - className="h-8" 329 - /> 330 - </div> 331 - <div className="grid gap-3"> 332 - <Label htmlFor="color">Color</Label> 333 - <Input 334 - id="color" 335 - type="color" 336 - value={colorValue} 337 - onChange={(e) => setColorValue(e.target.value)} 338 - className="h-8 px-2 py-1" 339 - /> 340 - </div> 341 - {/* FIXME: shouldnt saves the monitor form */} 342 - <Button 343 - onClick={() => { 344 - startTransition(() => { 345 - onSubmit({ 346 - name: inputValue.trim(), 347 - color: colorValue, 348 - }); 349 - setAccordionValue(""); 350 - }); 351 - }} 352 - disabled={disabled || isPending} 353 - > 354 - {isPending ? <LoadingAnimation /> : "Save"} 355 - </Button> 356 - <div className="flex items-center gap-4"> 357 - <AlertDialog> 358 - <AlertDialogTrigger asChild> 359 - <Button 360 - size="icon" 361 - variant="destructive" 362 - disabled={isPending} 363 - > 364 - <Icons.trash className="h-4 w-4" /> 365 - </Button> 366 - </AlertDialogTrigger> 367 - <AlertDialogContent> 368 - <AlertDialogHeader> 369 - <AlertDialogTitle>Are you sure sure?</AlertDialogTitle> 370 - <AlertDialogDescription asChild> 371 - <div> 372 - You are about to delete the tag{" "} 373 - <TagBadge color={color} name={name} /> . 374 - </div> 375 - </AlertDialogDescription> 376 - </AlertDialogHeader> 377 - <AlertDialogFooter> 378 - <AlertDialogCancel>Cancel</AlertDialogCancel> 379 - <AlertDialogAction 380 - onClick={() => { 381 - startTransition(async () => { 382 - await onDelete(); 383 - }); 384 - }} 385 - > 386 - {isPending ? <LoadingAnimation /> : "Delete"} 387 - </AlertDialogAction> 388 - </AlertDialogFooter> 389 - </AlertDialogContent> 390 - </AlertDialog> 391 - </div> 392 - </div> 393 - </AccordionContent> 394 - </AccordionItem> 395 - </Accordion> 396 - ); 397 - };
-138
apps/web/src/components/forms/notification/form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import { useTransition } from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import type { 9 - InsertNotification, 10 - InsertNotificationWithData, 11 - Monitor, 12 - NotificationProvider, 13 - WorkspacePlan, 14 - } from "@openstatus/db/src/schema"; 15 - import { InsertNotificationWithDataSchema } from "@openstatus/db/src/schema"; 16 - import { Badge, Form } from "@openstatus/ui"; 17 - 18 - import { 19 - Tabs, 20 - TabsContent, 21 - TabsList, 22 - TabsTrigger, 23 - } from "@/components/dashboard/tabs"; 24 - import { toast, toastAction } from "@/lib/toast"; 25 - import { api } from "@/trpc/client"; 26 - import { TRPCClientError } from "@trpc/client"; 27 - import { SaveButton } from "../shared/save-button"; 28 - import { General } from "./general"; 29 - import { SectionConnect } from "./section-connect"; 30 - 31 - interface Props { 32 - defaultValues?: InsertNotification; 33 - defaultSection?: string; 34 - onSubmit?: () => void; 35 - monitors?: Monitor[]; 36 - workspacePlan: WorkspacePlan; 37 - nextUrl?: string; 38 - provider: NotificationProvider; 39 - callbackData?: string; 40 - } 41 - 42 - export function NotificationForm({ 43 - defaultValues, 44 - defaultSection = "connect", 45 - onSubmit: onExternalSubmit, 46 - workspacePlan, 47 - monitors, 48 - nextUrl, 49 - provider, 50 - callbackData, 51 - }: Props) { 52 - const [isPending, startTransition] = useTransition(); 53 - const router = useRouter(); 54 - const form = useForm<InsertNotificationWithData>({ 55 - resolver: zodResolver(InsertNotificationWithDataSchema), 56 - defaultValues: { 57 - ...defaultValues, 58 - provider, 59 - name: defaultValues?.name || "", 60 - data: JSON.parse(defaultValues?.data || "{}"), 61 - }, 62 - }); 63 - 64 - async function onSubmit({ 65 - provider, 66 - data, 67 - ...rest 68 - }: InsertNotificationWithData) { 69 - console.log({ provider, data, ...rest }); 70 - startTransition(async () => { 71 - try { 72 - if (provider === "pagerduty") { 73 - if (callbackData) { 74 - data.pagerduty = callbackData; 75 - } 76 - } 77 - if (defaultValues) { 78 - await api.notification.update.mutate({ 79 - provider, 80 - data: JSON.stringify(data), 81 - ...rest, 82 - }); 83 - } else { 84 - await api.notification.create.mutate({ 85 - provider, 86 - data: JSON.stringify(data), 87 - ...rest, 88 - }); 89 - } 90 - if (nextUrl) { 91 - router.push(nextUrl); 92 - } 93 - router.refresh(); 94 - toastAction("saved"); 95 - } catch (e) { 96 - if (e instanceof TRPCClientError) toast.error(e.message); 97 - else toastAction("error"); 98 - } finally { 99 - onExternalSubmit?.(); 100 - } 101 - }); 102 - } 103 - 104 - return ( 105 - <Form {...form}> 106 - <form 107 - onSubmit={form.handleSubmit(onSubmit)} 108 - id="notification-form" // we use a form id to connect the submit button to the form (as we also have the form nested inside of `MonitorForm`) 109 - className="flex flex-col gap-4" 110 - > 111 - <General form={form} plan={workspacePlan} /> 112 - <Tabs defaultValue={defaultSection} className="w-full"> 113 - <TabsList> 114 - <TabsTrigger value="connect"> 115 - Connect{" "} 116 - {defaultValues?.monitors.length ? ( 117 - <Badge variant="secondary" className="ml-1"> 118 - {defaultValues?.monitors.length} 119 - </Badge> 120 - ) : null} 121 - </TabsTrigger> 122 - </TabsList> 123 - <TabsContent value="connect"> 124 - <SectionConnect form={form} monitors={monitors} /> 125 - </TabsContent> 126 - </Tabs> 127 - <div className="flex gap-4 sm:justify-end"> 128 - <SaveButton 129 - form="notification-form" 130 - isPending={isPending} 131 - isDirty={form.formState.isDirty} 132 - onSubmit={form.handleSubmit(onSubmit)} 133 - /> 134 - </div> 135 - </form> 136 - </Form> 137 - ); 138 - }
-96
apps/web/src/components/forms/notification/general.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import type { 6 - InsertNotificationWithData, 7 - WorkspacePlan, 8 - } from "@openstatus/db/src/schema"; 9 - import { 10 - FormControl, 11 - FormDescription, 12 - FormField, 13 - FormItem, 14 - FormLabel, 15 - FormMessage, 16 - Input, 17 - } from "@openstatus/ui"; 18 - 19 - import { SectionHeader } from "../shared/section-header"; 20 - import { SectionDiscord } from "./provider/section-discord"; 21 - import { SectionEmail } from "./provider/section-email"; 22 - import { SectionNtfy } from "./provider/section-ntfy"; 23 - import { SectionOpsGenie } from "./provider/section-opsgenie"; 24 - import { SectionPagerDuty } from "./provider/section-pagerduty"; 25 - import { SectionSlack } from "./provider/section-slack"; 26 - import { SectionSms } from "./provider/section-sms"; 27 - import { SectionWebhook } from "./provider/section-webhook"; 28 - 29 - const LABELS = { 30 - slack: "Slack", 31 - discord: "Discord", 32 - sms: "SMS", 33 - pagerduty: "PagerDuty", 34 - opsgenie: "OpsGenie", 35 - email: "Email", 36 - ntfy: "Ntfy.sh", 37 - webhook: "Webhook", 38 - }; 39 - 40 - interface Props { 41 - form: UseFormReturn<InsertNotificationWithData>; 42 - plan: WorkspacePlan; 43 - } 44 - 45 - export function General({ form, plan }: Props) { 46 - const watchProvider = form.watch("provider"); 47 - 48 - function renderProviderSection() { 49 - switch (watchProvider) { 50 - case "slack": 51 - return <SectionSlack form={form} />; 52 - case "discord": 53 - return <SectionDiscord form={form} />; 54 - case "sms": 55 - return <SectionSms form={form} />; 56 - case "pagerduty": 57 - return <SectionPagerDuty form={form} plan={plan} />; 58 - case "opsgenie": 59 - return <SectionOpsGenie form={form} plan={plan} />; 60 - case "email": 61 - return <SectionEmail form={form} />; 62 - case "ntfy": 63 - return <SectionNtfy form={form} />; 64 - case "webhook": 65 - return <SectionWebhook form={form} />; 66 - default: 67 - return <div>No provider selected</div>; 68 - } 69 - } 70 - 71 - return ( 72 - <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 73 - <SectionHeader 74 - title="Alert" 75 - description={`Update your ${LABELS[watchProvider]} settings`} 76 - /> 77 - <div className="grid gap-4 sm:col-span-2 sm:grid-cols-2"> 78 - <FormField 79 - control={form.control} 80 - name="name" 81 - render={({ field }) => ( 82 - <FormItem className="sm:col-span-1 sm:self-baseline"> 83 - <FormLabel>Name</FormLabel> 84 - <FormControl> 85 - <Input placeholder="Dev Team" {...field} /> 86 - </FormControl> 87 - <FormDescription>Define a name for the channel.</FormDescription> 88 - <FormMessage /> 89 - </FormItem> 90 - )} 91 - /> 92 - {renderProviderSection()} 93 - </div> 94 - </div> 95 - ); 96 - }
-48
apps/web/src/components/forms/notification/provider/actions.ts
··· 1 - "use server"; 2 - 3 - import { sendTest as sendOpsGenieAlert } from "@openstatus/notification-opsgenie"; 4 - 5 - import { sendTest as sendPagerDutyAlert } from "@openstatus/notification-pagerduty"; 6 - 7 - import { sendTest as sendNtfyAlert } from "@openstatus/notification-ntfy"; 8 - 9 - import { sendTest as sendWebhookAlert } from "@openstatus/notification-webhook"; 10 - 11 - export async function sendOpsGenieTestAlert( 12 - apiKey: string, 13 - region: "us" | "eu", 14 - ) { 15 - const isSuccessfull = await sendOpsGenieAlert({ apiKey, region }); 16 - return isSuccessfull; 17 - } 18 - 19 - export async function sendPagerDutyTestAlert(integrationKey: string) { 20 - const isSuccessfull = await sendPagerDutyAlert({ 21 - integrationKey: integrationKey, 22 - }); 23 - return isSuccessfull; 24 - } 25 - 26 - export async function sendNtfyTestAlert({ 27 - topic, 28 - serverUrl, 29 - token, 30 - }: { 31 - topic: string; 32 - serverUrl?: string; 33 - token?: string; 34 - }) { 35 - const isSuccessfull = await sendNtfyAlert({ topic, serverUrl, token }); 36 - return isSuccessfull; 37 - } 38 - 39 - export async function sendWebhookTestAlert({ 40 - url, 41 - headers, 42 - }: { 43 - url: string; 44 - headers?: { key: string; value: string }[]; 45 - }) { 46 - const isSuccessfull = await sendWebhookAlert({ url, headers }); 47 - return isSuccessfull; 48 - }
-90
apps/web/src/components/forms/notification/provider/section-discord.tsx
··· 1 - "use client"; 2 - 3 - import { useTransition } from "react"; 4 - import type { UseFormReturn } from "react-hook-form"; 5 - 6 - import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 7 - import { 8 - Button, 9 - FormControl, 10 - FormDescription, 11 - FormField, 12 - FormItem, 13 - FormLabel, 14 - FormMessage, 15 - Input, 16 - } from "@openstatus/ui"; 17 - 18 - import { LoadingAnimation } from "@/components/loading-animation"; 19 - import { toastAction } from "@/lib/toast"; 20 - import { sendTestDiscordMessage } from "@openstatus/notification-discord"; 21 - 22 - interface Props { 23 - form: UseFormReturn<InsertNotificationWithData>; 24 - } 25 - 26 - export function SectionDiscord({ form }: Props) { 27 - const [isTestPending, startTestTransition] = useTransition(); 28 - const watchUrl = form.watch("data.discord"); 29 - 30 - async function sendTestWebhookPing() { 31 - if (!watchUrl) return; 32 - startTestTransition(async () => { 33 - const isSuccessfull = await sendTestDiscordMessage(watchUrl); 34 - if (isSuccessfull) { 35 - toastAction("test-success"); 36 - } else { 37 - toastAction("test-error"); 38 - } 39 - }); 40 - } 41 - 42 - return ( 43 - <> 44 - <FormField 45 - control={form.control} 46 - name="data.discord" 47 - render={({ field }) => ( 48 - <FormItem className="sm:col-span-full"> 49 - <FormLabel>Webhook URL</FormLabel> 50 - <FormControl> 51 - <Input 52 - type="url" 53 - placeholder="https://discord.com/api/webhooks/{channelId}/xxx..." 54 - required 55 - {...field} 56 - /> 57 - </FormControl> 58 - <FormDescription className="flex items-center justify-between"> 59 - The data is required. 60 - <a 61 - href={"https://support.discord.com/hc/en-us/articles/228383668"} 62 - target="_blank" 63 - className="underline hover:no-underline" 64 - rel="noreferrer" 65 - > 66 - How to setup your Discord webhook 67 - </a> 68 - </FormDescription> 69 - <FormMessage /> 70 - </FormItem> 71 - )} 72 - /> 73 - <div className="col-span-full text-right"> 74 - <Button 75 - type="button" 76 - variant="secondary" 77 - className="w-full sm:w-auto" 78 - disabled={isTestPending || !watchUrl} 79 - onClick={sendTestWebhookPing} 80 - > 81 - {!isTestPending ? ( 82 - "Test Webhook" 83 - ) : ( 84 - <LoadingAnimation variant="inverse" /> 85 - )} 86 - </Button> 87 - </div> 88 - </> 89 - ); 90 - }
-44
apps/web/src/components/forms/notification/provider/section-email.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 6 - import { 7 - FormControl, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - Input, 14 - } from "@openstatus/ui"; 15 - 16 - interface Props { 17 - form: UseFormReturn<InsertNotificationWithData>; 18 - } 19 - 20 - export function SectionEmail({ form }: Props) { 21 - return ( 22 - <FormField 23 - control={form.control} 24 - name="data.email" 25 - render={({ field }) => ( 26 - <FormItem className="sm:col-span-full"> 27 - <FormLabel>Email</FormLabel> 28 - <FormControl> 29 - <Input 30 - type="email" 31 - placeholder="dev@documenso.com" 32 - required 33 - {...field} 34 - /> 35 - </FormControl> 36 - <FormDescription className="flex items-center justify-between"> 37 - The email is required. 38 - </FormDescription> 39 - <FormMessage /> 40 - </FormItem> 41 - )} 42 - /> 43 - ); 44 - }
-105
apps/web/src/components/forms/notification/provider/section-ntfy.tsx
··· 1 - "use client"; 2 - 3 - import { toastAction } from "@/lib/toast"; 4 - import type { UseFormReturn } from "react-hook-form"; 5 - 6 - import { LoadingAnimation } from "@/components/loading-animation"; 7 - import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 8 - import { 9 - Button, 10 - FormControl, 11 - FormField, 12 - FormItem, 13 - FormLabel, 14 - FormMessage, 15 - Input, 16 - } from "@openstatus/ui"; 17 - import { useTransition } from "react"; 18 - import { sendNtfyTestAlert } from "./actions"; 19 - 20 - interface Props { 21 - form: UseFormReturn<InsertNotificationWithData>; 22 - } 23 - 24 - export function SectionNtfy({ form }: Props) { 25 - const [isTestPending, startTestTransition] = useTransition(); 26 - 27 - const watchTopic = form.watch("data.ntfy.topic"); 28 - const watchUrl = form.watch("data.ntfy.serverUrl"); 29 - const watchToken = form.watch("data.ntfy.token"); 30 - 31 - async function sendTestAlert() { 32 - if (!watchTopic) return; 33 - startTestTransition(async () => { 34 - const isSuccessfull = await sendNtfyTestAlert({ 35 - topic: watchTopic, 36 - serverUrl: watchUrl || undefined, 37 - token: watchToken || undefined, 38 - }); 39 - if (isSuccessfull) { 40 - toastAction("test-success"); 41 - } else { 42 - toastAction("test-error"); 43 - } 44 - }); 45 - } 46 - 47 - return ( 48 - <> 49 - <FormField 50 - control={form.control} 51 - name="data.ntfy.topic" 52 - render={({ field }) => ( 53 - <FormItem className="sm:col-span-full"> 54 - <FormLabel>Topic</FormLabel> 55 - <FormControl> 56 - <Input type="text" placeholder="your-topic" required {...field} /> 57 - </FormControl> 58 - <FormMessage /> 59 - </FormItem> 60 - )} 61 - /> 62 - <FormField 63 - control={form.control} 64 - name="data.ntfy.serverUrl" 65 - render={({ field }) => ( 66 - <FormItem className="sm:col-span-full"> 67 - <FormLabel>URL</FormLabel> 68 - <FormControl> 69 - <Input type="url" placeholder="https://ntfy.sh" {...field} /> 70 - </FormControl> 71 - <FormMessage /> 72 - </FormItem> 73 - )} 74 - /> 75 - <FormField 76 - control={form.control} 77 - name="data.ntfy.token" 78 - render={({ field }) => ( 79 - <FormItem className="sm:col-span-full"> 80 - <FormLabel>Bearer Token</FormLabel> 81 - <FormControl> 82 - <Input type="url" placeholder="tk_iloveopenstatus" {...field} /> 83 - </FormControl> 84 - <FormMessage /> 85 - </FormItem> 86 - )} 87 - /> 88 - <div className="col-span-full text-right"> 89 - <Button 90 - type="button" 91 - variant="secondary" 92 - className="w-full sm:w-auto" 93 - disabled={isTestPending || !watchTopic} 94 - onClick={sendTestAlert} 95 - > 96 - {!isTestPending ? ( 97 - "Test Webhook" 98 - ) : ( 99 - <LoadingAnimation variant="inverse" /> 100 - )} 101 - </Button> 102 - </div> 103 - </> 104 - ); 105 - }
-124
apps/web/src/components/forms/notification/provider/section-opsgenie.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import { LoadingAnimation } from "@/components/loading-animation"; 6 - import { toastAction } from "@/lib/toast"; 7 - import type { 8 - InsertNotificationWithData, 9 - WorkspacePlan, 10 - } from "@openstatus/db/src/schema"; 11 - import { 12 - Button, 13 - FormControl, 14 - FormDescription, 15 - FormField, 16 - FormItem, 17 - FormLabel, 18 - FormMessage, 19 - Input, 20 - Select, 21 - SelectContent, 22 - SelectItem, 23 - SelectTrigger, 24 - SelectValue, 25 - } from "@openstatus/ui"; 26 - import { useTransition } from "react"; 27 - 28 - import { sendOpsGenieTestAlert } from "./actions"; 29 - 30 - interface Props { 31 - form: UseFormReturn<InsertNotificationWithData>; 32 - plan: WorkspacePlan; 33 - } 34 - 35 - export function SectionOpsGenie({ form }: Props) { 36 - const [isTestPending, startTestTransition] = useTransition(); 37 - const watchApiKey = form.watch("data.opsgenie.apiKey"); 38 - const watchRegion = form.watch("data.opsgenie.region"); 39 - 40 - async function sendTestAlert() { 41 - if (!watchApiKey || !watchRegion) return; 42 - startTestTransition(async () => { 43 - const isSuccessfull = await sendOpsGenieTestAlert( 44 - watchApiKey, 45 - watchRegion, 46 - ); 47 - if (isSuccessfull) { 48 - toastAction("test-success"); 49 - } else { 50 - toastAction("test-error"); 51 - } 52 - }); 53 - } 54 - 55 - return ( 56 - <> 57 - <FormField 58 - control={form.control} 59 - name="data.opsgenie.apiKey" 60 - render={({ field }) => ( 61 - <FormItem className="sm:col-span-full"> 62 - <FormLabel>API Key</FormLabel> 63 - <FormControl> 64 - <Input placeholder={"xxx-yyy-zzz"} {...field} /> 65 - </FormControl> 66 - <FormDescription className="flex items-center justify-between"> 67 - The API key is required. 68 - </FormDescription> 69 - <FormMessage /> 70 - </FormItem> 71 - )} 72 - /> 73 - <FormField 74 - control={form.control} 75 - name="data.opsgenie.region" 76 - render={({ field }) => ( 77 - <FormItem className="sm:col-span-full"> 78 - <FormLabel>Region</FormLabel> 79 - <FormControl> 80 - <Select onValueChange={field.onChange} defaultValue={field.value}> 81 - <FormControl> 82 - <SelectTrigger> 83 - <SelectValue placeholder="Select a region" /> 84 - </SelectTrigger> 85 - </FormControl> 86 - <SelectContent> 87 - <SelectItem value="us">US</SelectItem> 88 - <SelectItem value="eu">EU</SelectItem> 89 - </SelectContent> 90 - </Select> 91 - </FormControl> 92 - <FormDescription className="flex items-center justify-between"> 93 - The region is required. 94 - <a 95 - href="https://docs.openstatus.dev/synthetic/features/notification/opsgenie" 96 - target="_blank" 97 - className="underline hover:no-underline" 98 - rel="noreferrer" 99 - > 100 - How to setup your OpsGenie. 101 - </a> 102 - </FormDescription> 103 - <FormMessage /> 104 - </FormItem> 105 - )} 106 - /> 107 - <div className="col-span-full text-right"> 108 - <Button 109 - type="button" 110 - variant="secondary" 111 - className="w-full sm:w-auto" 112 - disabled={isTestPending || !watchApiKey || !watchRegion} 113 - onClick={sendTestAlert} 114 - > 115 - {!isTestPending ? ( 116 - "Test Alert" 117 - ) : ( 118 - <LoadingAnimation variant="inverse" /> 119 - )} 120 - </Button> 121 - </div> 122 - </> 123 - ); 124 - }
-65
apps/web/src/components/forms/notification/provider/section-pagerduty.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import { LoadingAnimation } from "@/components/loading-animation"; 6 - import { toastAction } from "@/lib/toast"; 7 - import type { 8 - InsertNotificationWithData, 9 - WorkspacePlan, 10 - } from "@openstatus/db/src/schema"; 11 - import { PagerDutySchema } from "@openstatus/notification-pagerduty"; 12 - import { Button } from "@openstatus/ui"; 13 - import { useSearchParams } from "next/navigation"; 14 - import { useTransition } from "react"; 15 - import { sendPagerDutyTestAlert } from "./actions"; 16 - 17 - interface Props { 18 - form: UseFormReturn<InsertNotificationWithData>; 19 - plan: WorkspacePlan; 20 - } 21 - 22 - export function SectionPagerDuty({ form }: Props) { 23 - const [isTestPending, startTestTransition] = useTransition(); 24 - const searchParams = useSearchParams(); 25 - 26 - const config = searchParams.get("config") || form.getValues("data.pagerduty"); 27 - 28 - const result = PagerDutySchema.safeParse(JSON.parse(config || "")); 29 - 30 - if (result.success === false) { 31 - return null; 32 - } 33 - 34 - const data = result.data; 35 - 36 - if (config) { 37 - form.setValue("data.pagerduty", config); 38 - } 39 - 40 - async function sendTestAlert() { 41 - startTestTransition(async () => { 42 - const isSuccessfull = await sendPagerDutyTestAlert( 43 - data.integration_keys[0].integration_key, 44 - ); 45 - if (isSuccessfull) { 46 - toastAction("test-success"); 47 - } else { 48 - toastAction("test-error"); 49 - } 50 - }); 51 - } 52 - 53 - return ( 54 - <div className="col-span-full text-right"> 55 - <Button 56 - type="button" 57 - variant="secondary" 58 - className="w-full sm:w-auto" 59 - onClick={sendTestAlert} 60 - > 61 - {!isTestPending ? "Test Alert" : <LoadingAnimation variant="inverse" />} 62 - </Button> 63 - </div> 64 - ); 65 - }
-92
apps/web/src/components/forms/notification/provider/section-slack.tsx
··· 1 - "use client"; 2 - 3 - import { useTransition } from "react"; 4 - import type { UseFormReturn } from "react-hook-form"; 5 - 6 - import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 7 - import { 8 - Button, 9 - FormControl, 10 - FormDescription, 11 - FormField, 12 - FormItem, 13 - FormLabel, 14 - FormMessage, 15 - Input, 16 - } from "@openstatus/ui"; 17 - 18 - import { LoadingAnimation } from "@/components/loading-animation"; 19 - import { toastAction } from "@/lib/toast"; 20 - import { sendTestSlackMessage } from "@openstatus/notification-slack"; 21 - 22 - interface Props { 23 - form: UseFormReturn<InsertNotificationWithData>; 24 - } 25 - 26 - export function SectionSlack({ form }: Props) { 27 - const [isTestPending, startTestTransition] = useTransition(); 28 - const watchUrl = form.watch("data.slack"); 29 - 30 - async function sendTestWebhookPing() { 31 - if (!watchUrl) return; 32 - startTestTransition(async () => { 33 - const isSuccessfull = await sendTestSlackMessage(watchUrl); 34 - if (isSuccessfull) { 35 - toastAction("test-success"); 36 - } else { 37 - toastAction("test-error"); 38 - } 39 - }); 40 - } 41 - 42 - return ( 43 - <> 44 - <FormField 45 - control={form.control} 46 - name="data.slack" 47 - render={({ field }) => ( 48 - <FormItem className="sm:col-span-full"> 49 - <FormLabel>Webhook URL</FormLabel> 50 - <FormControl> 51 - <Input 52 - type="url" 53 - placeholder="https://hooks.slack.com/services/xxx..." 54 - required 55 - {...field} 56 - /> 57 - </FormControl> 58 - <FormDescription className="flex items-center justify-between"> 59 - The data is required. 60 - <a 61 - href={ 62 - "https://api.slack.com/messaging/webhooks#getting_started" 63 - } 64 - target="_blank" 65 - className="underline hover:no-underline" 66 - rel="noreferrer" 67 - > 68 - How to setup your Slack webhook 69 - </a> 70 - </FormDescription> 71 - <FormMessage /> 72 - </FormItem> 73 - )} 74 - /> 75 - <div className="col-span-full text-right"> 76 - <Button 77 - type="button" 78 - variant="secondary" 79 - className="w-full sm:w-auto" 80 - disabled={isTestPending || !watchUrl} 81 - onClick={sendTestWebhookPing} 82 - > 83 - {!isTestPending ? ( 84 - "Test Webhook" 85 - ) : ( 86 - <LoadingAnimation variant="inverse" /> 87 - )} 88 - </Button> 89 - </div> 90 - </> 91 - ); 92 - }
-39
apps/web/src/components/forms/notification/provider/section-sms.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 6 - import { 7 - FormControl, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - Input, 14 - } from "@openstatus/ui"; 15 - 16 - interface Props { 17 - form: UseFormReturn<InsertNotificationWithData>; 18 - } 19 - 20 - export function SectionSms({ form }: Props) { 21 - return ( 22 - <FormField 23 - control={form.control} 24 - name="data.sms" 25 - render={({ field }) => ( 26 - <FormItem className="sm:col-span-full"> 27 - <FormLabel>Phone Number</FormLabel> 28 - <FormControl> 29 - <Input type="tel" placeholder="+1234567890" required {...field} /> 30 - </FormControl> 31 - <FormDescription className="flex items-center justify-between"> 32 - The phone number is required. 33 - </FormDescription> 34 - <FormMessage /> 35 - </FormItem> 36 - )} 37 - /> 38 - ); 39 - }
-137
apps/web/src/components/forms/notification/provider/section-webhook.tsx
··· 1 - "use client"; 2 - 3 - import { toastAction } from "@/lib/toast"; 4 - import { type UseFormReturn, useFieldArray } from "react-hook-form"; 5 - 6 - import { LoadingAnimation } from "@/components/loading-animation"; 7 - import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 8 - import { 9 - Button, 10 - FormControl, 11 - FormField, 12 - FormItem, 13 - FormLabel, 14 - FormMessage, 15 - Input, 16 - } from "@openstatus/ui"; 17 - import { X } from "lucide-react"; 18 - import { useTransition } from "react"; 19 - import { sendWebhookTestAlert } from "./actions"; 20 - 21 - interface Props { 22 - form: UseFormReturn<InsertNotificationWithData>; 23 - } 24 - 25 - export function SectionWebhook({ form }: Props) { 26 - const [isTestPending, startTestTransition] = useTransition(); 27 - 28 - const { fields, append, remove } = useFieldArray({ 29 - name: "data.webhook.headers", 30 - control: form.control, 31 - }); 32 - 33 - const watchUrl = form.watch("data.webhook.endpoint"); 34 - const watchHeaders = form.watch("data.webhook.headers"); 35 - 36 - async function sendTestAlert() { 37 - if (!watchUrl) return; 38 - startTestTransition(async () => { 39 - const isSuccessful = await sendWebhookTestAlert({ 40 - url: watchUrl, 41 - headers: watchHeaders, 42 - }); 43 - if (isSuccessful) { 44 - toastAction("test-success"); 45 - } else { 46 - toastAction("test-error"); 47 - } 48 - }); 49 - } 50 - 51 - return ( 52 - <> 53 - <FormField 54 - control={form.control} 55 - name="data.webhook.endpoint" 56 - render={({ field }) => ( 57 - <FormItem className="sm:col-span-full"> 58 - <FormLabel>URL</FormLabel> 59 - <FormControl> 60 - <Input 61 - type="url" 62 - placeholder="https://your-webhook-url" 63 - {...field} 64 - /> 65 - </FormControl> 66 - <FormMessage /> 67 - </FormItem> 68 - )} 69 - /> 70 - 71 - <div className="space-y-2 sm:col-span-full"> 72 - <FormLabel>Request Header</FormLabel> 73 - {fields.map((field, index) => ( 74 - <div key={field.id} className="grid grid-cols-6 gap-4"> 75 - <FormField 76 - control={form.control} 77 - name={`data.webhook.headers.${index}.key`} 78 - render={({ field }) => ( 79 - <FormItem className="col-span-2"> 80 - <FormControl> 81 - <Input placeholder="key" {...field} /> 82 - </FormControl> 83 - </FormItem> 84 - )} 85 - /> 86 - <div className="col-span-4 flex items-center space-x-2"> 87 - <FormField 88 - control={form.control} 89 - name={`data.webhook.headers.${index}.value`} 90 - render={({ field }) => ( 91 - <FormItem className="w-full"> 92 - <FormControl> 93 - <Input placeholder="value" {...field} /> 94 - </FormControl> 95 - </FormItem> 96 - )} 97 - /> 98 - <Button 99 - size="icon" 100 - variant="ghost" 101 - type="button" 102 - onClick={() => remove(index)} 103 - > 104 - <X className="h-4 w-4" /> 105 - </Button> 106 - </div> 107 - </div> 108 - ))} 109 - <div> 110 - <Button 111 - type="button" 112 - variant="outline" 113 - onClick={() => append({ key: "", value: "" })} 114 - > 115 - Add Custom Header 116 - </Button> 117 - </div> 118 - </div> 119 - 120 - <div className="col-span-full text-right"> 121 - <Button 122 - type="button" 123 - variant="secondary" 124 - className="w-full sm:w-auto" 125 - disabled={isTestPending} 126 - onClick={sendTestAlert} 127 - > 128 - {!isTestPending ? ( 129 - "Test Webhook" 130 - ) : ( 131 - <LoadingAnimation variant="inverse" /> 132 - )} 133 - </Button> 134 - </div> 135 - </> 136 - ); 137 - }
-89
apps/web/src/components/forms/notification/section-connect.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { 5 - InsertNotificationWithData, 6 - Monitor, 7 - } from "@openstatus/db/src/schema"; 8 - import { 9 - FormControl, 10 - FormDescription, 11 - FormField, 12 - FormItem, 13 - FormLabel, 14 - FormMessage, 15 - } from "@openstatus/ui"; 16 - 17 - import { CheckboxLabel } from "../shared/checkbox-label"; 18 - 19 - interface Props { 20 - form: UseFormReturn<InsertNotificationWithData>; 21 - monitors?: Monitor[]; 22 - } 23 - 24 - export function SectionConnect({ form, monitors }: Props) { 25 - return ( 26 - <div className="grid w-full gap-4"> 27 - <div className="flex flex-col gap-3"> 28 - <FormField 29 - control={form.control} 30 - name="monitors" 31 - render={() => ( 32 - <FormItem> 33 - <div className="mb-4"> 34 - <FormLabel>Monitors</FormLabel> 35 - <FormDescription> 36 - Attach the notification to specific monitors. 37 - </FormDescription> 38 - </div> 39 - <div className="grid grid-cols-1 grid-rows-1 gap-6 sm:grid-cols-2 md:grid-cols-3"> 40 - {monitors?.map((item) => ( 41 - <FormField 42 - key={item.id} 43 - control={form.control} 44 - name="monitors" 45 - render={({ field }) => { 46 - return ( 47 - <FormItem key={item.id} className="h-full w-full"> 48 - <FormControl className="w-full"> 49 - <CheckboxLabel 50 - id={String(item.id)} 51 - name="monitor" 52 - checked={field.value?.includes(item.id)} 53 - onCheckedChange={(checked) => { 54 - return checked 55 - ? field.onChange([ 56 - ...(field.value || []), 57 - item.id, 58 - ]) 59 - : field.onChange( 60 - field.value?.filter( 61 - (value) => value !== item.id, 62 - ), 63 - ); 64 - }} 65 - className="flex-col items-start truncate" 66 - > 67 - <span>{item.name}</span> 68 - <span className="font-normal text-muted-foreground text-sm"> 69 - {item.url} 70 - </span> 71 - </CheckboxLabel> 72 - </FormControl> 73 - </FormItem> 74 - ); 75 - }} 76 - /> 77 - ))} 78 - </div> 79 - {!monitors || monitors.length === 0 ? ( 80 - <FormDescription>Missing monitors.</FormDescription> 81 - ) : null} 82 - <FormMessage /> 83 - </FormItem> 84 - )} 85 - /> 86 - </div> 87 - </div> 88 - ); 89 - }
-35
apps/web/src/components/forms/skeleton-form.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - export function SkeletonForm() { 4 - return ( 5 - <div className="grid w-full gap-6"> 6 - <div className="col-span-full grid gap-4 sm:grid-cols-3"> 7 - <div className="flex flex-col gap-2"> 8 - <Skeleton className="h-6 w-24" /> 9 - <Skeleton className="h-6 w-40" /> 10 - </div> 11 - <div className="grid w-full gap-6 sm:col-span-2"> 12 - <div className="space-y-2"> 13 - <Skeleton className="h-6 w-24" /> 14 - <Skeleton className="h-9 w-full" /> 15 - <Skeleton className="h-5 w-64" /> 16 - </div> 17 - <div className="space-y-2"> 18 - <Skeleton className="h-6 w-24" /> 19 - <Skeleton className="h-9 w-full" /> 20 - <Skeleton className="h-5 w-64" /> 21 - </div> 22 - <div className="space-y-2"> 23 - <Skeleton className="h-6 w-24" /> 24 - <Skeleton className="h-9 w-full" /> 25 - <Skeleton className="h-5 w-64" /> 26 - </div> 27 - </div> 28 - </div> 29 - <Skeleton className="h-9 w-full" /> 30 - <div className="flex space-y-2 sm:col-span-full sm:justify-end"> 31 - <Skeleton className="h-9 w-full sm:w-28" /> 32 - </div> 33 - </div> 34 - ); 35 - }
-213
apps/web/src/components/forms/status-page/form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { usePathname, useRouter } from "next/navigation"; 5 - import { useCallback, useEffect, useTransition } from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import { insertPageSchema } from "@openstatus/db/src/schema"; 9 - import type { 10 - InsertPage, 11 - Monitor, 12 - WorkspacePlan, 13 - } from "@openstatus/db/src/schema"; 14 - import { Badge, Form } from "@openstatus/ui"; 15 - 16 - import { 17 - Tabs, 18 - TabsContent, 19 - TabsList, 20 - TabsTrigger, 21 - } from "@/components/dashboard/tabs"; 22 - import { useDebounce } from "@/hooks/use-debounce"; 23 - import { toast, toastAction } from "@/lib/toast"; 24 - import { slugify } from "@/lib/utils"; 25 - import { api } from "@/trpc/client"; 26 - import { SaveButton } from "../shared/save-button"; 27 - import { General } from "./general"; 28 - import { SectionAdvanced } from "./section-advanced"; 29 - import { SectionDanger } from "./section-danger"; 30 - import { SectionMonitor } from "./section-monitor"; 31 - import { SectionVisibility } from "./section-visibility"; 32 - 33 - interface Props { 34 - defaultSection?: string; 35 - defaultValues?: Omit<InsertPage, "configuration">; 36 - allMonitors?: Monitor[]; 37 - /** 38 - * gives the possibility to check all the monitors 39 - */ 40 - checkAllMonitors?: boolean; 41 - /** 42 - * on submit, allows to push a url 43 - */ 44 - nextUrl?: string; 45 - plan: WorkspacePlan; 46 - workspaceSlug: string; 47 - } 48 - 49 - export function StatusPageForm({ 50 - defaultSection, 51 - defaultValues, 52 - allMonitors, 53 - checkAllMonitors, 54 - nextUrl, 55 - plan, 56 - workspaceSlug, 57 - }: Props) { 58 - const form = useForm<Omit<InsertPage, "configuration">>({ 59 - resolver: zodResolver(insertPageSchema), 60 - defaultValues: { 61 - title: defaultValues?.title || "", // FIXME: you can save a page without title, causing unexpected slug behavior 62 - slug: defaultValues?.slug || "", 63 - description: defaultValues?.description || "", 64 - workspaceId: defaultValues?.workspaceId || 0, 65 - id: defaultValues?.id || 0, 66 - customDomain: defaultValues?.customDomain || "", 67 - icon: defaultValues?.icon || "", 68 - password: defaultValues?.password || "", 69 - passwordProtected: defaultValues?.passwordProtected || false, 70 - showMonitorValues: defaultValues?.showMonitorValues || true, 71 - monitors: 72 - checkAllMonitors && allMonitors 73 - ? allMonitors.map(({ id }) => ({ monitorId: id, order: 0 })) 74 - : defaultValues?.monitors ?? [], 75 - }, 76 - }); 77 - const pathname = usePathname(); 78 - const router = useRouter(); 79 - const [isPending, startTransition] = useTransition(); 80 - const watchSlug = form.watch("slug"); 81 - const watchTitle = form.watch("title"); 82 - const debouncedSlug = useDebounce(watchSlug, 1000); // using debounce to not exhaust the server 83 - 84 - const checkUniqueSlug = useCallback(async () => { 85 - const isUnique = await api.page.getSlugUniqueness.query({ 86 - slug: debouncedSlug, 87 - }); 88 - return ( 89 - isUnique || 90 - debouncedSlug.toLowerCase() === defaultValues?.slug.toLowerCase() 91 - ); 92 - }, [debouncedSlug, defaultValues?.slug]); 93 - 94 - useEffect(() => { 95 - async function watchSlugChanges() { 96 - const isUnique = await checkUniqueSlug(); 97 - if (!isUnique) { 98 - form.setError("slug", { 99 - message: "Already taken. Please select another slug.", 100 - }); 101 - } else { 102 - form.clearErrors("slug"); 103 - } 104 - } 105 - 106 - void watchSlugChanges(); 107 - // eslint-disable-next-line react-hooks/exhaustive-deps 108 - }, [checkUniqueSlug, form.clearErrors, form.setError]); 109 - 110 - useEffect(() => { 111 - if (!defaultValues?.title) { 112 - form.setValue("slug", slugify(watchTitle)); 113 - } 114 - }, [watchTitle, form, defaultValues?.title]); 115 - 116 - const onSubmit = async ({ ...props }: InsertPage) => { 117 - startTransition(async () => { 118 - try { 119 - const isUnique = await checkUniqueSlug(); 120 - if (!isUnique) { 121 - // the user will already have the "error" message - we include a toast as well 122 - toastAction("unique-slug"); 123 - } else { 124 - if (defaultValues) { 125 - await api.page.update.mutate(props); 126 - } else { 127 - await api.page.create.mutate(props); 128 - } 129 - 130 - toast.success("Saved successfully.", { 131 - description: "Your status page is ready to go.", 132 - action: { 133 - label: "Visit", 134 - onClick: () => 135 - window.open(`https://${props.slug}.openstatus.dev`, "_blank") 136 - ?.location, 137 - }, 138 - }); 139 - // otherwise, the form will stay dirty - keepValues is used to keep the current values in the form 140 - form.reset({}, { keepValues: true }); 141 - if (nextUrl) { 142 - router.push(nextUrl); 143 - } 144 - router.refresh(); 145 - } 146 - } catch { 147 - toastAction("error"); 148 - } 149 - }); 150 - }; 151 - 152 - function onValueChange(value: string) { 153 - // REMINDER: we are not merging the searchParams here 154 - // we are just setting the section to allow refreshing the page 155 - const params = new URLSearchParams(); 156 - params.set("section", value); 157 - router.push(`${pathname}?${params.toString()}`); 158 - } 159 - 160 - return ( 161 - <Form {...form}> 162 - <form 163 - onSubmit={async (e) => { 164 - e.preventDefault(); 165 - void form.handleSubmit(onSubmit)(e); 166 - }} 167 - className="grid w-full gap-6" 168 - > 169 - <General form={form} /> 170 - <Tabs 171 - defaultValue={defaultSection} 172 - className="w-full" 173 - onValueChange={onValueChange} 174 - > 175 - <TabsList> 176 - <TabsTrigger value="monitors"> 177 - Monitors{" "} 178 - {defaultValues?.monitors?.length ? ( 179 - <Badge variant="secondary" className="ml-1"> 180 - {defaultValues.monitors.length} 181 - </Badge> 182 - ) : null} 183 - </TabsTrigger> 184 - <TabsTrigger value="advanced">Advanced</TabsTrigger> 185 - <TabsTrigger value="visibility">Visibility</TabsTrigger> 186 - {defaultValues?.id ? ( 187 - <TabsTrigger value="danger">Danger</TabsTrigger> 188 - ) : null} 189 - </TabsList> 190 - <TabsContent value="monitors"> 191 - <SectionMonitor form={form} monitors={allMonitors} /> 192 - </TabsContent> 193 - <TabsContent value="advanced"> 194 - <SectionAdvanced {...{ form }} /> 195 - </TabsContent> 196 - <TabsContent value="visibility"> 197 - <SectionVisibility {...{ form, plan, workspaceSlug }} /> 198 - </TabsContent> 199 - {defaultValues?.id ? ( 200 - <TabsContent value="danger"> 201 - <SectionDanger pageId={defaultValues.id} /> 202 - </TabsContent> 203 - ) : null} 204 - </Tabs> 205 - <SaveButton 206 - isPending={isPending} 207 - isDirty={form.formState.isDirty} 208 - onSubmit={form.handleSubmit(onSubmit)} 209 - /> 210 - </form> 211 - </Form> 212 - ); 213 - }
-67
apps/web/src/components/forms/status-page/general.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { InsertPage } from "@openstatus/db/src/schema"; 5 - import { 6 - FormControl, 7 - FormDescription, 8 - FormField, 9 - FormItem, 10 - FormLabel, 11 - FormMessage, 12 - Input, 13 - InputWithAddons, 14 - } from "@openstatus/ui"; 15 - 16 - import { SectionHeader } from "../shared/section-header"; 17 - 18 - interface Props { 19 - form: UseFormReturn<InsertPage>; 20 - } 21 - 22 - export function General({ form }: Props) { 23 - return ( 24 - <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 25 - <SectionHeader 26 - title="Basic information" 27 - description="The public status page to update your users on service uptime." 28 - /> 29 - <div className="grid gap-4 sm:col-span-2"> 30 - <FormField 31 - control={form.control} 32 - name="title" 33 - render={({ field }) => ( 34 - <FormItem> 35 - <FormLabel>Title</FormLabel> 36 - <FormControl> 37 - <Input placeholder="Documenso Status" {...field} /> 38 - </FormControl> 39 - <FormDescription>The title of your page.</FormDescription> 40 - <FormMessage /> 41 - </FormItem> 42 - )} 43 - /> 44 - <FormField 45 - control={form.control} 46 - name="slug" 47 - render={({ field }) => ( 48 - <FormItem> 49 - <FormLabel>Slug</FormLabel> 50 - <FormControl> 51 - <InputWithAddons 52 - placeholder="documenso" 53 - trailing={".openstatus.dev"} 54 - {...field} 55 - /> 56 - </FormControl> 57 - <FormDescription> 58 - The subdomain for your status page. At least 3 chars. 59 - </FormDescription> 60 - <FormMessage /> 61 - </FormItem> 62 - )} 63 - /> 64 - </div> 65 - </div> 66 - ); 67 - }
-238
apps/web/src/components/forms/status-page/section-advanced.tsx
··· 1 - "use client"; 2 - 3 - import type { PutBlobResult } from "@vercel/blob"; 4 - import Image from "next/image"; 5 - import * as React from "react"; 6 - import { useRef } from "react"; 7 - import type { UseFormReturn } from "react-hook-form"; 8 - 9 - import type { InsertPage } from "@openstatus/db/src/schema"; 10 - import { 11 - AlertDialog, 12 - AlertDialogAction, 13 - AlertDialogCancel, 14 - AlertDialogContent, 15 - AlertDialogDescription, 16 - AlertDialogFooter, 17 - AlertDialogHeader, 18 - AlertDialogTitle, 19 - Button, 20 - Checkbox, 21 - FormControl, 22 - FormDescription, 23 - FormField, 24 - FormItem, 25 - FormLabel, 26 - FormMessage, 27 - Input, 28 - } from "@openstatus/ui"; 29 - 30 - import { BarDescription } from "@/components/tracker/tracker"; 31 - import { MousePointer2 } from "lucide-react"; 32 - import { SectionHeader } from "../shared/section-header"; 33 - 34 - interface Props { 35 - form: UseFormReturn<InsertPage>; 36 - } 37 - 38 - export function SectionAdvanced({ form }: Props) { 39 - const inputFileRef = useRef<HTMLInputElement>(null); 40 - const [open, setOpen] = React.useState(false); 41 - const [file, setFile] = React.useState<File | null>(null); 42 - 43 - /** 44 - * Determine the width and height of the uploaded image - it ideally is a square 45 - */ 46 - const getFileDimensions = async (file: File) => { 47 - const img = document.createElement("img"); 48 - img.src = URL.createObjectURL(file); 49 - await img.decode(); 50 - return { width: img.naturalWidth, height: img.naturalHeight }; 51 - }; 52 - 53 - const handleChange = async (file: FileList | null) => { 54 - if (!file || file.length === 0) { 55 - return; 56 - } 57 - 58 - const { height, width } = await getFileDimensions(file[0]); 59 - 60 - // remove rounding issues from transformations 61 - if (!(Math.abs(height - width) <= 1)) { 62 - setOpen(true); 63 - setFile(file[0]); 64 - return; 65 - } 66 - 67 - const newblob = await handleUpload(file[0]); 68 - form.setValue("icon", newblob.url); 69 - }; 70 - 71 - const handleUpload = async (file: File) => { 72 - const response = await fetch(`/api/upload?filename=${file.name}`, { 73 - method: "POST", 74 - body: file, 75 - }); 76 - 77 - const newblob = (await response.json()) as PutBlobResult; 78 - return newblob; 79 - }; 80 - 81 - const handleCancel = () => { 82 - // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> 83 - inputFileRef.current?.value && (inputFileRef.current.value = ""); 84 - }; 85 - 86 - const handleConfirm = async () => { 87 - if (file) { 88 - const newblob = await handleUpload(file); 89 - form.setValue("icon", newblob.url); 90 - setFile(null); 91 - } 92 - setOpen(false); 93 - }; 94 - 95 - return ( 96 - <div className="grid w-full gap-4 md:grid-cols-3"> 97 - <SectionHeader 98 - title="Advanced Settings" 99 - description="Provide informations about what your status page is for. A favicon can be uploaded to customize your status page. It will be used as an icon on the header as well." 100 - className="md:col-span-full" 101 - /> 102 - <FormField 103 - control={form.control} 104 - name="description" 105 - render={({ field }) => ( 106 - <FormItem className="md:col-span-full"> 107 - <FormLabel>Description</FormLabel> 108 - <FormControl> 109 - <Input 110 - placeholder="Stay informed about our api and website health." 111 - {...field} 112 - /> 113 - </FormControl> 114 - <FormDescription> 115 - Provide your users informations about it. 116 - </FormDescription> 117 - <FormMessage /> 118 - </FormItem> 119 - )} 120 - /> 121 - <FormField 122 - control={form.control} 123 - name="icon" 124 - render={({ field }) => ( 125 - <FormItem className="col-span-full md:col-span-1"> 126 - <FormLabel>Favicon</FormLabel> 127 - <FormControl> 128 - <> 129 - {!field.value && ( 130 - <Input 131 - type="file" 132 - accept="image/x-icon,image/png" 133 - ref={inputFileRef} 134 - onChange={(e) => handleChange(e.target.files)} 135 - /> 136 - )} 137 - {field.value && ( 138 - <div className="flex items-center"> 139 - <div className="h-10 w-10 rounded-sm border border-border p-1"> 140 - <Image 141 - src={field.value} 142 - width={64} 143 - height={64} 144 - alt="Favicon" 145 - /> 146 - </div> 147 - <Button 148 - variant="link" 149 - onClick={() => { 150 - form.setValue("icon", ""); 151 - }} 152 - > 153 - Remove 154 - </Button> 155 - </div> 156 - )} 157 - </> 158 - </FormControl> 159 - <FormDescription>Your status page favicon</FormDescription> 160 - <FormMessage /> 161 - </FormItem> 162 - )} 163 - /> 164 - <div className="grid w-full gap-4 md:col-span-full md:grid-cols-3 md:grid-rows-2"> 165 - <SectionHeader 166 - title="Monitor Values Visibility" 167 - description={ 168 - <> 169 - Toggle the visibility of the values on the status page. Share your{" "} 170 - <span className="font-medium text-foreground">uptime</span> and 171 - the{" "} 172 - <span className="font-medium text-foreground"> 173 - number of request 174 - </span>{" "} 175 - to your endpoint. 176 - </> 177 - } 178 - className="md:col-span-2" 179 - /> 180 - <div className="group flex flex-col justify-center gap-1 rounded-md border border-dashed p-3 md:row-span-2"> 181 - <div className="flex flex-row items-center justify-center gap-2 text-muted-foreground group-hover:text-foreground"> 182 - <MousePointer2 className="h-3 w-3" /> 183 - <p className="text-sm">Hover State</p> 184 - </div> 185 - <div className="mx-auto max-w-60"> 186 - <BarDescription 187 - label="Operational" 188 - day={new Date().toISOString()} 189 - count={5600} 190 - ok={5569} 191 - showValues={!!form.getValues("showMonitorValues")} 192 - barClassName="bg-status-operational" 193 - className="rounded-md border bg-popover p-2 text-popover-foreground shadow-md md:col-span-1" 194 - /> 195 - </div> 196 - </div> 197 - <FormField 198 - control={form.control} 199 - name="showMonitorValues" 200 - render={({ field }) => ( 201 - <FormItem className="flex flex-row items-start space-x-3 space-y-0 md:col-span-2"> 202 - <FormControl> 203 - <Checkbox 204 - disabled={field.disabled} 205 - checked={field.value ?? false} 206 - onCheckedChange={field.onChange} 207 - /> 208 - </FormControl> 209 - <div className="space-y-1 leading-none"> 210 - <FormLabel>Show values</FormLabel> 211 - <FormDescription> 212 - Share the numbers to your users. 213 - </FormDescription> 214 - </div> 215 - </FormItem> 216 - )} 217 - /> 218 - </div> 219 - <AlertDialog open={open} onOpenChange={(value) => setOpen(value)}> 220 - <AlertDialogContent> 221 - <AlertDialogHeader> 222 - <AlertDialogTitle>Incorrect image size</AlertDialogTitle> 223 - <AlertDialogDescription> 224 - For the best result, the image should be a square. You can still 225 - upload it, but it will be cropped. 226 - </AlertDialogDescription> 227 - </AlertDialogHeader> 228 - <AlertDialogFooter> 229 - <AlertDialogCancel onClick={handleCancel}>Cancel</AlertDialogCancel> 230 - <AlertDialogAction onClick={handleConfirm}> 231 - Continue 232 - </AlertDialogAction> 233 - </AlertDialogFooter> 234 - </AlertDialogContent> 235 - </AlertDialog> 236 - </div> 237 - ); 238 - }
-92
apps/web/src/components/forms/status-page/section-danger.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - 5 - import { 6 - AlertDialog, 7 - AlertDialogAction, 8 - AlertDialogCancel, 9 - AlertDialogContent, 10 - AlertDialogDescription, 11 - AlertDialogFooter, 12 - AlertDialogHeader, 13 - AlertDialogTitle, 14 - AlertDialogTrigger, 15 - Button, 16 - FormDescription, 17 - } from "@openstatus/ui"; 18 - 19 - import { LoadingAnimation } from "@/components/loading-animation"; 20 - import { toastAction } from "@/lib/toast"; 21 - import { api } from "@/trpc/client"; 22 - import React from "react"; 23 - import { SectionHeader } from "../shared/section-header"; 24 - 25 - interface Props { 26 - pageId: number; 27 - } 28 - 29 - export function SectionDanger({ pageId }: Props) { 30 - const router = useRouter(); 31 - const [open, setOpen] = React.useState(false); 32 - const [isPending, startTransition] = React.useTransition(); 33 - 34 - async function onDelete() { 35 - startTransition(async () => { 36 - try { 37 - await api.page.delete.mutate({ id: pageId }); 38 - toastAction("deleted"); 39 - setOpen(false); 40 - router.push("../"); 41 - } catch { 42 - toastAction("error"); 43 - } 44 - }); 45 - } 46 - 47 - return ( 48 - <div className="grid w-full gap-4"> 49 - <SectionHeader 50 - title="Danger Zone" 51 - description="Be aware of the changes you are about to make." 52 - /> 53 - <div className="grid gap-4 sm:grid-cols-3"> 54 - <div className="col-start-1 flex flex-col items-center gap-4 sm:col-span-2 sm:flex-row"> 55 - <AlertDialog open={open} onOpenChange={setOpen}> 56 - <AlertDialogTrigger asChild> 57 - <Button variant="destructive" className="w-full sm:w-auto"> 58 - Delete 59 - </Button> 60 - </AlertDialogTrigger> 61 - <AlertDialogContent> 62 - <AlertDialogHeader> 63 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 64 - <AlertDialogDescription> 65 - This action cannot be undone. This will permanently delete the 66 - status page. 67 - </AlertDialogDescription> 68 - </AlertDialogHeader> 69 - <AlertDialogFooter> 70 - <AlertDialogCancel>Cancel</AlertDialogCancel> 71 - <AlertDialogAction 72 - onClick={(e) => { 73 - e.preventDefault(); 74 - onDelete(); 75 - }} 76 - disabled={isPending} 77 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 78 - > 79 - {!isPending ? "Delete" : <LoadingAnimation />} 80 - </AlertDialogAction> 81 - </AlertDialogFooter> 82 - </AlertDialogContent> 83 - </AlertDialog> 84 - <FormDescription className="order-1 text-red-500 sm:order-2"> 85 - This action cannot be undone. This will permanently delete the 86 - status page. 87 - </FormDescription> 88 - </div> 89 - </div> 90 - </div> 91 - ); 92 - }
-161
apps/web/src/components/forms/status-page/section-monitor.tsx
··· 1 - "use client"; 2 - 3 - import { CheckIcon, ChevronsUpDown, GripVertical } from "lucide-react"; 4 - import { useState } from "react"; 5 - import type { UseFormReturn } from "react-hook-form"; 6 - import { useFieldArray } from "react-hook-form"; 7 - 8 - import type { InsertPage, Monitor } from "@openstatus/db/src/schema"; 9 - import { 10 - Button, 11 - Command, 12 - CommandEmpty, 13 - CommandGroup, 14 - CommandInput, 15 - CommandItem, 16 - CommandList, 17 - Popover, 18 - PopoverContent, 19 - PopoverTrigger, 20 - Sortable, 21 - SortableDragHandle, 22 - SortableItem, 23 - } from "@openstatus/ui"; 24 - 25 - import { StatusDot } from "@/components/monitor/status-dot"; 26 - import { cn } from "@/lib/utils"; 27 - import { SectionHeader } from "../shared/section-header"; 28 - 29 - interface Props { 30 - monitors?: Monitor[]; 31 - form: UseFormReturn<InsertPage>; 32 - } 33 - 34 - export function SectionMonitor({ form, monitors }: Props) { 35 - const [open, setOpen] = useState(false); 36 - const { fields, append, move, remove } = useFieldArray({ 37 - control: form.control, 38 - name: "monitors", 39 - }); 40 - const watchMonitors = form.watch("monitors"); 41 - 42 - return ( 43 - <div className="grid w-full gap-4"> 44 - <SectionHeader 45 - title="Connected Monitors" 46 - description="Select the monitors you want to display on your status page. Change the order by using the right-side handle. Inactive monitors will not be shown." 47 - /> 48 - <Popover open={open} onOpenChange={setOpen}> 49 - <PopoverTrigger asChild> 50 - <Button 51 - variant="outline" 52 - role="combobox" 53 - aria-expanded={open} 54 - className="w-[240px] justify-between" 55 - > 56 - {watchMonitors.length > 0 57 - ? `${watchMonitors.length} monitor${ 58 - watchMonitors.length > 1 ? "s" : "" 59 - } selected` 60 - : "Select monitors..."} 61 - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 62 - </Button> 63 - </PopoverTrigger> 64 - <PopoverContent side="bottom" className="w-[240px] p-0"> 65 - <Command> 66 - <CommandInput placeholder="Select monitors..." className="h-9" /> 67 - <CommandList> 68 - <CommandEmpty>No monitors found.</CommandEmpty> 69 - <CommandGroup> 70 - {monitors?.map((monitor) => ( 71 - <CommandItem 72 - key={monitor.id} 73 - value={`${monitor.name}-${String(monitor.id)}`} 74 - keywords={[monitor.name]} 75 - onSelect={(currentValue) => { 76 - const splitValue = currentValue.split("-"); 77 - const id = splitValue?.[splitValue.length - 1]; 78 - const monitorIndex = watchMonitors.findIndex( 79 - (m) => m.monitorId === Number.parseInt(id), 80 - ); 81 - if (monitorIndex !== -1) { 82 - remove(monitorIndex); 83 - } else { 84 - append({ 85 - monitorId: monitor.id, 86 - order: fields.length + 1, 87 - }); 88 - } 89 - }} 90 - > 91 - <div className="truncate"> 92 - <p>{monitor.name}</p> 93 - {/* <p className="text-muted-foreground truncate text-xs"> 94 - {monitor.url} 95 - </p> */} 96 - </div> 97 - <CheckIcon 98 - className={cn( 99 - "ml-auto h-4 w-4 shrink-0", 100 - watchMonitors.some((m) => m.monitorId === monitor.id) 101 - ? "opacity-100" 102 - : "opacity-0", 103 - )} 104 - /> 105 - </CommandItem> 106 - ))} 107 - </CommandGroup> 108 - </CommandList> 109 - </Command> 110 - </PopoverContent> 111 - </Popover> 112 - <div className="h-full w-full"> 113 - {/* FIXME: if we wanna use `overlay` we need to fix the scrollable/position-fixed issues */} 114 - <Sortable 115 - value={fields} 116 - onMove={({ activeIndex, overIndex }) => move(activeIndex, overIndex)} 117 - // overlay={ 118 - // <div className="grid grid-cols-[0.5fr_1fr_auto] items-center gap-2"> 119 - // <Skeleton className="h-8 w-full rounded-sm" /> 120 - // <Skeleton className="h-8 w-full rounded-sm" /> 121 - // <Skeleton className="size-8 shrink-0 rounded-sm" /> 122 - // </div> 123 - // } 124 - > 125 - <div className="w-full space-y-2"> 126 - {fields.map((field) => { 127 - const monitor = monitors?.find( 128 - ({ id }) => field.monitorId === id, 129 - ); 130 - if (!monitor) return null; 131 - return ( 132 - <SortableItem key={field.id} value={field.id} asChild> 133 - <div className="grid grid-cols-[0.5fr_1fr_auto] items-center gap-2"> 134 - <div className="flex items-center gap-2 truncate"> 135 - <StatusDot 136 - active={monitor.active} 137 - status={monitor.status} 138 - />{" "} 139 - <span className="truncate">{monitor.name}</span> 140 - </div> 141 - <div className="truncate text-muted-foreground"> 142 - {monitor?.url} 143 - </div> 144 - <SortableDragHandle 145 - variant="outline" 146 - size="icon" 147 - className="size-8 shrink-0" 148 - type="button" 149 - > 150 - <GripVertical className="size-4" aria-hidden="true" /> 151 - </SortableDragHandle> 152 - </div> 153 - </SortableItem> 154 - ); 155 - })} 156 - </div> 157 - </Sortable> 158 - </div> 159 - </div> 160 - ); 161 - }
-131
apps/web/src/components/forms/status-page/section-visibility.tsx
··· 1 - "use client"; 2 - 3 - import { X } from "lucide-react"; 4 - import type { UseFormReturn } from "react-hook-form"; 5 - 6 - import type { InsertPage, WorkspacePlan } from "@openstatus/db/src/schema"; 7 - import { 8 - Button, 9 - Checkbox, 10 - FormControl, 11 - FormDescription, 12 - FormField, 13 - FormItem, 14 - FormLabel, 15 - FormMessage, 16 - Input, 17 - } from "@openstatus/ui"; 18 - 19 - import { getBaseUrl } from "@/app/status-page/[domain]/utils"; 20 - import { ProFeatureHoverCard } from "@/components/billing/pro-feature-hover-card"; 21 - import { CopyToClipboardButton } from "@/components/dashboard/copy-to-clipboard-button"; 22 - import { SectionHeader } from "../shared/section-header"; 23 - 24 - interface Props { 25 - form: UseFormReturn<InsertPage>; 26 - plan: WorkspacePlan; 27 - workspaceSlug: string; 28 - } 29 - 30 - export function SectionVisibility({ form, plan, workspaceSlug }: Props) { 31 - const watchPasswordProtected = form.watch("passwordProtected"); 32 - const watchPassword = form.watch("password"); 33 - 34 - const baseUrl = getBaseUrl({ 35 - slug: form.getValues("slug"), 36 - customDomain: form.getValues("customDomain"), 37 - }); 38 - const link = `${baseUrl}?authorize=${watchPassword}`; 39 - 40 - const hasFreePlan = plan === "free"; 41 - 42 - return ( 43 - <div className="grid w-full gap-4 md:grid-cols-2"> 44 - <SectionHeader 45 - title="Visibility" 46 - description="Hide your page from the public by setting a password." 47 - className="md:col-span-full" 48 - /> 49 - <ProFeatureHoverCard 50 - workspaceSlug={workspaceSlug} 51 - plan={plan} 52 - minRequiredPlan="starter" 53 - > 54 - <div className="grid w-full gap-4 md:col-span-full md:grid-cols-2"> 55 - <FormField 56 - control={form.control} 57 - name="passwordProtected" 58 - disabled={hasFreePlan} 59 - render={({ field }) => ( 60 - <FormItem className="flex flex-row items-start space-x-3 space-y-0 md:col-span-full"> 61 - <FormControl> 62 - <Checkbox 63 - disabled={field.disabled} 64 - checked={field.value ?? false} 65 - onCheckedChange={field.onChange} 66 - /> 67 - </FormControl> 68 - <div className="space-y-1 leading-none"> 69 - <FormLabel>Protect with password</FormLabel> 70 - <FormDescription> 71 - Hide the page from the public 72 - </FormDescription> 73 - </div> 74 - </FormItem> 75 - )} 76 - /> 77 - <FormField 78 - control={form.control} 79 - name="password" 80 - disabled={hasFreePlan} 81 - render={({ field }) => ( 82 - <FormItem className="md:col-span-1"> 83 - <FormLabel>Password</FormLabel> 84 - <div className="flex items-center gap-2"> 85 - <FormControl> 86 - <Input 87 - {...field} 88 - placeholder="top-secret" 89 - disabled={!watchPasswordProtected} 90 - value={field.value ?? ""} // REMINDER: remove nullish coalescing from db schema 91 - /> 92 - </FormControl> 93 - <Button 94 - size="icon" 95 - variant="ghost" 96 - type="button" 97 - onClick={() => form.setValue("password", "")} 98 - disabled={!field.value || !watchPasswordProtected} 99 - > 100 - <X className="h-4 w-4" /> 101 - </Button> 102 - </div> 103 - <FormDescription> 104 - No restriction on the password. It&apos;s just a simple 105 - password you define. 106 - </FormDescription> 107 - <FormMessage /> 108 - </FormItem> 109 - )} 110 - /> 111 - {watchPasswordProtected ? ( 112 - <div className="text-sm md:col-span-full"> 113 - <p className="text-muted-foreground"> 114 - If you want to share the page without the need to enter the 115 - password, you can share the following link: 116 - </p> 117 - <div className="flex flex-wrap items-center gap-2"> 118 - <p className="text-foreground">{link} </p> 119 - {/* TODO: think of building a better shadcn like "CopyToClipboardButton" */} 120 - <CopyToClipboardButton 121 - text={link} 122 - tooltipText="Copy to clipboard" 123 - /> 124 - </div> 125 - </div> 126 - ) : null} 127 - </div> 128 - </ProFeatureHoverCard> 129 - </div> 130 - ); 131 - }
-346
apps/web/src/components/forms/status-report-form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import * as React from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import { 9 - insertStatusReportSchema, 10 - statusReportStatus, 11 - statusReportStatusSchema, 12 - } from "@openstatus/db/src/schema"; 13 - import type { InsertStatusReport, Monitor } from "@openstatus/db/src/schema"; 14 - import { 15 - Accordion, 16 - AccordionContent, 17 - AccordionItem, 18 - AccordionTrigger, 19 - Button, 20 - Checkbox, 21 - DateTimePickerPopover, 22 - Form, 23 - FormControl, 24 - FormDescription, 25 - FormField, 26 - FormItem, 27 - FormLabel, 28 - FormMessage, 29 - Input, 30 - RadioGroup, 31 - RadioGroupItem, 32 - Tabs, 33 - TabsContent, 34 - TabsList, 35 - TabsTrigger, 36 - Textarea, 37 - } from "@openstatus/ui"; 38 - 39 - import { Preview } from "@/components/content/preview"; 40 - import { Icons } from "@/components/icons"; 41 - import { LoadingAnimation } from "@/components/loading-animation"; 42 - import { statusDict } from "@/data/incidents-dictionary"; 43 - import { toastAction } from "@/lib/toast"; 44 - import { cn } from "@/lib/utils"; 45 - import { api } from "@/trpc/client"; 46 - 47 - interface Props { 48 - defaultValues?: InsertStatusReport; 49 - monitors?: Monitor[]; 50 - nextUrl?: string; 51 - pageId: number; 52 - } 53 - 54 - export function StatusReportForm({ 55 - defaultValues, 56 - monitors, 57 - nextUrl, 58 - pageId, 59 - }: Props) { 60 - const form = useForm<InsertStatusReport>({ 61 - resolver: zodResolver(insertStatusReportSchema), 62 - defaultValues: defaultValues 63 - ? { 64 - id: defaultValues.id, 65 - title: defaultValues.title, 66 - status: defaultValues.status, 67 - monitors: defaultValues.monitors, 68 - // include update on creation 69 - message: defaultValues.message, 70 - date: defaultValues.date, 71 - } 72 - : { 73 - status: "investigating", 74 - date: new Date(), 75 - }, 76 - }); 77 - const router = useRouter(); 78 - const [isPending, startTransition] = React.useTransition(); 79 - 80 - const onSubmit = ({ ...props }: InsertStatusReport) => { 81 - startTransition(async () => { 82 - try { 83 - if (defaultValues) { 84 - await api.statusReport.updateStatusReport.mutate({ 85 - pageId, 86 - ...props, 87 - }); 88 - } else { 89 - const { message, date, status, ...rest } = props; 90 - const statusReport = await api.statusReport.createStatusReport.mutate( 91 - { 92 - status, 93 - message, 94 - pageId, 95 - ...rest, 96 - }, 97 - ); 98 - // include update on creation 99 - if (statusReport?.id) { 100 - await api.statusReport.createStatusReportUpdate.mutate({ 101 - message, 102 - date, 103 - status, 104 - statusReportId: statusReport.id, 105 - }); 106 - } 107 - } 108 - if (nextUrl) { 109 - router.push(nextUrl); 110 - } 111 - router.refresh(); 112 - toastAction("saved"); 113 - } catch { 114 - toastAction("error"); 115 - } 116 - }); 117 - }; 118 - 119 - return ( 120 - <Form {...form}> 121 - <form 122 - onSubmit={async (e) => { 123 - e.preventDefault(); 124 - form.handleSubmit(onSubmit)(e); 125 - }} 126 - className="grid w-full gap-6" 127 - > 128 - <div className="grid gap-4 sm:grid-cols-3"> 129 - <div className="my-1.5 flex flex-col gap-2"> 130 - <p className="font-semibold text-sm leading-none">Inform</p> 131 - <p className="text-muted-foreground text-sm"> 132 - Keep your users informed about what just happened. 133 - </p> 134 - </div> 135 - <div className="grid gap-6 sm:col-span-2 sm:grid-cols-3"> 136 - <FormField 137 - control={form.control} 138 - name="title" 139 - render={({ field }) => ( 140 - <FormItem className="sm:col-span-4"> 141 - <FormLabel>Title</FormLabel> 142 - <FormControl> 143 - <Input placeholder="Downtime..." {...field} /> 144 - </FormControl> 145 - <FormDescription>The title of your outage.</FormDescription> 146 - <FormMessage /> 147 - </FormItem> 148 - )} 149 - /> 150 - <FormField 151 - control={form.control} 152 - name="status" 153 - render={({ field }) => ( 154 - <FormItem className="col-span-full space-y-1"> 155 - <FormLabel>Status</FormLabel> 156 - <FormDescription>Select the current status.</FormDescription> 157 - <FormMessage /> 158 - <RadioGroup 159 - onValueChange={(value) => 160 - field.onChange(statusReportStatusSchema.parse(value)) 161 - } // value is a string 162 - defaultValue={field.value} 163 - className="grid grid-cols-2 gap-4 sm:grid-cols-4" 164 - > 165 - {statusReportStatus.map((status) => { 166 - const { value, label, icon } = statusDict[status]; 167 - const Icon = Icons[icon]; 168 - return ( 169 - <FormItem key={value}> 170 - <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 171 - <FormControl> 172 - <RadioGroupItem 173 - value={value} 174 - className="sr-only" 175 - /> 176 - </FormControl> 177 - <div className="flex w-full items-center justify-center rounded-lg border border-border px-3 py-2 text-center text-muted-foreground text-sm"> 178 - <Icon className="mr-2 h-4 w-4 shrink-0" /> 179 - <span className="truncate">{label}</span> 180 - </div> 181 - </FormLabel> 182 - </FormItem> 183 - ); 184 - })} 185 - </RadioGroup> 186 - </FormItem> 187 - )} 188 - /> 189 - <FormField 190 - control={form.control} 191 - name="monitors" 192 - render={() => ( 193 - <FormItem className="sm:col-span-full"> 194 - <div className="mb-4"> 195 - <FormLabel>Monitors</FormLabel> 196 - {/* TODO: second phrase can be set inside of a (?) tooltip */} 197 - <FormDescription> 198 - Select the monitors that you want to refer the incident 199 - to. It will be displayed on the status page they are 200 - attached to. 201 - </FormDescription> 202 - </div> 203 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 204 - {monitors?.map((item) => ( 205 - <FormField 206 - key={item.id} 207 - control={form.control} 208 - name="monitors" 209 - render={({ field }) => { 210 - return ( 211 - <FormItem 212 - key={item.id} 213 - className="flex flex-row items-start space-x-3 space-y-0" 214 - > 215 - <FormControl> 216 - <Checkbox 217 - checked={field.value?.includes(item.id)} 218 - onCheckedChange={(checked) => { 219 - return checked 220 - ? field.onChange([ 221 - ...(field.value || []), 222 - item.id, 223 - ]) 224 - : field.onChange( 225 - field.value?.filter( 226 - (value) => value !== item.id, 227 - ), 228 - ); 229 - }} 230 - /> 231 - </FormControl> 232 - <div className="grid gap-1.5 leading-none"> 233 - <div className="flex items-center gap-2"> 234 - <FormLabel className="font-normal"> 235 - {item.name} 236 - </FormLabel> 237 - <span 238 - className={cn( 239 - "rounded-full p-1", 240 - item.active 241 - ? "bg-green-500" 242 - : "bg-red-500", 243 - )} 244 - /> 245 - </div> 246 - <p className="truncate text-muted-foreground text-sm"> 247 - {item.description} 248 - </p> 249 - </div> 250 - </FormItem> 251 - ); 252 - }} 253 - /> 254 - ))} 255 - </div> 256 - <FormMessage /> 257 - </FormItem> 258 - )} 259 - /> 260 - </div> 261 - </div> 262 - {/* include update on creation */} 263 - {!defaultValues ? ( 264 - <Accordion type="single" defaultValue="message" collapsible> 265 - <AccordionItem value="message"> 266 - <AccordionTrigger>Message</AccordionTrigger> 267 - <AccordionContent> 268 - <div className="grid gap-4 sm:grid-cols-3"> 269 - <div className="my-1.5 flex flex-col gap-2"> 270 - <p className="font-semibold text-sm leading-none"> 271 - Status Update 272 - </p> 273 - <p className="text-muted-foreground text-sm"> 274 - What is actually going wrong? 275 - </p> 276 - </div> 277 - <div className="grid gap-6 sm:col-span-2 sm:grid-cols-2"> 278 - <FormField 279 - control={form.control} 280 - name="message" 281 - render={({ field }) => ( 282 - <FormItem className="sm:col-span-4"> 283 - <FormLabel>Message</FormLabel> 284 - <Tabs defaultValue="write"> 285 - <TabsList> 286 - <TabsTrigger value="write">Write</TabsTrigger> 287 - <TabsTrigger value="preview">Preview</TabsTrigger> 288 - </TabsList> 289 - <TabsContent value="write"> 290 - <FormControl> 291 - <Textarea 292 - placeholder="We are encountering..." 293 - className="h-auto w-full resize-none" 294 - rows={9} 295 - {...field} 296 - /> 297 - </FormControl> 298 - </TabsContent> 299 - <TabsContent value="preview"> 300 - <Preview md={form.getValues("message")} /> 301 - </TabsContent> 302 - </Tabs> 303 - <FormDescription> 304 - Tell your user what&apos;s happening. Supports 305 - markdown. 306 - </FormDescription> 307 - <FormMessage /> 308 - </FormItem> 309 - )} 310 - /> 311 - <FormField 312 - control={form.control} 313 - name="date" 314 - render={({ field }) => ( 315 - <FormItem className="flex flex-col sm:col-span-full"> 316 - <FormLabel>Date</FormLabel> 317 - <DateTimePickerPopover 318 - date={ 319 - field.value ? new Date(field.value) : new Date() 320 - } 321 - setDate={(date) => { 322 - field.onChange(date); 323 - }} 324 - /> 325 - <FormDescription> 326 - The date and time when the incident took place. 327 - </FormDescription> 328 - <FormMessage /> 329 - </FormItem> 330 - )} 331 - /> 332 - </div> 333 - </div> 334 - </AccordionContent> 335 - </AccordionItem> 336 - </Accordion> 337 - ) : null} 338 - <div className="flex sm:justify-end"> 339 - <Button className="w-full sm:w-auto" size="lg"> 340 - {!isPending ? "Confirm" : <LoadingAnimation />} 341 - </Button> 342 - </div> 343 - </form> 344 - </Form> 345 - ); 346 - }
-199
apps/web/src/components/forms/status-report-update-form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import * as React from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import type { InsertStatusReportUpdate } from "@openstatus/db/src/schema"; 9 - import { 10 - insertStatusReportUpdateSchema, 11 - statusReportStatus, 12 - statusReportStatusSchema, 13 - } from "@openstatus/db/src/schema"; 14 - import { 15 - Button, 16 - DateTimePicker, 17 - Form, 18 - FormControl, 19 - FormDescription, 20 - FormField, 21 - FormItem, 22 - FormLabel, 23 - FormMessage, 24 - RadioGroup, 25 - RadioGroupItem, 26 - Tabs, 27 - TabsContent, 28 - TabsList, 29 - TabsTrigger, 30 - Textarea, 31 - } from "@openstatus/ui"; 32 - 33 - import { Preview } from "@/components/content/preview"; 34 - import { Icons } from "@/components/icons"; 35 - import { LoadingAnimation } from "@/components/loading-animation"; 36 - import { statusDict } from "@/data/incidents-dictionary"; 37 - import { toastAction } from "@/lib/toast"; 38 - import { api } from "@/trpc/client"; 39 - 40 - interface Props { 41 - defaultValues?: InsertStatusReportUpdate; 42 - statusReportId: number; 43 - onSubmit?: () => void; 44 - } 45 - 46 - export function StatusReportUpdateForm({ 47 - defaultValues, 48 - statusReportId, 49 - onSubmit, 50 - }: Props) { 51 - const form = useForm<InsertStatusReportUpdate>({ 52 - resolver: zodResolver(insertStatusReportUpdateSchema), 53 - defaultValues: { 54 - id: defaultValues?.id || 0, 55 - status: defaultValues?.status || "investigating", 56 - message: defaultValues?.message, 57 - date: defaultValues?.date || new Date(), 58 - statusReportId, 59 - }, 60 - }); 61 - const router = useRouter(); 62 - const [isPending, startTransition] = React.useTransition(); 63 - 64 - const handleSubmit = ({ ...props }: InsertStatusReportUpdate) => { 65 - startTransition(async () => { 66 - try { 67 - if (defaultValues) { 68 - await api.statusReport.updateStatusReportUpdate.mutate({ ...props }); 69 - } else { 70 - await api.statusReport.createStatusReportUpdate.mutate({ ...props }); 71 - } 72 - toastAction("saved"); 73 - onSubmit?.(); 74 - router.refresh(); 75 - } catch { 76 - toastAction("error"); 77 - } 78 - }); 79 - }; 80 - 81 - return ( 82 - <Form {...form}> 83 - <form 84 - onSubmit={async (e) => { 85 - e.preventDefault(); 86 - form.handleSubmit(handleSubmit)(e); 87 - }} 88 - className="grid w-full gap-6" 89 - > 90 - <div className="grid gap-4 sm:grid-cols-3"> 91 - <div className="my-1.5 flex flex-col gap-2"> 92 - <p className="font-semibold text-sm leading-none">Inform</p> 93 - <p className="text-muted-foreground text-sm"> 94 - Keep your users informed about what just happened. 95 - </p> 96 - </div> 97 - <div className="grid gap-6 sm:col-span-2 sm:grid-cols-3"> 98 - <FormField 99 - control={form.control} 100 - name="status" 101 - render={({ field }) => ( 102 - <FormItem className="space-y-1 sm:col-span-full"> 103 - <FormLabel>Status</FormLabel> 104 - <FormDescription>Select the current status.</FormDescription> 105 - <FormMessage /> 106 - <RadioGroup 107 - onValueChange={(value) => 108 - field.onChange(statusReportStatusSchema.parse(value)) 109 - } // value is a string 110 - defaultValue={field.value} 111 - className="grid grid-cols-2 gap-4 sm:grid-cols-4" 112 - > 113 - {statusReportStatus.map((status) => { 114 - const { value, label, icon } = statusDict[status]; 115 - const Icon = Icons[icon]; 116 - return ( 117 - <FormItem key={value}> 118 - <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 119 - <FormControl> 120 - <RadioGroupItem 121 - value={value} 122 - className="sr-only" 123 - /> 124 - </FormControl> 125 - <div className="flex w-full items-center justify-center rounded-lg border border-border px-3 py-2 text-center text-muted-foreground text-sm"> 126 - <Icon className="mr-2 h-4 w-4 shrink-0" /> 127 - <span className="truncate">{label}</span> 128 - </div> 129 - </FormLabel> 130 - </FormItem> 131 - ); 132 - })} 133 - </RadioGroup> 134 - </FormItem> 135 - )} 136 - /> 137 - <FormField 138 - control={form.control} 139 - name="message" 140 - render={({ field }) => ( 141 - <FormItem className="sm:col-span-full"> 142 - <FormLabel>Message</FormLabel> 143 - <Tabs defaultValue="write"> 144 - <TabsList> 145 - <TabsTrigger value="write">Write</TabsTrigger> 146 - <TabsTrigger value="preview">Preview</TabsTrigger> 147 - </TabsList> 148 - <TabsContent value="write"> 149 - <FormControl> 150 - <Textarea 151 - placeholder="We are encountering..." 152 - className="h-auto w-full resize-none" 153 - rows={9} 154 - {...field} 155 - /> 156 - </FormControl> 157 - </TabsContent> 158 - <TabsContent value="preview"> 159 - <Preview md={form.getValues("message")} /> 160 - </TabsContent> 161 - </Tabs> 162 - <FormDescription> 163 - Tell your user what&apos;s happening. Supports markdown. 164 - </FormDescription> 165 - <FormMessage /> 166 - </FormItem> 167 - )} 168 - /> 169 - <FormField 170 - control={form.control} 171 - name="date" 172 - render={({ field }) => ( 173 - <FormItem className="flex flex-col sm:col-span-2"> 174 - <FormLabel>Date</FormLabel> 175 - <DateTimePicker 176 - className="w-full" 177 - date={new Date(field.value)} 178 - setDate={(date) => { 179 - field.onChange(date); 180 - }} 181 - /> 182 - <FormDescription> 183 - The date and time when the incident took place. 184 - </FormDescription> 185 - <FormMessage /> 186 - </FormItem> 187 - )} 188 - /> 189 - </div> 190 - </div> 191 - <div className="flex sm:justify-end"> 192 - <Button className="w-full sm:w-auto" size="lg"> 193 - {!isPending ? "Confirm" : <LoadingAnimation />} 194 - </Button> 195 - </div> 196 - </form> 197 - </Form> 198 - ); 199 - }
-103
apps/web/src/components/forms/status-report-update/form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import * as React from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import type { InsertStatusReportUpdate } from "@openstatus/db/src/schema"; 9 - import { insertStatusReportUpdateSchema } from "@openstatus/db/src/schema"; 10 - import { Button, Form } from "@openstatus/ui"; 11 - 12 - import { 13 - Tabs, 14 - TabsContent, 15 - TabsList, 16 - TabsTrigger, 17 - } from "@/components/dashboard/tabs"; 18 - import { LoadingAnimation } from "@/components/loading-animation"; 19 - import { toastAction } from "@/lib/toast"; 20 - import { api } from "@/trpc/client"; 21 - import { General } from "./general"; 22 - import { SectionDate } from "./section-date"; 23 - import { SectionMessage } from "./section-message"; 24 - 25 - interface Props { 26 - defaultValues?: InsertStatusReportUpdate; 27 - statusReportId: number; 28 - onSubmit?: () => void; 29 - } 30 - 31 - export function StatusReportUpdateForm({ 32 - defaultValues, 33 - statusReportId, 34 - onSubmit, 35 - }: Props) { 36 - const form = useForm<InsertStatusReportUpdate>({ 37 - resolver: zodResolver(insertStatusReportUpdateSchema), 38 - defaultValues: { 39 - id: defaultValues?.id || 0, 40 - status: defaultValues?.status || "investigating", 41 - message: defaultValues?.message, 42 - date: defaultValues?.date || new Date(), 43 - statusReportId, 44 - }, 45 - }); 46 - const router = useRouter(); 47 - const [isPending, startTransition] = React.useTransition(); 48 - 49 - const handleSubmit = ({ ...props }: InsertStatusReportUpdate) => { 50 - startTransition(async () => { 51 - try { 52 - if (defaultValues) { 53 - await api.statusReport.updateStatusReportUpdate.mutate({ 54 - ...props, 55 - }); 56 - } else { 57 - const statusReportUpdate = 58 - await api.statusReport.createStatusReportUpdate.mutate({ 59 - ...props, 60 - }); 61 - if (!statusReportUpdate) return; 62 - await api.emailRouter.sendStatusReport.mutate({ 63 - id: statusReportUpdate.id, 64 - }); 65 - } 66 - toastAction("saved"); 67 - onSubmit?.(); 68 - router.refresh(); 69 - } catch { 70 - toastAction("error"); 71 - } 72 - }); 73 - }; 74 - 75 - return ( 76 - <Form {...form}> 77 - <form 78 - onSubmit={async (e) => { 79 - e.preventDefault(); 80 - form.handleSubmit(handleSubmit)(e); 81 - }} 82 - className="grid w-full gap-6" 83 - > 84 - <General form={form} /> 85 - <Tabs defaultValue="message"> 86 - <TabsList> 87 - <TabsTrigger value="message">Message</TabsTrigger> 88 - <TabsTrigger value="date">Date & Time</TabsTrigger> 89 - </TabsList> 90 - <TabsContent value="message"> 91 - <SectionMessage form={form} /> 92 - </TabsContent> 93 - <TabsContent value="date"> 94 - <SectionDate form={form} /> 95 - </TabsContent> 96 - </Tabs> 97 - <Button className="w-full sm:w-auto" size="lg"> 98 - {!isPending ? "Confirm" : <LoadingAnimation />} 99 - </Button> 100 - </form> 101 - </Form> 102 - ); 103 - }
-64
apps/web/src/components/forms/status-report-update/general.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import { 6 - statusReportStatus, 7 - statusReportStatusSchema, 8 - } from "@openstatus/db/src/schema"; 9 - import type { InsertStatusReportUpdate } from "@openstatus/db/src/schema"; 10 - import { 11 - FormControl, 12 - FormField, 13 - FormItem, 14 - FormLabel, 15 - FormMessage, 16 - RadioGroup, 17 - RadioGroupItem, 18 - } from "@openstatus/ui"; 19 - 20 - import { Icons } from "@/components/icons"; 21 - import { statusDict } from "@/data/incidents-dictionary"; 22 - 23 - interface Props { 24 - form: UseFormReturn<InsertStatusReportUpdate>; 25 - } 26 - export function General({ form }: Props) { 27 - return ( 28 - <FormField 29 - control={form.control} 30 - name="status" 31 - render={({ field }) => ( 32 - <FormItem className="space-y-1 sm:col-span-full"> 33 - <FormLabel>Status</FormLabel> 34 - <FormMessage /> 35 - <RadioGroup 36 - onValueChange={(value) => 37 - field.onChange(statusReportStatusSchema.parse(value)) 38 - } // value is a string 39 - defaultValue={field.value} 40 - className="grid grid-cols-2 gap-4 sm:grid-cols-4" 41 - > 42 - {statusReportStatus.map((status) => { 43 - const { value, label, icon } = statusDict[status]; 44 - const Icon = Icons[icon]; 45 - return ( 46 - <FormItem key={value}> 47 - <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 48 - <FormControl> 49 - <RadioGroupItem value={value} className="sr-only" /> 50 - </FormControl> 51 - <div className="flex w-full items-center justify-center rounded-lg border border-border px-3 py-2 text-center text-muted-foreground text-sm"> 52 - <Icon className="mr-2 h-4 w-4 shrink-0" /> 53 - <span className="truncate">{label}</span> 54 - </div> 55 - </FormLabel> 56 - </FormItem> 57 - ); 58 - })} 59 - </RadioGroup> 60 - </FormItem> 61 - )} 62 - /> 63 - ); 64 - }
-41
apps/web/src/components/forms/status-report-update/section-date.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import type { InsertStatusReportUpdate } from "@openstatus/db/src/schema"; 6 - import { 7 - DateTimePicker, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - } from "@openstatus/ui"; 14 - 15 - interface Props { 16 - form: UseFormReturn<InsertStatusReportUpdate>; 17 - } 18 - export function SectionDate({ form }: Props) { 19 - return ( 20 - <FormField 21 - control={form.control} 22 - name="date" 23 - render={({ field }) => ( 24 - <FormItem> 25 - <FormLabel>Date</FormLabel> 26 - <DateTimePicker 27 - className="max-w-min rounded-md border" 28 - date={new Date(field.value)} 29 - setDate={(date) => { 30 - field.onChange(date); 31 - }} 32 - /> 33 - <FormDescription> 34 - The date and time when the incident took place. 35 - </FormDescription> 36 - <FormMessage /> 37 - </FormItem> 38 - )} 39 - /> 40 - ); 41 - }
-60
apps/web/src/components/forms/status-report-update/section-message.tsx
··· 1 - "use client"; 2 - 3 - import type { UseFormReturn } from "react-hook-form"; 4 - 5 - import type { InsertStatusReportUpdate } from "@openstatus/db/src/schema"; 6 - import { 7 - FormControl, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - Tabs, 14 - TabsContent, 15 - TabsList, 16 - TabsTrigger, 17 - Textarea, 18 - } from "@openstatus/ui"; 19 - 20 - import { Preview } from "@/components/content/preview"; 21 - 22 - interface Props { 23 - form: UseFormReturn<InsertStatusReportUpdate>; 24 - } 25 - export function SectionMessage({ form }: Props) { 26 - return ( 27 - <FormField 28 - control={form.control} 29 - name="message" 30 - render={({ field }) => ( 31 - <FormItem className="sm:col-span-full"> 32 - <FormLabel>Message</FormLabel> 33 - <Tabs defaultValue="write"> 34 - <TabsList> 35 - <TabsTrigger value="write">Write</TabsTrigger> 36 - <TabsTrigger value="preview">Preview</TabsTrigger> 37 - </TabsList> 38 - <TabsContent value="write"> 39 - <FormControl> 40 - <Textarea 41 - placeholder="We are encountering..." 42 - className="h-auto w-full resize-none" 43 - rows={9} 44 - {...field} 45 - /> 46 - </FormControl> 47 - </TabsContent> 48 - <TabsContent value="preview"> 49 - <Preview md={form.getValues("message")} /> 50 - </TabsContent> 51 - </Tabs> 52 - <FormDescription> 53 - Tell your user what&apos;s happening. Supports markdown. 54 - </FormDescription> 55 - <FormMessage /> 56 - </FormItem> 57 - )} 58 - /> 59 - ); 60 - }
-153
apps/web/src/components/forms/status-report/form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { usePathname, useRouter } from "next/navigation"; 5 - import * as React from "react"; 6 - import { useForm } from "react-hook-form"; 7 - 8 - import { insertStatusReportSchema } from "@openstatus/db/src/schema"; 9 - import type { InsertStatusReport, Monitor } from "@openstatus/db/src/schema"; 10 - import { Form } from "@openstatus/ui"; 11 - 12 - import { 13 - Tabs, 14 - TabsContent, 15 - TabsList, 16 - TabsTrigger, 17 - } from "@/components/dashboard/tabs"; 18 - import { toastAction } from "@/lib/toast"; 19 - import { api } from "@/trpc/client"; 20 - import { SaveButton } from "../shared/save-button"; 21 - import { General } from "./general"; 22 - import { SectionConnect } from "./section-connect"; 23 - import { SectionUpdateMessage } from "./section-update-message"; 24 - 25 - interface Props { 26 - defaultSection?: string; 27 - defaultValues?: InsertStatusReport; 28 - monitors?: Monitor[]; 29 - nextUrl?: string; 30 - pageId: number; 31 - } 32 - 33 - export function StatusReportForm({ 34 - defaultSection, 35 - defaultValues, 36 - monitors, 37 - nextUrl, 38 - pageId, 39 - }: Props) { 40 - const form = useForm<InsertStatusReport>({ 41 - resolver: zodResolver(insertStatusReportSchema), 42 - defaultValues: defaultValues 43 - ? { 44 - id: defaultValues.id, 45 - title: defaultValues.title, 46 - status: defaultValues.status, 47 - monitors: defaultValues.monitors, 48 - // include update on creation 49 - message: defaultValues.message, 50 - date: defaultValues.date, 51 - } 52 - : { 53 - status: "investigating", 54 - date: new Date(), 55 - }, 56 - }); 57 - const pathname = usePathname(); 58 - const router = useRouter(); 59 - const [isPending, startTransition] = React.useTransition(); 60 - 61 - const onSubmit = ({ ...props }: InsertStatusReport) => { 62 - startTransition(async () => { 63 - try { 64 - if (defaultValues) { 65 - await api.statusReport.updateStatusReport.mutate({ 66 - pageId, 67 - ...props, 68 - }); 69 - } else { 70 - const { message, date, status, ...rest } = props; 71 - const statusReport = await api.statusReport.createStatusReport.mutate( 72 - { 73 - status, 74 - message, 75 - pageId, 76 - ...rest, 77 - }, 78 - ); 79 - // include update on creation 80 - if (statusReport?.id) { 81 - const statusReportUpdate = 82 - await api.statusReport.createStatusReportUpdate.mutate({ 83 - message, 84 - date, 85 - status, 86 - statusReportId: statusReport.id, 87 - }); 88 - 89 - if (statusReportUpdate) { 90 - await api.emailRouter.sendStatusReport.mutate({ 91 - id: statusReportUpdate.id, 92 - }); 93 - } 94 - } 95 - } 96 - if (nextUrl) { 97 - router.push(nextUrl); 98 - } 99 - router.refresh(); 100 - toastAction("saved"); 101 - } catch { 102 - toastAction("error"); 103 - } 104 - }); 105 - }; 106 - 107 - function onValueChange(value: string) { 108 - // REMINDER: we are not merging the searchParams here 109 - // we are just setting the section to allow refreshing the page 110 - const params = new URLSearchParams(); 111 - params.set("section", value); 112 - router.push(`${pathname}?${params.toString()}`); 113 - } 114 - 115 - return ( 116 - <Form {...form}> 117 - <form 118 - onSubmit={async (e) => { 119 - e.preventDefault(); 120 - form.handleSubmit(onSubmit)(e); 121 - }} 122 - className="grid w-full gap-6" 123 - > 124 - <General form={form} /> 125 - <Tabs 126 - defaultValue={defaultSection} 127 - className="w-full" 128 - onValueChange={onValueChange} 129 - > 130 - <TabsList> 131 - {!defaultValues ? ( 132 - <TabsTrigger value="update-message">Message</TabsTrigger> 133 - ) : null} 134 - <TabsTrigger value="connect">Connect</TabsTrigger> 135 - </TabsList> 136 - {!defaultValues ? ( 137 - <TabsContent value="update-message"> 138 - <SectionUpdateMessage form={form} /> 139 - </TabsContent> 140 - ) : null} 141 - <TabsContent value="connect"> 142 - <SectionConnect form={form} monitors={monitors} /> 143 - </TabsContent> 144 - </Tabs> 145 - <SaveButton 146 - isPending={isPending} 147 - isDirty={form.formState.isDirty} 148 - onSubmit={form.handleSubmit(onSubmit)} 149 - /> 150 - </form> 151 - </Form> 152 - ); 153 - }
-90
apps/web/src/components/forms/status-report/general.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import { 5 - statusReportStatus, 6 - statusReportStatusSchema, 7 - } from "@openstatus/db/src/schema"; 8 - import type { InsertStatusReport } from "@openstatus/db/src/schema"; 9 - import { 10 - FormControl, 11 - FormDescription, 12 - FormField, 13 - FormItem, 14 - FormLabel, 15 - FormMessage, 16 - Input, 17 - RadioGroup, 18 - RadioGroupItem, 19 - } from "@openstatus/ui"; 20 - 21 - import { Icons } from "@/components/icons"; 22 - import { statusDict } from "@/data/incidents-dictionary"; 23 - import { SectionHeader } from "../shared/section-header"; 24 - 25 - interface Props { 26 - form: UseFormReturn<InsertStatusReport>; 27 - } 28 - 29 - export function General({ form }: Props) { 30 - return ( 31 - <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 32 - <SectionHeader 33 - title="Inform" 34 - description="Keep your users informed about what just happened." 35 - /> 36 - <div className="grid gap-4 sm:col-span-2"> 37 - <FormField 38 - control={form.control} 39 - name="title" 40 - render={({ field }) => ( 41 - <FormItem> 42 - <FormLabel>Title</FormLabel> 43 - <FormControl> 44 - <Input placeholder="Downtime..." {...field} /> 45 - </FormControl> 46 - <FormDescription>The title of your outage.</FormDescription> 47 - <FormMessage /> 48 - </FormItem> 49 - )} 50 - /> 51 - <FormField 52 - control={form.control} 53 - name="status" 54 - render={({ field }) => ( 55 - <FormItem className="space-y-1"> 56 - <FormLabel>Status</FormLabel> 57 - <FormDescription>Select the current status.</FormDescription> 58 - <FormMessage /> 59 - <RadioGroup 60 - onValueChange={(value) => 61 - field.onChange(statusReportStatusSchema.parse(value)) 62 - } // value is a string 63 - defaultValue={field.value} 64 - className="grid grid-cols-2 gap-4 sm:grid-cols-4" 65 - > 66 - {statusReportStatus.map((status) => { 67 - const { value, label, icon } = statusDict[status]; 68 - const Icon = Icons[icon]; 69 - return ( 70 - <FormItem key={value}> 71 - <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 72 - <FormControl> 73 - <RadioGroupItem value={value} className="sr-only" /> 74 - </FormControl> 75 - <div className="flex w-full items-center justify-center rounded-lg border border-border px-3 py-2 text-center text-muted-foreground text-sm"> 76 - <Icon className="mr-2 h-4 w-4 shrink-0" /> 77 - <span className="truncate">{label}</span> 78 - </div> 79 - </FormLabel> 80 - </FormItem> 81 - ); 82 - })} 83 - </RadioGroup> 84 - </FormItem> 85 - )} 86 - /> 87 - </div> 88 - </div> 89 - ); 90 - }
-84
apps/web/src/components/forms/status-report/section-connect.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { InsertStatusReport, Monitor } from "@openstatus/db/src/schema"; 5 - import { 6 - FormControl, 7 - FormDescription, 8 - FormField, 9 - FormItem, 10 - FormLabel, 11 - FormMessage, 12 - } from "@openstatus/ui"; 13 - 14 - import { CheckboxLabel } from "../shared/checkbox-label"; 15 - 16 - interface Props { 17 - form: UseFormReturn<InsertStatusReport>; 18 - monitors?: Monitor[]; 19 - } 20 - 21 - export function SectionConnect({ form, monitors }: Props) { 22 - return ( 23 - <div className="grid w-full gap-4"> 24 - <div className="flex flex-col gap-3"> 25 - <FormField 26 - control={form.control} 27 - name="monitors" 28 - render={() => ( 29 - <FormItem> 30 - <div className="mb-4"> 31 - <FormLabel>Monitors</FormLabel> 32 - {/* TODO: second phrase can be set inside of a (?) tooltip */} 33 - <FormDescription> 34 - Select the monitors that you want to refer the incident to. It 35 - will be displayed on the status page they are attached to. 36 - </FormDescription> 37 - </div> 38 - <div className="grid grid-cols-1 grid-rows-1 gap-6 sm:grid-cols-2 md:grid-cols-3"> 39 - {monitors?.map((item) => ( 40 - <FormField 41 - key={item.id} 42 - control={form.control} 43 - name="monitors" 44 - render={({ field }) => { 45 - return ( 46 - <FormItem key={item.id} className="h-full w-full"> 47 - <FormControl className="w-full"> 48 - <CheckboxLabel 49 - id={String(item.id)} 50 - name="monitor" 51 - checked={field.value?.includes(item.id)} 52 - onCheckedChange={(checked) => { 53 - return checked 54 - ? field.onChange([ 55 - ...(field.value || []), 56 - item.id, 57 - ]) 58 - : field.onChange( 59 - field.value?.filter( 60 - (value) => value !== item.id, 61 - ), 62 - ); 63 - }} 64 - > 65 - {item.name} 66 - </CheckboxLabel> 67 - </FormControl> 68 - </FormItem> 69 - ); 70 - }} 71 - /> 72 - ))} 73 - </div> 74 - {!monitors || monitors.length === 0 ? ( 75 - <FormDescription>Missing monitors.</FormDescription> 76 - ) : null} 77 - <FormMessage /> 78 - </FormItem> 79 - )} 80 - /> 81 - </div> 82 - </div> 83 - ); 84 - }
-87
apps/web/src/components/forms/status-report/section-update-message.tsx
··· 1 - "use client"; 2 - import type { UseFormReturn } from "react-hook-form"; 3 - 4 - import type { InsertStatusReport } from "@openstatus/db/src/schema"; 5 - import { 6 - DateTimePickerPopover, 7 - FormControl, 8 - FormDescription, 9 - FormField, 10 - FormItem, 11 - FormLabel, 12 - FormMessage, 13 - Tabs, 14 - TabsContent, 15 - TabsList, 16 - TabsTrigger, 17 - Textarea, 18 - } from "@openstatus/ui"; 19 - 20 - import { Preview } from "@/components/content/preview"; 21 - import { SectionHeader } from "../shared/section-header"; 22 - 23 - interface Props { 24 - form: UseFormReturn<InsertStatusReport>; 25 - } 26 - 27 - export function SectionUpdateMessage({ form }: Props) { 28 - return ( 29 - <div className="grid w-full gap-4"> 30 - <SectionHeader 31 - title="Status Update Message" 32 - description="Describe the current status of the incident." 33 - /> 34 - <FormField 35 - control={form.control} 36 - name="message" 37 - render={({ field }) => ( 38 - <FormItem className="sm:col-span-4"> 39 - <FormLabel>Message</FormLabel> 40 - <Tabs defaultValue="write"> 41 - <TabsList> 42 - <TabsTrigger value="write">Write</TabsTrigger> 43 - <TabsTrigger value="preview">Preview</TabsTrigger> 44 - </TabsList> 45 - <TabsContent value="write"> 46 - <FormControl> 47 - <Textarea 48 - placeholder="We are encountering..." 49 - className="h-auto w-full resize-none" 50 - rows={9} 51 - {...field} 52 - /> 53 - </FormControl> 54 - </TabsContent> 55 - <TabsContent value="preview"> 56 - <Preview md={form.getValues("message")} /> 57 - </TabsContent> 58 - </Tabs> 59 - <FormDescription> 60 - Tell your user what&apos;s happening. Supports markdown. 61 - </FormDescription> 62 - <FormMessage /> 63 - </FormItem> 64 - )} 65 - /> 66 - <FormField 67 - control={form.control} 68 - name="date" 69 - render={({ field }) => ( 70 - <FormItem className="flex flex-col sm:col-span-full"> 71 - <FormLabel>Date</FormLabel> 72 - <DateTimePickerPopover 73 - date={field.value ? new Date(field.value) : new Date()} 74 - setDate={(date) => { 75 - field.onChange(date); 76 - }} 77 - /> 78 - <FormDescription> 79 - The date and time when the incident took place. 80 - </FormDescription> 81 - <FormMessage /> 82 - </FormItem> 83 - )} 84 - /> 85 - </div> 86 - ); 87 - }
-79
apps/web/src/components/forms/workspace-form.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import { useTransition } from "react"; 6 - import { useForm } from "react-hook-form"; 7 - import * as z from "zod"; 8 - 9 - import { 10 - Button, 11 - Form, 12 - FormControl, 13 - FormDescription, 14 - FormField, 15 - FormItem, 16 - FormLabel, 17 - FormMessage, 18 - Input, 19 - } from "@openstatus/ui"; 20 - 21 - import { toastAction } from "@/lib/toast"; 22 - import { api } from "@/trpc/client"; 23 - import { LoadingAnimation } from "../loading-animation"; 24 - 25 - // or insertWorkspaceSchema.pick({ name: true }) and updating name to not be nullable 26 - const schema = z.object({ 27 - name: z.string().min(3, "workspace names must contain at least 3 characters"), 28 - }); 29 - type Schema = z.infer<typeof schema>; 30 - 31 - export function WorkspaceForm({ defaultValues }: { defaultValues: Schema }) { 32 - const form = useForm<Schema>({ 33 - resolver: zodResolver(schema), 34 - defaultValues, 35 - }); 36 - const router = useRouter(); 37 - const [isPending, startTransition] = useTransition(); 38 - 39 - async function onSubmit(data: Schema) { 40 - startTransition(async () => { 41 - try { 42 - await api.workspace.updateWorkspace.mutate(data); 43 - toastAction("saved"); 44 - router.refresh(); 45 - } catch { 46 - toastAction("error"); 47 - } 48 - }); 49 - } 50 - 51 - return ( 52 - <Form {...form}> 53 - <form 54 - onSubmit={form.handleSubmit(onSubmit)} 55 - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 56 - > 57 - <FormField 58 - control={form.control} 59 - name="name" 60 - render={({ field }) => ( 61 - <FormItem className="sm:col-span-4"> 62 - <FormLabel>Name</FormLabel> 63 - <FormControl> 64 - <Input placeholder="Documenso" {...field} /> 65 - </FormControl> 66 - <FormDescription>The name of your workspace.</FormDescription> 67 - <FormMessage /> 68 - </FormItem> 69 - )} 70 - /> 71 - <div className="sm:col-span-full"> 72 - <Button className="w-full sm:w-auto" size="lg"> 73 - {!isPending ? "Confirm" : <LoadingAnimation />} 74 - </Button> 75 - </div> 76 - </form> 77 - </Form> 78 - ); 79 - }
-30
apps/web/src/components/layout/app-footer.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import { Shell } from "../dashboard/shell"; 4 - 5 - export function AppFooter() { 6 - return ( 7 - <footer className="w-full"> 8 - <Shell className="flex items-center justify-between"> 9 - <div className="font-light text-muted-foreground text-xs"> 10 - All rights reserved &copy; 11 - </div> 12 - <div className="text-right text-xs"> 13 - <Link 14 - href="/legal/terms" 15 - className="text-foreground underline underline-offset-4 hover:no-underline" 16 - > 17 - Terms 18 - </Link> 19 - <span className="mx-1 text-muted-foreground/70">&bull;</span> 20 - <Link 21 - href="/legal/privacy" 22 - className="text-foreground underline underline-offset-4 hover:no-underline" 23 - > 24 - Privacy 25 - </Link> 26 - </div> 27 - </Shell> 28 - </footer> 29 - ); 30 - }
-42
apps/web/src/components/layout/app-menu.tsx
··· 1 - "use client"; 2 - 3 - import { ChevronsUpDown } from "lucide-react"; 4 - import { useSelectedLayoutSegment } from "next/navigation"; 5 - import * as React from "react"; 6 - 7 - import { 8 - Collapsible, 9 - CollapsibleContent, 10 - CollapsibleTrigger, 11 - } from "@openstatus/ui/src/components/collapsible"; 12 - 13 - import type { Page } from "@/config/pages"; 14 - import { AppSidebar } from "./app-sidebar"; 15 - 16 - export function AppMenu({ page }: { page?: Page }) { 17 - const [open, setOpen] = React.useState(false); 18 - 19 - const selectedSegment = useSelectedLayoutSegment(); 20 - 21 - if (!page) return null; 22 - 23 - const activeChild = page?.children?.find( 24 - ({ segment }) => segment === selectedSegment, 25 - ); 26 - 27 - return ( 28 - <Collapsible open={open} onOpenChange={(value) => setOpen(value)}> 29 - <CollapsibleTrigger className="flex w-full items-center justify-between"> 30 - <span className="font-medium text-foreground"> 31 - {activeChild?.title} 32 - </span> 33 - <span className="inline-flex h-9 w-9 items-center justify-center rounded-md font-medium text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"> 34 - <ChevronsUpDown className="h-4 w-4" /> 35 - </span> 36 - </CollapsibleTrigger> 37 - <CollapsibleContent className="mt-2"> 38 - <AppSidebar page={page} /> 39 - </CollapsibleContent> 40 - </Collapsible> 41 - ); 42 - }
-20
apps/web/src/components/layout/app-page-layout.tsx
··· 1 - import { Shell } from "@/components/dashboard/shell"; 2 - import { cn } from "@/lib/utils"; 3 - 4 - export default function AppPageLayout({ 5 - children, 6 - className, 7 - }: { 8 - children: React.ReactNode; 9 - className?: string; 10 - }) { 11 - return ( 12 - <Shell className="relative flex flex-1 flex-col overflow-x-hidden"> 13 - <div 14 - className={cn("flex h-full flex-1 flex-col gap-6 md:gap-8", className)} 15 - > 16 - {children} 17 - </div> 18 - </Shell> 19 - ); 20 - }
-39
apps/web/src/components/layout/app-page-with-sidebar-layout.tsx
··· 1 - import { Shell } from "@/components/dashboard/shell"; 2 - import type { PageId } from "@/config/pages"; 3 - import { pagesConfig } from "@/config/pages"; 4 - import { cn } from "@/lib/utils"; 5 - import { AppMenu } from "./app-menu"; 6 - import { AppSidebar } from "./app-sidebar"; 7 - 8 - export default function AppPageWithSidebarLayout({ 9 - id, 10 - className, 11 - children, 12 - }: { 13 - id?: PageId; 14 - className?: string; 15 - children: React.ReactNode; 16 - }) { 17 - const page = pagesConfig.find((page) => page.segment === id); 18 - 19 - return ( 20 - <div className="flex w-full flex-1 flex-col gap-6 lg:flex-row lg:gap-8"> 21 - <Shell className="block py-3 md:py-3 lg:hidden"> 22 - <AppMenu page={page} /> 23 - </Shell> 24 - <Shell className="hidden max-h-[calc(100vh-8rem)] max-w-min shrink-0 lg:sticky lg:top-28 lg:block"> 25 - <AppSidebar page={page} /> 26 - </Shell> 27 - <Shell className="relative flex-1 overflow-hidden"> 28 - <div 29 - className={cn( 30 - "flex h-full flex-1 flex-col gap-6 md:gap-8", 31 - className, 32 - )} 33 - > 34 - {children} 35 - </div> 36 - </Shell> 37 - </div> 38 - ); 39 - }
-52
apps/web/src/components/layout/app-sidebar.tsx
··· 1 - "use client"; 2 - 3 - import { useParams, useSelectedLayoutSegment } from "next/navigation"; 4 - 5 - import type { Page } from "@/config/pages"; 6 - import { ProBanner } from "../billing/pro-banner"; 7 - import { AppLink } from "./app-link"; 8 - 9 - function replacePlaceholders( 10 - template: string, 11 - values: { [key: string]: string }, 12 - ): string { 13 - return template.replace(/\[([^\]]+)\]/g, (_, key) => { 14 - return values[key] || `[${key}]`; 15 - }); 16 - } 17 - 18 - export function AppSidebar({ page }: { page?: Page }) { 19 - const params = useParams<Record<string, string>>(); 20 - const selectedSegment = useSelectedLayoutSegment(); 21 - 22 - if (!page) return null; 23 - 24 - return ( 25 - <div className="flex h-full flex-col justify-between gap-2"> 26 - <div className="grid gap-2"> 27 - <p className="hidden px-3 font-medium text-foreground text-lg lg:block"> 28 - {page?.title} 29 - </p> 30 - <ul className="grid gap-2"> 31 - {page?.children?.map(({ title, segment, icon, disabled, href }) => { 32 - const prefix = `/app/${params.workspaceSlug}`; 33 - return ( 34 - <li key={title} className="w-full"> 35 - <AppLink 36 - label={title} 37 - href={`${prefix}${replacePlaceholders(href, params)}`} 38 - disabled={disabled} 39 - active={segment === selectedSegment} 40 - icon={icon} 41 - /> 42 - </li> 43 - ); 44 - })} 45 - </ul> 46 - </div> 47 - <div className="hidden lg:block"> 48 - <ProBanner /> 49 - </div> 50 - </div> 51 - ); 52 - }
-78
apps/web/src/components/layout/header/app-header.tsx
··· 1 - "use client"; 2 - 3 - import { allChangelogs } from "content-collections"; 4 - import { ArrowUpRight } from "lucide-react"; 5 - import Link from "next/link"; 6 - 7 - import { Button, Skeleton } from "@openstatus/ui"; 8 - 9 - import { Shell } from "@/components/dashboard/shell"; 10 - import { useCookieState } from "@/hooks/use-cookie-state"; 11 - import { AppTabs } from "./app-tabs"; 12 - import { Breadcrumbs } from "./breadcrumbs"; 13 - import { UserNav } from "./user-nav"; 14 - 15 - const lastChangelog = allChangelogs 16 - .sort( 17 - (a, b) => 18 - new Date(a.publishedAt).getTime() - new Date(b.publishedAt).getTime(), 19 - ) 20 - .pop(); 21 - 22 - export function AppHeader() { 23 - const [lastViewed, setLastViewed] = useCookieState( 24 - "last-viewed-changelog", 25 - new Date(0).toISOString(), 26 - ); 27 - 28 - const show = 29 - lastChangelog && lastViewed 30 - ? new Date(lastViewed) < new Date(lastChangelog.publishedAt) 31 - : false; 32 - 33 - return ( 34 - // TODO: discuss amount of top-3 and top-6 35 - <header className="sticky top-2 z-50 w-full border-border"> 36 - <Shell className="bg-background/70 px-3 py-3 backdrop-blur-lg md:px-6 md:py-3"> 37 - <div className="flex w-full items-center justify-between"> 38 - <Breadcrumbs /> 39 - {/* */} 40 - <div className="flex items-center gap-1"> 41 - <ul className="hidden gap-1 sm:flex"> 42 - <li className="w-full"> 43 - <Button variant="link" asChild> 44 - <Link 45 - href="/changelog" 46 - target="_blank" 47 - onClick={() => setLastViewed(new Date().toISOString())} 48 - className="relative" 49 - > 50 - Changelog 51 - {show ? ( 52 - <span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-green-500" /> 53 - ) : null} 54 - </Link> 55 - </Button> 56 - </li> 57 - <li className="w-full"> 58 - <Button variant="link" asChild> 59 - <Link href="/docs" target="_blank" className="group"> 60 - Docs 61 - <ArrowUpRight className="ml-1 h-4 w-4 shrink-0 text-muted-foreground group-hover:text-foreground" /> 62 - </Link> 63 - </Button> 64 - </li> 65 - </ul> 66 - <div className="relative"> 67 - <Skeleton className="h-8 w-8 rounded-full" /> 68 - <div className="absolute inset-0"> 69 - <UserNav /> 70 - </div> 71 - </div> 72 - </div> 73 - </div> 74 - <AppTabs /> 75 - </Shell> 76 - </header> 77 - ); 78 - }
-60
apps/web/src/components/layout/header/app-tabs.tsx
··· 1 - "use client"; 2 - 3 - import { useParams, useSelectedLayoutSegment } from "next/navigation"; 4 - 5 - import { TabsContainer, TabsLink } from "@/components/dashboard/tabs-link"; 6 - import { StatusDot } from "@/components/monitor/status-dot"; 7 - import { pagesConfig } from "@/config/pages"; 8 - import { api } from "@/trpc/client"; 9 - import { useEffect, useState } from "react"; 10 - 11 - export function AppTabs() { 12 - const params = useParams(); 13 - const selectedSegment = useSelectedLayoutSegment(); 14 - 15 - if (!params?.workspaceSlug) return null; 16 - 17 - return ( 18 - <div className="-mb-3"> 19 - <TabsContainer> 20 - {pagesConfig.map(({ title, segment, href }) => { 21 - const active = segment === selectedSegment; 22 - return ( 23 - <TabsLink 24 - key={segment} 25 - active={active} 26 - href={`/app/${params?.workspaceSlug}${href}`} 27 - prefetch={false} 28 - className="relative" 29 - > 30 - {title} 31 - {/* {segment === "incidents" ? <IncidentsDot /> : null} */} 32 - </TabsLink> 33 - ); 34 - })} 35 - </TabsContainer> 36 - </div> 37 - ); 38 - } 39 - 40 - // FIXME: use react-query - once the user resolves the incident, the dot should disappear without refresh 41 - export function IncidentsDot() { 42 - const [open, setOpen] = useState(false); 43 - 44 - useEffect(() => { 45 - async function checkOpenIncidents() { 46 - const incidents = await api.incident.getOpenIncidents.query(); 47 - if (incidents.length) setOpen(true); 48 - } 49 - 50 - checkOpenIncidents(); 51 - }, []); 52 - 53 - if (!open) return null; 54 - 55 - return ( 56 - <div className="absolute top-1 right-1"> 57 - <StatusDot status="error" active /> 58 - </div> 59 - ); 60 - }
-107
apps/web/src/components/layout/header/breadcrumbs.tsx
··· 1 - "use client"; 2 - 3 - import { Slash } from "lucide-react"; 4 - import Image from "next/image"; 5 - import Link from "next/link"; 6 - import { 7 - useParams, 8 - useSelectedLayoutSegment, 9 - useSelectedLayoutSegments, 10 - } from "next/navigation"; 11 - import { Fragment, useEffect, useState } from "react"; 12 - 13 - import { SelectWorkspace } from "@/components/workspace/select-workspace"; 14 - import { notEmpty } from "@/lib/utils"; 15 - import { api } from "@/trpc/client"; 16 - 17 - export function Breadcrumbs() { 18 - const params = useParams(); 19 - // const selectedSegment = useSelectedLayoutSegment(); 20 - // const selectedSegments = useSelectedLayoutSegments(); 21 - // const label = useIdLabel(); 22 - 23 - // // remove route groups like '(overview)' from the segments 24 - // const segmentsWithoutRouteGroup = selectedSegments.filter( 25 - // (segment) => !segment.startsWith("("), 26 - // ); 27 - 28 - // const isRoot = segmentsWithoutRouteGroup.length <= 1; 29 - 30 - // const page = pagesConfig.find(({ segment }) => segment === selectedSegment); 31 - const breadcrumbs = [ 32 - // !isRoot ? page?.title : null, 33 - // label, 34 - ].filter(notEmpty); 35 - 36 - const _isWorkspaceSlug = params.workspaceSlug; 37 - 38 - return ( 39 - <div className="flex items-center"> 40 - <Link href="/app" className="shrink-0"> 41 - <Image 42 - src="/icon.png" 43 - alt="OpenStatus" 44 - height={30} 45 - width={30} 46 - className="rounded-full border border-border" 47 - /> 48 - </Link> 49 - <Slash className="-rotate-12 mr-0.5 ml-2.5 h-4 w-4 text-muted-foreground" /> 50 - {params.workspaceSlug ? ( 51 - <div className="w-40"> 52 - <SelectWorkspace /> 53 - </div> 54 - ) : null} 55 - {breadcrumbs.map((breadcrumb) => ( 56 - <Fragment key={breadcrumb}> 57 - <Slash className="-rotate-12 mr-2.5 ml-0.5 h-4 w-4 text-muted-foreground" /> 58 - <p className="rounded-md font-medium text-primary text-sm"> 59 - {breadcrumb} 60 - </p> 61 - </Fragment> 62 - ))} 63 - </div> 64 - ); 65 - } 66 - 67 - // This is a custom hook that returns the label of the current id 68 - // biome-ignore lint/correctness/noUnusedVariables: <explanation> 69 - function useIdLabel() { 70 - const params = useParams(); 71 - const selectedSegment = useSelectedLayoutSegment(); 72 - const selectedSegments = useSelectedLayoutSegments(); 73 - const [label, setLabel] = useState<string>(); 74 - 75 - // remove route groups like '(overview)' from the segments 76 - const segmentsWithoutRouteGroup = selectedSegments.filter( 77 - (segment) => !segment.startsWith("("), 78 - ); 79 - 80 - const isRoot = segmentsWithoutRouteGroup.length <= 1; 81 - 82 - useEffect(() => { 83 - async function getInfos() { 84 - const { id } = params; 85 - if (!isRoot && id) { 86 - if (selectedSegment === "monitors") { 87 - const monitor = await api.monitor.getMonitorById.query({ 88 - id: Number(id), 89 - }); 90 - if (monitor) setLabel(monitor.name); 91 - } 92 - if (selectedSegment === "status-pages") { 93 - const statusPage = await api.page.getPageById.query({ 94 - id: Number(id), 95 - }); 96 - if (statusPage) setLabel(statusPage.title); 97 - } 98 - } 99 - if (isRoot && label) { 100 - setLabel(undefined); 101 - } 102 - } 103 - getInfos(); 104 - }, [params, selectedSegment, isRoot, label]); 105 - 106 - return label; 107 - }
-77
apps/web/src/components/status-update/delete-status-update.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - import * as React from "react"; 5 - 6 - import { 7 - AlertDialog, 8 - AlertDialogAction, 9 - AlertDialogCancel, 10 - AlertDialogContent, 11 - AlertDialogDescription, 12 - AlertDialogFooter, 13 - AlertDialogHeader, 14 - AlertDialogTitle, 15 - AlertDialogTrigger, 16 - } from "@openstatus/ui/src/components/alert-dialog"; 17 - import { Button } from "@openstatus/ui/src/components/button"; 18 - 19 - import { Icons } from "@/components/icons"; 20 - import { LoadingAnimation } from "@/components/loading-animation"; 21 - import { toastAction } from "@/lib/toast"; 22 - import { api } from "@/trpc/client"; 23 - 24 - export function DeleteStatusReportUpdateButtonIcon({ id }: { id: number }) { 25 - const router = useRouter(); 26 - const [alertOpen, setAlertOpen] = React.useState(false); 27 - const [isPending, startTransition] = React.useTransition(); 28 - 29 - async function onDelete() { 30 - startTransition(async () => { 31 - try { 32 - await api.statusReport.deleteStatusReportUpdate.mutate({ id }); 33 - toastAction("deleted"); 34 - router.refresh(); 35 - setAlertOpen(false); 36 - } catch { 37 - toastAction("error"); 38 - } 39 - }); 40 - } 41 - 42 - return ( 43 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 44 - <AlertDialogTrigger asChild> 45 - <Button 46 - size="icon" 47 - variant="outline" 48 - className="border-destructive/50 text-destructive/80 hover:bg-destructive/10 hover:text-destructive" 49 - > 50 - <Icons.trash className="h-4 w-4" /> 51 - </Button> 52 - </AlertDialogTrigger> 53 - <AlertDialogContent> 54 - <AlertDialogHeader> 55 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 56 - <AlertDialogDescription> 57 - This action cannot be undone. This will permanently delete the 58 - status report update. 59 - </AlertDialogDescription> 60 - </AlertDialogHeader> 61 - <AlertDialogFooter> 62 - <AlertDialogCancel>Cancel</AlertDialogCancel> 63 - <AlertDialogAction 64 - onClick={(e) => { 65 - e.preventDefault(); 66 - onDelete(); 67 - }} 68 - disabled={isPending} 69 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 70 - > 71 - {!isPending ? "Delete" : <LoadingAnimation />} 72 - </AlertDialogAction> 73 - </AlertDialogFooter> 74 - </AlertDialogContent> 75 - </AlertDialog> 76 - ); 77 - }
-49
apps/web/src/components/status-update/edit-status-update.tsx
··· 1 - "use client"; 2 - 3 - import { useState } from "react"; 4 - 5 - import type { InsertStatusReportUpdate } from "@openstatus/db/src/schema"; 6 - import { Button } from "@openstatus/ui/src/components/button"; 7 - import { 8 - Dialog, 9 - DialogContent, 10 - DialogDescription, 11 - DialogHeader, 12 - DialogTitle, 13 - DialogTrigger, 14 - } from "@openstatus/ui/src/components/dialog"; 15 - 16 - import { StatusReportUpdateForm } from "../forms/status-report-update/form"; 17 - import { Icons } from "../icons"; 18 - 19 - export function EditStatusReportUpdateIconButton({ 20 - statusReportId, 21 - statusReportUpdate, 22 - }: { 23 - statusReportId: number; 24 - statusReportUpdate?: InsertStatusReportUpdate; 25 - }) { 26 - const [open, setOpen] = useState(false); 27 - return ( 28 - <Dialog open={open} onOpenChange={setOpen}> 29 - <DialogTrigger asChild> 30 - <Button size="icon" variant="outline"> 31 - <Icons.pencil className="h-4 w-4" /> 32 - </Button> 33 - </DialogTrigger> 34 - <DialogContent className="max-h-screen overflow-y-scroll sm:max-w-[650px]"> 35 - <DialogHeader> 36 - <DialogTitle>Edit Status Report</DialogTitle> 37 - <DialogDescription> 38 - Update your status report with new information. 39 - </DialogDescription> 40 - </DialogHeader> 41 - <StatusReportUpdateForm 42 - statusReportId={statusReportId} 43 - defaultValues={statusReportUpdate} 44 - onSubmit={() => setOpen(false)} 45 - /> 46 - </DialogContent> 47 - </Dialog> 48 - ); 49 - }
-113
apps/web/src/components/status-update/events.tsx
··· 1 - "use client"; 2 - 3 - import { format } from "date-fns"; 4 - import * as React from "react"; 5 - 6 - import type { StatusReportUpdate } from "@openstatus/db/src/schema"; 7 - import { Button } from "@openstatus/ui/src/components/button"; 8 - 9 - import { Icons } from "@/components/icons"; 10 - import { statusDict } from "@/data/incidents-dictionary"; 11 - import { useProcessor } from "@/hooks/use-preprocessor"; 12 - import { cn } from "@/lib/utils"; 13 - import { DeleteStatusReportUpdateButtonIcon } from "./delete-status-update"; 14 - import { EditStatusReportUpdateIconButton } from "./edit-status-update"; 15 - 16 - export function Events({ 17 - statusReportUpdates, 18 - editable = false, 19 - collabsible = false, 20 - }: { 21 - statusReportUpdates: StatusReportUpdate[]; 22 - editable?: boolean; 23 - collabsible?: boolean; 24 - }) { 25 - const [open, toggle] = React.useReducer((open) => !open, false); 26 - 27 - const sortedArray = statusReportUpdates.sort((a, b) => { 28 - return b.date.getTime() - a.date.getTime(); 29 - }); 30 - 31 - const slicedArray = 32 - open || !collabsible 33 - ? sortedArray 34 - : sortedArray.length > 0 35 - ? [sortedArray[0]] 36 - : []; 37 - // 38 - 39 - return ( 40 - <div className="grid gap-3"> 41 - {slicedArray?.map((update, i) => { 42 - const { icon, label } = statusDict[update.status]; 43 - const StatusIcon = Icons[icon]; 44 - return ( 45 - <div 46 - key={update.id} 47 - className={cn( 48 - "group -m-2 relative flex gap-4 border border-transparent p-2", 49 - editable && "hover:rounded-lg hover:bg-accent/40", 50 - )} 51 - > 52 - <div className="relative"> 53 - <div className="rounded-full border border-border bg-background p-2"> 54 - <StatusIcon className="h-4 w-4" /> 55 - </div> 56 - {i !== sortedArray.length - 1 ? ( 57 - <div className="absolute inset-x-0 mx-auto h-full w-[2px] bg-muted" /> 58 - ) : null} 59 - </div> 60 - <div className="mt-1 grid flex-1"> 61 - {editable ? ( 62 - <div className="absolute top-2 right-2 hidden gap-2 group-hover:flex group-active:flex"> 63 - <EditStatusReportUpdateIconButton 64 - statusReportId={update.statusReportId} 65 - statusReportUpdate={update} 66 - /> 67 - <DeleteStatusReportUpdateButtonIcon id={update.id} /> 68 - </div> 69 - ) : undefined} 70 - <div className="flex items-center gap-2"> 71 - <p className="font-medium text-sm">{label}</p> 72 - <p className="mt-px text-muted-foreground text-xs"> 73 - <code> 74 - {format(new Date(update.date), "LLL dd, y HH:mm")} 75 - </code> 76 - </p> 77 - </div> 78 - {/* <p className="max-w-3xl text-sm">{update.message}</p> */} 79 - <EventMessage message={update.message} /> 80 - </div> 81 - </div> 82 - ); 83 - })} 84 - {collabsible && statusReportUpdates.length > 1 ? ( 85 - <div className="text-center"> 86 - <Button variant="ghost" onClick={toggle}> 87 - {open ? "Close" : "More"} 88 - </Button> 89 - </div> 90 - ) : null} 91 - </div> 92 - ); 93 - } 94 - 95 - function EventMessage({ 96 - message, 97 - className, 98 - }: { 99 - message: string; 100 - className?: string; 101 - }) { 102 - const Component = useProcessor(message); // FIXME: make it work with markdown without hook! 103 - return ( 104 - <div 105 - className={cn( 106 - "prose dark:prose-invert prose-sm overflow-hidden text-ellipsis prose-headings:font-cal", // fixes very long words 107 - className, 108 - )} 109 - > 110 - {Component} 111 - </div> 112 - ); 113 - }
-23
apps/web/src/components/status-update/status-badge.tsx
··· 1 - import type { StatusReport } from "@openstatus/db/src/schema"; 2 - import { Badge } from "@openstatus/ui/src/components/badge"; 3 - 4 - import { statusDict } from "@/data/incidents-dictionary"; 5 - import { cn } from "@/lib/utils"; 6 - import { Icons } from "../icons"; 7 - 8 - export function StatusBadge({ 9 - status, 10 - className, 11 - }: { 12 - status: StatusReport["status"]; 13 - className?: string; 14 - }) { 15 - const { label, icon, color } = statusDict[status]; 16 - const Icon = Icons[icon]; 17 - return ( 18 - <Badge variant="outline" className={cn("font-normal", color, className)}> 19 - <Icon className="mr-1 h-3 w-3" /> 20 - {label} 21 - </Badge> 22 - ); 23 - }
-54
apps/web/src/components/status-update/summary.tsx
··· 1 - import { format, formatDistanceStrict } from "date-fns"; 2 - 3 - import type { 4 - Monitor, 5 - StatusReport, 6 - StatusReportUpdate, 7 - } from "@openstatus/db/src/schema"; 8 - import { Badge } from "@openstatus/ui/src/components/badge"; 9 - 10 - import { StatusBadge } from "./status-badge"; 11 - 12 - export function Summary({ 13 - report, 14 - monitors, 15 - }: { 16 - report: StatusReport & { statusReportUpdates: StatusReportUpdate[] }; 17 - monitors: Pick<Monitor, "name" | "id">[]; 18 - }) { 19 - const firstUpdate = report.statusReportUpdates?.[0]; 20 - const lastUpdate = 21 - report.statusReportUpdates?.[report.statusReportUpdates.length - 1]; 22 - 23 - return ( 24 - <div className="grid grid-cols-5 gap-3 text-sm"> 25 - <p className="col-start-1 text-muted-foreground">Started</p> 26 - <p className="col-span-4"> 27 - {firstUpdate ? ( 28 - <code>{format(new Date(firstUpdate.date), "LLL dd, y HH:mm")}</code> 29 - ) : null} 30 - </p> 31 - <p className="col-start-1 text-muted-foreground">Status</p> 32 - <div className="col-span-4 flex items-center gap-2"> 33 - <StatusBadge status={report.status} /> 34 - {firstUpdate && lastUpdate && report.status === "resolved" ? ( 35 - <span className="text-muted-foreground text-xs"> 36 - after {formatDistanceStrict(firstUpdate.date, lastUpdate.date)} 37 - </span> 38 - ) : null} 39 - </div> 40 - <p className="col-start-1 text-muted-foreground">Affected</p> 41 - <ul className="col-span-4 flex gap-2"> 42 - {monitors.length > 0 ? ( 43 - monitors.map(({ name, id }) => ( 44 - <li key={id} className="text-xs"> 45 - <Badge variant="outline">{name}</Badge> 46 - </li> 47 - )) 48 - ) : ( 49 - <li>-</li> 50 - )} 51 - </ul> 52 - </div> 53 - ); 54 - }
-116
apps/web/src/components/workspace/select-workspace.tsx
··· 1 - "use client"; 2 - 3 - import { Check, ChevronsUpDown, Copy, CopyCheck, Plus } from "lucide-react"; 4 - import Link from "next/link"; 5 - import { usePathname } from "next/navigation"; 6 - import * as React from "react"; 7 - 8 - import type { Workspace } from "@openstatus/db/src/schema"; 9 - import { Button } from "@openstatus/ui/src/components/button"; 10 - import { 11 - DropdownMenu, 12 - DropdownMenuContent, 13 - DropdownMenuItem, 14 - DropdownMenuLabel, 15 - DropdownMenuSeparator, 16 - DropdownMenuTrigger, 17 - } from "@openstatus/ui/src/components/dropdown-menu"; 18 - import { Skeleton } from "@openstatus/ui/src/components/skeleton"; 19 - 20 - import { copyToClipboard } from "@/lib/utils"; 21 - import { api } from "@/trpc/client"; 22 - 23 - export function SelectWorkspace() { 24 - const [workspaces, setWorkspaces] = React.useState<Workspace[]>([]); 25 - const [active, setActive] = React.useState<string>(); 26 - const pathname = usePathname(); 27 - const [hasCopied, setHasCopied] = React.useState(false); 28 - 29 - React.useEffect(() => { 30 - if (hasCopied) { 31 - setTimeout(() => { 32 - setHasCopied(false); 33 - }, 2000); 34 - } 35 - }, [hasCopied]); 36 - 37 - React.useEffect(() => { 38 - if (pathname?.split("/")?.[2] && workspaces.length > 0) { 39 - setActive(pathname?.split("/")?.[2]); 40 - } 41 - }, [pathname, workspaces]); 42 - 43 - React.useEffect(() => { 44 - // REMINDER: avoid prop drilling to get data from the layout.tsx component. instead use client trpc 45 - async function fetchWorkspaces() { 46 - const _workspaces = await api.workspace.getUserWorkspaces.query(); 47 - setWorkspaces(_workspaces); 48 - } 49 - fetchWorkspaces(); 50 - }, []); 51 - 52 - return ( 53 - <DropdownMenu> 54 - <DropdownMenuTrigger asChild> 55 - <Button 56 - variant="ghost" 57 - className="flex w-full items-center justify-between" 58 - > 59 - {active ? ( 60 - <span className="truncate">{active}</span> 61 - ) : ( 62 - <Skeleton className="h-5 w-full" /> 63 - )} 64 - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 65 - </Button> 66 - </DropdownMenuTrigger> 67 - <DropdownMenuContent 68 - style={{ width: "var(--radix-dropdown-menu-trigger-width)" }} 69 - > 70 - <DropdownMenuLabel>Workspaces</DropdownMenuLabel> 71 - <DropdownMenuSeparator /> 72 - {workspaces.map((workspace) => ( 73 - <DropdownMenuItem key={workspace.id} asChild> 74 - <a 75 - href={`/app/${workspace.slug}/monitors`} 76 - className="group justify-between" 77 - > 78 - <span className="truncate">{workspace.slug}</span> 79 - <Button 80 - type="button" 81 - variant="ghost" 82 - size="icon" 83 - className="mx-0.5 hidden h-5 w-5 group-hover:block" 84 - onClick={(e) => { 85 - e.stopPropagation(); 86 - e.preventDefault(); 87 - copyToClipboard(workspace.slug); 88 - setHasCopied(true); 89 - }} 90 - > 91 - {!hasCopied ? ( 92 - <Copy className="h-3 w-3" /> 93 - ) : ( 94 - <CopyCheck className="h-3 w-3" /> 95 - )} 96 - </Button> 97 - {active === workspace.slug ? ( 98 - <Check className="ml-2 h-4 w-4 shrink-0" /> 99 - ) : null} 100 - </a> 101 - </DropdownMenuItem> 102 - ))} 103 - <DropdownMenuSeparator /> 104 - <DropdownMenuItem asChild> 105 - <Link 106 - href={`/app/${active}/settings/team`} 107 - className="flex items-center justify-between" 108 - > 109 - Invite Members 110 - <Plus className="ml-2 h-4 w-4" /> 111 - </Link> 112 - </DropdownMenuItem> 113 - </DropdownMenuContent> 114 - </DropdownMenu> 115 - ); 116 - }