Openstatus www.openstatus.dev

Feat/onboarding (#310)

* feat: onboarding

* wip:

authored by

Maximilian Kaske and committed by
GitHub
167e83ee 22cc725a

+766 -91
+19
apps/web/src/app/api/analytics/route.ts
··· 1 + import { auth } from "@clerk/nextjs"; 2 + 3 + import { analytics, trackAnalytics } from "@openstatus/analytics"; 4 + 5 + /** 6 + * Simple Jitsu Event 7 + */ 8 + export async function GET() { 9 + const { userId } = auth(); 10 + 11 + await analytics.identify(userId, { 12 + userId: userId, 13 + }); 14 + await trackAnalytics({ 15 + event: "User Vercel Beta", 16 + }); 17 + 18 + return new Response("OK", { status: 200 }); 19 + }
+25 -15
apps/web/src/app/api/checker/regions/_checker.ts
··· 9 9 } from "@openstatus/tinybird"; 10 10 11 11 import { env } from "@/env"; 12 + import type { Payload } from "../schema"; 12 13 import { payloadSchema } from "../schema"; 13 14 14 15 export const monitorSchema = tbIngestPingResponse.pick({ ··· 78 79 throw new Error("Invalid response body"); 79 80 } 80 81 81 - const headers = 82 - result.data?.headers?.reduce((o, v) => ({ ...o, [v.key]: v.value }), {}) || 83 - {}; 84 - 85 82 try { 86 83 const startTime = Date.now(); 87 - const res = await fetch(result.data?.url, { 88 - method: result.data?.method, 89 - cache: "no-store", 90 - headers: { 91 - "OpenStatus-Ping": "true", 92 - ...headers, 93 - }, 94 - // Avoid having "TypeError: Request with a GET or HEAD method cannot have a body." error 95 - ...(result.data.method !== "GET" && { body: result.data?.body }), 96 - }); 97 - 84 + const res = await ping(result.data); 98 85 const endTime = Date.now(); 99 86 const latency = endTime - startTime; 100 87 await monitor(res, result.data, region, latency); ··· 111 98 } 112 99 } 113 100 }; 101 + 102 + export const ping = async ( 103 + data: Pick<Payload, "headers" | "body" | "method" | "url">, 104 + ) => { 105 + const headers = 106 + data?.headers?.reduce((o, v) => { 107 + if (v.key.trim() === "") return o; // removes empty keys from the header 108 + return { ...o, [v.key]: v.value }; 109 + }, {}) || {}; 110 + 111 + const res = await fetch(data?.url, { 112 + method: data?.method, 113 + cache: "no-store", 114 + headers: { 115 + "OpenStatus-Ping": "true", 116 + ...headers, 117 + }, 118 + // Avoid having "TypeError: Request with a GET or HEAD method cannot have a body." error 119 + ...(data.method !== "GET" && { body: data?.body }), 120 + }); 121 + 122 + return res; 123 + };
+2
apps/web/src/app/api/checker/schema.ts
··· 12 12 cronTimestamp: z.number(), 13 13 pageIds: z.array(z.string()), 14 14 }); 15 + 16 + export type Payload = z.infer<typeof payloadSchema>;
+30
apps/web/src/app/api/checker/test/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + 3 + import { ping } from "../regions/_checker"; 4 + import { payloadSchema } from "../schema"; 5 + 6 + export const runtime = "edge"; 7 + export const preferredRegion = "auto"; 8 + export const dynamic = "force-dynamic"; 9 + // Fix is a random region let's figure where does vercel push it 10 + 11 + export async function POST(request: Request) { 12 + const json = await request.json(); 13 + const _valid = payloadSchema 14 + .pick({ url: true, method: true, headers: true, body: true }) 15 + .safeParse(json); 16 + 17 + if (!_valid.success) { 18 + return NextResponse.json({ success: false }, { status: 400 }); 19 + } 20 + 21 + const check = await ping(_valid.data); 22 + 23 + console.log(check.status); 24 + 25 + if (!check.ok) { 26 + return NextResponse.json({ success: false }, { status: 400 }); 27 + } 28 + 29 + return NextResponse.json({ success: true }); 30 + }
+1 -1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/layout.tsx
··· 24 24 <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4 lg:p-8"> 25 25 <AppHeader /> 26 26 <div className="flex w-full flex-1 gap-6 lg:gap-8"> 27 - <Shell className="hidden max-h-[calc(100vh-9rem)] max-w-min shrink-0 lg:sticky lg:top-20 lg:block"> 27 + <Shell className="hidden max-h-[calc(100vh-9rem)] max-w-min shrink-0 lg:sticky lg:top-28 lg:block"> 28 28 <AppSidebar /> 29 29 </Shell> 30 30 <main className="z-10 flex w-full flex-1 flex-col items-start justify-center">
+5 -32
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx
··· 2 2 import * as z from "zod"; 3 3 4 4 import { Header } from "@/components/dashboard/header"; 5 - import { AdvancedMonitorForm } from "@/components/forms/advanced-monitor-form"; 6 5 import { MonitorForm } from "@/components/forms/montitor-form"; 7 - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 8 6 import { api } from "@/trpc/server"; 9 7 10 8 /** ··· 38 36 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 39 37 <Header title="Monitor" description="Upsert your monitor." /> 40 38 <div className="col-span-full"> 41 - <Tabs defaultValue="settings" className="relative mr-auto w-full"> 42 - <TabsList className="h-9 w-full justify-start rounded-none border-b bg-transparent p-0"> 43 - <TabsTrigger 44 - className="text-muted-foreground data-[state=active]:border-b-primary data-[state=active]:text-foreground relative h-9 rounded-none border-b-2 border-b-transparent bg-transparent px-4 pb-3 pt-2 font-semibold shadow-none transition-none data-[state=active]:shadow-none" 45 - value="settings" 46 - > 47 - Settings 48 - </TabsTrigger> 49 - <TabsTrigger 50 - className="text-muted-foreground data-[state=active]:border-b-primary data-[state=active]:text-foreground relative h-9 rounded-none border-b-2 border-b-transparent bg-transparent px-4 pb-3 pt-2 font-semibold shadow-none transition-none data-[state=active]:shadow-none" 51 - value="advanced" 52 - disabled={!monitor} 53 - > 54 - Advanced 55 - </TabsTrigger> 56 - </TabsList> 57 - <TabsContent value="settings" className="pt-3"> 58 - <MonitorForm 59 - workspaceSlug={params.workspaceSlug} 60 - defaultValues={monitor || undefined} 61 - plan={workspace?.plan} 62 - /> 63 - </TabsContent> 64 - <TabsContent value="advanced" className="pt-3"> 65 - <AdvancedMonitorForm 66 - workspaceSlug={params.workspaceSlug} 67 - defaultValues={monitor || undefined} 68 - /> 69 - </TabsContent> 70 - </Tabs> 39 + <MonitorForm 40 + workspaceSlug={params.workspaceSlug} 41 + defaultValues={monitor || undefined} 42 + plan={workspace?.plan} 43 + /> 71 44 </div> 72 45 </div> 73 46 );
+84
apps/web/src/app/app/(dashboard)/onboarding/_components/description.tsx
··· 1 + import { Icons } from "@/components/icons"; 2 + import { cn } from "@/lib/utils"; 3 + 4 + const steps = ["monitor", "status-page"] as const; 5 + // potentially move to `config/onboarding` 6 + const onboardingConfig = { 7 + monitor: { 8 + icon: "activity", 9 + name: "Monitor", 10 + description: [ 11 + { 12 + title: "What is a monitor?", 13 + text: "A monitor is a website or api endpoint that you are going to ping on a regular basis.", 14 + }, 15 + { 16 + title: "How to create monitors?", 17 + text: "You can create a monitor like you are about to via our dashboard or with our API. E.g. you can create a monitor for every instance you deploy programmatically.", 18 + }, 19 + ], 20 + }, 21 + "status-page": { 22 + icon: "panel-top", 23 + name: "Status Page", 24 + description: [ 25 + { 26 + title: "How to use status pages?", 27 + text: "Add the monitors you'd like to track to a status page and inform your users if your services are down.", 28 + }, 29 + { 30 + title: "Subdomain or custom domains?", 31 + text: "Start with a unique subdomain slug and move to your own custom domains afterwards by updating the DNS settings.", 32 + }, 33 + ], 34 + }, 35 + } as const; 36 + 37 + export function Description({ 38 + step, 39 + }: { 40 + step?: keyof typeof onboardingConfig; 41 + }) { 42 + const config = step && onboardingConfig[step]; 43 + return ( 44 + <div className="border-border flex h-full flex-col gap-6 border-l pl-6 md:pl-8"> 45 + <div className="flex gap-5"> 46 + {steps.map((item, i) => { 47 + const { icon, name } = onboardingConfig[item]; 48 + const StepIcon = Icons[icon]; 49 + const active = step === item; 50 + return ( 51 + <div key={i} className="flex items-center gap-2"> 52 + <div 53 + className={cn( 54 + "border-border max-w-max rounded-full border p-2", 55 + active && "border-accent-foreground", 56 + )} 57 + > 58 + <StepIcon className="h-4 w-4" /> 59 + </div> 60 + <p 61 + className={cn( 62 + "text-xs", 63 + active 64 + ? "text-foreground font-semibold" 65 + : "text-muted-foreground", 66 + )} 67 + > 68 + {name} 69 + </p> 70 + </div> 71 + ); 72 + })} 73 + </div> 74 + {config?.description.map(({ title, text }, i) => { 75 + return ( 76 + <dl key={i} className="grid gap-2"> 77 + <dt>{title}</dt> 78 + <dd className="text-muted-foreground text-sm">{text}</dd> 79 + </dl> 80 + ); 81 + })} 82 + </div> 83 + ); 84 + }
+115
apps/web/src/app/app/(dashboard)/onboarding/_components/path.tsx
··· 1 + "use client"; 2 + 3 + import { useRouter } from "next/navigation"; 4 + 5 + import { Badge } from "@/components/ui/badge"; 6 + import { Button } from "@/components/ui/button"; 7 + import { 8 + Card, 9 + CardDescription, 10 + CardFooter, 11 + CardHeader, 12 + CardTitle, 13 + } from "@/components/ui/card"; 14 + import { 15 + Dialog, 16 + DialogContent, 17 + DialogDescription, 18 + DialogHeader, 19 + DialogTitle, 20 + DialogTrigger, 21 + } from "@/components/ui/dialog"; 22 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 23 + 24 + export function Path() { 25 + return ( 26 + <div className="grid gap-6 md:grid-cols-4"> 27 + <HttpCard /> 28 + <VercelCard /> 29 + </div> 30 + ); 31 + } 32 + 33 + function HttpCard() { 34 + const updateSearchParams = useUpdateSearchParams(); 35 + const router = useRouter(); 36 + return ( 37 + <Card className="relative flex flex-col justify-between md:col-span-2"> 38 + <Badge 39 + variant="secondary" 40 + className="bg-background text-muted-foreground absolute -top-3 right-2" 41 + > 42 + Standard 43 + </Badge> 44 + <CardHeader> 45 + <CardTitle>HTTP Endpoint</CardTitle> 46 + <CardDescription> 47 + Monitor your API or website via POST or GET requests including custom 48 + headers and body payload. 49 + </CardDescription> 50 + </CardHeader> 51 + <CardFooter> 52 + <Button 53 + size="lg" 54 + onClick={() => { 55 + router.push(`?${updateSearchParams({ path: "http" })}`); 56 + }} 57 + > 58 + Continue 59 + </Button> 60 + </CardFooter> 61 + </Card> 62 + ); 63 + } 64 + 65 + function VercelCard() { 66 + async function trackEvent() { 67 + await fetch("/api/analytics"); 68 + } 69 + 70 + return ( 71 + <Card className="relative flex flex-col justify-between md:col-span-2"> 72 + <Badge 73 + variant="secondary" 74 + className="bg-background text-muted-foreground absolute -top-3 right-2" 75 + > 76 + Beta 77 + </Badge> 78 + <CardHeader> 79 + <CardTitle>Vercel Integration</CardTitle> 80 + <CardDescription> 81 + Monitor your Vercel applications with ease. 82 + </CardDescription> 83 + </CardHeader> 84 + <CardFooter> 85 + <Dialog> 86 + <DialogTrigger asChild> 87 + <Button size="lg" onClick={trackEvent}> 88 + Continue 89 + </Button> 90 + </DialogTrigger> 91 + <DialogContent> 92 + <DialogHeader> 93 + <DialogTitle className="flex items-center gap-2"> 94 + Vercel Integration <Badge>Beta</Badge> 95 + </DialogTitle> 96 + <DialogDescription> 97 + The integration is currently in closed beta. 98 + </DialogDescription> 99 + </DialogHeader> 100 + <div> 101 + <p className=""> 102 + Please contact us:{" "} 103 + <Button variant="link" className="font-mono" asChild> 104 + <a href="mailto:thibault@openstatus.dev"> 105 + thibault@openstatus.dev 106 + </a> 107 + </Button> 108 + </p> 109 + </div> 110 + </DialogContent> 111 + </Dialog> 112 + </CardFooter> 113 + </Card> 114 + ); 115 + }
+22
apps/web/src/app/app/(dashboard)/onboarding/layout.tsx
··· 1 + import * as React from "react"; 2 + 3 + import { Shell } from "@/components/dashboard/shell"; 4 + import { AppHeader } from "@/components/layout/app-header"; 5 + 6 + // TODO: make the container min-h-screen and the footer below! 7 + export default async function AppLayout({ 8 + children, 9 + }: { 10 + children: React.ReactNode; 11 + }) { 12 + return ( 13 + <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4 lg:p-8"> 14 + <AppHeader /> 15 + <div className="flex w-full flex-1 gap-6 lg:gap-8"> 16 + <main className="z-10 flex w-full flex-1 flex-col items-start justify-center"> 17 + <Shell className="relative flex-1">{children}</Shell> 18 + </main> 19 + </div> 20 + </div> 21 + ); 22 + }
+117
apps/web/src/app/app/(dashboard)/onboarding/page.tsx
··· 1 + import Link from "next/link"; 2 + import { notFound } from "next/navigation"; 3 + import * as z from "zod"; 4 + 5 + import { Header } from "@/components/dashboard/header"; 6 + import { MonitorForm } from "@/components/forms/montitor-form"; 7 + import { StatusPageForm } from "@/components/forms/status-page-form"; 8 + import { Button } from "@/components/ui/button"; 9 + import { api } from "@/trpc/server"; 10 + import { Description } from "./_components/description"; 11 + import { Path } from "./_components/path"; 12 + 13 + /** 14 + * allowed URL search params 15 + */ 16 + const searchParamsSchema = z.object({ 17 + path: z.string().optional(), // "vercel" | "http" 18 + id: z.coerce.number().optional(), // monitorId 19 + workspaceSlug: z.string().optional(), 20 + }); 21 + 22 + export default async function Onboarding({ 23 + searchParams, 24 + }: { 25 + searchParams: { [key: string]: string | string[] | undefined }; 26 + }) { 27 + const search = searchParamsSchema.safeParse(searchParams); 28 + 29 + if (!search.success) { 30 + return notFound(); 31 + } 32 + 33 + // Instead of having the workspaceSlug in the search params, we can get it from the auth user 34 + const { workspaceSlug, id: monitorId, path } = search.data; 35 + 36 + if (!workspaceSlug) { 37 + return "Waiting for Slug"; 38 + } 39 + 40 + if (!path) { 41 + return ( 42 + <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 43 + <Header 44 + title="Get Started" 45 + description="Create your first status page." 46 + actions={ 47 + <Button variant="link" className="text-muted-foreground"> 48 + <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 49 + </Button> 50 + } 51 + /> 52 + <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 53 + <div className="md:col-span-2"> 54 + <Path /> 55 + </div> 56 + <div className="hidden h-full md:col-span-1 md:block"> 57 + <Description /> 58 + </div> 59 + </div> 60 + </div> 61 + ); 62 + } 63 + 64 + const allMonitors = await api.monitor.getMonitorsByWorkspace.query({ 65 + workspaceSlug, 66 + }); 67 + 68 + if (!monitorId) { 69 + return ( 70 + <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 71 + <Header 72 + title="Get Started" 73 + description="Create your first monitor." 74 + actions={ 75 + <Button variant="link" className="text-muted-foreground"> 76 + <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 77 + </Button> 78 + } 79 + /> 80 + <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 81 + <div className="md:col-span-2"> 82 + <MonitorForm {...{ workspaceSlug }} /> 83 + </div> 84 + <div className="hidden h-full md:col-span-1 md:block"> 85 + <Description step="monitor" /> 86 + </div> 87 + </div> 88 + </div> 89 + ); 90 + } 91 + 92 + return ( 93 + <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 94 + <Header 95 + title="Get Started" 96 + description="Create your first status page." 97 + actions={ 98 + <Button variant="link" className="text-muted-foreground"> 99 + <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 100 + </Button> 101 + } 102 + /> 103 + <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 104 + <div className="md:col-span-2"> 105 + <StatusPageForm 106 + {...{ workspaceSlug, allMonitors }} 107 + nextUrl={`/app/${workspaceSlug}/status-pages`} 108 + checkAllMonitors 109 + /> 110 + </div> 111 + <div className="hidden h-full md:col-span-1 md:block"> 112 + <Description step="status-page" /> 113 + </div> 114 + </div> 115 + </div> 116 + ); 117 + }
+18 -8
apps/web/src/app/app/page.tsx
··· 4 4 import { useRouter } from "next/navigation"; 5 5 6 6 import { Shell } from "@/components/dashboard/shell"; 7 + import { AppHeader } from "@/components/layout/app-header"; 7 8 import { LoadingAnimation } from "@/components/loading-animation"; 8 9 9 10 // TODO: discuss how to make that page a bit more enjoyable ··· 14 15 setTimeout(() => router.refresh(), 1000); 15 16 16 17 return ( 17 - <div className="flex min-h-screen flex-col items-center justify-center"> 18 - <Shell className="mx-auto grid w-auto gap-4"> 19 - <div className="grid gap-1 text-center"> 20 - <p className="text-lg font-bold">Creating Workspace</p> 21 - <p className="text-muted-foreground">Should be done in a second.</p> 22 - </div> 23 - <LoadingAnimation variant="inverse" size="lg" /> 24 - </Shell> 18 + <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4 lg:p-8"> 19 + <AppHeader /> 20 + <div className="flex w-full flex-1 gap-6 lg:gap-8"> 21 + <main className="z-10 flex w-full flex-1 flex-col items-start justify-center"> 22 + <Shell className="relative flex flex-1 flex-col items-center justify-center"> 23 + <div className="grid gap-4"> 24 + <div className="text-center"> 25 + <p className="mb-1 text-lg font-bold">Creating Workspace</p> 26 + <p className="text-muted-foreground"> 27 + Should be done in a second. 28 + </p> 29 + </div> 30 + <LoadingAnimation variant="inverse" size="lg" /> 31 + </div> 32 + </Shell> 33 + </main> 34 + </div> 25 35 </div> 26 36 ); 27 37 }
+19
apps/web/src/app/status-page/[domain]/_components/user-button.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import { useUser } from "@clerk/nextjs"; 5 + 6 + import { Button } from "@/components/ui/button"; 7 + 8 + // Create a button only displayed if you are logged in and are the owner of the status page 9 + export function UserButton() { 10 + const { isSignedIn } = useUser(); 11 + if (isSignedIn) { 12 + return ( 13 + <Button asChild> 14 + <Link href="https://openstatus.dev/app">OpenStatus Dashboard</Link> 15 + </Button> 16 + ); 17 + } 18 + return null; 19 + }
+1
apps/web/src/app/status-page/[domain]/layout.tsx
··· 1 1 import { Shell } from "@/components/dashboard/shell"; 2 2 import NavigationLink from "./_components/navigation-link"; 3 + import { UserButton } from "./_components/user-button"; 3 4 4 5 export default function StatusPageLayout({ 5 6 children,
+1 -1
apps/web/src/components/forms/custom-domain-form.tsx
··· 115 115 )} 116 116 /> 117 117 <div className="sm:col-span-full"> 118 - <Button className="w-full sm:w-auto"> 118 + <Button className="w-full sm:w-auto" size="lg"> 119 119 {!isPending ? "Confirm" : <LoadingAnimation />} 120 120 </Button> 121 121 </div>
+1 -1
apps/web/src/components/forms/incident-form.tsx
··· 287 287 )} 288 288 /> 289 289 <div className="sm:col-span-full"> 290 - <Button className="w-full sm:w-auto"> 290 + <Button className="w-full sm:w-auto" size="lg"> 291 291 {!isPending ? "Confirm" : <LoadingAnimation />} 292 292 </Button> 293 293 </div>
+1 -1
apps/web/src/components/forms/incident-update-form.tsx
··· 176 176 )} 177 177 /> 178 178 <div className="sm:col-span-full"> 179 - <Button className="w-full sm:w-auto"> 179 + <Button className="w-full sm:w-auto" size="lg"> 180 180 {!isPending ? "Confirm" : <LoadingAnimation />} 181 181 </Button> 182 182 </div>
+246 -23
apps/web/src/components/forms/montitor-form.tsx
··· 1 1 "use client"; 2 2 3 + import { METHODS } from "http"; 3 4 import * as React from "react"; 4 5 import { useRouter } from "next/navigation"; 5 6 import { zodResolver } from "@hookform/resolvers/zod"; 6 - import { Check, ChevronsUpDown } from "lucide-react"; 7 - import { useForm } from "react-hook-form"; 8 - import type * as z from "zod"; 7 + import { Check, ChevronsUpDown, Wand2, X } from "lucide-react"; 8 + import { useFieldArray, useForm } from "react-hook-form"; 9 + import * as z from "zod"; 9 10 10 11 import { 11 12 insertMonitorSchema, ··· 44 45 SelectValue, 45 46 } from "@/components/ui/select"; 46 47 import { Switch } from "@/components/ui/switch"; 48 + import { Textarea } from "@/components/ui/textarea"; 49 + import { 50 + Tooltip, 51 + TooltipContent, 52 + TooltipProvider, 53 + TooltipTrigger, 54 + } from "@/components/ui/tooltip"; 47 55 import { regionsDict } from "@/data/regions-dictionary"; 48 56 import { useToastAction } from "@/hooks/use-toast-action"; 57 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 49 58 import { cn } from "@/lib/utils"; 50 59 import { api } from "@/trpc/client"; 51 60 import { LoadingAnimation } from "../loading-animation"; 61 + import { InputWithAddons } from "../ui/input-with-addons"; 52 62 53 63 const cronJobs = [ 54 64 { value: "1m", label: "1 minute" }, ··· 58 68 { value: "1h", label: "1 hour" }, 59 69 ] as const; 60 70 61 - type MonitorProps = z.infer<typeof insertMonitorSchema>; 71 + const methods = ["POST", "GET"] as const; 72 + const methodsEnum = z.enum(methods); 73 + 74 + const headersSchema = z 75 + .array(z.object({ key: z.string(), value: z.string() })) 76 + .optional(); 77 + 78 + const advancedSchema = z.object({ 79 + method: methodsEnum, 80 + body: z.string().optional(), 81 + headers: headersSchema, 82 + }); 83 + 84 + const mergedSchema = insertMonitorSchema.merge(advancedSchema); 85 + 86 + type MonitorProps = z.infer<typeof mergedSchema>; 62 87 63 88 interface Props { 64 89 defaultValues?: MonitorProps; ··· 72 97 plan = "free", 73 98 }: Props) { 74 99 const form = useForm<MonitorProps>({ 75 - resolver: zodResolver(insertMonitorSchema), // too much - we should only validate the values we ask inside of the form! 100 + resolver: zodResolver(mergedSchema), // too much - we should only validate the values we ask inside of the form! 76 101 defaultValues: { 77 102 url: defaultValues?.url || "", 78 103 name: defaultValues?.name || "", ··· 81 106 active: defaultValues?.active ?? true, 82 107 id: defaultValues?.id || undefined, 83 108 regions: defaultValues?.regions || [], 109 + headers: Boolean(defaultValues?.headers?.length) 110 + ? defaultValues?.headers 111 + : [{ key: "", value: "" }], 112 + body: defaultValues?.body ?? "", 113 + method: defaultValues?.method ?? "GET", 84 114 }, 85 115 }); 86 116 const router = useRouter(); 87 117 const [isPending, startTransition] = React.useTransition(); 118 + const [isTestPending, startTestTransition] = React.useTransition(); 88 119 const { toast } = useToastAction(); 120 + const watchMethod = form.watch("method"); 121 + const updateSearchParams = useUpdateSearchParams(); 122 + 123 + const { fields, append, remove } = useFieldArray({ 124 + name: "headers", 125 + control: form.control, 126 + }); 89 127 90 128 const onSubmit = ({ ...props }: MonitorProps) => { 91 129 startTransition(async () => { ··· 98 136 data: props, 99 137 workspaceSlug, 100 138 }); 101 - router.replace(`./edit?id=${monitor?.id}`); // to stay on same page and enable 'Advanced' tab 139 + const id = monitor?.id || null; 140 + router.replace(`?${updateSearchParams({ id })}`); 102 141 } 103 142 router.refresh(); 104 143 toast("saved"); ··· 108 147 }); 109 148 }; 110 149 150 + const validateJSON = (value?: string) => { 151 + if (!value) return; 152 + try { 153 + const obj = JSON.parse(value) as Record<string, unknown>; 154 + form.clearErrors("body"); 155 + return obj; 156 + } catch (e) { 157 + form.setError("body", { 158 + message: "Not a valid JSON object", 159 + }); 160 + return false; 161 + } 162 + }; 163 + 164 + const onPrettifyJSON = () => { 165 + const body = form.getValues("body"); 166 + const obj = validateJSON(body); 167 + if (obj) { 168 + const pretty = JSON.stringify(obj, undefined, 4); 169 + form.setValue("body", pretty); 170 + } 171 + }; 172 + 173 + const sendTestPing = () => { 174 + startTestTransition(async () => { 175 + const res = await fetch(`/api/checker/test`, { 176 + method: "POST", 177 + headers: new Headers({ 178 + "Content-Type": "application/json", 179 + }), 180 + body: JSON.stringify({ 181 + url: form.getValues("url"), 182 + body: form.getValues("body"), 183 + method: form.getValues("method"), 184 + headers: form.getValues("headers"), 185 + }), 186 + }); 187 + if (res.ok) { 188 + toast("test-success"); 189 + } else { 190 + toast("test-error"); 191 + } 192 + }); 193 + }; 194 + 111 195 const limit = allPlans[plan].limits.periodicity; 112 196 113 197 return ( ··· 125 209 <FormControl> 126 210 <Input placeholder="Documenso" {...field} /> 127 211 </FormControl> 128 - <FormDescription> 129 - The name of the monitor displayed on the status page. 130 - </FormDescription> 212 + <FormDescription>Displayed on the status page.</FormDescription> 131 213 <FormMessage /> 132 214 </FormItem> 133 215 )} 134 216 /> 135 217 <FormField 136 218 control={form.control} 137 - name="url" 219 + name="description" 138 220 render={({ field }) => ( 139 - <FormItem className="sm:col-span-4"> 140 - <FormLabel>URL</FormLabel> 221 + <FormItem className="sm:col-span-5"> 222 + <FormLabel>Description</FormLabel> 141 223 <FormControl> 142 - {/* Should we use `InputWithAddons here? */} 143 224 <Input 144 - placeholder="https://documenso.com/api/health" 225 + placeholder="Determines the api health of our services." 145 226 {...field} 146 227 /> 147 228 </FormControl> 148 229 <FormDescription> 149 - Here is the URL you want to monitor.{" "} 230 + Provide your users with information about it.{" "} 150 231 </FormDescription> 151 232 <FormMessage /> 152 233 </FormItem> ··· 154 235 /> 155 236 <FormField 156 237 control={form.control} 157 - name="description" 238 + name="method" 239 + render={({ field }) => ( 240 + <FormItem className="sm:col-span-1 sm:col-start-1 sm:self-baseline"> 241 + <FormLabel>Method</FormLabel> 242 + <Select 243 + onValueChange={(value) => { 244 + field.onChange(methodsEnum.parse(value)); 245 + form.resetField("body", { defaultValue: "" }); 246 + }} 247 + defaultValue={field.value} 248 + > 249 + <FormControl> 250 + <SelectTrigger> 251 + <SelectValue placeholder="Select" /> 252 + </SelectTrigger> 253 + </FormControl> 254 + <SelectContent> 255 + {methods.map((method) => ( 256 + <SelectItem key={method} value={method}> 257 + {method} 258 + </SelectItem> 259 + ))} 260 + </SelectContent> 261 + </Select> 262 + <FormMessage /> 263 + </FormItem> 264 + )} 265 + /> 266 + <FormField 267 + control={form.control} 268 + name="url" 158 269 render={({ field }) => ( 159 - <FormItem className="sm:col-span-5"> 160 - <FormLabel>Description</FormLabel> 270 + <FormItem className="sm:col-span-4"> 271 + <FormLabel>URL</FormLabel> 161 272 <FormControl> 273 + {/* <InputWithAddons 274 + leading="https://" 275 + placeholder="documenso.com/api/health" 276 + {...field} 277 + /> */} 162 278 <Input 163 - placeholder="Determines the api health of our services." 279 + placeholder="https://documenso.com/api/health" 164 280 {...field} 165 281 /> 166 282 </FormControl> 167 283 <FormDescription> 168 - Provide your users with information about it.{" "} 284 + Here is the URL you want to monitor.{" "} 169 285 </FormDescription> 170 286 <FormMessage /> 171 287 </FormItem> 172 288 )} 173 289 /> 290 + <div className="space-y-2 sm:col-span-full"> 291 + {/* TODO: add FormDescription for latest key/value */} 292 + <FormLabel>Request Header</FormLabel> 293 + {fields.map((field, index) => ( 294 + <div key={field.id} className="grid grid-cols-6 gap-6"> 295 + <FormField 296 + control={form.control} 297 + name={`headers.${index}.key`} 298 + render={({ field }) => ( 299 + <FormItem className="col-span-2"> 300 + <FormControl> 301 + <Input placeholder="key" {...field} /> 302 + </FormControl> 303 + </FormItem> 304 + )} 305 + /> 306 + <div className="col-span-4 flex items-center space-x-2"> 307 + <FormField 308 + control={form.control} 309 + name={`headers.${index}.value`} 310 + render={({ field }) => ( 311 + <FormItem className="w-full"> 312 + <FormControl> 313 + <Input placeholder="value" {...field} /> 314 + </FormControl> 315 + </FormItem> 316 + )} 317 + /> 318 + <Button 319 + size="icon" 320 + variant="ghost" 321 + type="button" 322 + onClick={() => remove(Number(field.id))} 323 + > 324 + <X className="h-4 w-4" /> 325 + </Button> 326 + </div> 327 + </div> 328 + ))} 329 + <div> 330 + <Button 331 + type="button" 332 + variant="outline" 333 + size="sm" 334 + onClick={() => append({ key: "", value: "" })} 335 + > 336 + Add Custom Header 337 + </Button> 338 + </div> 339 + </div> 340 + {watchMethod === "POST" && ( 341 + <div className="sm:col-span-4 sm:col-start-1"> 342 + <FormField 343 + control={form.control} 344 + name="body" 345 + render={({ field }) => ( 346 + <FormItem> 347 + <div className="flex items-end justify-between"> 348 + <FormLabel>Body</FormLabel> 349 + <TooltipProvider> 350 + <Tooltip> 351 + <TooltipTrigger asChild> 352 + <Button 353 + type="button" 354 + variant="ghost" 355 + size="icon" 356 + onClick={onPrettifyJSON} 357 + > 358 + <Wand2 className="h-4 w-4" /> 359 + </Button> 360 + </TooltipTrigger> 361 + <TooltipContent> 362 + <p>Prettify JSON</p> 363 + </TooltipContent> 364 + </Tooltip> 365 + </TooltipProvider> 366 + </div> 367 + <FormControl> 368 + <Textarea 369 + rows={8} 370 + placeholder='{ "hello": "world" }' 371 + {...field} 372 + /> 373 + </FormControl> 374 + <FormDescription>Write your json payload.</FormDescription> 375 + <FormMessage /> 376 + </FormItem> 377 + )} 378 + /> 379 + </div> 380 + )} 381 + <div className="sm:col-span-2 sm:col-start-1"> 382 + <Button 383 + type="button" 384 + variant="default" 385 + className="w-full md:w-auto" 386 + size="lg" 387 + onClick={sendTestPing} 388 + > 389 + {!isTestPending ? "Test Request" : <LoadingAnimation />} 390 + </Button> 391 + </div> 174 392 <FormField 175 393 control={form.control} 176 394 name="periodicity" 177 395 render={({ field }) => ( 178 - <FormItem className="sm:col-span-3 sm:self-baseline"> 396 + <FormItem className="sm:col-span-3 sm:col-start-1 sm:self-baseline"> 179 397 <FormLabel>Frequency</FormLabel> 180 398 <Select 181 399 onValueChange={(value) => ··· 276 494 control={form.control} 277 495 name="active" 278 496 render={({ field }) => ( 279 - <FormItem className="flex flex-row items-center justify-between sm:col-span-3"> 497 + <FormItem className="flex flex-row items-center justify-between sm:col-span-4"> 280 498 <div className="space-y-0.5"> 281 499 <FormLabel>Active</FormLabel> 282 500 <FormDescription> ··· 295 513 )} 296 514 /> 297 515 <div className="sm:col-span-full"> 298 - <Button className="w-full sm:w-auto"> 516 + {/* 517 + * We could think of having a 'double confirmation' one, 518 + * to check if the endpoint works and approve afterwards 519 + * and confirm anyways even if endpoint failed 520 + */} 521 + <Button className="w-full sm:w-auto" size="lg" disabled={isPending}> 299 522 {!isPending ? "Confirm" : <LoadingAnimation />} 300 523 </Button> 301 524 </div>
+25 -5
apps/web/src/components/forms/status-page-form.tsx
··· 27 27 import { InputWithAddons } from "@/components/ui/input-with-addons"; 28 28 import { useDebounce } from "@/hooks/use-debounce"; 29 29 import { useToastAction } from "@/hooks/use-toast-action"; 30 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 30 31 import { slugify } from "@/lib/utils"; 31 32 import { api } from "@/trpc/client"; 32 33 import { LoadingAnimation } from "../loading-animation"; ··· 39 40 defaultValues?: Schema; 40 41 workspaceSlug: string; 41 42 allMonitors?: z.infer<typeof allMonitorsExtendedSchema>; 43 + /** 44 + * gives the possibility to check all the monitors 45 + */ 46 + checkAllMonitors?: boolean; 47 + /** 48 + * on submit, allows to push a url 49 + */ 50 + nextUrl?: string; 42 51 } 43 52 44 53 export function StatusPageForm({ 45 54 defaultValues, 46 55 workspaceSlug, 47 56 allMonitors, 57 + checkAllMonitors, 58 + nextUrl, 48 59 }: Props) { 49 60 const form = useForm<Schema>({ 50 61 resolver: zodResolver(insertPageSchemaWithMonitors), ··· 54 65 description: defaultValues?.description || "", 55 66 workspaceId: defaultValues?.workspaceId || 0, 56 67 id: defaultValues?.id || 0, 57 - monitors: defaultValues?.monitors ?? [], 58 - customDomain: defaultValues?.customDomain || "", // HOTFIX: we need to keep all the other overwrites. Ideally, we append it in the api.update({ ...defaultValues, ...props }) 68 + monitors: 69 + checkAllMonitors && allMonitors 70 + ? allMonitors.map(({ id }) => id) 71 + : defaultValues?.monitors ?? [], 72 + customDomain: defaultValues?.customDomain || "", 59 73 workspaceSlug: "", 60 74 icon: defaultValues?.icon || "", 61 75 }, ··· 67 81 const watchTitle = form.watch("title"); 68 82 const debouncedSlug = useDebounce(watchSlug, 1000); // using debounce to not exhaust the server 69 83 const { toast } = useToastAction(); 84 + const updateSearchParams = useUpdateSearchParams(); 85 + 70 86 const checkUniqueSlug = useCallback(async () => { 71 87 const isUnique = await api.page.getSlugUniqueness.query({ 72 88 slug: debouncedSlug, ··· 112 128 ...props, 113 129 workspaceSlug, 114 130 }); 115 - router.replace(`./edit?id=${page?.id}`); // to stay on same page and enable 'Advanced' tab 131 + const id = page?.id || null; 132 + router.replace(`?${updateSearchParams({ id })}`); // to stay on same page and enable 'Advanced' tab 116 133 } 117 134 toast("saved"); 135 + if (nextUrl) { 136 + router.push(nextUrl); 137 + } 118 138 router.refresh(); 119 139 } catch { 120 140 toast("error"); ··· 294 314 <FormLabel className="font-normal"> 295 315 {item.name} 296 316 </FormLabel> 297 - <FormDescription>{item.description}</FormDescription> 317 + <FormDescription>{item.url}</FormDescription> 298 318 </div> 299 319 </FormItem> 300 320 ); ··· 306 326 )} 307 327 /> 308 328 <div className="sm:col-span-full"> 309 - <Button className="w-full sm:w-auto"> 329 + <Button className="w-full sm:w-auto" size="lg"> 310 330 {!isPending ? "Confirm" : <LoadingAnimation />} 311 331 </Button> 312 332 </div>
+1 -1
apps/web/src/components/layout/app-header.tsx
··· 21 21 const { isLoaded, isSignedIn } = useUser(); 22 22 23 23 return ( 24 - <header className="border-border sticky top-3 z-50 w-full"> 24 + <header className="border-border sticky top-3 z-50 w-full md:top-6"> 25 25 <Shell className="bg-background/70 flex w-full items-center justify-between px-3 py-3 backdrop-blur-lg md:px-6 md:py-3"> 26 26 <Link 27 27 href="/"
+8
apps/web/src/hooks/use-toast-action.tsx
··· 22 22 success: { title: "Success" }, 23 23 deleted: { title: "Deleted successfully." }, // TODO: we are not informing the user besides the visual changes when an entry has been deleted 24 24 saved: { title: "Saved successfully." }, 25 + "test-error": { 26 + title: "Endpoint configuration failed.", 27 + // description: "Be sure to include the auth headers.", 28 + variant: "destructive", 29 + }, 30 + "test-success": { 31 + title: "Endpoint configuration passed.", 32 + }, 25 33 } as const satisfies Record<string, Toast>; 26 34 27 35 type ToastAction = keyof typeof config;
+22 -1
apps/web/src/middleware.ts
··· 3 3 import { authMiddleware, redirectToSignIn } from "@clerk/nextjs"; 4 4 5 5 import { db, eq } from "@openstatus/db"; 6 - import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 6 + import { 7 + monitor, 8 + user, 9 + usersToWorkspaces, 10 + workspace, 11 + } from "@openstatus/db/src/schema"; 7 12 8 13 import { env } from "./env"; 9 14 ··· 108 113 .where(eq(workspace.id, result[0].users_to_workspaces.workspaceId)) 109 114 .get(); 110 115 if (currentWorkspace) { 116 + const firstMonitor = await db 117 + .select() 118 + .from(monitor) 119 + .where(eq(monitor.workspaceId, currentWorkspace.id)) 120 + .get(); 121 + 122 + if (!firstMonitor) { 123 + console.log(`>>> Redirecting to onboarding`); 124 + const onboarding = new URL( 125 + `/app/onboarding?workspaceSlug=${currentWorkspace.slug}`, 126 + req.url, 127 + ); 128 + return NextResponse.redirect(onboarding); 129 + } 130 + 111 131 const orgSelection = new URL( 112 132 `/app/${currentWorkspace.slug}/monitors`, 113 133 req.url, ··· 163 183 "/app/integrations/vercel/configure", 164 184 "/(api/webhook|api/trpc)(.*)", 165 185 "/(!api/checker/:path*|!api/og|!api/ping)", 186 + "/api/analytics", // used for tracking vercel beta integration click events 166 187 ], 167 188 };
+2 -1
packages/analytics/src/type.ts
··· 14 14 slug: string; 15 15 } 16 16 | { event: "User Upgraded"; email: string } 17 - | { event: "User Signed In" }; 17 + | { event: "User Signed In" } 18 + | { event: "User Vercel Beta" };
+1 -1
packages/db/src/schema/monitor.ts
··· 117 117 active: z.boolean().default(false), 118 118 regions: z.array(RegionEnum).default([]).optional(), 119 119 method: z.enum(METHODS).default("GET"), 120 - body: z.string().default(""), 120 + body: z.string().default("").optional(), 121 121 headers: z 122 122 .array(z.object({ key: z.string(), value: z.string() })) 123 123 .default([]),