Openstatus www.openstatus.dev

feat: region selections (#398)

* feat: select regions for cron

* wip:

* chore: remove related vercel integration

* chore: support random regions

* chore: toggle monitor active setting in action button

* chore: add HEAD method

* chore: remove verce cli from env

authored by

Maximilian Kaske and committed by
GitHub
50b6aea9 72ebeb31

+162 -542
+1 -1
apps/server/src/checker/checker.ts
··· 88 88 ...headers, 89 89 }, 90 90 // Avoid having "TypeError: Request with a GET or HEAD method cannot have a body." error 91 - ...(data.method !== "GET" && { body: data?.body }), 91 + ...(data.method === "POST" && { body: data?.body }), 92 92 }); 93 93 94 94 return res;
+2 -2
apps/server/src/checker/schema.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { METHODS, status } from "@openstatus/db/src/schema"; 3 + import { methods, status } from "@openstatus/db/src/schema"; 4 4 5 5 export const payloadSchema = z.object({ 6 6 workspaceId: z.string(), 7 7 monitorId: z.string(), 8 - method: z.enum(METHODS), 8 + method: z.enum(methods), 9 9 body: z.string().optional(), 10 10 headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), 11 11 url: z.string(),
+3 -3
apps/server/src/v1/monitor.ts
··· 3 3 import { db, eq, sql } from "@openstatus/db"; 4 4 import { 5 5 flyRegions, 6 - METHODS, 6 + methods, 7 7 monitor, 8 8 periodicity, 9 9 } from "@openstatus/db/src/schema/monitor"; ··· 63 63 description: "The description of your monitor", 64 64 }) 65 65 .nullable(), 66 - method: z.enum(METHODS).default("GET").openapi({ example: "GET" }), 66 + method: z.enum(methods).default("GET").openapi({ example: "GET" }), 67 67 body: z 68 68 .preprocess((val) => { 69 69 return String(val); ··· 120 120 example: "Documenso website", 121 121 description: "The description of your monitor", 122 122 }), 123 - method: z.enum(METHODS).default("GET").openapi({ example: "GET" }), 123 + method: z.enum(methods).default("GET").openapi({ example: "GET" }), 124 124 body: z.string().openapi({ 125 125 example: "Hello World", 126 126 description: "The body",
+6 -2
apps/web/.env.example
··· 66 66 UNKEY_API_ID= 67 67 UNKEY_TOKEN= 68 68 69 - # FIXME: https://github.com/vercel/vercel/issues/10564 70 - VERCEL_CLI_VERSION=vercel@32.2.5 69 + # GCP Messaging Queue 70 + GCP_PROJECT_ID= 71 + GCP_LOCATION= 72 + GCP_CLIENT_EMAIL= 73 + GCP_PRIVATE_KEY= 74 + CRON_SECRET=
+4 -38
apps/web/src/app/api/checker/cron/_cron.ts
··· 6 6 7 7 import { createTRPCContext } from "@openstatus/api"; 8 8 import { edgeRouter } from "@openstatus/api/src/edge"; 9 - import { flyRegions, selectMonitorSchema } from "@openstatus/db/src/schema"; 9 + import { selectMonitorSchema } from "@openstatus/db/src/schema"; 10 10 11 11 import { env } from "@/env"; 12 12 import type { payloadSchema } from "../schema"; ··· 58 58 const allPages = await caller.monitor.getAllPagesForMonitor({ 59 59 monitorId: row.id, 60 60 }); 61 - 62 - for (const region of flyRegions) { 61 + const selectedRegions = row.regions.length > 1 ? row.regions : ["auto"]; 62 + for (const region of selectedRegions) { 63 63 const payload: z.infer<typeof payloadSchema> = { 64 64 workspaceId: String(row.workspaceId), 65 65 monitorId: String(row.id), ··· 76 76 httpRequest: { 77 77 headers: { 78 78 "Content-Type": "application/json", // Set content type to ensure compatibility your application's request parsing 79 - "fly-prefer-region": region, 79 + ...(region !== "auto" && { "fly-prefer-region": region }), // Specify the region you want the request to be sent to 80 80 Authorization: `Basic ${env.CRON_SECRET}`, 81 81 }, 82 82 httpMethod: "POST", ··· 85 85 }, 86 86 }; 87 87 const request = { parent: parent, task: task }; 88 - const [response] = await client.createTask(request); 89 - 90 - allResult.push(response); 91 - } 92 - } 93 - // our first legacy monitor 94 - if (periodicity === "10m") { 95 - // Right now we are just checking the ping endpoint 96 - for (const region of flyRegions) { 97 - const payload: z.infer<typeof payloadSchema> = { 98 - workspaceId: "openstatus", 99 - monitorId: "openstatusPing", 100 - url: `https://api.openstatus.dev/ping`, 101 - cronTimestamp: timestamp, 102 - method: "GET", 103 - pageIds: ["openstatus"], 104 - status: "active", 105 - }; 106 - 107 - const task: google.cloud.tasks.v2beta3.ITask = { 108 - httpRequest: { 109 - headers: { 110 - "Content-Type": "application/json", // Set content type to ensure compatibility your application's request parsing 111 - "fly-prefer-region": region, 112 - Authorization: `Basic ${env.CRON_SECRET}`, 113 - }, 114 - httpMethod: "POST", 115 - url: "https://api.openstatus.dev/checkerV2", 116 - body: Buffer.from(JSON.stringify(payload)).toString("base64"), 117 - }, 118 - }; 119 - 120 - // TODO: fetch + try - catch + retry once 121 - const request = { parent, task }; 122 88 const [response] = await client.createTask(request); 123 89 124 90 allResult.push(response);
+2 -2
apps/web/src/app/api/checker/schema.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { METHODS, status } from "@openstatus/db/src/schema"; 3 + import { methods, status } from "@openstatus/db/src/schema"; 4 4 5 5 export const payloadSchema = z.object({ 6 6 workspaceId: z.string(), 7 7 monitorId: z.string(), 8 - method: z.enum(METHODS), 8 + method: z.enum(methods), 9 9 body: z.string().optional(), 10 10 headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), 11 11 url: z.string(),
+1
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/_components/chart-wrapper.tsx
··· 67 67 day: !isInDay ? "numeric" : undefined, 68 68 hour: "2-digit", 69 69 minute: "2-digit", 70 + hour12: false, 70 71 }); 71 72 }
-114
apps/web/src/app/app/(dashboard)/onboarding/_components/path.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - 5 - import { 6 - Badge, 7 - Button, 8 - Card, 9 - CardDescription, 10 - CardFooter, 11 - CardHeader, 12 - CardTitle, 13 - Dialog, 14 - DialogContent, 15 - DialogDescription, 16 - DialogHeader, 17 - DialogTitle, 18 - DialogTrigger, 19 - } from "@openstatus/ui"; 20 - 21 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 22 - 23 - export function Path() { 24 - return ( 25 - <div className="grid gap-6 md:grid-cols-4"> 26 - <HttpCard /> 27 - <VercelCard /> 28 - </div> 29 - ); 30 - } 31 - 32 - function HttpCard() { 33 - const updateSearchParams = useUpdateSearchParams(); 34 - const router = useRouter(); 35 - return ( 36 - <Card className="relative flex flex-col justify-between md:col-span-2"> 37 - <Badge 38 - variant="secondary" 39 - className="bg-background text-muted-foreground absolute -top-3 right-2" 40 - > 41 - Standard 42 - </Badge> 43 - <CardHeader> 44 - <CardTitle>HTTP Endpoint</CardTitle> 45 - <CardDescription> 46 - Monitor your API or website via POST or GET requests including custom 47 - headers and body payload. 48 - </CardDescription> 49 - </CardHeader> 50 - <CardFooter> 51 - <Button 52 - size="lg" 53 - onClick={() => { 54 - router.push(`?${updateSearchParams({ path: "http" })}`); 55 - }} 56 - > 57 - Continue 58 - </Button> 59 - </CardFooter> 60 - </Card> 61 - ); 62 - } 63 - 64 - function VercelCard() { 65 - async function trackEvent() { 66 - await fetch("/api/analytics"); 67 - } 68 - 69 - return ( 70 - <Card className="relative flex flex-col justify-between md:col-span-2"> 71 - <Badge 72 - variant="secondary" 73 - className="bg-background text-muted-foreground absolute -top-3 right-2" 74 - > 75 - Beta 76 - </Badge> 77 - <CardHeader> 78 - <CardTitle>Vercel Integration</CardTitle> 79 - <CardDescription> 80 - Monitor your Vercel applications with ease. 81 - </CardDescription> 82 - </CardHeader> 83 - <CardFooter> 84 - <Dialog> 85 - <DialogTrigger asChild> 86 - <Button size="lg" onClick={trackEvent}> 87 - Continue 88 - </Button> 89 - </DialogTrigger> 90 - <DialogContent> 91 - <DialogHeader> 92 - <DialogTitle className="flex items-center gap-2"> 93 - Vercel Integration <Badge>Beta</Badge> 94 - </DialogTitle> 95 - <DialogDescription> 96 - The integration is currently in closed beta. 97 - </DialogDescription> 98 - </DialogHeader> 99 - <div> 100 - <p className=""> 101 - Please contact us:{" "} 102 - <Button variant="link" className="font-mono" asChild> 103 - <a href="mailto:thibault@openstatus.dev"> 104 - thibault@openstatus.dev 105 - </a> 106 - </Button> 107 - </p> 108 - </div> 109 - </DialogContent> 110 - </Dialog> 111 - </CardFooter> 112 - </Card> 113 - ); 114 - }
+8 -25
apps/web/src/app/app/(dashboard)/onboarding/page.tsx
··· 9 9 import { StatusPageForm } from "@/components/forms/status-page-form"; 10 10 import { api } from "@/trpc/server"; 11 11 import { Description } from "./_components/description"; 12 - import { Path } from "./_components/path"; 13 12 14 13 /** 15 14 * allowed URL search params 16 15 */ 17 16 const searchParamsSchema = z.object({ 18 - path: z.string().optional(), // "vercel" | "http" 19 17 id: z.coerce.number().optional(), // monitorId 20 18 workspaceSlug: z.string().optional(), 21 19 }); ··· 32 30 } 33 31 34 32 // Instead of having the workspaceSlug in the search params, we can get it from the auth user 35 - const { workspaceSlug, id: monitorId, path } = search.data; 33 + const { workspaceSlug, id: monitorId } = search.data; 36 34 37 35 if (!workspaceSlug) { 38 - return "Waiting for Slug"; 39 - } 40 - 41 - if (!path) { 42 36 return ( 43 - <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 44 - <Header 45 - title="Get Started" 46 - description="Create your first status page." 47 - actions={ 48 - <Button variant="link" className="text-muted-foreground"> 49 - <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 50 - </Button> 51 - } 52 - /> 53 - <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 54 - <div className="md:col-span-2"> 55 - <Path /> 56 - </div> 57 - <div className="hidden h-full md:col-span-1 md:block"> 58 - <Description /> 59 - </div> 37 + <div className="flex flex-col gap-3"> 38 + <p className="text-lg">Waiting for Slug </p> 39 + <div> 40 + <Button variant="outline" asChild> 41 + <Link href="/app">Retry</Link> 42 + </Button> 60 43 </div> 61 44 </div> 62 45 ); ··· 73 56 title="Get Started" 74 57 description="Create your first monitor." 75 58 actions={ 76 - <Button variant="link" className="text-muted-foreground"> 59 + <Button variant="link" className="text-muted-foreground" asChild> 77 60 <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 78 61 </Button> 79 62 }
+24 -1
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 21 21 DropdownMenu, 22 22 DropdownMenuContent, 23 23 DropdownMenuItem, 24 + DropdownMenuSeparator, 24 25 DropdownMenuTrigger, 25 26 } from "@openstatus/ui"; 26 27 ··· 55 56 }); 56 57 } 57 58 59 + async function onToggleActive() { 60 + startTransition(async () => { 61 + try { 62 + const { jobType, ...rest } = monitor; 63 + if (!monitor.id) return; 64 + await api.monitor.updateMonitor.mutate({ 65 + ...rest, 66 + active: !monitor.active, 67 + }); 68 + toast("success"); 69 + router.refresh(); 70 + } catch { 71 + toast("error"); 72 + } 73 + }); 74 + } 75 + 58 76 async function onTest() { 59 77 startTransition(async () => { 60 78 const { url, body, method, headers } = monitor; ··· 92 110 <Link href={`./monitors/${monitor.id}/data`}> 93 111 <DropdownMenuItem>Details</DropdownMenuItem> 94 112 </Link> 95 - <DropdownMenuItem onClick={onTest}>Test</DropdownMenuItem> 113 + <DropdownMenuSeparator /> 114 + <DropdownMenuItem onClick={onTest}>Test endpoint</DropdownMenuItem> 115 + <DropdownMenuItem onClick={onToggleActive}> 116 + {monitor.active ? "Pause" : "Resume"} monitor 117 + </DropdownMenuItem> 118 + <DropdownMenuSeparator /> 96 119 <AlertDialogTrigger asChild> 97 120 <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 98 121 Delete
-263
apps/web/src/components/forms/advanced-monitor-form.tsx
··· 1 - "use client"; 2 - 3 - import * as React from "react"; 4 - import { useRouter, useSearchParams } from "next/navigation"; 5 - import { zodResolver } from "@hookform/resolvers/zod"; 6 - import { Wand2, X } from "lucide-react"; 7 - import { useFieldArray, useForm } from "react-hook-form"; 8 - import * as z from "zod"; 9 - 10 - import { 11 - Button, 12 - Form, 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 - Textarea, 26 - Tooltip, 27 - TooltipContent, 28 - TooltipProvider, 29 - TooltipTrigger, 30 - } from "@openstatus/ui"; 31 - 32 - import { useToastAction } from "@/hooks/use-toast-action"; 33 - import { api } from "@/trpc/client"; 34 - import { LoadingAnimation } from "../loading-animation"; 35 - 36 - const methods = ["POST", "GET"] as const; 37 - const methodsEnum = z.enum(methods); 38 - 39 - const headersSchema = z 40 - .array(z.object({ key: z.string(), value: z.string() })) 41 - .optional(); 42 - 43 - const advancedSchema = z.discriminatedUnion("method", [ 44 - z.object({ 45 - method: z.literal("GET"), 46 - body: z.string().length(0).optional(), 47 - headers: headersSchema, 48 - }), 49 - z.object({ 50 - method: z.literal("POST"), 51 - body: z.string().optional(), 52 - headers: headersSchema, 53 - }), 54 - ]); 55 - 56 - type AdvancedMonitorProps = z.infer<typeof advancedSchema>; 57 - 58 - interface Props { 59 - defaultValues?: AdvancedMonitorProps; 60 - workspaceSlug: string; 61 - } 62 - 63 - export function AdvancedMonitorForm({ defaultValues, workspaceSlug }: Props) { 64 - const form = useForm<AdvancedMonitorProps>({ 65 - resolver: zodResolver(advancedSchema), 66 - defaultValues: { 67 - headers: Boolean(defaultValues?.headers?.length) 68 - ? defaultValues?.headers 69 - : [{ key: "", value: "" }], 70 - body: defaultValues?.body ?? "", 71 - method: defaultValues?.method ?? "GET", 72 - }, 73 - }); 74 - const watchMethod = form.watch("method"); 75 - const router = useRouter(); 76 - const { toast } = useToastAction(); 77 - const searchParams = useSearchParams(); 78 - const monitorId = searchParams?.get("id"); 79 - const [isPending, startTransition] = React.useTransition(); 80 - 81 - const { fields, append, remove } = useFieldArray({ 82 - name: "headers", 83 - control: form.control, 84 - }); 85 - 86 - const onSubmit = ({ headers, ...props }: AdvancedMonitorProps) => { 87 - startTransition(async () => { 88 - try { 89 - if (!monitorId) return; 90 - if (validateJSON(props.body) === false) return; 91 - await api.monitor.updateMonitorAdvanced.mutate({ 92 - id: Number(monitorId), 93 - headers: headers?.filter(({ key }) => key !== ""), // avoid saving empty key headers 94 - ...props, 95 - }); 96 - toast("saved"); 97 - router.refresh(); 98 - } catch (e) { 99 - toast("error"); 100 - } 101 - }); 102 - }; 103 - 104 - const validateJSON = (value?: string) => { 105 - if (!value) return; 106 - try { 107 - const obj = JSON.parse(value) as Record<string, unknown>; 108 - form.clearErrors("body"); 109 - return obj; 110 - } catch (e) { 111 - form.setError("body", { 112 - message: "Not a valid JSON object", 113 - }); 114 - return false; 115 - } 116 - }; 117 - 118 - const onPrettifyJSON = () => { 119 - const body = form.getValues("body"); 120 - const obj = validateJSON(body); 121 - if (obj) { 122 - const pretty = JSON.stringify(obj, undefined, 4); 123 - form.setValue("body", pretty); 124 - } 125 - }; 126 - 127 - return ( 128 - <Form {...form}> 129 - <form 130 - onSubmit={form.handleSubmit(onSubmit)} 131 - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 132 - > 133 - <div className="space-y-2 sm:col-span-full"> 134 - {/* TODO: add FormDescription for latest key/value */} 135 - <FormLabel>Request Header</FormLabel> 136 - {fields.map((field, index) => ( 137 - <div key={field.id} className="grid grid-cols-6 gap-6"> 138 - <FormField 139 - control={form.control} 140 - name={`headers.${index}.key`} 141 - render={({ field }) => ( 142 - <FormItem className="col-span-2"> 143 - <FormControl> 144 - <Input placeholder="key" {...field} /> 145 - </FormControl> 146 - </FormItem> 147 - )} 148 - /> 149 - <div className="col-span-4 flex items-center space-x-2"> 150 - <FormField 151 - control={form.control} 152 - name={`headers.${index}.value`} 153 - render={({ field }) => ( 154 - <FormItem className="w-full"> 155 - <FormControl> 156 - <Input placeholder="value" {...field} /> 157 - </FormControl> 158 - </FormItem> 159 - )} 160 - /> 161 - <Button 162 - size="icon" 163 - variant="ghost" 164 - type="button" 165 - onClick={() => remove(Number(field.id))} 166 - > 167 - <X className="h-4 w-4" /> 168 - </Button> 169 - </div> 170 - </div> 171 - ))} 172 - <div> 173 - <Button 174 - type="button" 175 - variant="outline" 176 - size="sm" 177 - onClick={() => append({ key: "", value: "" })} 178 - > 179 - Add Custom Header 180 - </Button> 181 - </div> 182 - </div> 183 - <FormField 184 - control={form.control} 185 - name="method" 186 - render={({ field }) => ( 187 - <FormItem className="sm:col-span-2 sm:col-start-1 sm:self-baseline"> 188 - <FormLabel>Method</FormLabel> 189 - <Select 190 - onValueChange={(value) => { 191 - field.onChange(methodsEnum.parse(value)); 192 - form.resetField("body", { defaultValue: "" }); 193 - }} 194 - defaultValue={field.value} 195 - > 196 - <FormControl> 197 - <SelectTrigger> 198 - <SelectValue placeholder="Select" /> 199 - </SelectTrigger> 200 - </FormControl> 201 - <SelectContent> 202 - {methods.map((method) => ( 203 - <SelectItem key={method} value={method}> 204 - {method} 205 - </SelectItem> 206 - ))} 207 - </SelectContent> 208 - </Select> 209 - <FormDescription>What method to use?</FormDescription> 210 - <FormMessage /> 211 - </FormItem> 212 - )} 213 - /> 214 - {watchMethod === "POST" && ( 215 - <div className="sm:col-span-4 sm:col-start-1"> 216 - <FormField 217 - control={form.control} 218 - name="body" 219 - render={({ field }) => ( 220 - <FormItem> 221 - <div className="flex items-end justify-between"> 222 - <FormLabel>Body</FormLabel> 223 - <TooltipProvider> 224 - <Tooltip> 225 - <TooltipTrigger asChild> 226 - <Button 227 - type="button" 228 - variant="ghost" 229 - size="icon" 230 - onClick={onPrettifyJSON} 231 - > 232 - <Wand2 className="h-4 w-4" /> 233 - </Button> 234 - </TooltipTrigger> 235 - <TooltipContent> 236 - <p>Prettify JSON</p> 237 - </TooltipContent> 238 - </Tooltip> 239 - </TooltipProvider> 240 - </div> 241 - <FormControl> 242 - <Textarea 243 - rows={8} 244 - placeholder='{ "hello": "world" }' 245 - {...field} 246 - /> 247 - </FormControl> 248 - <FormDescription>Write your json payload.</FormDescription> 249 - <FormMessage /> 250 - </FormItem> 251 - )} 252 - /> 253 - </div> 254 - )} 255 - <div className="sm:col-span-full"> 256 - <Button className="w-full sm:w-auto"> 257 - {!isPending ? "Confirm" : <LoadingAnimation />} 258 - </Button> 259 - </div> 260 - </form> 261 - </Form> 262 - ); 263 - }
+92 -75
apps/web/src/components/forms/monitor-form.tsx
··· 9 9 10 10 import type { selectNotificationSchema } from "@openstatus/db/src/schema"; 11 11 import { 12 + flyRegions, 12 13 insertMonitorSchema, 14 + methods, 15 + methodsSchema, 13 16 periodicityEnum, 14 17 } from "@openstatus/db/src/schema"; 15 18 import { allPlans } from "@openstatus/plans"; ··· 62 65 import useUpdateSearchParams from "@/hooks/use-update-search-params"; 63 66 import { cn } from "@/lib/utils"; 64 67 import { api } from "@/trpc/client"; 68 + import type { Writeable } from "@/types/utils"; 65 69 import { NotificationForm } from "./notification-form"; 66 70 67 71 const cronJobs = [ ··· 72 76 { value: "1h", label: "1 hour" }, 73 77 ] as const; 74 78 75 - const methods = ["POST", "GET"] as const; 76 - const methodsEnum = z.enum(methods); 77 - 78 79 const headersSchema = z 79 80 .array(z.object({ key: z.string(), value: z.string() })) 80 81 .optional(); 81 82 82 83 const advancedSchema = z.object({ 83 - method: methodsEnum, 84 + method: methodsSchema, 84 85 body: z.string().optional(), 85 86 headers: headersSchema, 86 87 }); ··· 111 112 periodicity: defaultValues?.periodicity || "30m", 112 113 active: defaultValues?.active ?? true, 113 114 id: defaultValues?.id || undefined, 114 - regions: defaultValues?.regions || [], 115 + regions: 116 + defaultValues?.regions || (flyRegions as Writeable<typeof flyRegions>), 115 117 headers: Boolean(defaultValues?.headers?.length) 116 118 ? defaultValues?.headers 117 119 : [{ key: "", value: "" }], ··· 294 296 <FormLabel>Method</FormLabel> 295 297 <Select 296 298 onValueChange={(value) => { 297 - field.onChange(methodsEnum.parse(value)); 299 + field.onChange(methodsSchema.parse(value)); 298 300 form.resetField("body", { defaultValue: "" }); 299 301 }} 300 302 defaultValue={field.value} ··· 464 466 <FormField 465 467 control={form.control} 466 468 name="regions" 467 - render={({ field }) => ( 468 - <FormItem className="sm:col-span-1 sm:self-baseline"> 469 - <FormLabel>Regions</FormLabel> 470 - <Popover> 471 - <PopoverTrigger asChild> 472 - <FormControl> 473 - <Button 474 - variant="outline" 475 - role="combobox" 476 - className={cn( 477 - "h-10 w-full justify-between", 478 - !field.value && "text-muted-foreground", 479 - )} 480 - > 481 - {/* This is a hotfix */} 482 - {field.value?.length === 1 && 483 - field.value[0].length > 0 484 - ? flyRegionsDict[ 485 - field 486 - .value[0] as keyof typeof flyRegionsDict 487 - ].location 488 - : "Select region"} 489 - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 490 - </Button> 491 - </FormControl> 492 - </PopoverTrigger> 493 - <PopoverContent className="w-full p-0"> 494 - <Command> 495 - <CommandInput placeholder="Select a region..." /> 496 - <CommandEmpty>No regions found.</CommandEmpty> 497 - <CommandGroup className="max-h-[150px] overflow-y-scroll"> 498 - {Object.keys(flyRegionsDict).map((region) => { 499 - const { code, location } = 500 - flyRegionsDict[ 501 - region as keyof typeof flyRegionsDict 502 - ]; 503 - const isSelected = 504 - field.value?.includes(code); 505 - return ( 506 - <CommandItem 507 - value={code} 508 - key={code} 509 - onSelect={() => { 510 - form.setValue("regions", [code]); // TODO: allow more than one to be selected in the future 511 - }} 512 - > 513 - <Check 514 - className={cn( 515 - "mr-2 h-4 w-4", 516 - isSelected 517 - ? "opacity-100" 518 - : "opacity-0", 519 - )} 520 - /> 521 - {location} 522 - </CommandItem> 523 - ); 524 - })} 525 - </CommandGroup> 526 - </Command> 527 - </PopoverContent> 528 - </Popover> 529 - <FormDescription> 530 - Select your region. Leave blank for random picked 531 - regions. 532 - </FormDescription> 533 - <FormMessage /> 534 - </FormItem> 535 - )} 469 + render={({ field }) => { 470 + const numberOfSelectedRegions = 471 + field.value?.length || 0; 472 + function renderText() { 473 + if (numberOfSelectedRegions === 0) 474 + return "Select region"; 475 + if (numberOfSelectedRegions === flyRegions.length) 476 + return "All regions"; 477 + return `${numberOfSelectedRegions} regions`; 478 + } 479 + return ( 480 + <FormItem className="sm:col-span-1 sm:self-baseline"> 481 + <FormLabel>Regions</FormLabel> 482 + <Popover> 483 + <PopoverTrigger asChild> 484 + <FormControl> 485 + <Button 486 + variant="outline" 487 + role="combobox" 488 + className={cn( 489 + "h-10 w-full justify-between", 490 + !field.value && "text-muted-foreground", 491 + )} 492 + > 493 + {renderText()} 494 + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 495 + </Button> 496 + </FormControl> 497 + </PopoverTrigger> 498 + <PopoverContent className="w-full p-0"> 499 + <Command> 500 + <CommandInput placeholder="Select a region..." /> 501 + <CommandEmpty>No regions found.</CommandEmpty> 502 + <CommandGroup className="max-h-[150px] overflow-y-scroll"> 503 + {Object.keys(flyRegionsDict).map( 504 + (region) => { 505 + const { code, location } = 506 + flyRegionsDict[ 507 + region as keyof typeof flyRegionsDict 508 + ]; 509 + const isSelected = 510 + field.value?.includes(code); 511 + return ( 512 + <CommandItem 513 + value={code} 514 + key={code} 515 + onSelect={() => { 516 + const currentRegions = 517 + form.getValues("regions") || []; 518 + form.setValue( 519 + "regions", 520 + currentRegions.includes(code) 521 + ? currentRegions.filter( 522 + (r) => r !== code, 523 + ) 524 + : [...currentRegions, code], 525 + ); 526 + }} 527 + > 528 + <Check 529 + className={cn( 530 + "mr-2 h-4 w-4", 531 + isSelected 532 + ? "opacity-100" 533 + : "opacity-0", 534 + )} 535 + /> 536 + {location} 537 + </CommandItem> 538 + ); 539 + }, 540 + )} 541 + </CommandGroup> 542 + </Command> 543 + </PopoverContent> 544 + </Popover> 545 + <FormDescription> 546 + Select your regions. If none, region will be 547 + random. 548 + </FormDescription> 549 + <FormMessage /> 550 + </FormItem> 551 + ); 552 + }} 536 553 /> 537 554 <FormField 538 555 control={form.control}
+1 -1
apps/web/src/components/marketing/faqs.tsx
··· 21 21 }, 22 22 { 23 23 q: "What regions do we support?", 24 - a: "We support all the <strong>Vercel regions</strong>. Find the full list <a href='https://vercel.com/docs/concepts/edge-network/regions' target='_blank'>here</a>.", 24 + a: "We support one region for each continent to allow multi-regions monitoring.", 25 25 }, 26 26 { 27 27 q: "How can I help?",
+6 -6
apps/web/src/config/pages.ts
··· 34 34 href: "/incidents", 35 35 icon: "siren", 36 36 }, 37 - { 38 - title: "Integrations", 39 - description: "Where you can see all the integrations.", 40 - href: "/integrations", 41 - icon: "plug", 42 - }, 37 + // { 38 + // title: "Integrations", 39 + // description: "Where you can see all the integrations.", 40 + // href: "/integrations", 41 + // icon: "plug", 42 + // }, 43 43 // ... 44 44 ];
+2 -1
apps/web/src/lib/tb.ts
··· 36 36 return; 37 37 } 38 38 39 + // Includes caching of data for 10 minutes 39 40 export async function getHomeMonitorListData() { 40 41 try { 41 - const res = await getHomeMonitorList(tb)({ monitorId: "openstatusPing" }); 42 + const res = await getHomeMonitorList(tb)({ monitorId: "1" }); 42 43 return res.data; 43 44 } catch (e) { 44 45 console.error(e);
+1
apps/web/src/types/utils.ts
··· 1 + export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
+2 -2
packages/api/src/router/monitor.ts
··· 6 6 import { 7 7 allMonitorsExtendedSchema, 8 8 insertMonitorSchema, 9 - METHODS, 9 + methods, 10 10 monitor, 11 11 monitorsToPages, 12 12 notification, ··· 236 236 .input( 237 237 z.object({ 238 238 id: z.number(), 239 - method: z.enum(METHODS).default("GET"), 239 + method: z.enum(methods).default("GET"), 240 240 body: z.string().default("").optional(), 241 241 headers: z 242 242 .array(z.object({ key: z.string(), value: z.string() }))
+7 -6
packages/db/src/schema/monitor.ts
··· 37 37 export const flyRegions = ["ams", "iad", "hkg", "jnb", "syd", "gru"] as const; 38 38 39 39 export const periodicity = ["1m", "5m", "10m", "30m", "1h", "other"] as const; 40 - export const METHODS = ["GET", "POST"] as const; 40 + export const periodicityEnum = z.enum(periodicity); 41 + export const methods = ["GET", "POST", "HEAD"] as const; 42 + export const methodsSchema = z.enum(methods); 41 43 export const status = ["active", "error"] as const; 42 44 export const statusSchema = z.enum(status); 43 45 export const RegionEnum = z.enum([...flyRegions, ...vercelRegions, "auto"]); ··· 62 64 63 65 headers: text("headers").default(""), 64 66 body: text("body").default(""), 65 - method: text("method", METHODS).default("GET"), 67 + method: text("method", methods).default("GET"), 66 68 workspaceId: integer("workspace_id").references(() => workspace.id), 67 69 68 70 createdAt: integer("created_at", { mode: "timestamp" }).default( ··· 112 114 }), 113 115 ); 114 116 115 - export const periodicityEnum = z.enum(periodicity); 116 117 // Schema for inserting a Monitor - can be used to validate API requests 117 118 export const insertMonitorSchema = createInsertSchema(monitor, { 118 119 periodicity: periodicityEnum, ··· 120 121 status: z.enum(status).default("active"), 121 122 active: z.boolean().default(false), 122 123 regions: z.array(RegionEnum).default([]).optional(), 123 - method: z.enum(METHODS).default("GET"), 124 + method: z.enum(methods).default("GET"), 124 125 body: z.string().default("").optional(), 125 126 headers: z 126 127 .array(z.object({ key: z.string(), value: z.string() })) ··· 146 147 } 147 148 }, z.array(RegionEnum)) 148 149 .default([]), 149 - method: z.enum(METHODS).default("GET"), 150 + method: z.enum(methods).default("GET"), 150 151 }); 151 152 152 153 // FIXME: can be removed as we do not use the advanced tab anymore 153 154 export const selectMonitorExtendedSchema = selectMonitorSchema.extend({ 154 - method: z.enum(METHODS).default("GET"), 155 + method: z.enum(methods).default("GET"), 155 156 body: z 156 157 .preprocess((val) => { 157 158 return String(val);