Openstatus www.openstatus.dev

feat: play checker (#990)

* wip:

* wip: chart

* wip:

* wip: stream

* wip:

* chore: use table

* wip:

* wip:

* fix:

* chore: add checker id

* fix: toasts

* fix:

* ci: apply automated fixes

* feat: expire date and floating action

* ci: apply automated fixes

* fix: date description

* chore: remove caret

* chore: improve seo

* wip:

* wip:

* ci: apply automated fixes

* chore: empty state

* ci: apply automated fixes

* feaat: add dot legend

* ci: apply automated fixes

* chore: cta and object cover

* ci: apply automated fixes

* chore: landing

* fix: readable stream

* ci: apply automated fixes

* wip:

* ci: apply automated fixes

* wip:

* ci: apply automated fixes

* style: tooltip content

* wip:

* ci: apply automated fixes

* fix:

* fix:

* ci: apply automated fixes

* fix: data table time format

* ci: apply automated fixes

* chore: fetch default value if id

* ci: apply automated fixes

* feat: add auto

* style: revert floation button colors

* chore: update image

* ci: apply automated fixes

* chore: update changelog image

* wip:

* chore: mute

* ci: apply automated fixes

---------

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

authored by

Maximilian Kaske
autofix-ci[bot]
and committed by
GitHub
cdc96d84 7925b569

+2872 -287
+3 -1
.vscode/settings.json
··· 1 1 { 2 - "editor.codeActionsOnSave": {}, 2 + "editor.codeActionsOnSave": { 3 + "source.organizeImports.biome": "explicit" 4 + }, 3 5 "editor.defaultFormatter": "biomejs.biome", 4 6 "editor.formatOnSave": true, 5 7 "typescript.tsdk": "node_modules/typescript/lib",
+1
apps/web/package.json
··· 75 75 "react-hook-form": "7.47.0", 76 76 "react-tweet": "3.1.1", 77 77 "reading-time": "1.5.0", 78 + "recharts": "2.12.7", 78 79 "rehype-pretty-code": "0.10.0", 79 80 "rehype-react": "7.2.0", 80 81 "remark-parse": "10.0.2",
apps/web/public/assets/changelog/play-checker-improvements.png

This is a binary file and will not be displayed.

-11
apps/web/src/app/play/_components/header-play.tsx
··· 1 - export function HeaderPlay({ 2 - title, 3 - description, 4 - }: Record<"title" | "description", string>) { 5 - return ( 6 - <div className="mx-auto grid max-w-md gap-4 text-center"> 7 - <p className="mb-1 font-cal text-3xl">{title}</p> 8 - <p className="text-muted-foreground">{description}</p> 9 - </div> 10 - ); 11 - }
+24 -28
apps/web/src/app/play/checker/[id]/page.tsx
··· 3 3 import { redirect } from "next/navigation"; 4 4 import * as z from "zod"; 5 5 6 - import { monitorFlyRegionSchema } from "@openstatus/db/src/schema/constants"; 6 + import { 7 + flyRegions, 8 + monitorFlyRegionSchema, 9 + } from "@openstatus/db/src/schema/constants"; 7 10 import { Separator } from "@openstatus/ui"; 8 11 9 12 import { ··· 12 15 twitterMetadata, 13 16 } from "@/app/shared-metadata"; 14 17 import { Shell } from "@/components/dashboard/shell"; 15 - import { BackButton } from "@/components/layout/back-button"; 16 18 import { CopyLinkButton } from "@/components/ping-response-analysis/copy-link-button"; 17 19 import { MultiRegionTabs } from "@/components/ping-response-analysis/multi-region-tabs"; 18 - import { RegionInfo } from "@/components/ping-response-analysis/region-info"; 19 - import { ResponseDetailTabs } from "@/components/ping-response-analysis/response-detail-tabs"; 20 - import { SelectRegion } from "@/components/ping-response-analysis/select-region"; 21 20 import { 22 21 getCheckerDataById, 23 22 timestampFormatter, 24 23 } from "@/components/ping-response-analysis/utils"; 24 + import type { Region } from "@openstatus/tinybird"; 25 25 26 26 /** 27 27 * allowed URL search params 28 28 */ 29 29 const searchParamsSchema = z.object({ 30 - region: monitorFlyRegionSchema.optional(), 30 + regions: z 31 + .string() 32 + .optional() 33 + .transform( 34 + (value) => 35 + value 36 + ?.trim() 37 + ?.split(",") 38 + .filter((i) => flyRegions.includes(i as Region)) ?? flyRegions, 39 + ) 40 + .pipe(monitorFlyRegionSchema.array().optional()), 31 41 }); 32 42 33 43 interface Props { ··· 38 48 export default async function CheckPage({ params, searchParams }: Props) { 39 49 const search = searchParamsSchema.safeParse(searchParams); 40 50 41 - const selectedRegion = search.success ? search.data.region : undefined; 51 + const selectedRegions = search.success ? search.data.regions : undefined; 42 52 43 53 const data = await getCheckerDataById(params.id); 44 54 45 55 if (!data) redirect("/play/checker"); 46 56 47 - const check = 48 - data.checks.find((i) => i.region === selectedRegion) || data.checks?.[0]; 49 - 50 - const { region, headers, timing, status } = check; 51 - 52 57 return ( 53 - <Shell className="my-8 flex flex-col gap-8 md:my-16"> 58 + <Shell className="flex flex-col gap-8"> 54 59 <div className="flex justify-between gap-4"> 55 60 <div className="flex max-w-[calc(100%-50px)] flex-col gap-1"> 56 61 <h1 className="truncate text-wrap font-semibold text-lg sm:text-xl md:text-3xl"> ··· 64 69 <CopyLinkButton /> 65 70 </div> 66 71 </div> 67 - <MultiRegionTabs regions={data.checks} /> 68 - <Separator /> 69 - <div className="flex flex-col gap-8"> 70 - <div className="grid gap-8 md:grid-cols-2"> 71 - <div> 72 - <SelectRegion defaultValue={region} /> 73 - </div> 74 - <div> 75 - <RegionInfo check={check} /> 76 - </div> 77 - </div> 78 - <ResponseDetailTabs {...{ timing, headers, status }} hideInfo={false} /> 79 - </div> 72 + <MultiRegionTabs 73 + regions={data.checks} 74 + selectedRegions={selectedRegions} 75 + /> 80 76 <Separator /> 81 77 <p className="text-muted-foreground text-sm"> 82 78 The data will be stored for{" "} 83 - <span className="text-foreground">1 day</span>. If you want to persist 79 + <span className="text-foreground">7 days</span>. If you want to persist 84 80 the data,{" "} 85 81 <Link 86 82 href="/app/login" ··· 95 91 } 96 92 97 93 export async function generateMetadata({ params }: Props): Promise<Metadata> { 98 - const title = "Speed Checker"; 94 + const title = "Global Speed Checker"; 99 95 const description = 100 96 "Get speed insights for your api, website from multiple regions."; 101 97 return {
+359 -61
apps/web/src/app/play/checker/_components/checker-form.tsx
··· 1 1 "use client"; 2 2 3 3 import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import { useTransition } from "react"; 4 + import { usePathname, useRouter, useSearchParams } from "next/navigation"; 5 + import { useMemo, useState, useTransition } from "react"; 6 6 import { useForm } from "react-hook-form"; 7 7 import * as z from "zod"; 8 8 9 9 import { 10 + Alert, 11 + AlertDescription, 12 + AlertTitle, 10 13 Button, 14 + Checkbox, 11 15 Form, 12 16 FormControl, 17 + FormDescription, 13 18 FormField, 14 19 FormItem, 15 20 FormLabel, ··· 20 25 SelectItem, 21 26 SelectTrigger, 22 27 SelectValue, 28 + Table, 29 + TableBody, 30 + TableCaption, 31 + TableCell, 32 + TableHead, 33 + TableHeader, 34 + TableRow, 35 + Tooltip, 36 + TooltipContent, 37 + TooltipProvider, 38 + TooltipTrigger, 23 39 } from "@openstatus/ui"; 24 40 41 + import { Icons } from "@/components/icons"; 25 42 import { LoadingAnimation } from "@/components/loading-animation"; 43 + import { 44 + type RegionChecker, 45 + is32CharHex, 46 + latencyFormatter, 47 + regionFormatter, 48 + } from "@/components/ping-response-analysis/utils"; 49 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 50 + import { toast } from "@/lib/toast"; 51 + import { notEmpty } from "@/lib/utils"; 52 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 53 + import { ChevronRight, FileSearch, Info, Loader } from "lucide-react"; 54 + import dynamic from "next/dynamic"; 55 + import Link from "next/link"; 56 + 57 + const FloatingActionNoSSR = dynamic( 58 + () => 59 + import("../_components/floating-action").then((mod) => mod.FloatingAction), 60 + { 61 + ssr: false, 62 + loading: () => <></>, 63 + }, 64 + ); 65 + 66 + /** 67 + * IDEA we can create a list of last requests and show them in a list, but 68 + * we will have to sync both expiration dates, for redis and localStorage 69 + */ 26 70 27 71 const METHODS = ["GET", "POST", "PUT", "DELETE"] as const; 28 72 29 73 const formSchema = z.object({ 30 - url: z.string().url(), // add pattern and use InputWithAddon `https://` 74 + url: z.string().url(), 31 75 method: z.enum(METHODS).default("GET"), 76 + redirect: z.boolean().default(false), 32 77 }); 33 78 34 79 type FormSchema = z.infer<typeof formSchema>; 35 80 36 - export function CheckerForm() { 81 + interface CheckerFormProps { 82 + defaultValues?: FormSchema; 83 + defaultData?: RegionChecker[]; 84 + } 85 + 86 + export function CheckerForm({ defaultValues, defaultData }: CheckerFormProps) { 87 + const pathname = usePathname(); 37 88 const router = useRouter(); 38 89 const [isPending, startTransition] = useTransition(); 39 90 const form = useForm<FormSchema>({ 40 91 resolver: zodResolver(formSchema), 41 - defaultValues: { method: "GET", url: "" }, 92 + defaultValues, 42 93 }); 94 + const [result, setResult] = useState<RegionChecker[]>(defaultData || []); 95 + const updateSearchParams = useUpdateSearchParams(); 96 + const searchParams = useSearchParams(); 97 + 98 + const id = useMemo(() => { 99 + return searchParams.get("id"); 100 + }, [searchParams]); 43 101 44 102 function onSubmit(data: FormSchema) { 45 103 startTransition(async () => { 46 - const { url, method } = data; 104 + const { url, method, redirect } = data; 47 105 try { 48 - const res = await fetch("/play/checker/api", { 49 - method: "POST", 50 - body: JSON.stringify({ url, method }), 51 - }); 52 - const { uuid } = (await res.json()) as { uuid?: string }; 53 - if (typeof uuid === "string") { 54 - router.push(`/play/checker/${uuid}`); 106 + async function fetchAndReadStream() { 107 + // reset the array 108 + setResult([]); 109 + 110 + const toastId = toast.loading("Loading data from regions...", { 111 + duration: Number.POSITIVE_INFINITY, 112 + }); 113 + 114 + try { 115 + const response = await fetch("/play/checker/api", { 116 + method: "POST", 117 + body: JSON.stringify({ url, method }), 118 + }); 119 + const reader = response?.body?.getReader(); 120 + if (!reader) return; 121 + 122 + const decoder = new TextDecoder(); 123 + 124 + let done = false; 125 + let currentResult: RegionChecker[] = []; 126 + 127 + while (!done) { 128 + const { value, done: streamDone } = await reader.read(); 129 + done = streamDone; 130 + if (value) { 131 + const decoded = decoder.decode(value, { stream: true }); 132 + // REMINDER: validation 133 + 134 + if (!decoded) continue; 135 + 136 + const array = decoded.split("\n").filter(Boolean); 137 + 138 + const _result = array 139 + .map((item) => { 140 + try { 141 + if (is32CharHex(item)) { 142 + if (redirect) { 143 + router.push(`/play/checker/${item}`); 144 + toast.success("Data is available! Redirecting...", { 145 + id: toastId, 146 + duration: 2000, 147 + }); 148 + } else { 149 + const searchParams = updateSearchParams({ 150 + id: item, 151 + }); 152 + router.replace(`${pathname}?${searchParams}`); 153 + toast.success("Data is available!", { 154 + id: toastId, 155 + duration: 3000, 156 + description: "Click the button below to more.", 157 + action: { 158 + label: "Details", 159 + onClick: () => 160 + router.push(`/play/checker/${item}`), 161 + }, 162 + }); 163 + } 164 + } else { 165 + const parsed = JSON.parse(item) as RegionChecker; 166 + return parsed; 167 + } 168 + return null; 169 + } catch (e) { 170 + console.error(e); 171 + return null; 172 + } 173 + }) 174 + .filter(notEmpty); 175 + 176 + if (!_result.length) continue; 177 + 178 + currentResult = [...currentResult, ..._result]; 179 + // setResult((prev) => [...prev, ..._result]); 180 + setResult(currentResult); 181 + 182 + if (_result) { 183 + toast.loading( 184 + `Checking ${regionFormatter(_result[0].region, "long")} (${latencyFormatter(_result[0].latency)})`, 185 + { 186 + id: toastId, 187 + }, 188 + ); 189 + } 190 + } 191 + } 192 + } catch (e) { 193 + console.log(e); 194 + const searchParams = updateSearchParams({ id: null }); 195 + router.replace(`${pathname}?${searchParams}`); 196 + toast.error("Something went wrong", { 197 + description: "Please try again", 198 + id: toastId, 199 + duration: 2000, 200 + }); 201 + } 55 202 } 56 - } catch { 203 + 204 + await fetchAndReadStream(); 205 + } catch (_e) { 57 206 // TODO: better error handling, including e.g. toast 58 207 form.setError("url", { message: "Something went wrong" }); 59 208 } ··· 61 210 } 62 211 63 212 return ( 64 - <Form {...form}> 65 - <form onSubmit={form.handleSubmit(onSubmit)}> 66 - <div className="grid grid-cols-4 gap-2 sm:grid-cols-5"> 67 - <FormField 68 - control={form.control} 69 - name="method" 70 - render={({ field }) => ( 71 - <FormItem className="col-span-1"> 72 - <FormLabel className="sr-only">Method</FormLabel> 73 - <Select 74 - onValueChange={field.onChange} 75 - defaultValue={field.value} 76 - > 213 + <> 214 + <Form {...form}> 215 + <form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-3"> 216 + <div className="grid grid-cols-4 gap-2 sm:grid-cols-5"> 217 + <FormField 218 + control={form.control} 219 + name="method" 220 + render={({ field }) => ( 221 + <FormItem className="col-span-1"> 222 + <FormLabel className="sr-only">Method</FormLabel> 223 + <Select 224 + onValueChange={field.onChange} 225 + defaultValue={field.value} 226 + > 227 + <FormControl> 228 + <SelectTrigger> 229 + <SelectValue /> 230 + </SelectTrigger> 231 + </FormControl> 232 + <SelectContent> 233 + {METHODS.map((method) => ( 234 + <SelectItem key={method} value={method}> 235 + {method} 236 + </SelectItem> 237 + ))} 238 + </SelectContent> 239 + </Select> 240 + <FormMessage /> 241 + </FormItem> 242 + )} 243 + /> 244 + <FormField 245 + control={form.control} 246 + name="url" 247 + render={({ field }) => ( 248 + <FormItem className="col-span-3"> 249 + <FormLabel className="sr-only">URL</FormLabel> 77 250 <FormControl> 78 - <SelectTrigger> 79 - <SelectValue /> 80 - </SelectTrigger> 251 + <Input 252 + placeholder="https://documenso.com" 253 + className="bg-muted" 254 + {...field} 255 + /> 256 + </FormControl> 257 + <FormMessage /> 258 + </FormItem> 259 + )} 260 + /> 261 + <div className="col-span-full mt-2 sm:col-span-1"> 262 + <Button disabled={isPending} className="h-10 w-full"> 263 + {isPending ? <LoadingAnimation /> : "Check"} 264 + </Button> 265 + </div> 266 + </div> 267 + <div> 268 + <FormField 269 + control={form.control} 270 + name="redirect" 271 + render={({ field }) => ( 272 + <FormItem className="flex flex-row items-start space-x-2 space-y-0"> 273 + <FormControl> 274 + <Checkbox 275 + checked={field.value} 276 + onCheckedChange={field.onChange} 277 + /> 81 278 </FormControl> 82 - <SelectContent> 83 - {METHODS.map((method) => ( 84 - <SelectItem key={method} value={method}> 85 - {method} 86 - </SelectItem> 87 - ))} 88 - </SelectContent> 89 - </Select> 90 - <FormMessage /> 91 - </FormItem> 92 - )} 93 - /> 94 - <FormField 95 - control={form.control} 96 - name="url" 97 - render={({ field }) => ( 98 - <FormItem className="col-span-3"> 99 - <FormLabel className="sr-only">URL</FormLabel> 100 - <FormControl> 101 - <Input placeholder="https://documenso.com" {...field} /> 102 - </FormControl> 103 - <FormMessage /> 104 - </FormItem> 105 - )} 106 - /> 107 - <div className="col-span-full mt-2 sm:col-span-1"> 108 - <Button disabled={isPending} className="h-10 w-full"> 109 - {isPending ? <LoadingAnimation /> : "Check"} 110 - </Button> 279 + <div className="space-y-1 leading-none"> 280 + <FormLabel>Redirect to extended details</FormLabel> 281 + <FormDescription className="max-w-md"> 282 + Get response header, timing phases and more. 283 + </FormDescription> 284 + </div> 285 + </FormItem> 286 + )} 287 + /> 111 288 </div> 289 + </form> 290 + </Form> 291 + <div className="grid gap-4"> 292 + <TableResult result={result} loading={isPending} id={id} /> 293 + <DotLegend /> 294 + </div> 295 + 296 + <FloatingActionNoSSR id={id} /> 297 + </> 298 + ); 299 + } 300 + 301 + function TableResult({ 302 + result, 303 + loading, 304 + id, 305 + }: { 306 + loading: boolean; 307 + result: RegionChecker[]; 308 + id: string | null; 309 + }) { 310 + return ( 311 + <Table> 312 + <TableHeader> 313 + <TableRow> 314 + <TableHead className="flex items-center gap-1"> 315 + <p className="w-[95px]"> 316 + Region{" "} 317 + <span className="font-normal text-xs tabular-nums"> 318 + ({result.length}/{flyRegions.length}) 319 + </span> 320 + </p> 321 + {loading ? ( 322 + <Loader className="inline h-4 w-4 animate-spin" /> 323 + ) : result.length ? ( 324 + <Icons.check className="inline h-4 w-4 text-green-500" /> 325 + ) : null} 326 + {id && 327 + !loading && 328 + result.length > 0 && 329 + result.length !== flyRegions.length ? ( 330 + <TooltipProvider> 331 + <Tooltip> 332 + <TooltipTrigger> 333 + <Info className="h-4 w-4 text-muted-foreground" /> 334 + </TooltipTrigger> 335 + <TooltipContent className="text-muted-foreground"> 336 + <p>Not all regions were hit.</p> 337 + <p> 338 + Still{" "} 339 + <Link 340 + href={`/play/checker/${id}`} 341 + className="text-foreground underline underline-offset-4 hover:no-underline" 342 + > 343 + check the results 344 + </Link> 345 + ? 346 + </p> 347 + </TooltipContent> 348 + </Tooltip> 349 + </TooltipProvider> 350 + ) : null} 351 + </TableHead> 352 + <TableHead className="w-[100px] text-right">Latency</TableHead> 353 + </TableRow> 354 + </TableHeader> 355 + <TableBody> 356 + {result.length > 0 ? ( 357 + result.map((item) => ( 358 + <TableRow key={item.region}> 359 + <TableCell className="flex items-center gap-2 font-medium"> 360 + {regionFormatter(item.region, "long")} 361 + <StatusDot value={item.status} /> 362 + </TableCell> 363 + <TableCell className="text-right"> 364 + {latencyFormatter(item.latency)} 365 + </TableCell> 366 + </TableRow> 367 + )) 368 + ) : ( 369 + <TableRow> 370 + <TableCell 371 + colSpan={2} 372 + className="border border-border border-dashed text-center" 373 + > 374 + No data available 375 + </TableCell> 376 + </TableRow> 377 + )} 378 + </TableBody> 379 + </Table> 380 + ); 381 + } 382 + 383 + function DotLegend() { 384 + return ( 385 + <div className="flex items-center justify-center gap-3 text-center"> 386 + {[1, 2, 3, 4, 5].map((i) => ( 387 + <div 388 + key={i} 389 + className="flex items-center justify-between gap-2 font-mono text-muted-foreground text-xs" 390 + > 391 + <StatusDot value={i * 100} /> 392 + <span>{i}xx</span> 112 393 </div> 113 - </form> 114 - </Form> 394 + ))} 395 + </div> 115 396 ); 116 397 } 398 + 399 + function StatusDot({ value }: { value: number }) { 400 + switch (String(value).charAt(0)) { 401 + case "1": 402 + return <div className="h-1.5 w-1.5 rounded-full bg-gray-500" />; 403 + case "2": 404 + return <div className="h-1.5 w-1.5 rounded-full bg-green-500" />; 405 + case "3": 406 + return <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />; 407 + case "4": 408 + return <div className="h-1.5 w-1.5 rounded-full bg-yellow-500" />; 409 + case "5": 410 + return <div className="h-1.5 w-1.5 rounded-full bg-red-500" />; 411 + default: 412 + return <div className="h-1.5 w-1.5 rounded-full bg-gray-500" />; 413 + } 414 + }
+36 -10
apps/web/src/app/play/checker/_components/checker-play.tsx
··· 1 - import { Shell } from "@/components/dashboard/shell"; 2 - import { HeaderPlay } from "../../_components/header-play"; 1 + import { 2 + CardContainer, 3 + CardDescription, 4 + CardHeader, 5 + CardIcon, 6 + CardTitle, 7 + } from "@/components/marketing/card"; 8 + import type { CachedRegionChecker } from "@/components/ping-response-analysis/utils"; 9 + import { Suspense } from "react"; 3 10 import { CheckerForm } from "./checker-form"; 4 11 5 - export default async function CheckerPlay() { 12 + export default async function CheckerPlay({ 13 + data, 14 + }: { 15 + data: CachedRegionChecker | null; 16 + }) { 6 17 return ( 7 - <Shell className="flex flex-col gap-8"> 8 - <HeaderPlay 9 - title="Is your endpoint globally fast?" 10 - description="Test your website and API performance across all continents. " 11 - /> 18 + <CardContainer> 19 + <CardHeader> 20 + <CardIcon icon="gauge" /> 21 + <CardTitle>Global Speed Checker</CardTitle> 22 + <CardDescription className="max-w-md"> 23 + Is your{" "} 24 + <span className="text-foreground">endpoint globally fast</span>? Test 25 + your website and API performance across all continents. 26 + </CardDescription> 27 + </CardHeader> 28 + 12 29 <div className="mx-auto grid w-full max-w-xl gap-6"> 13 - <CheckerForm /> 30 + <Suspense fallback={null}> 31 + <CheckerForm 32 + defaultValues={ 33 + data 34 + ? { redirect: false, ...data } 35 + : { redirect: false, url: "", method: "GET" } 36 + } 37 + defaultData={data?.checks.sort((a, b) => a.latency - b.latency)} 38 + /> 39 + </Suspense> 14 40 </div> 15 - </Shell> 41 + </CardContainer> 16 42 ); 17 43 }
+24
apps/web/src/app/play/checker/_components/floating-action.tsx
··· 1 + "use client"; 2 + 3 + import { Button } from "@openstatus/ui"; 4 + import * as Portal from "@radix-ui/react-portal"; 5 + import { ArrowRight, ChevronRight } from "lucide-react"; 6 + import Link from "next/link"; 7 + 8 + export function FloatingAction({ id }: { id: string | null }) { 9 + if (!id) return null; 10 + 11 + return ( 12 + <Portal.Root> 13 + <div className="group fixed right-4 bottom-4 z-50 mx-auto w-fit"> 14 + <Button asChild> 15 + <Link href={`/play/checker/${id}`}> 16 + <span className="mr-1">Response Details</span> 17 + <ArrowRight className="relative mb-[1px] inline h-4 w-0 transition-all group-hover:w-4" /> 18 + <ChevronRight className="relative mb-[1px] inline h-4 w-4 transition-all group-hover:w-0" /> 19 + </Link> 20 + </Button> 21 + </div> 22 + </Portal.Root> 23 + ); 24 + }
+27 -26
apps/web/src/app/play/checker/_components/global-monitoring.tsx
··· 2 2 3 3 import { Button } from "@openstatus/ui/src/components/button"; 4 4 5 - import { Shell } from "@/components/dashboard/shell"; 6 5 import type { ValidIcon } from "@/components/icons"; 7 6 import { 8 7 CardContainer, 9 - CardContent, 10 8 CardFeature, 11 - CardFeatureContainer, 12 9 CardHeader, 13 10 CardIcon, 14 11 CardTitle, 15 12 } from "@/components/marketing/card"; 16 - import { Globe } from "@/components/marketing/monitor/globe"; 17 13 18 14 const features: { 19 15 icon: ValidIcon; ··· 21 17 description: string; 22 18 }[] = [ 23 19 { 24 - icon: "globe", 25 - catchline: "Latency Monitoring.", 20 + icon: "gauge", 21 + catchline: "Speed Test for Websites", 26 22 description: 27 - "Monitor the latency of your endpoints from all over the world. We support 35 regions.", 23 + "Enter your URL and get a website speed check. Get insights on page load, header details and timing phases (DNS, Connect, TLS, TTFB) of the response.", 28 24 }, 29 25 { 30 - icon: "play", 31 - catchline: "Monitor anything.", 26 + icon: "globe", 27 + catchline: "Global Latency", 32 28 description: 33 - "We can monitor your website, API, DNS, TCP or any other service you have running. ", 29 + "Monitor performance in different regions to ensure quick load times for users across 35 regions around the world.", 34 30 }, 35 31 { 36 - icon: "bot", 37 - catchline: "Synthetic Monitoring.", 38 - description: "Run your tests in your CI/CD pipeline, or on a schedule. ", 32 + icon: "link", 33 + catchline: "Share the Results", 34 + description: 35 + "Quickly share the results of your website speed test with your team or clients. We share the results for 7 days, so you can easily collaborate on performance.", 39 36 }, 40 37 ]; 41 38 export const GlobalMonitoring = () => { 42 39 return ( 43 - <Shell className="mx-auto"> 40 + <CardContainer> 44 41 <CardHeader> 45 - <CardIcon icon={"activity"} /> 42 + <CardIcon icon="activity" /> 46 43 <CardTitle>Start monitoring your services</CardTitle> 47 44 </CardHeader> 48 - <> 49 - <div className="mt-12"> 50 - <div className="list-none space-y-4"> 51 - {features?.map((feature, i) => ( 52 - // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 53 - <CardFeature key={i} {...feature} /> 54 - ))} 55 - </div> 56 - </div> 57 - </> 58 - </Shell> 45 + <ul className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-6"> 46 + {features?.map((feature, i) => ( 47 + // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 48 + <CardFeature key={i} {...feature} /> 49 + ))} 50 + </ul> 51 + <div className="order-first flex items-center justify-center gap-2 text-center md:order-none"> 52 + <Button variant="outline" className="rounded-full" asChild> 53 + <Link href="/features/status-page">Status Page</Link> 54 + </Button> 55 + <Button className="rounded-full" asChild> 56 + <Link href="/features/monitoring">Monitoring</Link> 57 + </Button> 58 + </div> 59 + </CardContainer> 59 60 ); 60 61 };
+1279
apps/web/src/app/play/checker/api/mock.ts
··· 1 + import type { RegionChecker } from "@/components/ping-response-analysis/utils"; 2 + import { wait } from "@/lib/utils"; 3 + import type { Region } from "@openstatus/tinybird"; 4 + 5 + export async function mockCheckRegion(region: Region) { 6 + const response = data.checks.find((check) => check.region === region); 7 + 8 + if (!response) { 9 + throw new Error("Region not found"); 10 + } 11 + 12 + await wait(response.latency); 13 + 14 + return response as RegionChecker; 15 + } 16 + 17 + export const data = { 18 + url: "https://openstatus.dev", 19 + time: 1724415466600, 20 + method: "GET", 21 + checks: [ 22 + { 23 + status: 200, 24 + latency: 889, 25 + headers: { 26 + Age: "0", 27 + "Cache-Control": 28 + "private, no-cache, no-store, max-age=0, must-revalidate", 29 + "Content-Type": "text/html; charset=utf-8", 30 + Date: "Fri, 23 Aug 2024 12:17:47 GMT", 31 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 32 + Server: "Vercel", 33 + "Set-Cookie": 34 + "__Host-authjs.csrf-token=60f9ccb1cb9453b783b14b29eb9e43601eff00062d94b8c57fdaad37e6f10ce7%7C53949e7215f2a3d72af01e3f56727018ff57976e927e05aec83b795736fda2e7; Path=/; HttpOnly; Secure; SameSite=Lax", 35 + "Strict-Transport-Security": "max-age=63072000", 36 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 37 + "X-Matched-Path": "/", 38 + "X-Powered-By": "Next.js", 39 + "X-Vercel-Cache": "MISS", 40 + "X-Vercel-Id": "fra1::fra1::82mqm-1724415466843-d608bd28fa1c", 41 + }, 42 + time: 1724415466739, 43 + timing: { 44 + dnsStart: 1724415466810, 45 + dnsDone: 1724415466812, 46 + connectStart: 1724415466812, 47 + connectDone: 1724415466813, 48 + tlsHandshakeStart: 1724415466813, 49 + tlsHandshakeDone: 1724415466833, 50 + firstByteStart: 1724415466833, 51 + firstByteDone: 1724415467629, 52 + transferStart: 1724415467629, 53 + transferDone: 1724415467629, 54 + }, 55 + region: "ams", 56 + }, 57 + { 58 + status: 200, 59 + latency: 1602, 60 + headers: { 61 + Age: "0", 62 + "Cache-Control": 63 + "private, no-cache, no-store, max-age=0, must-revalidate", 64 + "Content-Type": "text/html; charset=utf-8", 65 + Date: "Fri, 23 Aug 2024 12:17:48 GMT", 66 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 67 + Server: "Vercel", 68 + "Set-Cookie": 69 + "__Host-authjs.csrf-token=25da392216bca70f47304c9c2eb42f471d91d7b118247a1d24dcb33e38e478b8%7Cf1d4e0f661eb0c8ba478cd5e7fa75d5b1e78f463e28f819c506f9a526f6e4412; Path=/; HttpOnly; Secure; SameSite=Lax", 70 + "Strict-Transport-Security": "max-age=63072000", 71 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 72 + "X-Matched-Path": "/", 73 + "X-Powered-By": "Next.js", 74 + "X-Vercel-Cache": "MISS", 75 + "X-Vercel-Execution-Region": "fra1", 76 + "X-Vercel-Id": "arn1::fra1::9855t-1724415466874-b441298d1373", 77 + }, 78 + time: 1724415466771, 79 + timing: { 80 + dnsStart: 1724415466822, 81 + dnsDone: 1724415466839, 82 + connectStart: 1724415466839, 83 + connectDone: 1724415466841, 84 + tlsHandshakeStart: 1724415466841, 85 + tlsHandshakeDone: 1724415466850, 86 + firstByteStart: 1724415466850, 87 + firstByteDone: 1724415468373, 88 + transferStart: 1724415468373, 89 + transferDone: 1724415468373, 90 + }, 91 + region: "arn", 92 + }, 93 + { 94 + status: 200, 95 + latency: 823, 96 + headers: { 97 + Age: "0", 98 + "Cache-Control": 99 + "private, no-cache, no-store, max-age=0, must-revalidate", 100 + "Content-Type": "text/html; charset=utf-8", 101 + Date: "Fri, 23 Aug 2024 12:17:47 GMT", 102 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 103 + Server: "Vercel", 104 + "Set-Cookie": 105 + "__Host-authjs.csrf-token=ea47cff80032077dfa9caa8a7462eabe1ab0885848e7c5b3c2867d1245236cfc%7C751f839b3c6906deca1fc497bfcd09a88cbbd716d187b0da3d1b3cddf929a469; Path=/; HttpOnly; Secure; SameSite=Lax", 106 + "Strict-Transport-Security": "max-age=63072000", 107 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 108 + "X-Matched-Path": "/", 109 + "X-Powered-By": "Next.js", 110 + "X-Vercel-Cache": "MISS", 111 + "X-Vercel-Execution-Region": "fra1", 112 + "X-Vercel-Id": "iad1::fra1::knlx7-1724415466923-6d8a2defb96b", 113 + }, 114 + time: 1724415466780, 115 + timing: { 116 + dnsStart: 1724415466872, 117 + dnsDone: 1724415466874, 118 + connectStart: 1724415466874, 119 + connectDone: 1724415466875, 120 + tlsHandshakeStart: 1724415466875, 121 + tlsHandshakeDone: 1724415466914, 122 + firstByteStart: 1724415466914, 123 + firstByteDone: 1724415467603, 124 + transferStart: 1724415467603, 125 + transferDone: 1724415467603, 126 + }, 127 + region: "atl", 128 + }, 129 + { 130 + status: 200, 131 + latency: 1198, 132 + headers: { 133 + Age: "0", 134 + "Cache-Control": 135 + "private, no-cache, no-store, max-age=0, must-revalidate", 136 + "Content-Type": "text/html; charset=utf-8", 137 + Date: "Fri, 23 Aug 2024 12:17:47 GMT", 138 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 139 + Server: "Vercel", 140 + "Set-Cookie": 141 + "__Host-authjs.csrf-token=b257a8d1854130d47bca81546e2b9f7b4ece148a7cfe12067b9ac8ffead17d86%7Cb55b9f314f742151aacdb968ff02b9fe974fff849b7f42edb542bbbb3f144c76; Path=/; HttpOnly; Secure; SameSite=Lax", 142 + "Strict-Transport-Security": "max-age=63072000", 143 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 144 + "X-Matched-Path": "/", 145 + "X-Powered-By": "Next.js", 146 + "X-Vercel-Cache": "MISS", 147 + "X-Vercel-Execution-Region": "fra1", 148 + "X-Vercel-Id": "iad1::fra1::zv2ml-1724415467303-bd78546dd164", 149 + }, 150 + time: 1724415466805, 151 + timing: { 152 + dnsStart: 1724415467056, 153 + dnsDone: 1724415467130, 154 + connectStart: 1724415467130, 155 + connectDone: 1724415467131, 156 + tlsHandshakeStart: 1724415467131, 157 + tlsHandshakeDone: 1724415467267, 158 + firstByteStart: 1724415467267, 159 + firstByteDone: 1724415468003, 160 + transferStart: 1724415468003, 161 + transferDone: 1724415468003, 162 + }, 163 + region: "bog", 164 + }, 165 + { 166 + status: 200, 167 + latency: 1423, 168 + headers: { 169 + Age: "0", 170 + "Cache-Control": 171 + "private, no-cache, no-store, max-age=0, must-revalidate", 172 + "Content-Type": "text/html; charset=utf-8", 173 + Date: "Fri, 23 Aug 2024 12:17:48 GMT", 174 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 175 + Server: "Vercel", 176 + "Set-Cookie": 177 + "__Host-authjs.csrf-token=8a075b8d193fee6d0bb5b7b146a8091a4823b6fdcf130ccfe59c6ef3f0231503%7C576e86b7bc8fd6583eeec0e420fe6e24fad8236c48855c9cca71bc487d3cfc0d; Path=/; HttpOnly; Secure; SameSite=Lax", 178 + "Strict-Transport-Security": "max-age=63072000", 179 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 180 + "X-Matched-Path": "/", 181 + "X-Powered-By": "Next.js", 182 + "X-Vercel-Cache": "MISS", 183 + "X-Vercel-Execution-Region": "fra1", 184 + "X-Vercel-Id": "bom1::fra1::24p8d-1724415467151-89ab94155a7b", 185 + }, 186 + time: 1724415466933, 187 + timing: { 188 + dnsStart: 1724415467117, 189 + dnsDone: 1724415467144, 190 + connectStart: 1724415467144, 191 + connectDone: 1724415467145, 192 + tlsHandshakeStart: 1724415467145, 193 + tlsHandshakeDone: 1724415467152, 194 + firstByteStart: 1724415467152, 195 + firstByteDone: 1724415468356, 196 + transferStart: 1724415468356, 197 + transferDone: 1724415468356, 198 + }, 199 + region: "bom", 200 + }, 201 + { 202 + status: 200, 203 + latency: 1134, 204 + headers: { 205 + Age: "0", 206 + "Cache-Control": 207 + "private, no-cache, no-store, max-age=0, must-revalidate", 208 + "Content-Type": "text/html; charset=utf-8", 209 + Date: "Fri, 23 Aug 2024 12:17:47 GMT", 210 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 211 + Server: "Vercel", 212 + "Set-Cookie": 213 + "__Host-authjs.csrf-token=790cc402a44b4a6fec5a32657bd549f2e70c0893007b1e799f9625039564b7dd%7C42453bba78875cf8010fe68ab340db0b3cf23c37eed94fe448566d143d25f579; Path=/; HttpOnly; Secure; SameSite=Lax", 214 + "Strict-Transport-Security": "max-age=63072000", 215 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 216 + "X-Matched-Path": "/", 217 + "X-Powered-By": "Next.js", 218 + "X-Vercel-Cache": "MISS", 219 + "X-Vercel-Execution-Region": "fra1", 220 + "X-Vercel-Id": "iad1::fra1::89mxf-1724415466920-c57e2b6af701", 221 + }, 222 + time: 1724415466802, 223 + timing: { 224 + dnsStart: 1724415466872, 225 + dnsDone: 1724415466885, 226 + connectStart: 1724415466885, 227 + connectDone: 1724415466885, 228 + tlsHandshakeStart: 1724415466885, 229 + tlsHandshakeDone: 1724415466912, 230 + firstByteStart: 1724415466913, 231 + firstByteDone: 1724415467936, 232 + transferStart: 1724415467936, 233 + transferDone: 1724415467936, 234 + }, 235 + region: "bos", 236 + }, 237 + { 238 + status: 200, 239 + latency: 812, 240 + headers: { 241 + Age: "0", 242 + "Cache-Control": 243 + "private, no-cache, no-store, max-age=0, must-revalidate", 244 + "Content-Type": "text/html; charset=utf-8", 245 + Date: "Fri, 23 Aug 2024 12:17:48 GMT", 246 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 247 + Server: "Vercel", 248 + "Set-Cookie": 249 + "__Host-authjs.csrf-token=5dac3b94bba1f3cbefd8d50fb0fea119f159004d32847669434c86a5d8d54b15%7C1c00c016c8ba9f442c0c7aa616706c3f8b162c82d1f715aa49a51699637b7208; Path=/; HttpOnly; Secure; SameSite=Lax", 250 + "Strict-Transport-Security": "max-age=63072000", 251 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 252 + "X-Matched-Path": "/", 253 + "X-Powered-By": "Next.js", 254 + "X-Vercel-Cache": "MISS", 255 + "X-Vercel-Execution-Region": "fra1", 256 + "X-Vercel-Id": "cdg1::fra1::g7g65-1724415467776-ef2e03e01f49", 257 + }, 258 + time: 1724415467743, 259 + timing: { 260 + dnsStart: 1724415467766, 261 + dnsDone: 1724415467769, 262 + connectStart: 1724415467769, 263 + connectDone: 1724415467770, 264 + tlsHandshakeStart: 1724415467770, 265 + tlsHandshakeDone: 1724415467775, 266 + firstByteStart: 1724415467775, 267 + firstByteDone: 1724415468555, 268 + transferStart: 1724415468555, 269 + transferDone: 1724415468555, 270 + }, 271 + region: "cdg", 272 + }, 273 + { 274 + status: 200, 275 + latency: 1081, 276 + headers: { 277 + Age: "0", 278 + "Cache-Control": 279 + "private, no-cache, no-store, max-age=0, must-revalidate", 280 + "Content-Type": "text/html; charset=utf-8", 281 + Date: "Fri, 23 Aug 2024 12:17:48 GMT", 282 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 283 + Server: "Vercel", 284 + "Set-Cookie": 285 + "__Host-authjs.csrf-token=58ff47d67b2d21ac08cb6c1d3b20eb12e29dd53aaa2051d90c6f364ab5e0d544%7C46683fe543ad2887543880291cd12267eeb2d82b956d3c59ff01e324fe846fd5; Path=/; HttpOnly; Secure; SameSite=Lax", 286 + "Strict-Transport-Security": "max-age=63072000", 287 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 288 + "X-Matched-Path": "/", 289 + "X-Powered-By": "Next.js", 290 + "X-Vercel-Cache": "MISS", 291 + "X-Vercel-Execution-Region": "fra1", 292 + "X-Vercel-Id": "sfo1::fra1::k8shj-1724415468145-cc12e589b9ec", 293 + }, 294 + time: 1724415467973, 295 + timing: { 296 + dnsStart: 1724415468074, 297 + dnsDone: 1724415468075, 298 + connectStart: 1724415468075, 299 + connectDone: 1724415468075, 300 + tlsHandshakeStart: 1724415468075, 301 + tlsHandshakeDone: 1724415468132, 302 + firstByteStart: 1724415468133, 303 + firstByteDone: 1724415469055, 304 + transferStart: 1724415469055, 305 + transferDone: 1724415469055, 306 + }, 307 + region: "den", 308 + }, 309 + { 310 + status: 200, 311 + latency: 1329, 312 + headers: { 313 + Age: "0", 314 + "Cache-Control": 315 + "private, no-cache, no-store, max-age=0, must-revalidate", 316 + "Content-Type": "text/html; charset=utf-8", 317 + Date: "Fri, 23 Aug 2024 12:17:49 GMT", 318 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 319 + Server: "Vercel", 320 + "Set-Cookie": 321 + "__Host-authjs.csrf-token=76ffd678bc935c42a7dbca29fd4f0ba40698fa20d2554b224a581fa7f0f09853%7C44a90f79d338272da4630c8275f8c3b5571b5f8fb39de169ab11e25632d2c144; Path=/; HttpOnly; Secure; SameSite=Lax", 322 + "Strict-Transport-Security": "max-age=63072000", 323 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 324 + "X-Matched-Path": "/", 325 + "X-Powered-By": "Next.js", 326 + "X-Vercel-Cache": "MISS", 327 + "X-Vercel-Execution-Region": "fra1", 328 + "X-Vercel-Id": "cle1::fra1::sxq92-1724415468662-2cd7c277d058", 329 + }, 330 + time: 1724415468380, 331 + timing: { 332 + dnsStart: 1724415468552, 333 + dnsDone: 1724415468554, 334 + connectStart: 1724415468554, 335 + connectDone: 1724415468554, 336 + tlsHandshakeStart: 1724415468554, 337 + tlsHandshakeDone: 1724415468641, 338 + firstByteStart: 1724415468641, 339 + firstByteDone: 1724415469710, 340 + transferStart: 1724415469710, 341 + transferDone: 1724415469710, 342 + }, 343 + region: "dfw", 344 + }, 345 + { 346 + status: 200, 347 + latency: 380, 348 + headers: { 349 + Age: "0", 350 + "Cache-Control": 351 + "private, no-cache, no-store, max-age=0, must-revalidate", 352 + "Content-Type": "text/html; charset=utf-8", 353 + Date: "Fri, 23 Aug 2024 12:17:48 GMT", 354 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 355 + Server: "Vercel", 356 + "Set-Cookie": 357 + "__Host-authjs.csrf-token=195d8721e93ff6a135dc1a2bf7c4c7b76961ddd3662e56815c7ec6a3ce1abd3d%7C2993b6c22635423908d6f2ec1a1aaced8ed391d05fc3125f31dac992d7428197; Path=/; HttpOnly; Secure; SameSite=Lax", 358 + "Strict-Transport-Security": "max-age=63072000", 359 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 360 + "X-Matched-Path": "/", 361 + "X-Powered-By": "Next.js", 362 + "X-Vercel-Cache": "MISS", 363 + "X-Vercel-Execution-Region": "fra1", 364 + "X-Vercel-Id": "iad1::fra1::8dpsb-1724415468744-2a4aa1e3d5ab", 365 + }, 366 + time: 1724415468672, 367 + timing: { 368 + dnsStart: 1724415468716, 369 + dnsDone: 1724415468721, 370 + connectStart: 1724415468721, 371 + connectDone: 1724415468722, 372 + tlsHandshakeStart: 1724415468722, 373 + tlsHandshakeDone: 1724415468740, 374 + firstByteStart: 1724415468740, 375 + firstByteDone: 1724415469052, 376 + transferStart: 1724415469052, 377 + transferDone: 1724415469052, 378 + }, 379 + region: "ewr", 380 + }, 381 + { 382 + status: 200, 383 + latency: 802, 384 + headers: { 385 + Age: "0", 386 + "Cache-Control": 387 + "private, no-cache, no-store, max-age=0, must-revalidate", 388 + "Content-Type": "text/html; charset=utf-8", 389 + Date: "Fri, 23 Aug 2024 12:17:49 GMT", 390 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 391 + Server: "Vercel", 392 + "Set-Cookie": 393 + "__Host-authjs.csrf-token=4c6a43acbb9675aa48a54684ba789486dcdd8551b3cce40cc3089646627b9226%7C1ed25c2ef54680a28c75592c15f459c2b48bf294ec578191f9c1047a3b8ffe79; Path=/; HttpOnly; Secure; SameSite=Lax", 394 + "Strict-Transport-Security": "max-age=63072000", 395 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 396 + "X-Matched-Path": "/", 397 + "X-Powered-By": "Next.js", 398 + "X-Vercel-Cache": "MISS", 399 + "X-Vercel-Execution-Region": "fra1", 400 + "X-Vercel-Id": "gru1::fra1::g8pl7-1724415469119-c65e63554739", 401 + }, 402 + time: 1724415468749, 403 + timing: { 404 + dnsStart: 1724415468984, 405 + dnsDone: 1724415469043, 406 + connectStart: 1724415469043, 407 + connectDone: 1724415469043, 408 + tlsHandshakeStart: 1724415469043, 409 + tlsHandshakeDone: 1724415469103, 410 + firstByteStart: 1724415469104, 411 + firstByteDone: 1724415469552, 412 + transferStart: 1724415469552, 413 + transferDone: 1724415469552, 414 + }, 415 + region: "eze", 416 + }, 417 + { 418 + status: 200, 419 + latency: 615, 420 + headers: { 421 + Age: "0", 422 + "Cache-Control": 423 + "private, no-cache, no-store, max-age=0, must-revalidate", 424 + "Content-Type": "text/html; charset=utf-8", 425 + Date: "Fri, 23 Aug 2024 12:17:49 GMT", 426 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 427 + Server: "Vercel", 428 + "Set-Cookie": 429 + "__Host-authjs.csrf-token=1cc1e9eb9bdc56c9b698df0afc275633753d12d06f312e353cc34f7a84531412%7Ce2e75b4bfba94809563f68e206c52f0389ac93aa31094b5b4db3771fd5c41781; Path=/; HttpOnly; Secure; SameSite=Lax", 430 + "Strict-Transport-Security": "max-age=63072000", 431 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 432 + "X-Matched-Path": "/", 433 + "X-Powered-By": "Next.js", 434 + "X-Vercel-Cache": "MISS", 435 + "X-Vercel-Id": "fra1::fra1::kv28h-1724415468785-c9192137c9ed", 436 + }, 437 + time: 1724415468739, 438 + timing: { 439 + dnsStart: 1724415468775, 440 + dnsDone: 1724415468779, 441 + connectStart: 1724415468779, 442 + connectDone: 1724415468782, 443 + tlsHandshakeStart: 1724415468782, 444 + tlsHandshakeDone: 1724415468789, 445 + firstByteStart: 1724415468789, 446 + firstByteDone: 1724415469354, 447 + transferStart: 1724415469354, 448 + transferDone: 1724415469354, 449 + }, 450 + region: "fra", 451 + }, 452 + { 453 + status: 200, 454 + latency: 1481, 455 + headers: { 456 + Age: "0", 457 + "Cache-Control": 458 + "private, no-cache, no-store, max-age=0, must-revalidate", 459 + "Content-Type": "text/html; charset=utf-8", 460 + Date: "Fri, 23 Aug 2024 12:17:50 GMT", 461 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 462 + Server: "Vercel", 463 + "Set-Cookie": 464 + "__Host-authjs.csrf-token=938d20933200b21638ed84e60832085dc135e3193a233720de87b0e41a24e2fd%7Cfcd423e5d8e965dc0e23728cf52a6f3e531cfe5a007942f6cb0a57fc90ac2bd8; Path=/; HttpOnly; Secure; SameSite=Lax", 465 + "Strict-Transport-Security": "max-age=63072000", 466 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 467 + "X-Matched-Path": "/", 468 + "X-Powered-By": "Next.js", 469 + "X-Vercel-Cache": "MISS", 470 + "X-Vercel-Execution-Region": "fra1", 471 + "X-Vercel-Id": "sfo1::fra1::jn45p-1724415470146-7160122c2733", 472 + }, 473 + time: 1724415469619, 474 + timing: { 475 + dnsStart: 1724415469889, 476 + dnsDone: 1724415469947, 477 + connectStart: 1724415469947, 478 + connectDone: 1724415469953, 479 + tlsHandshakeStart: 1724415469953, 480 + tlsHandshakeDone: 1724415470104, 481 + firstByteStart: 1724415470104, 482 + firstByteDone: 1724415471100, 483 + transferStart: 1724415471100, 484 + transferDone: 1724415471100, 485 + }, 486 + region: "gdl", 487 + }, 488 + { 489 + status: 200, 490 + latency: 768, 491 + headers: { 492 + Age: "0", 493 + "Cache-Control": 494 + "private, no-cache, no-store, max-age=0, must-revalidate", 495 + "Content-Type": "text/html; charset=utf-8", 496 + Date: "Fri, 23 Aug 2024 12:17:50 GMT", 497 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 498 + Server: "Vercel", 499 + "Set-Cookie": 500 + "__Host-authjs.csrf-token=05ef42987dc7df7b05cee0541b776c2cd51eba97ba713153f215e8550ae0448c%7C3c1a941480a5c26bc3e7434709576dacc81ea4f05359505eebd2d195e63aeb74; Path=/; HttpOnly; Secure; SameSite=Lax", 501 + "Strict-Transport-Security": "max-age=63072000", 502 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 503 + "X-Matched-Path": "/", 504 + "X-Powered-By": "Next.js", 505 + "X-Vercel-Cache": "MISS", 506 + "X-Vercel-Execution-Region": "fra1", 507 + "X-Vercel-Id": "gru1::fra1::df9rf-1724415469769-16109f11a918", 508 + }, 509 + time: 1724415469592, 510 + timing: { 511 + dnsStart: 1724415469639, 512 + dnsDone: 1724415469748, 513 + connectStart: 1724415469748, 514 + connectDone: 1724415469748, 515 + tlsHandshakeStart: 1724415469748, 516 + tlsHandshakeDone: 1724415469765, 517 + firstByteStart: 1724415469765, 518 + firstByteDone: 1724415470358, 519 + transferStart: 1724415470358, 520 + transferDone: 1724415470360, 521 + }, 522 + region: "gig", 523 + }, 524 + { 525 + status: 200, 526 + latency: 662, 527 + headers: { 528 + Age: "0", 529 + "Cache-Control": 530 + "private, no-cache, no-store, max-age=0, must-revalidate", 531 + "Content-Type": "text/html; charset=utf-8", 532 + Date: "Fri, 23 Aug 2024 12:17:50 GMT", 533 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 534 + Server: "Vercel", 535 + "Set-Cookie": 536 + "__Host-authjs.csrf-token=79dbfc2a5ba28ebb28864cdeee17cbc42ce9901946370dec247bcdb0f363cd5e%7Ca332b8cc3c8a8713af109bdbc5b9ef831e6302f86a2455c662d84c74e62b09b8; Path=/; HttpOnly; Secure; SameSite=Lax", 537 + "Strict-Transport-Security": "max-age=63072000", 538 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 539 + "X-Matched-Path": "/", 540 + "X-Powered-By": "Next.js", 541 + "X-Vercel-Cache": "MISS", 542 + "X-Vercel-Execution-Region": "fra1", 543 + "X-Vercel-Id": "gru1::fra1::ps6s8-1724415469878-6e2b2b9481e6", 544 + }, 545 + time: 1724415469642, 546 + timing: { 547 + dnsStart: 1724415469762, 548 + dnsDone: 1724415469871, 549 + connectStart: 1724415469871, 550 + connectDone: 1724415469872, 551 + tlsHandshakeStart: 1724415469872, 552 + tlsHandshakeDone: 1724415469877, 553 + firstByteStart: 1724415469877, 554 + firstByteDone: 1724415470304, 555 + transferStart: 1724415470304, 556 + transferDone: 1724415470304, 557 + }, 558 + region: "gru", 559 + }, 560 + { 561 + status: 200, 562 + latency: 1543, 563 + headers: { 564 + Age: "0", 565 + "Cache-Control": 566 + "private, no-cache, no-store, max-age=0, must-revalidate", 567 + "Content-Type": "text/html; charset=utf-8", 568 + Date: "Fri, 23 Aug 2024 12:17:51 GMT", 569 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 570 + Server: "Vercel", 571 + "Set-Cookie": 572 + "__Host-authjs.csrf-token=f0b519b719c63760474410a3ae680668fbc06da25fddab90c3e5fb04e3dd5320%7C630e28ddd164b4c4107414be9daca7a65cee210ca88c355366ef7855a63efb0f; Path=/; HttpOnly; Secure; SameSite=Lax", 573 + "Strict-Transport-Security": "max-age=63072000", 574 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 575 + "X-Matched-Path": "/", 576 + "X-Powered-By": "Next.js", 577 + "X-Vercel-Cache": "MISS", 578 + "X-Vercel-Execution-Region": "fra1", 579 + "X-Vercel-Id": "hkg1::fra1::fxvwk-1724415469898-52ddb6281608", 580 + }, 581 + time: 1724415469801, 582 + timing: { 583 + dnsStart: 1724415469859, 584 + dnsDone: 1724415469892, 585 + connectStart: 1724415469892, 586 + connectDone: 1724415469893, 587 + tlsHandshakeStart: 1724415469893, 588 + tlsHandshakeDone: 1724415469898, 589 + firstByteStart: 1724415469898, 590 + firstByteDone: 1724415471344, 591 + transferStart: 1724415471344, 592 + transferDone: 1724415471344, 593 + }, 594 + region: "hkg", 595 + }, 596 + { 597 + status: 200, 598 + latency: 369, 599 + headers: { 600 + Age: "0", 601 + "Cache-Control": 602 + "private, no-cache, no-store, max-age=0, must-revalidate", 603 + "Content-Type": "text/html; charset=utf-8", 604 + Date: "Fri, 23 Aug 2024 12:17:50 GMT", 605 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 606 + Server: "Vercel", 607 + "Set-Cookie": 608 + "__Host-authjs.csrf-token=e40f4b2e630f81375e79b742870729b7e008bf85b4523cd8c8b83a39fd31bb9f%7Ca062303e57c49fefa52f5bb256282d5b257bc8c98e8b34a1ededddfe77215003; Path=/; HttpOnly; Secure; SameSite=Lax", 609 + "Strict-Transport-Security": "max-age=63072000", 610 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 611 + "X-Matched-Path": "/", 612 + "X-Powered-By": "Next.js", 613 + "X-Vercel-Cache": "MISS", 614 + "X-Vercel-Execution-Region": "fra1", 615 + "X-Vercel-Id": "iad1::fra1::lpxzz-1724415470208-6564146436d5", 616 + }, 617 + time: 1724415470173, 618 + timing: { 619 + dnsStart: 1724415470193, 620 + dnsDone: 1724415470196, 621 + connectStart: 1724415470196, 622 + connectDone: 1724415470198, 623 + tlsHandshakeStart: 1724415470198, 624 + tlsHandshakeDone: 1724415470206, 625 + firstByteStart: 1724415470206, 626 + firstByteDone: 1724415470542, 627 + transferStart: 1724415470542, 628 + transferDone: 1724415470542, 629 + }, 630 + region: "iad", 631 + }, 632 + { 633 + status: 200, 634 + latency: 1264, 635 + headers: { 636 + Age: "0", 637 + "Cache-Control": 638 + "private, no-cache, no-store, max-age=0, must-revalidate", 639 + "Content-Type": "text/html; charset=utf-8", 640 + Date: "Fri, 23 Aug 2024 12:17:51 GMT", 641 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 642 + Server: "Vercel", 643 + "Set-Cookie": 644 + "__Host-authjs.csrf-token=d4ad7992b9fd60115ddc589306804e8cd65d569d6afd66f95eec72045a0c74ed%7C0dcddba799b4a2e429448cea0f347886e730f2905e8a464d332889cf0dd2afc2; Path=/; HttpOnly; Secure; SameSite=Lax", 645 + "Strict-Transport-Security": "max-age=63072000", 646 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 647 + "X-Matched-Path": "/", 648 + "X-Powered-By": "Next.js", 649 + "X-Vercel-Cache": "MISS", 650 + "X-Vercel-Execution-Region": "fra1", 651 + "X-Vercel-Id": "cpt1::fra1::lnlth-1724415471275-cf922a128799", 652 + }, 653 + time: 1724415470963, 654 + timing: { 655 + dnsStart: 1724415471216, 656 + dnsDone: 1724415471218, 657 + connectStart: 1724415471218, 658 + connectDone: 1724415471218, 659 + tlsHandshakeStart: 1724415471218, 660 + tlsHandshakeDone: 1724415471262, 661 + firstByteStart: 1724415471262, 662 + firstByteDone: 1724415472228, 663 + transferStart: 1724415472228, 664 + transferDone: 1724415472228, 665 + }, 666 + region: "jnb", 667 + }, 668 + { 669 + status: 200, 670 + latency: 642, 671 + headers: { 672 + Age: "0", 673 + "Cache-Control": 674 + "private, no-cache, no-store, max-age=0, must-revalidate", 675 + "Content-Type": "text/html; charset=utf-8", 676 + Date: "Fri, 23 Aug 2024 12:17:51 GMT", 677 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 678 + Server: "Vercel", 679 + "Set-Cookie": 680 + "__Host-authjs.csrf-token=7c255e198252b31b58e293332d0cea5198cd5d92acf2a3745214680530a48bae%7C15bfe39b4d88d60cb55d7e7e34ccff17ccb7bbe9a5c76200584c510cfd310586; Path=/; HttpOnly; Secure; SameSite=Lax", 681 + "Strict-Transport-Security": "max-age=63072000", 682 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 683 + "X-Matched-Path": "/", 684 + "X-Powered-By": "Next.js", 685 + "X-Vercel-Cache": "MISS", 686 + "X-Vercel-Execution-Region": "fra1", 687 + "X-Vercel-Id": "sfo1::fra1::tpm8t-1724415471358-ec064b0eab3d", 688 + }, 689 + time: 1724415471269, 690 + timing: { 691 + dnsStart: 1724415471325, 692 + dnsDone: 1724415471327, 693 + connectStart: 1724415471327, 694 + connectDone: 1724415471328, 695 + tlsHandshakeStart: 1724415471328, 696 + tlsHandshakeDone: 1724415471352, 697 + firstByteStart: 1724415471352, 698 + firstByteDone: 1724415471912, 699 + transferStart: 1724415471912, 700 + transferDone: 1724415471912, 701 + }, 702 + region: "lax", 703 + }, 704 + { 705 + status: 200, 706 + latency: 627, 707 + headers: { 708 + Age: "0", 709 + "Cache-Control": 710 + "private, no-cache, no-store, max-age=0, must-revalidate", 711 + "Content-Type": "text/html; charset=utf-8", 712 + Date: "Fri, 23 Aug 2024 12:17:51 GMT", 713 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 714 + Server: "Vercel", 715 + "Set-Cookie": 716 + "__Host-authjs.csrf-token=399ab06338d52f31d31203af84d5f3657e380eb9317fd1a71bfd41d77abe27bb%7Cae0a0909fe500b07deb5867b0336f0176a501bb34460de004432aa7c3ccd1de2; Path=/; HttpOnly; Secure; SameSite=Lax", 717 + "Strict-Transport-Security": "max-age=63072000", 718 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 719 + "X-Matched-Path": "/", 720 + "X-Powered-By": "Next.js", 721 + "X-Vercel-Cache": "MISS", 722 + "X-Vercel-Execution-Region": "fra1", 723 + "X-Vercel-Id": "lhr1::fra1::7lf7n-1724415471374-7ef5cab51600", 724 + }, 725 + time: 1724415471324, 726 + timing: { 727 + dnsStart: 1724415471360, 728 + dnsDone: 1724415471366, 729 + connectStart: 1724415471366, 730 + connectDone: 1724415471368, 731 + tlsHandshakeStart: 1724415471368, 732 + tlsHandshakeDone: 1724415471373, 733 + firstByteStart: 1724415471373, 734 + firstByteDone: 1724415471952, 735 + transferStart: 1724415471952, 736 + transferDone: 1724415471952, 737 + }, 738 + region: "lhr", 739 + }, 740 + { 741 + status: 200, 742 + latency: 951, 743 + headers: { 744 + Age: "0", 745 + "Cache-Control": 746 + "private, no-cache, no-store, max-age=0, must-revalidate", 747 + "Content-Type": "text/html; charset=utf-8", 748 + Date: "Fri, 23 Aug 2024 12:17:52 GMT", 749 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 750 + Server: "Vercel", 751 + "Set-Cookie": 752 + "__Host-authjs.csrf-token=d3c263dfb22b4a07fb9fde65227c2d3b97d969874b6c7a394a73a543c35d9364%7Cdea8ecc64ee8311f4a7904b6fea3aefeca1250b9c1ee9b3ae7efd23d9938d5bb; Path=/; HttpOnly; Secure; SameSite=Lax", 753 + "Strict-Transport-Security": "max-age=63072000", 754 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 755 + "X-Matched-Path": "/", 756 + "X-Powered-By": "Next.js", 757 + "X-Vercel-Cache": "MISS", 758 + "X-Vercel-Execution-Region": "fra1", 759 + "X-Vercel-Id": "cdg1::fra1::jszwl-1724415471627-5821aab52b06", 760 + }, 761 + time: 1724415471373, 762 + timing: { 763 + dnsStart: 1724415471554, 764 + dnsDone: 1724415471556, 765 + connectStart: 1724415471556, 766 + connectDone: 1724415471571, 767 + tlsHandshakeStart: 1724415471571, 768 + tlsHandshakeDone: 1724415471613, 769 + firstByteStart: 1724415471613, 770 + firstByteDone: 1724415472324, 771 + transferStart: 1724415472324, 772 + transferDone: 1724415472324, 773 + }, 774 + region: "mad", 775 + }, 776 + { 777 + status: 200, 778 + latency: 808, 779 + headers: { 780 + Age: "0", 781 + "Cache-Control": 782 + "private, no-cache, no-store, max-age=0, must-revalidate", 783 + "Content-Type": "text/html; charset=utf-8", 784 + Date: "Fri, 23 Aug 2024 12:17:52 GMT", 785 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 786 + Server: "Vercel", 787 + "Set-Cookie": 788 + "__Host-authjs.csrf-token=6b10a5354dfa3d39ee556f78017e896e0bde4abe9e9583693044bf81f9af32ee%7Cea36450ea545200dc9a035d5b4c6ca6c8df7e88b1a4c71c97a9c9313ca39aebf; Path=/; HttpOnly; Secure; SameSite=Lax", 789 + "Strict-Transport-Security": "max-age=63072000", 790 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 791 + "X-Matched-Path": "/", 792 + "X-Powered-By": "Next.js", 793 + "X-Vercel-Cache": "MISS", 794 + "X-Vercel-Execution-Region": "fra1", 795 + "X-Vercel-Id": "iad1::fra1::jscbc-1724415472309-b6db76af787c", 796 + }, 797 + time: 1724415472154, 798 + timing: { 799 + dnsStart: 1724415472239, 800 + dnsDone: 1724415472240, 801 + connectStart: 1724415472240, 802 + connectDone: 1724415472266, 803 + tlsHandshakeStart: 1724415472266, 804 + tlsHandshakeDone: 1724415472296, 805 + firstByteStart: 1724415472296, 806 + firstByteDone: 1724415472963, 807 + transferStart: 1724415472963, 808 + transferDone: 1724415472963, 809 + }, 810 + region: "mia", 811 + }, 812 + { 813 + status: 200, 814 + latency: 1301, 815 + headers: { 816 + Age: "0", 817 + "Cache-Control": 818 + "private, no-cache, no-store, max-age=0, must-revalidate", 819 + "Content-Type": "text/html; charset=utf-8", 820 + Date: "Fri, 23 Aug 2024 12:17:53 GMT", 821 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 822 + Server: "Vercel", 823 + "Set-Cookie": 824 + "__Host-authjs.csrf-token=3414880cc0d90ac4a648e9e8a1aad136d3db41001cfcb8fda20129777cae0449%7Cf971b35cc728fe0fc205ef61ca344be04e81e52e0b92067745fdd3b520a481cd; Path=/; HttpOnly; Secure; SameSite=Lax", 825 + "Strict-Transport-Security": "max-age=63072000", 826 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 827 + "X-Matched-Path": "/", 828 + "X-Powered-By": "Next.js", 829 + "X-Vercel-Cache": "MISS", 830 + "X-Vercel-Execution-Region": "fra1", 831 + "X-Vercel-Id": "hnd1::fra1::kxkwr-1724415472423-82ba874bb590", 832 + }, 833 + time: 1724415472357, 834 + timing: { 835 + dnsStart: 1724415472405, 836 + dnsDone: 1724415472410, 837 + connectStart: 1724415472410, 838 + connectDone: 1724415472412, 839 + tlsHandshakeStart: 1724415472412, 840 + tlsHandshakeDone: 1724415472420, 841 + firstByteStart: 1724415472421, 842 + firstByteDone: 1724415473658, 843 + transferStart: 1724415473658, 844 + transferDone: 1724415473658, 845 + }, 846 + region: "nrt", 847 + }, 848 + { 849 + status: 200, 850 + latency: 1079, 851 + headers: { 852 + Age: "0", 853 + "Cache-Control": 854 + "private, no-cache, no-store, max-age=0, must-revalidate", 855 + "Content-Type": "text/html; charset=utf-8", 856 + Date: "Fri, 23 Aug 2024 12:17:53 GMT", 857 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 858 + Server: "Vercel", 859 + "Set-Cookie": 860 + "__Host-authjs.csrf-token=b840e2646e8198e2c657d1002914c88d6c5b93189b34ed82855975164b9675ac%7Cf35eda6a163c51d096d35e6c520d663115bfaa195889068885e68bd38b9f8ac7; Path=/; HttpOnly; Secure; SameSite=Lax", 861 + "Strict-Transport-Security": "max-age=63072000", 862 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 863 + "X-Matched-Path": "/", 864 + "X-Powered-By": "Next.js", 865 + "X-Vercel-Cache": "MISS", 866 + "X-Vercel-Execution-Region": "fra1", 867 + "X-Vercel-Id": "cle1::fra1::8zclb-1724415472414-c402d33465fc", 868 + }, 869 + time: 1724415472323, 870 + timing: { 871 + dnsStart: 1724415472378, 872 + dnsDone: 1724415472380, 873 + connectStart: 1724415472380, 874 + connectDone: 1724415472382, 875 + tlsHandshakeStart: 1724415472382, 876 + tlsHandshakeDone: 1724415472408, 877 + firstByteStart: 1724415472408, 878 + firstByteDone: 1724415473402, 879 + transferStart: 1724415473402, 880 + transferDone: 1724415473402, 881 + }, 882 + region: "ord", 883 + }, 884 + { 885 + status: 200, 886 + latency: 1349, 887 + headers: { 888 + Age: "0", 889 + "Cache-Control": 890 + "private, no-cache, no-store, max-age=0, must-revalidate", 891 + "Content-Type": "text/html; charset=utf-8", 892 + Date: "Fri, 23 Aug 2024 12:17:53 GMT", 893 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 894 + Server: "Vercel", 895 + "Set-Cookie": 896 + "__Host-authjs.csrf-token=637d460fe50112a95818857adbea59b9cf1f184f57d90001eaefc2e3b613ad61%7Cfa046a39b9170382ba0a848d9ea21ad86f035d0ff11f677cf7cac182d2a8a744; Path=/; HttpOnly; Secure; SameSite=Lax", 897 + "Strict-Transport-Security": "max-age=63072000", 898 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 899 + "X-Matched-Path": "/", 900 + "X-Powered-By": "Next.js", 901 + "X-Vercel-Cache": "MISS", 902 + "X-Vercel-Id": "fra1::fra1::tjkng-1724415472738-a6c3d672835c", 903 + }, 904 + time: 1724415472487, 905 + timing: { 906 + dnsStart: 1724415472627, 907 + dnsDone: 1724415472658, 908 + connectStart: 1724415472658, 909 + connectDone: 1724415472676, 910 + tlsHandshakeStart: 1724415472676, 911 + tlsHandshakeDone: 1724415472723, 912 + firstByteStart: 1724415472723, 913 + firstByteDone: 1724415473837, 914 + transferStart: 1724415473837, 915 + transferDone: 1724415473837, 916 + }, 917 + region: "otp", 918 + }, 919 + { 920 + status: 200, 921 + latency: 970, 922 + headers: { 923 + Age: "0", 924 + "Cache-Control": 925 + "private, no-cache, no-store, max-age=0, must-revalidate", 926 + "Content-Type": "text/html; charset=utf-8", 927 + Date: "Fri, 23 Aug 2024 12:17:53 GMT", 928 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 929 + Server: "Vercel", 930 + "Set-Cookie": 931 + "__Host-authjs.csrf-token=d9b23db6a8a5c5d9cfee5f1803059ee708aca2e2bd43c9b59854ba3899acd6f0%7Cac93af4a4b2944ebd4bbdadbed95d64726f7e841c6ce75ba3d73d40516e1652b; Path=/; HttpOnly; Secure; SameSite=Lax", 932 + "Strict-Transport-Security": "max-age=63072000", 933 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 934 + "X-Matched-Path": "/", 935 + "X-Powered-By": "Next.js", 936 + "X-Vercel-Cache": "MISS", 937 + "X-Vercel-Execution-Region": "fra1", 938 + "X-Vercel-Id": "sfo1::fra1::jn45p-1724415473337-466a2679cdf6", 939 + }, 940 + time: 1724415472895, 941 + timing: { 942 + dnsStart: 1724415473151, 943 + dnsDone: 1724415473169, 944 + connectStart: 1724415473169, 945 + connectDone: 1724415473212, 946 + tlsHandshakeStart: 1724415473212, 947 + tlsHandshakeDone: 1724415473311, 948 + firstByteStart: 1724415473311, 949 + firstByteDone: 1724415473865, 950 + transferStart: 1724415473865, 951 + transferDone: 1724415473865, 952 + }, 953 + region: "phx", 954 + }, 955 + { 956 + status: 200, 957 + latency: 1539, 958 + headers: { 959 + Age: "0", 960 + "Cache-Control": 961 + "private, no-cache, no-store, max-age=0, must-revalidate", 962 + "Content-Type": "text/html; charset=utf-8", 963 + Date: "Fri, 23 Aug 2024 12:17:55 GMT", 964 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 965 + Server: "Vercel", 966 + "Set-Cookie": 967 + "__Host-authjs.csrf-token=6c483b6918c385243a810bdfad2268d79f7a5501a45b2f12d5b073c10c1ebd96%7C56115dbd9537df4a9579c3bcb15ace5ea2265f679eac9f8ea1d0e850bf618f41; Path=/; HttpOnly; Secure; SameSite=Lax", 968 + "Strict-Transport-Security": "max-age=63072000", 969 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 970 + "X-Matched-Path": "/", 971 + "X-Powered-By": "Next.js", 972 + "X-Vercel-Cache": "MISS", 973 + "X-Vercel-Execution-Region": "fra1", 974 + "X-Vercel-Id": "sfo1::fra1::cfhm9-1724415474388-d835d118b121", 975 + }, 976 + time: 1724415473741, 977 + timing: { 978 + dnsStart: 1724415474074, 979 + dnsDone: 1724415474203, 980 + connectStart: 1724415474203, 981 + connectDone: 1724415474204, 982 + tlsHandshakeStart: 1724415474204, 983 + tlsHandshakeDone: 1724415474350, 984 + firstByteStart: 1724415474350, 985 + firstByteDone: 1724415475280, 986 + transferStart: 1724415475280, 987 + transferDone: 1724415475280, 988 + }, 989 + region: "qro", 990 + }, 991 + { 992 + status: 200, 993 + latency: 1347, 994 + headers: { 995 + Age: "0", 996 + "Cache-Control": 997 + "private, no-cache, no-store, max-age=0, must-revalidate", 998 + "Content-Type": "text/html; charset=utf-8", 999 + Date: "Fri, 23 Aug 2024 12:17:55 GMT", 1000 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1001 + Server: "Vercel", 1002 + "Set-Cookie": 1003 + "__Host-authjs.csrf-token=06921285d96e59b991fb4e99210f3d65e85d8f56adb67d3e722598ef8766ce33%7Cc197f92fb2deac3a9cce800c50c2be456fdd3e076f8e2faecbbf72c4bdae4559; Path=/; HttpOnly; Secure; SameSite=Lax", 1004 + "Strict-Transport-Security": "max-age=63072000", 1005 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1006 + "X-Matched-Path": "/", 1007 + "X-Powered-By": "Next.js", 1008 + "X-Vercel-Cache": "MISS", 1009 + "X-Vercel-Execution-Region": "fra1", 1010 + "X-Vercel-Id": "gru1::fra1::dtczd-1724415474697-bc49fc211c26", 1011 + }, 1012 + time: 1724415474183, 1013 + timing: { 1014 + dnsStart: 1724415474470, 1015 + dnsDone: 1724415474578, 1016 + connectStart: 1724415474578, 1017 + connectDone: 1724415474578, 1018 + tlsHandshakeStart: 1724415474578, 1019 + tlsHandshakeDone: 1724415474676, 1020 + firstByteStart: 1724415474676, 1021 + firstByteDone: 1724415475530, 1022 + transferStart: 1724415475530, 1023 + transferDone: 1724415475530, 1024 + }, 1025 + region: "scl", 1026 + }, 1027 + { 1028 + status: 200, 1029 + latency: 400, 1030 + headers: { 1031 + Age: "0", 1032 + "Cache-Control": 1033 + "private, no-cache, no-store, max-age=0, must-revalidate", 1034 + "Content-Type": "text/html; charset=utf-8", 1035 + Date: "Fri, 23 Aug 2024 12:17:54 GMT", 1036 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1037 + Server: "Vercel", 1038 + "Set-Cookie": 1039 + "__Host-authjs.csrf-token=810c745c559dd6cd590e3ecf94d45604336ef8be9ffea1cde0c53e48da57347d%7C561ec5a1fb89b523d861a3d0da4f095a50ad8386ac6a67c1325a0fee483467de; Path=/; HttpOnly; Secure; SameSite=Lax", 1040 + "Strict-Transport-Security": "max-age=63072000", 1041 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1042 + "X-Matched-Path": "/", 1043 + "X-Powered-By": "Next.js", 1044 + "X-Vercel-Cache": "MISS", 1045 + "X-Vercel-Execution-Region": "fra1", 1046 + "X-Vercel-Id": "sfo1::fra1::wwf2m-1724415474188-b0c6f71df2db", 1047 + }, 1048 + time: 1724415474161, 1049 + timing: { 1050 + dnsStart: 1724415474177, 1051 + dnsDone: 1724415474180, 1052 + connectStart: 1724415474180, 1053 + connectDone: 1724415474180, 1054 + tlsHandshakeStart: 1724415474180, 1055 + tlsHandshakeDone: 1724415474187, 1056 + firstByteStart: 1724415474187, 1057 + firstByteDone: 1724415474561, 1058 + transferStart: 1724415474561, 1059 + transferDone: 1724415474562, 1060 + }, 1061 + region: "sjc", 1062 + }, 1063 + { 1064 + status: 200, 1065 + latency: 883, 1066 + headers: { 1067 + Age: "0", 1068 + "Cache-Control": 1069 + "private, no-cache, no-store, max-age=0, must-revalidate", 1070 + "Content-Type": "text/html; charset=utf-8", 1071 + Date: "Fri, 23 Aug 2024 12:17:55 GMT", 1072 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1073 + Server: "Vercel", 1074 + "Set-Cookie": 1075 + "__Host-authjs.csrf-token=401b5b1bf92e519ea7487cbc10260464ed784af62dd6469cca701013a256a801%7C2b2e8fd45b7c29091dfe92642ea51a4473ec6f06af28181ce374a1db856b5d38; Path=/; HttpOnly; Secure; SameSite=Lax", 1076 + "Strict-Transport-Security": "max-age=63072000", 1077 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1078 + "X-Matched-Path": "/", 1079 + "X-Powered-By": "Next.js", 1080 + "X-Vercel-Cache": "MISS", 1081 + "X-Vercel-Execution-Region": "fra1", 1082 + "X-Vercel-Id": "pdx1::fra1::fdxft-1724415474318-e900cbf850a7", 1083 + }, 1084 + time: 1724415474239, 1085 + timing: { 1086 + dnsStart: 1724415474291, 1087 + dnsDone: 1724415474294, 1088 + connectStart: 1724415474294, 1089 + connectDone: 1724415474294, 1090 + tlsHandshakeStart: 1724415474294, 1091 + tlsHandshakeDone: 1724415474315, 1092 + firstByteStart: 1724415474315, 1093 + firstByteDone: 1724415475122, 1094 + transferStart: 1724415475122, 1095 + transferDone: 1724415475122, 1096 + }, 1097 + region: "sea", 1098 + }, 1099 + { 1100 + status: 200, 1101 + latency: 825, 1102 + headers: { 1103 + Age: "0", 1104 + "Cache-Control": 1105 + "private, no-cache, no-store, max-age=0, must-revalidate", 1106 + "Content-Type": "text/html; charset=utf-8", 1107 + Date: "Fri, 23 Aug 2024 12:17:55 GMT", 1108 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1109 + Server: "Vercel", 1110 + "Set-Cookie": 1111 + "__Host-authjs.csrf-token=7f7ea0f4e87ec4aa383bf93a55a06f51cc8f94c6b47f1dd7a4fa64ddd30f7224%7Cfe689d60c5036e0b56e410d2422c0f96dca2039184d3bbc41f8e910fd7824bbc; Path=/; HttpOnly; Secure; SameSite=Lax", 1112 + "Strict-Transport-Security": "max-age=63072000", 1113 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1114 + "X-Matched-Path": "/", 1115 + "X-Powered-By": "Next.js", 1116 + "X-Vercel-Cache": "MISS", 1117 + "X-Vercel-Execution-Region": "fra1", 1118 + "X-Vercel-Id": "sin1::fra1::b8msv-1724415474849-b7ad6db342b4", 1119 + }, 1120 + time: 1724415474736, 1121 + timing: { 1122 + dnsStart: 1724415474838, 1123 + dnsDone: 1724415474840, 1124 + connectStart: 1724415474840, 1125 + connectDone: 1724415474841, 1126 + tlsHandshakeStart: 1724415474841, 1127 + tlsHandshakeDone: 1724415474848, 1128 + firstByteStart: 1724415474848, 1129 + firstByteDone: 1724415475561, 1130 + transferStart: 1724415475561, 1131 + transferDone: 1724415475561, 1132 + }, 1133 + region: "sin", 1134 + }, 1135 + { 1136 + status: 200, 1137 + latency: 526, 1138 + headers: { 1139 + Age: "0", 1140 + "Cache-Control": 1141 + "private, no-cache, no-store, max-age=0, must-revalidate", 1142 + "Content-Type": "text/html; charset=utf-8", 1143 + Date: "Fri, 23 Aug 2024 12:17:55 GMT", 1144 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1145 + Server: "Vercel", 1146 + "Set-Cookie": 1147 + "__Host-authjs.csrf-token=9fafdef83adf38bf4b48c801e2b1d5c2ce04858dcddbe69ca576e6d9945f2d9a%7Ced402758800ee1c6dee8fd49119ef549b0a4ecbbccd9f178c87ac32c26d29433; Path=/; HttpOnly; Secure; SameSite=Lax", 1148 + "Strict-Transport-Security": "max-age=63072000", 1149 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1150 + "X-Matched-Path": "/", 1151 + "X-Powered-By": "Next.js", 1152 + "X-Vercel-Cache": "MISS", 1153 + "X-Vercel-Execution-Region": "fra1", 1154 + "X-Vercel-Id": "syd1::fra1::p48zw-1724415475067-dce86bb55ee4", 1155 + }, 1156 + time: 1724415475023, 1157 + timing: { 1158 + dnsStart: 1724415475058, 1159 + dnsDone: 1724415475059, 1160 + connectStart: 1724415475059, 1161 + connectDone: 1724415475060, 1162 + tlsHandshakeStart: 1724415475060, 1163 + tlsHandshakeDone: 1724415475065, 1164 + firstByteStart: 1724415475065, 1165 + firstByteDone: 1724415475549, 1166 + transferStart: 1724415475549, 1167 + transferDone: 1724415475549, 1168 + }, 1169 + region: "syd", 1170 + }, 1171 + { 1172 + status: 200, 1173 + latency: 869, 1174 + headers: { 1175 + Age: "0", 1176 + "Cache-Control": 1177 + "private, no-cache, no-store, max-age=0, must-revalidate", 1178 + "Content-Type": "text/html; charset=utf-8", 1179 + Date: "Fri, 23 Aug 2024 12:17:56 GMT", 1180 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1181 + Server: "Vercel", 1182 + "Set-Cookie": 1183 + "__Host-authjs.csrf-token=7c97710f74a86e1e01522a7b84e73f02099553eb09a1b3f6bb2bdaad009b2dbd%7C5e26b720c7c54a98dc858077c2496989dfaf4f24b129ad9ecce09136c1bc1d4f; Path=/; HttpOnly; Secure; SameSite=Lax", 1184 + "Strict-Transport-Security": "max-age=63072000", 1185 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1186 + "X-Matched-Path": "/", 1187 + "X-Powered-By": "Next.js", 1188 + "X-Vercel-Cache": "MISS", 1189 + "X-Vercel-Id": "fra1::fra1::dlk76-1724415475594-7c5b9c827a3d", 1190 + }, 1191 + time: 1724415475389, 1192 + timing: { 1193 + dnsStart: 1724415475504, 1194 + dnsDone: 1724415475527, 1195 + connectStart: 1724415475527, 1196 + connectDone: 1724415475545, 1197 + tlsHandshakeStart: 1724415475545, 1198 + tlsHandshakeDone: 1724415475582, 1199 + firstByteStart: 1724415475582, 1200 + firstByteDone: 1724415476259, 1201 + transferStart: 1724415476259, 1202 + transferDone: 1724415476259, 1203 + }, 1204 + region: "waw", 1205 + }, 1206 + { 1207 + status: 200, 1208 + latency: 1133, 1209 + headers: { 1210 + Age: "0", 1211 + "Cache-Control": 1212 + "private, no-cache, no-store, max-age=0, must-revalidate", 1213 + "Content-Type": "text/html; charset=utf-8", 1214 + Date: "Fri, 23 Aug 2024 12:17:56 GMT", 1215 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1216 + Server: "Vercel", 1217 + "Set-Cookie": 1218 + "__Host-authjs.csrf-token=c00e54372624088fced657d119128dc6d43eee5d5ea8209d16655f4cc67d930a%7Cfff1ca52bcf8e9cd9b92ac4de667991ffa5b445b8c77aad31cfa38c6e86a3d14; Path=/; HttpOnly; Secure; SameSite=Lax", 1219 + "Strict-Transport-Security": "max-age=63072000", 1220 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1221 + "X-Matched-Path": "/", 1222 + "X-Powered-By": "Next.js", 1223 + "X-Vercel-Cache": "MISS", 1224 + "X-Vercel-Execution-Region": "fra1", 1225 + "X-Vercel-Id": "iad1::fra1::dc8tg-1724415476028-5d259d762ec1", 1226 + }, 1227 + time: 1724415475892, 1228 + timing: { 1229 + dnsStart: 1724415475952, 1230 + dnsDone: 1724415475978, 1231 + connectStart: 1724415475978, 1232 + connectDone: 1724415475979, 1233 + tlsHandshakeStart: 1724415475979, 1234 + tlsHandshakeDone: 1724415476020, 1235 + firstByteStart: 1724415476020, 1236 + firstByteDone: 1724415477025, 1237 + transferStart: 1724415477025, 1238 + transferDone: 1724415477025, 1239 + }, 1240 + region: "yul", 1241 + }, 1242 + { 1243 + status: 200, 1244 + latency: 447, 1245 + headers: { 1246 + Age: "0", 1247 + "Cache-Control": 1248 + "private, no-cache, no-store, max-age=0, must-revalidate", 1249 + "Content-Type": "text/html; charset=utf-8", 1250 + Date: "Fri, 23 Aug 2024 12:17:56 GMT", 1251 + Link: '</_next/static/media/162bf645eb375add-s.p.ttf>; rel=preload; as="font"; crossorigin=""; type="font/ttf", </_next/static/media/a34f9d1faa5f3315-s.p.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"', 1252 + Server: "Vercel", 1253 + "Set-Cookie": 1254 + "__Host-authjs.csrf-token=aca1d19c78d030efdcedc23883f78d9bc1f2d6d21bd5e2a8ac0026560f3d7f17%7Cf87108886605e4a306ce630f3b322c83fd8cab2dc49c709be5b4a59f5c22b4d0; Path=/; HttpOnly; Secure; SameSite=Lax", 1255 + "Strict-Transport-Security": "max-age=63072000", 1256 + Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch", 1257 + "X-Matched-Path": "/", 1258 + "X-Powered-By": "Next.js", 1259 + "X-Vercel-Cache": "MISS", 1260 + "X-Vercel-Execution-Region": "fra1", 1261 + "X-Vercel-Id": "cle1::fra1::bckhc-1724415476215-f3c71058df41", 1262 + }, 1263 + time: 1724415476081, 1264 + timing: { 1265 + dnsStart: 1724415476155, 1266 + dnsDone: 1724415476157, 1267 + connectStart: 1724415476157, 1268 + connectDone: 1724415476158, 1269 + tlsHandshakeStart: 1724415476158, 1270 + tlsHandshakeDone: 1724415476204, 1271 + firstByteStart: 1724415476204, 1272 + firstByteDone: 1724415476529, 1273 + transferStart: 1724415476529, 1274 + transferDone: 1724415476529, 1275 + }, 1276 + region: "yyz", 1277 + }, 1278 + ], 1279 + };
+58 -9
apps/web/src/app/play/checker/api/route.ts
··· 1 - import { setCheckerData } from "@/components/ping-response-analysis/utils"; 1 + import { 2 + type Method, 3 + checkRegion, 4 + storeBaseCheckerData, 5 + storeCheckerData, 6 + } from "@/components/ping-response-analysis/utils"; 7 + import { iteratorToStream, yieldMany } from "@/lib/stream"; 8 + import { flyRegions } from "@openstatus/db/src/schema/constants"; 9 + import { mockCheckRegion } from "./mock"; 2 10 3 11 export const runtime = "edge"; 4 12 13 + const encoder = new TextEncoder(); 14 + 15 + async function* makeIterator({ 16 + url, 17 + method, 18 + id, 19 + }: { url: string; method: Method; id: string }) { 20 + // Create an array to store all the promises 21 + const promises = flyRegions.map(async (region, index) => { 22 + try { 23 + // Perform the fetch operation 24 + const check = 25 + process.env.NODE_ENV === "production" 26 + ? await checkRegion(url, region, { method }) 27 + : await mockCheckRegion(region); 28 + 29 + if ("body" in check) { 30 + check.body = undefined; // Drop the body to avoid storing it in Redis Cache 31 + } 32 + 33 + storeCheckerData({ check, id }); 34 + 35 + return encoder.encode( 36 + `${JSON.stringify({ 37 + ...check, 38 + index, 39 + })}\n`, 40 + ); 41 + } catch (error) { 42 + console.error(error); 43 + return encoder.encode(""); 44 + } 45 + }); 46 + 47 + yield* yieldMany(promises); 48 + // return the id as the last value 49 + yield* generator(id); 50 + } 51 + 52 + async function* generator(id: string) { 53 + yield await Promise.resolve(encoder.encode(id)); 54 + } 55 + 5 56 export async function POST(request: Request) { 6 57 const json = await request.json(); 7 - 8 58 const { url, method } = json; 9 59 10 - try { 11 - const uuid = await setCheckerData(url, { method }); 12 - return new Response(JSON.stringify({ uuid })); 13 - } catch (e) { 14 - console.log(e); 15 - return new Response("Internal Server Error", { status: 500 }); 16 - } 60 + const uuid = crypto.randomUUID().replace(/-/g, ""); 61 + storeBaseCheckerData({ url, method, id: uuid }); 62 + 63 + const iterator = makeIterator({ url, method, id: uuid }); 64 + const stream = iteratorToStream(iterator); 65 + return new Response(stream); 17 66 }
+26 -8
apps/web/src/app/play/checker/page.tsx
··· 1 1 import type { Metadata } from "next"; 2 2 3 - import { BackButton } from "@/components/layout/back-button"; 4 3 import { BottomCTA } from "@/components/marketing/in-between-cta"; 4 + import { getCheckerDataById } from "@/components/ping-response-analysis/utils"; 5 + import { redirect } from "next/navigation"; 6 + import { z } from "zod"; 5 7 import CheckerPlay from "./_components/checker-play"; 6 8 import { GlobalMonitoring } from "./_components/global-monitoring"; 7 9 import { Testimonial } from "./_components/testimonial"; 8 10 9 11 export const metadata: Metadata = { 10 - title: "Speed Checker", 12 + title: "Global Speed Checker", 11 13 description: 12 - "Test the performance your api, website from multiple regions. Get speed insights for free.", 14 + "Test the performance of your api, website from multiple regions. Get speed insights for free.", 13 15 openGraph: { 14 - title: "Speed Checker", 16 + title: "Global Speed Checker", 15 17 description: 16 - "Test the performance your api, website from multiple regions. Get speed insights for free.", 18 + "Test the performance of your api, website from multiple regions. Get speed insights for free.", 17 19 }, 18 20 }; 19 21 20 - export default async function PlayPage() { 22 + const searchParamsSchema = z.object({ 23 + id: z.string().optional(), 24 + }); 25 + 26 + export default async function PlayPage({ 27 + searchParams, 28 + }: { 29 + searchParams: { [key: string]: string | string[] | undefined }; 30 + }) { 31 + const search = searchParamsSchema.safeParse(searchParams); 32 + 33 + const id = search.success ? search.data.id : undefined; 34 + 35 + const data = id ? await getCheckerDataById(id) : null; 36 + 37 + if (id && !data) return redirect("/play/checker"); 38 + 21 39 return ( 22 - <div className="my-8 grid h-full w-full gap-12 md:my-16"> 23 - <CheckerPlay /> 40 + <div className="grid h-full w-full gap-12"> 41 + <CheckerPlay data={data} /> 24 42 <Testimonial /> 25 43 <GlobalMonitoring /> 26 44 <div className="mx-auto max-w-2xl lg:max-w-4xl">
+2 -2
apps/web/src/app/play/page.tsx
··· 34 34 export default async function PlayPage() { 35 35 return ( 36 36 <> 37 - <div className="my-8 grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:my-16 md:grid-cols-3"> 37 + <div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3"> 38 38 {playgrounds.map((play, i) => { 39 39 const isFirst = i === 0; 40 40 return ( ··· 53 53 const playgrounds: CardProps[] = [ 54 54 { 55 55 href: "/play/checker", 56 - title: "Speed Checker", 56 + title: "Global Speed Checker", 57 57 description: 58 58 "Get speed insights for your api, website from multiple regions. No account needed.", 59 59 icon: Gauge,
+17 -8
apps/web/src/app/play/status/_components/status-play.tsx
··· 1 1 import { OSTinybird } from "@openstatus/tinybird"; 2 2 3 - import { Shell } from "@/components/dashboard/shell"; 3 + import { 4 + CardContainer, 5 + CardDescription, 6 + CardHeader, 7 + CardIcon, 8 + CardTitle, 9 + } from "@/components/marketing/card"; 4 10 import { Tracker } from "@/components/tracker/tracker"; 5 11 import { env } from "@/env"; 6 12 import { getServerTimezoneFormat } from "@/lib/timezone"; 7 - import { HeaderPlay } from "../../_components/header-play"; 8 13 9 14 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 10 15 ··· 21 26 const formattedServerDate = getServerTimezoneFormat(); 22 27 23 28 return ( 24 - <Shell> 29 + <CardContainer> 30 + <CardHeader> 31 + <CardIcon icon="panel-top" /> 32 + <CardTitle>Status Page</CardTitle> 33 + <CardDescription className="max-w-md"> 34 + Gain the trust of your users by showing them the uptime of your API or 35 + website. 36 + </CardDescription> 37 + </CardHeader> 25 38 <div className="relative grid gap-4"> 26 - <HeaderPlay 27 - title="Status Page" 28 - description="Gain the trust of your users by showing them the uptime of your API or website." 29 - /> 30 39 <div className="mx-auto w-full max-w-md"> 31 40 {data && <Tracker data={data} name="Ping" description="Pong" />} 32 41 </div> ··· 35 44 </p> 36 45 {/* REMINDER: more playground component */} 37 46 </div> 38 - </Shell> 47 + </CardContainer> 39 48 ); 40 49 }
+1 -1
apps/web/src/app/play/status/page.tsx
··· 17 17 18 18 export default async function PlayPage() { 19 19 return ( 20 - <div className="my-8 md:my-16"> 20 + <div className="w-full"> 21 21 <StatusPlay /> 22 22 </div> 23 23 );
+1 -1
apps/web/src/components/content/changelog.tsx
··· 17 17 src={post.image} 18 18 fill={true} 19 19 alt={post.title} 20 - className="object-contain" 20 + className="object-cover" 21 21 /> 22 22 </div> 23 23 </div>
+56
apps/web/src/components/data-table/data-table-view-options.tsx
··· 1 + "use client"; 2 + 3 + import type { Table } from "@tanstack/react-table"; 4 + import { Settings2 } from "lucide-react"; 5 + 6 + import { Button } from "@openstatus/ui/src/components/button"; 7 + import { 8 + DropdownMenu, 9 + DropdownMenuCheckboxItem, 10 + DropdownMenuContent, 11 + DropdownMenuLabel, 12 + DropdownMenuSeparator, 13 + DropdownMenuTrigger, 14 + } from "@openstatus/ui/src/components/dropdown-menu"; 15 + 16 + interface DataTableViewOptionsProps<TData> { 17 + table: Table<TData>; 18 + } 19 + 20 + export function DataTableViewOptions<TData>({ 21 + table, 22 + }: DataTableViewOptionsProps<TData>) { 23 + return ( 24 + <DropdownMenu> 25 + <DropdownMenuTrigger asChild> 26 + <Button variant="outline" size="sm" className="shadow-none"> 27 + <Settings2 className="mr-2 h-4 w-4" /> 28 + View 29 + </Button> 30 + </DropdownMenuTrigger> 31 + <DropdownMenuContent align="end" className="w-[150px]"> 32 + <DropdownMenuLabel>Display properties</DropdownMenuLabel> 33 + <DropdownMenuSeparator /> 34 + {table 35 + .getAllColumns() 36 + .filter( 37 + (column) => 38 + typeof column.accessorFn !== "undefined" && column.getCanHide(), 39 + ) 40 + .map((column) => { 41 + return ( 42 + <DropdownMenuCheckboxItem 43 + key={column.id} 44 + className={"capitalize"} 45 + onSelect={(e) => e.preventDefault()} 46 + checked={column.getIsVisible()} 47 + onCheckedChange={(value) => column.toggleVisibility(!!value)} 48 + > 49 + {column.id} 50 + </DropdownMenuCheckboxItem> 51 + ); 52 + })} 53 + </DropdownMenuContent> 54 + </DropdownMenu> 55 + ); 56 + }
+12 -2
apps/web/src/components/marketing/card.tsx
··· 39 39 ); 40 40 } 41 41 42 - export function CardDescription({ children }: { children: React.ReactNode }) { 43 - return <p className="text-center text-muted-foreground">{children}</p>; 42 + export function CardDescription({ 43 + children, 44 + className, 45 + }: { 46 + children: React.ReactNode; 47 + className?: string; 48 + }) { 49 + return ( 50 + <p className={cn("text-center text-muted-foreground", className)}> 51 + {children} 52 + </p> 53 + ); 44 54 } 45 55 46 56 export function CardContent({
+4
apps/web/src/components/marketing/hero.tsx
··· 7 7 8 8 import { getGitHubStars } from "@/lib/github"; 9 9 import { cn, numberFormatter } from "@/lib/utils"; 10 + import { SpeedCheckerButton } from "./speed-checker-button"; 10 11 11 12 export function Hero() { 12 13 return ( ··· 57 58 </Suspense> 58 59 </Link> 59 60 </Button> 61 + </div> 62 + <div className="col-span-full"> 63 + <SpeedCheckerButton variant="ghost" className="w-48 sm:w-auto" /> 60 64 </div> 61 65 </div> 62 66 </div>
+2 -3
apps/web/src/components/marketing/monitor/card.tsx
··· 12 12 CardIcon, 13 13 CardTitle, 14 14 } from "../card"; 15 + import { SpeedCheckerButton } from "../speed-checker-button"; 15 16 import { Globe } from "./globe"; 16 17 17 18 export function MonitoringCard() { ··· 31 32 ))} 32 33 <div className="order-first flex items-center justify-center gap-2 text-center md:order-none"> 33 34 <Button variant="outline" className="rounded-full" asChild> 34 - <Link href="/play/checker">Playground</Link> 35 - </Button> 36 - <Button className="rounded-full" asChild> 37 35 <Link href="/features/monitoring">Learn more</Link> 38 36 </Button> 37 + <SpeedCheckerButton /> 39 38 </div> 40 39 </CardFeatureContainer> 41 40 </CardContent>
+14
apps/web/src/components/marketing/speed-checker-button.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + import { Button, type ButtonProps } from "@openstatus/ui"; 3 + import Link from "next/link"; 4 + import { Icons } from "../icons"; 5 + 6 + export function SpeedCheckerButton({ className, ...props }: ButtonProps) { 7 + return ( 8 + <Button className={cn("rounded-full", className)} asChild {...props}> 9 + <Link href="/play/checker"> 10 + Speed Checker <Icons.gauge className="ml-1 h-4 w-4" /> 11 + </Link> 12 + </Button> 13 + ); 14 + }
+25 -15
apps/web/src/components/monitor-dashboard/region-preset.tsx
··· 5 5 import * as React from "react"; 6 6 7 7 import type { Region } from "@openstatus/tinybird"; 8 - import { Button } from "@openstatus/ui/src/components/button"; 8 + import { Button, type ButtonProps } from "@openstatus/ui/src/components/button"; 9 9 import { 10 10 Command, 11 11 CommandEmpty, ··· 29 29 import useUpdateSearchParams from "@/hooks/use-update-search-params"; 30 30 import { cn } from "@/lib/utils"; 31 31 32 + interface RegionsPresetProps extends ButtonProps { 33 + regions: Region[]; 34 + selectedRegions: Region[]; 35 + } 36 + 32 37 export function RegionsPreset({ 33 38 regions, 34 39 selectedRegions, 35 40 className, 36 - }: { 37 - regions: Region[]; 38 - selectedRegions: Region[]; 39 - className?: string; 40 - }) { 41 - const [selected, setSelected] = React.useState<Region[]>(selectedRegions); 41 + ...props 42 + }: RegionsPresetProps) { 43 + const [selected, setSelected] = React.useState<Region[]>( 44 + selectedRegions.filter((r) => regions.includes(r)), 45 + ); // REMINDER: init without regions that failed to load 42 46 const router = useRouter(); 43 47 const pathname = usePathname(); 44 48 const updateSearchParams = useUpdateSearchParams(); ··· 59 63 (prev, curr) => { 60 64 const region = flyRegionsDict[curr]; 61 65 62 - if (prev[region.continent]) { 63 - prev[region.continent].push(region); 66 + const item = prev.find((r) => r.continent === region.continent); 67 + 68 + if (item) { 69 + item.data.push(region); 64 70 } else { 65 - prev[region.continent] = [region]; 71 + prev.push({ 72 + continent: region.continent, 73 + data: [region], 74 + }); 66 75 } 67 76 68 77 return prev; 69 78 }, 70 - {} as Record<Continent, RegionInfo[]>, 79 + [] as { continent: Continent; data: RegionInfo[] }[], 71 80 ); 72 81 73 82 return ( ··· 77 86 size="lg" 78 87 variant="outline" 79 88 className={cn("px-3 shadow-none", className)} 89 + {...props} 80 90 > 81 91 <Globe2 className="mr-2 h-4 w-4" /> 82 - <span> 92 + <span className="whitespace-nowrap"> 83 93 <code>{selected.length}</code> Regions 84 94 </span> 85 95 <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> ··· 105 115 </CommandItem> 106 116 </CommandGroup> 107 117 <CommandSeparator /> 108 - {Object.entries(regionsByContinent).map(([key, regions]) => { 118 + {regionsByContinent.map(({ continent, data }) => { 109 119 return ( 110 - <CommandGroup key={key} heading={key}> 111 - {regions.map((region) => { 120 + <CommandGroup key={continent} heading={continent}> 121 + {data.map((region) => { 112 122 const { code, flag, location, continent } = region; 113 123 const isSelected = selected.includes(code); 114 124 return (
+82 -10
apps/web/src/components/ping-response-analysis/columns.tsx
··· 1 1 "use client"; 2 2 3 3 import type { ColumnDef } from "@tanstack/react-table"; 4 - import { type RegionChecker, latencyFormatter, regionFormatter } from "./utils"; 4 + import { 5 + type RegionChecker, 6 + continentFormatter, 7 + latencyFormatter, 8 + regionFormatter, 9 + timestampFormatter, 10 + } from "./utils"; 5 11 12 + import { format } from "date-fns"; 13 + import { utcToZonedTime } from "date-fns-tz"; 6 14 import { DataTableColumnHeader } from "../data-table/data-table-column-header"; 7 15 import { StatusCodeBadge } from "../monitor/status-code-badge"; 8 16 9 17 export const columns: ColumnDef<RegionChecker>[] = [ 10 18 { 19 + id: "key", 20 + accessorFn: (row) => row.region, 21 + header: "Key", 22 + cell: ({ row }) => { 23 + return <div className="font-mono">{row.original.region}</div>; 24 + }, 25 + enableHiding: false, 26 + }, 27 + { 11 28 accessorKey: "region", 29 + header: "Region", 12 30 cell: ({ row }) => { 13 - return <div>{regionFormatter(row.original.region)}</div>; 31 + return ( 32 + <div className="text-muted-foreground"> 33 + {regionFormatter(row.original.region, "long")} 34 + </div> 35 + ); 14 36 }, 37 + }, 38 + { 39 + id: "continent", 40 + accessorFn: (row) => continentFormatter(row.region), 15 41 header: ({ column }) => { 16 - return <DataTableColumnHeader column={column} title="Region" />; 42 + return <DataTableColumnHeader column={column} title="Continent" />; 43 + }, 44 + cell: ({ row }) => { 45 + return <div>{row.getValue("continent")}</div>; 17 46 }, 18 47 }, 19 48 { 20 49 accessorKey: "status", 21 - header: ({ column }) => { 22 - return <DataTableColumnHeader column={column} title="Status" />; 23 - }, 50 + header: "Status", 24 51 cell: ({ row }) => { 25 52 return <StatusCodeBadge statusCode={row.original.status} />; 26 53 }, 27 54 }, 28 55 { 29 - id: "dns", 56 + id: "DNS", 30 57 header: ({ column }) => { 31 58 return <DataTableColumnHeader column={column} title="DNS" />; 32 59 }, 33 60 accessorFn: (row) => `${row.timing.dnsDone - row.timing.dnsStart}`, 61 + cell: ({ row, column }) => { 62 + return ( 63 + <div className="font-mono"> 64 + {latencyFormatter(row.getValue(column.id))} 65 + </div> 66 + ); 67 + }, 34 68 }, 35 69 { 36 70 id: "connect", ··· 38 72 header: ({ column }) => { 39 73 return <DataTableColumnHeader column={column} title="Connect" />; 40 74 }, 75 + cell: ({ row, column }) => { 76 + return ( 77 + <div className="font-mono"> 78 + {latencyFormatter(row.getValue(column.id))} 79 + </div> 80 + ); 81 + }, 41 82 }, 42 83 { 43 - id: "tls", 84 + id: "TLS", 85 + 44 86 accessorFn: (row) => 45 87 `${row.timing.tlsHandshakeDone - row.timing.tlsHandshakeStart}`, 46 88 header: ({ column }) => { 47 89 return <DataTableColumnHeader column={column} title="TLS" />; 48 90 }, 91 + cell: ({ row, column }) => { 92 + return ( 93 + <div className="font-mono"> 94 + {latencyFormatter(row.getValue(column.id))} 95 + </div> 96 + ); 97 + }, 49 98 }, 50 99 { 51 - id: "ttfb", 100 + id: "TTFB", 52 101 accessorFn: (row) => 53 102 `${row.timing.firstByteDone - row.timing.firstByteStart}`, 54 103 header: ({ column }) => { 55 104 return <DataTableColumnHeader column={column} title="TTFB" />; 56 105 }, 106 + cell: ({ row, column }) => { 107 + return ( 108 + <div className="font-mono"> 109 + {latencyFormatter(row.getValue(column.id))} 110 + </div> 111 + ); 112 + }, 57 113 }, 58 114 { 59 115 accessorKey: "latency", ··· 61 117 return <DataTableColumnHeader column={column} title="Latency" />; 62 118 }, 63 119 cell: ({ row }) => { 64 - return <div>{latencyFormatter(row.original.latency)}</div>; 120 + return ( 121 + <div className="font-mono"> 122 + {latencyFormatter(row.original.latency)} 123 + </div> 124 + ); 125 + }, 126 + }, 127 + { 128 + id: "Time (UTC)", 129 + accessorFn: (row) => row.time, 130 + cell: ({ row }) => { 131 + const date = format( 132 + utcToZonedTime(row.original.time, "UTC"), 133 + "dd LLL hh:mm a", 134 + ); 135 + 136 + return <div className="whitespace-nowrap">{date}</div>; 65 137 }, 66 138 }, 67 139 ];
+27
apps/web/src/components/ping-response-analysis/data-table-collapse-button.tsx
··· 1 + "use client"; 2 + 3 + import { Badge, Button } from "@openstatus/ui"; 4 + import type { Table } from "@tanstack/react-table"; 5 + import { ChevronsDownUp } from "lucide-react"; 6 + 7 + interface DataTableCollapseButtonProps<TData> { 8 + table: Table<TData>; 9 + } 10 + 11 + export function DataTableCollapseButton<TData>({ 12 + table, 13 + }: DataTableCollapseButtonProps<TData>) { 14 + const isExpanded = Object.keys(table.getState().expanded).length; 15 + 16 + if (!isExpanded) return null; 17 + 18 + return ( 19 + <Button variant="ghost" size="sm" onClick={() => table.resetExpanded()}> 20 + <ChevronsDownUp className="mr-2 h-4 w-4" /> 21 + Collapse 22 + <Badge variant="secondary" className="ml-2"> 23 + {isExpanded} 24 + </Badge> 25 + </Button> 26 + ); 27 + }
+92 -17
apps/web/src/components/ping-response-analysis/multi-region-chart.tsx
··· 1 1 "use client"; 2 2 3 - import { BarChart } from "@tremor/react"; 3 + import type { RegionChecker } from "./utils"; 4 + import { getTimingPhases, regionFormatter } from "./utils"; 5 + 6 + import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; 7 + 8 + import { 9 + type ChartConfig, 10 + ChartContainer, 11 + ChartLegend, 12 + ChartLegendContent, 13 + ChartTooltip, 14 + ChartTooltipContent, 15 + } from "@openstatus/ui/src/components/chart"; 4 16 5 - import type { RegionChecker } from "./utils"; 6 - import { getTimingPhases, latencyFormatter, regionFormatter } from "./utils"; 17 + const chartConfig = { 18 + dns: { 19 + label: "DNS", 20 + color: "hsl(var(--chart-1))", 21 + }, 22 + connection: { 23 + label: "Connection", 24 + color: "hsl(var(--chart-2))", 25 + }, 26 + tls: { 27 + label: "TLS", 28 + color: "hsl(var(--chart-3))", 29 + }, 30 + ttfb: { 31 + label: "TTFB", 32 + color: "hsl(var(--chart-4))", 33 + }, 34 + transfer: { 35 + label: "Transfer", 36 + color: "hsl(var(--chart-5))", 37 + }, 38 + } satisfies ChartConfig; 7 39 8 40 export function MultiRegionChart({ regions }: { regions: RegionChecker[] }) { 9 - const data = regions 10 - .sort((a, b) => a.latency - b.latency) 41 + const chartData = regions 42 + .sort((a, b) => a.latency - b.latency) // FIXME: seems to be off 11 43 .map((item) => { 12 44 const { dns, connection, tls, ttfb, transfer } = getTimingPhases( 13 45 item.timing, 14 46 ); 15 47 return { 16 - region: regionFormatter(item.region), 48 + region: item.region, 17 49 dns, 18 50 connection, 19 51 tls, ··· 22 54 }; 23 55 }); 24 56 return ( 25 - <BarChart 26 - data={data} 27 - index="region" 28 - categories={["dns", "connection", "tls", "ttfb", "transfer"]} 29 - colors={["blue", "teal", "amber", "slate", "indigo"]} 30 - valueFormatter={latencyFormatter} 31 - stack 32 - layout="vertical" 33 - yAxisWidth={65} 34 - className="h-[64rem] w-full" 35 - /> 57 + <div className="relative"> 58 + <ChartContainer config={chartConfig}> 59 + <BarChart accessibilityLayer data={chartData}> 60 + <CartesianGrid vertical={false} /> 61 + <XAxis 62 + dataKey="region" 63 + tickLine={false} 64 + tickMargin={10} 65 + axisLine={false} 66 + interval={0} 67 + tick={(props) => { 68 + const { x, y, payload } = props; 69 + return ( 70 + <g transform={`translate(${x},${y})`}> 71 + <text 72 + x={0} 73 + y={0} 74 + dy={2} 75 + textAnchor="end" 76 + fill="#666" 77 + transform="rotate(-35)" 78 + className="font-mono" 79 + > 80 + {payload.value} 81 + </text> 82 + </g> 83 + ); 84 + }} 85 + /> 86 + <ChartTooltip 87 + content={ 88 + <ChartTooltipContent 89 + labelFormatter={(label) => regionFormatter(label, "long")} 90 + /> 91 + } 92 + /> 93 + <ChartLegend content={<ChartLegendContent />} /> 94 + <Bar dataKey="dns" stackId="a" fill="var(--color-dns)" /> 95 + <Bar 96 + dataKey="connection" 97 + stackId="a" 98 + fill="var(--color-connection)" 99 + /> 100 + <Bar dataKey="tls" stackId="a" fill="var(--color-tls)" /> 101 + <Bar dataKey="ttfb" stackId="a" fill="var(--color-ttfb)" /> 102 + <Bar dataKey="transfer" stackId="a" fill="var(--color-transfer)" /> 103 + </BarChart> 104 + </ChartContainer> 105 + {regions.length ? null : ( 106 + <div className="absolute inset-0 flex w-full items-center justify-center bg-muted/50"> 107 + <div className="text-foreground">No results</div> 108 + </div> 109 + )} 110 + </div> 36 111 ); 37 112 }
+105 -44
apps/web/src/components/ping-response-analysis/multi-region-table.tsx
··· 12 12 13 13 import { 14 14 type ColumnDef, 15 + type ExpandedState, 16 + type Row, 15 17 type SortingState, 18 + type VisibilityState, 16 19 flexRender, 17 20 getCoreRowModel, 21 + getExpandedRowModel, 18 22 getSortedRowModel, 19 23 useReactTable, 20 24 } from "@tanstack/react-table"; 21 - import { useState } from "react"; 25 + import { Fragment, useState } from "react"; 26 + import { DataTableViewOptions } from "../data-table/data-table-view-options"; 27 + import { DataTableCollapseButton } from "./data-table-collapse-button"; 22 28 23 29 // TBD: add the popover infos about timing details 24 30 25 31 interface DataTableProps<TData, TValue> { 26 32 columns: ColumnDef<TData, TValue>[]; 27 33 data: TData[]; 34 + renderSubComponent(props: { row: Row<TData> }): React.ReactElement; 35 + getRowCanExpand(row: Row<TData>): boolean; 36 + autoResetExpanded?: boolean; 28 37 } 29 38 30 39 export function MultiRegionTable<TData, TValue>({ 31 40 columns, 32 41 data, 42 + renderSubComponent, 43 + getRowCanExpand, 44 + autoResetExpanded, 33 45 }: DataTableProps<TData, TValue>) { 34 - const [sorting, setSorting] = useState<SortingState>([]); 46 + const [sorting, setSorting] = useState<SortingState>([ 47 + { id: "latency", desc: false }, 48 + ]); 49 + const [expanded, setExpanded] = useState<ExpandedState>({}); 50 + const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ 51 + DNS: false, 52 + TLS: false, 53 + TTFB: false, 54 + connect: false, 55 + }); 35 56 36 57 const table = useReactTable({ 37 58 data, 38 59 columns, 39 60 onSortingChange: setSorting, 61 + onExpandedChange: setExpanded, 62 + onColumnVisibilityChange: setColumnVisibility, 40 63 getCoreRowModel: getCoreRowModel(), 41 64 getSortedRowModel: getSortedRowModel(), 42 - 65 + getExpandedRowModel: getExpandedRowModel(), 66 + getRowCanExpand, 67 + autoResetExpanded, 43 68 state: { 44 69 sorting, 70 + expanded, 71 + columnVisibility, 45 72 }, 46 73 }); 74 + 47 75 return ( 48 - <Table> 49 - <TableCaption>Multi Regions</TableCaption> 50 - <TableHeader className="bg-muted/50"> 51 - {table.getHeaderGroups().map((headerGroup) => ( 52 - <TableRow key={headerGroup.id}> 53 - {headerGroup.headers.map((header) => { 54 - return ( 55 - <TableHead key={header.id}> 56 - {header.isPlaceholder 57 - ? null 58 - : flexRender( 59 - header.column.columnDef.header, 60 - header.getContext(), 76 + <div className="grid gap-4"> 77 + <div className="flex items-end justify-between gap-2"> 78 + <p className="text-muted-foreground text-xs"> 79 + Select a row to expand the response details. 80 + </p> 81 + <div className="flex items-center justify-end gap-2"> 82 + <DataTableCollapseButton table={table} /> 83 + <DataTableViewOptions table={table} /> 84 + </div> 85 + </div> 86 + <Table> 87 + <TableCaption>Multi Regions</TableCaption> 88 + <TableHeader className="bg-muted/50"> 89 + {table.getHeaderGroups().map((headerGroup) => ( 90 + <TableRow key={headerGroup.id}> 91 + {headerGroup.headers.map((header) => { 92 + return ( 93 + <TableHead 94 + key={header.id} 95 + className={header.column.columnDef.meta?.headerClassName} 96 + > 97 + {header.isPlaceholder 98 + ? null 99 + : flexRender( 100 + header.column.columnDef.header, 101 + header.getContext(), 102 + )} 103 + </TableHead> 104 + ); 105 + })} 106 + </TableRow> 107 + ))} 108 + </TableHeader> 109 + <TableBody> 110 + {table.getRowModel().rows?.length ? ( 111 + table.getRowModel().rows.map((row) => ( 112 + <Fragment key={row.id}> 113 + <TableRow 114 + data-state={ 115 + (row.getIsSelected() || row.getIsExpanded()) && "selected" 116 + } 117 + onClick={() => row.toggleExpanded()} 118 + className="cursor-pointer" 119 + > 120 + {row.getVisibleCells().map((cell) => ( 121 + <TableCell key={cell.id}> 122 + {flexRender( 123 + cell.column.columnDef.cell, 124 + cell.getContext(), 61 125 )} 62 - </TableHead> 63 - ); 64 - })} 65 - </TableRow> 66 - ))} 67 - </TableHeader> 68 - <TableBody> 69 - {table.getRowModel().rows?.length ? ( 70 - table.getRowModel().rows.map((row) => ( 71 - <TableRow 72 - key={row.id} 73 - data-state={row.getIsSelected() && "selected"} 74 - > 75 - {row.getVisibleCells().map((cell) => ( 76 - <TableCell key={cell.id}> 77 - {flexRender(cell.column.columnDef.cell, cell.getContext())} 78 - </TableCell> 79 - ))} 126 + </TableCell> 127 + ))} 128 + </TableRow> 129 + {row.getIsExpanded() && ( 130 + <TableRow 131 + data-state="expanded" 132 + className="hover:bg-muted/10 data-[state=expanded]:bg-muted/10" 133 + > 134 + {/* 2nd row is a custom 1 cell row */} 135 + <TableCell colSpan={row.getVisibleCells().length}> 136 + {renderSubComponent({ row })} 137 + </TableCell> 138 + </TableRow> 139 + )} 140 + </Fragment> 141 + )) 142 + ) : ( 143 + <TableRow> 144 + <TableCell colSpan={columns.length} className="h-24 text-center"> 145 + No results. 146 + </TableCell> 80 147 </TableRow> 81 - )) 82 - ) : ( 83 - <TableRow> 84 - <TableCell colSpan={columns.length} className="h-24 text-center"> 85 - No results. 86 - </TableCell> 87 - </TableRow> 88 - )} 89 - </TableBody> 90 - </Table> 148 + )} 149 + </TableBody> 150 + </Table> 151 + </div> 91 152 ); 92 153 }
+38 -8
apps/web/src/components/ping-response-analysis/multi-region-tabs.tsx
··· 1 + "use client"; 2 + 1 3 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@openstatus/ui"; 2 4 5 + import type { Region } from "@openstatus/tinybird"; 6 + import type { Row } from "@tanstack/react-table"; 7 + import { RegionsPreset } from "../monitor-dashboard/region-preset"; 3 8 import { columns } from "./columns"; 4 9 import { MultiRegionChart } from "./multi-region-chart"; 5 10 import { MultiRegionTable } from "./multi-region-table"; 11 + import { ResponseDetailTabs } from "./response-detail-tabs"; 6 12 import type { RegionChecker } from "./utils"; 7 13 8 - export function MultiRegionTabs({ regions }: { regions: RegionChecker[] }) { 14 + export function MultiRegionTabs({ 15 + regions, 16 + selectedRegions, 17 + }: { 18 + regions: RegionChecker[]; 19 + selectedRegions?: Region[]; 20 + }) { 9 21 return ( 10 - <Tabs defaultValue="chart"> 11 - <TabsList> 12 - <TabsTrigger value="chart">Chart</TabsTrigger> 13 - <TabsTrigger value="table">Table</TabsTrigger> 14 - </TabsList> 22 + <Tabs defaultValue="table"> 23 + <div className="flex items-center justify-between"> 24 + <TabsList> 25 + <TabsTrigger value="table">Table</TabsTrigger> 26 + <TabsTrigger value="chart">Chart</TabsTrigger> 27 + </TabsList> 28 + <RegionsPreset 29 + regions={regions.map((i) => i.region)} 30 + selectedRegions={selectedRegions ?? []} 31 + size="sm" 32 + /> 33 + </div> 15 34 <TabsContent value="chart"> 16 - <MultiRegionChart regions={regions} /> 35 + <MultiRegionChart 36 + regions={regions.filter((i) => selectedRegions?.includes(i.region))} 37 + /> 17 38 </TabsContent> 18 39 <TabsContent value="table"> 19 - <MultiRegionTable data={regions} columns={columns} /> 40 + <MultiRegionTable 41 + data={regions.filter((i) => selectedRegions?.includes(i.region))} 42 + columns={columns} 43 + getRowCanExpand={() => true} 44 + renderSubComponent={renderSubComponent} 45 + /> 20 46 </TabsContent> 21 47 </Tabs> 22 48 ); 23 49 } 50 + 51 + function renderSubComponent({ row }: { row: Row<RegionChecker> }) { 52 + return <ResponseDetailTabs {...row.original} />; 53 + }
+69 -14
apps/web/src/components/ping-response-analysis/utils.ts
··· 6 6 monitorFlyRegionSchema, 7 7 } from "@openstatus/db/src/schema/constants"; 8 8 import type { MonitorFlyRegion } from "@openstatus/db/src/schema/constants"; 9 - import { flyRegionsDict } from "@openstatus/utils"; 9 + import { continentDict, flyRegionsDict } from "@openstatus/utils"; 10 10 11 11 export function latencyFormatter(value: number) { 12 12 return `${new Intl.NumberFormat("us").format(value).toString()}ms`; ··· 16 16 return new Date(timestamp).toUTCString(); // GMT format 17 17 } 18 18 19 + export function continentFormatter(region: MonitorFlyRegion) { 20 + const continent = flyRegionsDict[region].continent; 21 + return continentDict[continent].code; 22 + } 23 + 19 24 export function regionFormatter( 20 25 region: MonitorFlyRegion, 21 26 type: "short" | "long" = "short", 22 27 ) { 23 28 const { code, flag, location } = flyRegionsDict[region]; 24 29 if (type === "short") return `${code} ${flag}`; 25 - return `${location}`; 30 + return `${location} ${flag}`; 26 31 } 27 32 28 33 export function getTotalLatency(timing: Timing) { ··· 114 119 export type Checker = z.infer<typeof checkerSchema>; 115 120 export type RegionChecker = Checker & { region: MonitorFlyRegion }; 116 121 export type Method = "GET" | "POST" | "PUT" | "DELETE" | "HEAD"; 122 + export type CachedRegionChecker = z.infer<typeof cachedCheckerSchema>; 117 123 118 124 export async function checkRegion( 119 125 url: string, ··· 182 188 ); 183 189 } 184 190 185 - export async function setCheckerData(url: string, opts?: { method: Method }) { 191 + export async function storeBaseCheckerData({ 192 + url, 193 + method, 194 + id, 195 + }: { 196 + url: string; 197 + method: Method; 198 + id: string; 199 + }) { 186 200 const redis = Redis.fromEnv(); 187 201 const time = new Date().getTime(); 188 - const checks = await checkAllRegions(url, opts); 189 - const { method } = opts || {}; 202 + const cache = { url, method, time }; 203 + 204 + const parsed = cachedCheckerSchema 205 + .pick({ url: true, method: true, time: true }) 206 + .safeParse(cache); 207 + 208 + if (!parsed.success) { 209 + throw new Error(parsed.error.message); 210 + } 211 + 212 + await redis.hset(`check:base:${id}`, parsed.data); 213 + const expire = 60 * 60 * 24 * 7; // 7days 214 + await redis.expire(`check:base:${id}`, expire); 190 215 191 - const cache = { time, url, checks, method }; 216 + return id; 217 + } 192 218 193 - const uuid = crypto.randomUUID().replace(/-/g, ""); 219 + export async function storeCheckerData({ 220 + check, 221 + id, 222 + }: { 223 + check: RegionChecker; 224 + id: string; 225 + }) { 226 + const redis = Redis.fromEnv(); 194 227 195 - const parsed = cachedCheckerSchema.safeParse(cache); 228 + const parsed = cachedCheckerSchema 229 + .pick({ checks: true }) 230 + .safeParse({ checks: [check] }); 196 231 197 232 if (!parsed.success) { 198 233 throw new Error(parsed.error.message); 199 234 } 200 235 201 - await redis.set(uuid, JSON.stringify(parsed.data), { ex: 86_400 }); // 60 * 60 * 24 = 1d 236 + const first = parsed.data.checks?.[0]; 237 + 238 + if (first) await redis.sadd(`check:data:${id}`, first); 202 239 203 - return uuid; 240 + return id; 204 241 } 205 242 206 243 export async function getCheckerDataById(id: string) { 207 244 const redis = Redis.fromEnv(); 208 - const cache = await redis.get(id); 245 + const pipe = redis.pipeline(); 246 + pipe.hgetall(`check:base:${id}`); 247 + pipe.smembers(`check:data:${id}`); 248 + 249 + const res = 250 + await pipe.exec< 251 + [{ url: string; method: Method; time: number }, RegionChecker] 252 + >(); 209 253 210 - if (!cache) { 254 + if (!res) { 211 255 return null; 212 256 } 213 257 214 - const parsed = cachedCheckerSchema.safeParse(cache); 258 + const parsed = cachedCheckerSchema.safeParse({ ...res[0], checks: res[1] }); 215 259 216 260 if (!parsed.success) { 217 - throw new Error(parsed.error.message); 261 + // throw new Error(parsed.error.message); 262 + return null; 218 263 } 219 264 220 265 return parsed.data; 221 266 } 267 + 268 + /** 269 + * Simple function to validate crypto.randomUUID() format like "aec4e0ec3c4f4557b8ce46e55078fc95" 270 + * @param uuid 271 + * @returns 272 + */ 273 + export function is32CharHex(uuid: string) { 274 + const hexRegex = /^[0-9a-fA-F]{32}$/; 275 + return hexRegex.test(uuid); 276 + }
+1 -1
apps/web/src/config/features.ts
··· 10 10 export type FeatureDescription = { 11 11 icon: ValidIcon; 12 12 catchline: string; 13 - description: string; 13 + description: React.ReactNode; 14 14 badge?: "Coming soon" | "New"; 15 15 }; 16 16
+10
apps/web/src/content/changelog/play-checker-improvements.mdx
··· 1 + --- 2 + title: Speed Checker 3 + description: Improving the speed checker playground for a better experience. 4 + image: /assets/changelog/play-checker-improvements.png 5 + publishedAt: 2024-08-27 6 + --- 7 + 8 + We've refreshed the Playground for a better experience. Now, access real-time data without leaving the page. 9 + 10 + Check out the [Speed Checker](/play/checker).
+77
apps/web/src/lib/stream.ts
··· 1 + export async function* yieldMany(promises: Promise<unknown>[]) { 2 + // Attach .then() handlers to the promises to remove them as soon as they resolve 3 + // biome-ignore lint/complexity/noForEach: REMINDER: do not use for await (const p of promises) as it will not work as expected 4 + promises.forEach((p) => { 5 + p.then((value) => { 6 + promises.splice(promises.indexOf(p), 1); 7 + return value; 8 + }); 9 + }); 10 + 11 + // Continue yielding the results of the promises as they resolve 12 + while (promises.length > 0) { 13 + yield await Promise.race(promises); 14 + } 15 + 16 + return "done"; 17 + } 18 + 19 + export function iteratorToStream(iterator: AsyncGenerator) { 20 + return new ReadableStream({ 21 + async pull(controller) { 22 + try { 23 + const { value, done } = await iterator.next(); 24 + if (done) { 25 + controller.close(); 26 + } else { 27 + controller.enqueue(value); 28 + } 29 + } catch (err) { 30 + console.error("Stream error:", err); 31 + controller.error(err); 32 + } 33 + }, 34 + }); 35 + } 36 + 37 + const encoder = new TextEncoder(); 38 + const decoder = new TextDecoder(); 39 + 40 + /** 41 + * HOW TO USE IT IN YOUR ROUTE 42 + * @returns {Response} 43 + */ 44 + export async function POST(request: Request) { 45 + // extract your params from the request 46 + const _json = await request.json(); 47 + 48 + const generator = yieldMany([ 49 + new Promise((resolve) => 50 + setTimeout(() => resolve(encoder.encode("1")), 200), 51 + ), 52 + new Promise((resolve) => resolve(encoder.encode("2"))), 53 + new Promise((resolve) => 54 + setTimeout(() => resolve(encoder.encode("3")), 500), 55 + ), 56 + ]); 57 + 58 + const stream = iteratorToStream(generator); 59 + return new Response(stream); 60 + } 61 + 62 + /** 63 + * HOW TO USE IT IN YOUR CLIENT 64 + */ 65 + async function clientConsumeStream() { 66 + const response = await POST(new Request("")); // fetch("/api/path/to/route", { method: "POST" }); 67 + const reader = response.body?.getReader(); 68 + if (!reader) return; 69 + 70 + while (true) { 71 + const { value, done } = await reader.read(); 72 + if (done) break; 73 + console.log("Stream output:", decoder.decode(value)); 74 + } 75 + 76 + console.log("Stream processing complete."); 77 + }
+15
apps/web/src/styles/globals.css
··· 35 35 36 36 --radius: 0.5rem; 37 37 38 + /** Chart Colors */ 39 + --chart-1: 12 76% 61%; 40 + --chart-2: 173 58% 39%; 41 + --chart-3: 197 37% 24%; 42 + --chart-4: 43 74% 66%; 43 + --chart-5: 27 87% 67%; 44 + 38 45 /* Status Tracker Colors - Radix Color */ 39 46 --status-degraded: 50 100% 52%; /* Amber 10 */ 40 47 --status-operational: 131 39% 51%; /* Grass 10 */ ··· 72 79 73 80 --ring: 217.2 32.6% 17.5%; 74 81 82 + /* Chart Colors */ 83 + --chart-1: 220 70% 50%; 84 + --chart-2: 160 60% 45%; 85 + --chart-3: 30 80% 55%; 86 + --chart-4: 280 65% 60%; 87 + --chart-5: 340 75% 55%; 88 + 75 89 /* Status Tracker Colors - Radix Color */ 76 90 --status-degraded: 50 100% 52%; /* Amber 10 */ 77 91 --status-operational: 131 39% 51%; /* Grass 10 */ 78 92 --status-down: 11 82% 59%; /* Tomato 10 */ 79 93 --status-monitoring: 210 100% 62%; /* Blue 10 */ 94 + 80 95 } 81 96 } 82 97
+1 -1
biome.jsonc
··· 17 17 "noUnusedVariables": "warn" 18 18 }, 19 19 "nursery": { 20 - "useSortedClasses": "error" 20 + "useSortedClasses": "warn" 21 21 } 22 22 }, 23 23 "ignore": [
+1
packages/ui/package.json
··· 53 53 "react": "18.3.1", 54 54 "react-day-picker": "8.8.2", 55 55 "react-hook-form": "7.47.0", 56 + "recharts": "^2.12.7", 56 57 "tailwind-merge": "1.14.0", 57 58 "tailwindcss-animate": "1.0.7", 58 59 "zod": "3.23.8"
+365
packages/ui/src/components/chart.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import * as RechartsPrimitive from "recharts"; 5 + 6 + import { cn } from "@/lib/utils"; 7 + 8 + // Format: { THEME_NAME: CSS_SELECTOR } 9 + const THEMES = { light: "", dark: ".dark" } as const; 10 + 11 + export type ChartConfig = { 12 + [k in string]: { 13 + label?: React.ReactNode; 14 + icon?: React.ComponentType; 15 + } & ( 16 + | { color?: string; theme?: never } 17 + | { color?: never; theme: Record<keyof typeof THEMES, string> } 18 + ); 19 + }; 20 + 21 + type ChartContextProps = { 22 + config: ChartConfig; 23 + }; 24 + 25 + const ChartContext = React.createContext<ChartContextProps | null>(null); 26 + 27 + function useChart() { 28 + const context = React.useContext(ChartContext); 29 + 30 + if (!context) { 31 + throw new Error("useChart must be used within a <ChartContainer />"); 32 + } 33 + 34 + return context; 35 + } 36 + 37 + const ChartContainer = React.forwardRef< 38 + HTMLDivElement, 39 + React.ComponentProps<"div"> & { 40 + config: ChartConfig; 41 + children: React.ComponentProps< 42 + typeof RechartsPrimitive.ResponsiveContainer 43 + >["children"]; 44 + } 45 + >(({ id, className, children, config, ...props }, ref) => { 46 + const uniqueId = React.useId(); 47 + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; 48 + 49 + return ( 50 + <ChartContext.Provider value={{ config }}> 51 + <div 52 + data-chart={chartId} 53 + ref={ref} 54 + className={cn( 55 + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", 56 + className 57 + )} 58 + {...props} 59 + > 60 + <ChartStyle id={chartId} config={config} /> 61 + <RechartsPrimitive.ResponsiveContainer> 62 + {children} 63 + </RechartsPrimitive.ResponsiveContainer> 64 + </div> 65 + </ChartContext.Provider> 66 + ); 67 + }); 68 + ChartContainer.displayName = "Chart"; 69 + 70 + const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { 71 + const colorConfig = Object.entries(config).filter( 72 + ([_, config]) => config.theme || config.color 73 + ); 74 + 75 + if (!colorConfig.length) { 76 + return null; 77 + } 78 + 79 + return ( 80 + <style 81 + dangerouslySetInnerHTML={{ 82 + __html: Object.entries(THEMES) 83 + .map( 84 + ([theme, prefix]) => ` 85 + ${prefix} [data-chart=${id}] { 86 + ${colorConfig 87 + .map(([key, itemConfig]) => { 88 + const color = 89 + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || 90 + itemConfig.color; 91 + return color ? ` --color-${key}: ${color};` : null; 92 + }) 93 + .join("\n")} 94 + } 95 + ` 96 + ) 97 + .join("\n"), 98 + }} 99 + /> 100 + ); 101 + }; 102 + 103 + const ChartTooltip = RechartsPrimitive.Tooltip; 104 + 105 + const ChartTooltipContent = React.forwardRef< 106 + HTMLDivElement, 107 + React.ComponentProps<typeof RechartsPrimitive.Tooltip> & 108 + React.ComponentProps<"div"> & { 109 + hideLabel?: boolean; 110 + hideIndicator?: boolean; 111 + indicator?: "line" | "dot" | "dashed"; 112 + nameKey?: string; 113 + labelKey?: string; 114 + } 115 + >( 116 + ( 117 + { 118 + active, 119 + payload, 120 + className, 121 + indicator = "dot", 122 + hideLabel = false, 123 + hideIndicator = false, 124 + label, 125 + labelFormatter, 126 + labelClassName, 127 + formatter, 128 + color, 129 + nameKey, 130 + labelKey, 131 + }, 132 + ref 133 + ) => { 134 + const { config } = useChart(); 135 + 136 + const tooltipLabel = React.useMemo(() => { 137 + if (hideLabel || !payload?.length) { 138 + return null; 139 + } 140 + 141 + const [item] = payload; 142 + const key = `${labelKey || item.dataKey || item.name || "value"}`; 143 + const itemConfig = getPayloadConfigFromPayload(config, item, key); 144 + const value = 145 + !labelKey && typeof label === "string" 146 + ? config[label as keyof typeof config]?.label || label 147 + : itemConfig?.label; 148 + 149 + if (labelFormatter) { 150 + return ( 151 + <div className={cn("font-medium", labelClassName)}> 152 + {labelFormatter(value, payload)} 153 + </div> 154 + ); 155 + } 156 + 157 + if (!value) { 158 + return null; 159 + } 160 + 161 + return <div className={cn("font-medium", labelClassName)}>{value}</div>; 162 + }, [ 163 + label, 164 + labelFormatter, 165 + payload, 166 + hideLabel, 167 + labelClassName, 168 + config, 169 + labelKey, 170 + ]); 171 + 172 + if (!active || !payload?.length) { 173 + return null; 174 + } 175 + 176 + const nestLabel = payload.length === 1 && indicator !== "dot"; 177 + 178 + return ( 179 + <div 180 + ref={ref} 181 + className={cn( 182 + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", 183 + className 184 + )} 185 + > 186 + {!nestLabel ? tooltipLabel : null} 187 + <div className="grid gap-1.5"> 188 + {payload.map((item, index) => { 189 + const key = `${nameKey || item.name || item.dataKey || "value"}`; 190 + const itemConfig = getPayloadConfigFromPayload(config, item, key); 191 + const indicatorColor = color || item.payload.fill || item.color; 192 + 193 + return ( 194 + <div 195 + key={item.dataKey} 196 + className={cn( 197 + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", 198 + indicator === "dot" && "items-center" 199 + )} 200 + > 201 + {formatter && item?.value !== undefined && item.name ? ( 202 + formatter(item.value, item.name, item, index, item.payload) 203 + ) : ( 204 + <> 205 + {itemConfig?.icon ? ( 206 + <itemConfig.icon /> 207 + ) : ( 208 + !hideIndicator && ( 209 + <div 210 + className={cn( 211 + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", 212 + { 213 + "h-2.5 w-2.5": indicator === "dot", 214 + "w-1": indicator === "line", 215 + "w-0 border-[1.5px] border-dashed bg-transparent": 216 + indicator === "dashed", 217 + "my-0.5": nestLabel && indicator === "dashed", 218 + } 219 + )} 220 + style={ 221 + { 222 + "--color-bg": indicatorColor, 223 + "--color-border": indicatorColor, 224 + } as React.CSSProperties 225 + } 226 + /> 227 + ) 228 + )} 229 + <div 230 + className={cn( 231 + "flex flex-1 justify-between leading-none", 232 + nestLabel ? "items-end" : "items-center" 233 + )} 234 + > 235 + <div className="grid gap-1.5"> 236 + {nestLabel ? tooltipLabel : null} 237 + <span className="text-muted-foreground"> 238 + {itemConfig?.label || item.name} 239 + </span> 240 + </div> 241 + {item.value && ( 242 + <span className="font-mono font-medium tabular-nums text-foreground"> 243 + {item.value.toLocaleString()} 244 + </span> 245 + )} 246 + </div> 247 + </> 248 + )} 249 + </div> 250 + ); 251 + })} 252 + </div> 253 + </div> 254 + ); 255 + } 256 + ); 257 + ChartTooltipContent.displayName = "ChartTooltip"; 258 + 259 + const ChartLegend = RechartsPrimitive.Legend; 260 + 261 + const ChartLegendContent = React.forwardRef< 262 + HTMLDivElement, 263 + React.ComponentProps<"div"> & 264 + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { 265 + hideIcon?: boolean; 266 + nameKey?: string; 267 + } 268 + >( 269 + ( 270 + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, 271 + ref 272 + ) => { 273 + const { config } = useChart(); 274 + 275 + if (!payload?.length) { 276 + return null; 277 + } 278 + 279 + return ( 280 + <div 281 + ref={ref} 282 + className={cn( 283 + "flex items-center justify-center gap-4", 284 + verticalAlign === "top" ? "pb-3" : "pt-3", 285 + className 286 + )} 287 + > 288 + {payload.map((item) => { 289 + const key = `${nameKey || item.dataKey || "value"}`; 290 + const itemConfig = getPayloadConfigFromPayload(config, item, key); 291 + 292 + return ( 293 + <div 294 + key={item.value} 295 + className={cn( 296 + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" 297 + )} 298 + > 299 + {itemConfig?.icon && !hideIcon ? ( 300 + <itemConfig.icon /> 301 + ) : ( 302 + <div 303 + className="h-2 w-2 shrink-0 rounded-[2px]" 304 + style={{ 305 + backgroundColor: item.color, 306 + }} 307 + /> 308 + )} 309 + {itemConfig?.label} 310 + </div> 311 + ); 312 + })} 313 + </div> 314 + ); 315 + } 316 + ); 317 + ChartLegendContent.displayName = "ChartLegend"; 318 + 319 + // Helper to extract item config from a payload. 320 + function getPayloadConfigFromPayload( 321 + config: ChartConfig, 322 + payload: unknown, 323 + key: string 324 + ) { 325 + if (typeof payload !== "object" || payload === null) { 326 + return undefined; 327 + } 328 + 329 + const payloadPayload = 330 + "payload" in payload && 331 + typeof payload.payload === "object" && 332 + payload.payload !== null 333 + ? payload.payload 334 + : undefined; 335 + 336 + let configLabelKey: string = key; 337 + 338 + if ( 339 + key in payload && 340 + typeof payload[key as keyof typeof payload] === "string" 341 + ) { 342 + configLabelKey = payload[key as keyof typeof payload] as string; 343 + } else if ( 344 + payloadPayload && 345 + key in payloadPayload && 346 + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" 347 + ) { 348 + configLabelKey = payloadPayload[ 349 + key as keyof typeof payloadPayload 350 + ] as string; 351 + } 352 + 353 + return configLabelKey in config 354 + ? config[configLabelKey] 355 + : config[key as keyof typeof config]; 356 + } 357 + 358 + export { 359 + ChartContainer, 360 + ChartTooltip, 361 + ChartTooltipContent, 362 + ChartLegend, 363 + ChartLegendContent, 364 + ChartStyle, 365 + };
+1
packages/ui/src/index.tsx
··· 39 39 export * from "./components/sortable"; 40 40 export * from "./components/navigation-menu"; 41 41 export * from "./components/slider"; 42 + export * from "./components/chart";
+10
packages/utils/index.ts
··· 140 140 continent: Continent; 141 141 }; 142 142 143 + // TODO: we could think of doing the inverse and use "EU" as key 144 + export const continentDict: Record<Continent, { code: string }> = { 145 + Europe: { code: "EU" }, 146 + "North America": { code: "NA" }, 147 + "South America": { code: "SA" }, 148 + Asia: { code: "AS" }, 149 + Africa: { code: "AF" }, 150 + Oceania: { code: "OC" }, 151 + }; 152 + 143 153 export const flyRegionsDict: Record<MonitorFlyRegion, RegionInfo> = { 144 154 ams: { 145 155 code: "ams",
+7 -6
pnpm-lock.yaml
··· 428 428 reading-time: 429 429 specifier: 1.5.0 430 430 version: 1.5.0 431 + recharts: 432 + specifier: 2.12.7 433 + version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 431 434 rehype-pretty-code: 432 435 specifier: 0.10.0 433 436 version: 0.10.0(shiki@0.14.4) ··· 1087 1090 react-hook-form: 1088 1091 specifier: 7.47.0 1089 1092 version: 7.47.0(react@18.3.1) 1093 + recharts: 1094 + specifier: ^2.12.7 1095 + version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 1090 1096 sonner: 1091 1097 specifier: 1.3.1 1092 1098 version: 1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ··· 9555 9561 9556 9562 through@2.3.8: 9557 9563 resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} 9558 - 9559 - tiny-invariant@1.3.1: 9560 - resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} 9561 9564 9562 9565 tiny-invariant@1.3.3: 9563 9566 resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} ··· 19923 19926 react-is: 16.13.1 19924 19927 react-smooth: 4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 19925 19928 recharts-scale: 0.4.5 19926 - tiny-invariant: 1.3.1 19929 + tiny-invariant: 1.3.3 19927 19930 victory-vendor: 36.6.11 19928 19931 19929 19932 rechoir@0.6.2: ··· 20773 20776 any-promise: 1.3.0 20774 20777 20775 20778 through@2.3.8: {} 20776 - 20777 - tiny-invariant@1.3.1: {} 20778 20779 20779 20780 tiny-invariant@1.3.3: {} 20780 20781