Openstatus www.openstatus.dev

feat: response details (header and timing phases) (#607)

* chore: update port numbers

* feat: add response details to data table

* Revert "chore: update port numbers"

This reverts commit 1d3c06b9f9e1a72ba3848a71f251cf50f8e977e3.

* chore: improve modal

authored by

Maximilian Kaske and committed by
GitHub
7f1e2779 20c95ea9

+429 -97
+43
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/_components/response-details.tsx
··· 1 + import type { ResponseDetailsParams } from "@openstatus/tinybird"; 2 + 3 + import { RegionInfo } from "@/app/play/checker/[id]/_components/region-info"; 4 + import { ResponseHeaderTable } from "@/app/play/checker/[id]/_components/response-header-table"; 5 + import { ResponseTimingTable } from "@/app/play/checker/[id]/_components/response-timing-table"; 6 + import { getResponseDetailsData } from "@/lib/tb"; 7 + 8 + export async function ResponseDetails(props: ResponseDetailsParams) { 9 + const details = await getResponseDetailsData(props); 10 + 11 + if (!details || details?.length === 0) return null; 12 + 13 + const response = details[0]; 14 + 15 + return ( 16 + <div className="grid gap-8"> 17 + <RegionInfo 18 + check={{ 19 + latency: response.latency || 0, 20 + region: response.region, 21 + status: response.statusCode || 0, 22 + time: response.cronTimestamp || 0, 23 + }} 24 + /> 25 + {response.timing ? ( 26 + <ResponseTimingTable timing={response.timing} hideInfo /> 27 + ) : null} 28 + {response?.headers ? ( 29 + <ResponseHeaderTable headers={response.headers} /> 30 + ) : null} 31 + {response.message ? ( 32 + <div> 33 + <pre className="bg-muted rounded-md p-4 text-sm"> 34 + {response.message} 35 + </pre> 36 + <p className="text-muted-foreground mt-4 text-center text-sm"> 37 + Response Message 38 + </p> 39 + </div> 40 + ) : null} 41 + </div> 42 + ); 43 + }
+3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/@modal/(..)details/loading.tsx
··· 1 + export default function Loading() { 2 + return null; 3 + }
+36
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/@modal/(..)details/modal.tsx
··· 1 + "use client"; 2 + 3 + import { useRouter } from "next/navigation"; 4 + 5 + import { 6 + Dialog, 7 + DialogContent, 8 + DialogDescription, 9 + DialogHeader, 10 + DialogTitle, 11 + } from "@openstatus/ui"; 12 + 13 + export function Modal({ children }: { children: React.ReactNode }) { 14 + const router = useRouter(); 15 + 16 + const handleOpenChange = (open: boolean) => { 17 + if (!open) { 18 + router.back(); 19 + } 20 + }; 21 + 22 + return ( 23 + <Dialog open onOpenChange={handleOpenChange}> 24 + {/* overflow-auto should happen inside content table */} 25 + <DialogContent className="max-h-[80%] w-full overflow-auto sm:max-w-3xl sm:p-8"> 26 + <DialogHeader> 27 + <DialogTitle>Details</DialogTitle> 28 + <DialogDescription> 29 + Response details of the request. 30 + </DialogDescription> 31 + </DialogHeader> 32 + {children} 33 + </DialogContent> 34 + </Dialog> 35 + ); 36 + }
+45
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/@modal/(..)details/page.tsx
··· 1 + import * as z from "zod"; 2 + 3 + import { Separator } from "@openstatus/ui"; 4 + import { flyRegions } from "@openstatus/utils"; 5 + 6 + import { EmptyState } from "@/components/dashboard/empty-state"; 7 + import { ResponseDetails } from "../../../_components/response-details"; 8 + import { Modal } from "./modal"; 9 + 10 + // 11 + 12 + /** 13 + * allowed URL search params 14 + */ 15 + const searchParamsSchema = z.object({ 16 + monitorId: z.string(), 17 + region: z.enum(flyRegions).optional(), 18 + cronTimestamp: z.coerce.number(), 19 + }); 20 + 21 + export default async function DetailsModal({ 22 + params, 23 + searchParams, 24 + }: { 25 + params: { id: string }; 26 + searchParams: { [key: string]: string | string[] | undefined }; 27 + }) { 28 + const search = searchParamsSchema.safeParse(searchParams); 29 + 30 + if (!search.success) 31 + return ( 32 + <EmptyState 33 + title="No log found" 34 + description="Seems like we couldn't find what you are looking for." 35 + icon="alert-triangle" 36 + /> 37 + ); 38 + 39 + return ( 40 + <Modal> 41 + <Separator className="my-4" /> 42 + <ResponseDetails {...search.data} /> 43 + </Modal> 44 + ); 45 + }
+3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/@modal/default.tsx
··· 1 + export default function Default() { 2 + return null; 3 + }
+3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/default.tsx
··· 1 + export default function Default() { 2 + return null; 3 + }
+16
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/layout.tsx
··· 1 + import * as React from "react"; 2 + 3 + export default function DataTableLayout({ 4 + children, 5 + modal, 6 + }: { 7 + children: React.ReactNode; 8 + modal: React.ReactNode; 9 + }) { 10 + return ( 11 + <> 12 + {children} 13 + {modal} 14 + </> 15 + ); 16 + }
+45
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/details/page.tsx
··· 1 + import Link from "next/link"; 2 + import * as z from "zod"; 3 + 4 + import { Button } from "@openstatus/ui"; 5 + import { flyRegions } from "@openstatus/utils"; 6 + 7 + import { EmptyState } from "@/components/dashboard/empty-state"; 8 + import { ResponseDetails } from "../_components/response-details"; 9 + 10 + // 11 + 12 + /** 13 + * allowed URL search params 14 + */ 15 + const searchParamsSchema = z.object({ 16 + monitorId: z.string(), 17 + region: z.enum(flyRegions).optional(), 18 + cronTimestamp: z.coerce.number(), 19 + }); 20 + 21 + export default async function Details({ 22 + params, 23 + searchParams, 24 + }: { 25 + params: { id: string }; 26 + searchParams: { [key: string]: string | string[] | undefined }; 27 + }) { 28 + const search = searchParamsSchema.safeParse(searchParams); 29 + 30 + if (!search.success) 31 + return ( 32 + <EmptyState 33 + title="No log found" 34 + description="Seems like we couldn't find what you are looking for." 35 + icon="alert-triangle" 36 + action={ 37 + <Button asChild> 38 + <Link href="./data">Response Logs</Link> 39 + </Button> 40 + } 41 + /> 42 + ); 43 + 44 + return <ResponseDetails {...search.data} />; 45 + }
+2 -2
apps/web/src/app/monitor/[id]/page.tsx
··· 1 1 import * as z from "zod"; 2 2 3 - import { availableRegions } from "@openstatus/utils"; 3 + import { flyRegions } from "@openstatus/utils"; 4 4 5 5 import { columns } from "@/components/data-table/columns"; 6 6 import { DataTable } from "@/components/data-table/data-table"; ··· 13 13 */ 14 14 const searchParamsSchema = z.object({ 15 15 statusCode: z.coerce.number().optional(), 16 - region: z.enum(availableRegions).optional(), 16 + region: z.enum(flyRegions).optional(), 17 17 cronTimestamp: z.coerce.number().optional(), 18 18 fromDate: z.coerce.number().optional(), 19 19 toDate: z.coerce.number().optional(),
+2 -2
apps/web/src/app/play/@modal/(..)monitor/[id]/page.tsx
··· 1 1 import * as z from "zod"; 2 2 3 - import { availableRegions } from "@openstatus/utils"; 3 + import { flyRegions } from "@openstatus/utils"; 4 4 5 5 import { columns } from "@/components/data-table/columns"; 6 6 import { DataTable } from "@/components/data-table/data-table"; ··· 14 14 */ 15 15 const searchParamsSchema = z.object({ 16 16 statusCode: z.coerce.number().optional(), 17 - region: z.enum(availableRegions).optional(), 17 + region: z.enum(flyRegions).optional(), 18 18 cronTimestamp: z.coerce.number().optional(), 19 19 fromDate: z.coerce.number().optional(), 20 20 toDate: z.coerce.number().optional(),
+14 -10
apps/web/src/app/play/checker/[id]/_components/region-info.tsx
··· 6 6 import type { RegionChecker } from "../utils"; 7 7 import { StatusBadge } from "./status-badge"; 8 8 9 - export function RegionInfo({ check }: { check: RegionChecker }) { 9 + export function RegionInfo({ 10 + check, 11 + }: { 12 + check: Pick<RegionChecker, "region" | "time" | "latency" | "status">; 13 + }) { 10 14 return ( 11 - <div className="grid grid-cols-4 gap-2 text-sm sm:grid-cols-6"> 12 - <div className="col-span-1"> 15 + <div className="grid grid-cols-5 gap-2 text-sm sm:grid-cols-9"> 16 + <div className="col-span-2"> 13 17 <p className="text-muted-foreground">Time:</p> 14 18 </div> 15 - <div className="col-span-3 sm:col-span-5"> 19 + <div className="col-span-3 sm:col-span-6"> 16 20 <p>{timestampFormatter(check.time)}</p> 17 21 </div> 18 - <div className="col-span-1"> 22 + <div className="col-span-2"> 19 23 <p className="text-muted-foreground">Region:</p> 20 24 </div> 21 - <div className="col-span-3 sm:col-span-5"> 25 + <div className="col-span-3 sm:col-span-6"> 22 26 <p>{regionFormatter(check.region)}</p> 23 27 </div> 24 - <div className="col-span-1"> 28 + <div className="col-span-2"> 25 29 <p className="text-muted-foreground">Latency:</p> 26 30 </div> 27 - <div className="col-span-3 sm:col-span-5"> 31 + <div className="col-span-3 sm:col-span-6"> 28 32 <p> 29 33 <code>{latencyFormatter(check.latency)}</code> 30 34 </p> 31 35 </div> 32 - <div className="col-span-1"> 36 + <div className="col-span-2"> 33 37 <p className="text-muted-foreground">Status:</p> 34 38 </div> 35 - <div className="col-span-3 sm:col-span-5"> 39 + <div className="col-span-3 sm:col-span-6"> 36 40 <StatusBadge statusCode={check.status} /> 37 41 </div> 38 42 </div>
+20 -12
apps/web/src/app/play/checker/[id]/_components/response-timing-table.tsx
··· 17 17 import type { Timing } from "../utils"; 18 18 import { getTimingPhases, getTotalLatency } from "../utils"; 19 19 20 - export function ResponseTimingTable({ timing }: { timing: Timing }) { 20 + export function ResponseTimingTable({ 21 + timing, 22 + hideInfo = false, 23 + }: { 24 + timing: Timing; 25 + hideInfo?: boolean; 26 + }) { 21 27 const total = getTotalLatency(timing); 22 28 const timingPhases = getTimingPhases(timing); 23 29 return ( ··· 39 45 <TableCell> 40 46 <div className="flex w-[80px] items-center justify-between gap-2"> 41 47 <p className="text-muted-foreground">{short}</p> 42 - <Popover> 43 - <PopoverTrigger className="text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"> 44 - <Info className="h-4 w-4" /> 45 - </PopoverTrigger> 46 - <PopoverContent> 47 - <p className="font-medium">{long}</p> 48 - <p className="text-muted-foreground text-sm"> 49 - {description} 50 - </p> 51 - </PopoverContent> 52 - </Popover> 48 + {!hideInfo ? ( 49 + <Popover> 50 + <PopoverTrigger className="text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"> 51 + <Info className="h-4 w-4" /> 52 + </PopoverTrigger> 53 + <PopoverContent> 54 + <p className="font-medium">{long}</p> 55 + <p className="text-muted-foreground text-sm"> 56 + {description} 57 + </p> 58 + </PopoverContent> 59 + </Popover> 60 + ) : null} 53 61 </div> 54 62 </TableCell> 55 63 <TableCell>
+2 -2
apps/web/src/components/dashboard/empty-state.tsx
··· 7 7 icon: ValidIcon; 8 8 title: string; 9 9 description: string; 10 - action: React.ReactNode; 10 + action?: React.ReactNode; 11 11 } 12 12 13 13 export function EmptyState({ icon, title, description, action }: Props) { ··· 20 20 <p className="text-foreground text-base">{title}</p> 21 21 <p className="text-muted-foreground text-center">{description}</p> 22 22 </div> 23 - <div>{action}</div> 23 + {action ? <div>{action}</div> : null} 24 24 </div> 25 25 </div> 26 26 );
+11
apps/web/src/components/data-table/columns.tsx
··· 14 14 import { regionsDict } from "@openstatus/utils"; 15 15 16 16 import { DataTableColumnHeader } from "./data-table-column-header"; 17 + import { DataTableRowActions } from "./data-table-row-action"; 17 18 import { DataTableStatusBadge } from "./data-table-status-badge"; 18 19 19 20 export const columns: ColumnDef<Ping>[] = [ ··· 87 88 }, 88 89 filterFn: (row, id, value) => { 89 90 return value.includes(row.getValue(id)); 91 + }, 92 + }, 93 + { 94 + id: "actions", 95 + cell: ({ row }) => { 96 + return ( 97 + <div className="text-right"> 98 + <DataTableRowActions row={row} /> 99 + </div> 100 + ); 90 101 }, 91 102 }, 92 103 ];
+20 -60
apps/web/src/components/data-table/data-table-row-action.tsx
··· 1 1 "use client"; 2 2 3 + import Link from "next/link"; 3 4 import type { Row } from "@tanstack/react-table"; 4 5 import { MoreHorizontal } from "lucide-react"; 5 - import * as z from "zod"; 6 6 7 7 import { tbBuildResponseList } from "@openstatus/tinybird"; 8 8 import { 9 9 Button, 10 - Dialog, 11 - DialogContent, 12 - DialogDescription, 13 - DialogHeader, 14 - DialogTitle, 15 - DialogTrigger, 16 10 DropdownMenu, 17 11 DropdownMenuContent, 18 12 DropdownMenuItem, 19 - DropdownMenuLabel, 20 13 DropdownMenuTrigger, 21 14 } from "@openstatus/ui"; 22 15 23 - // REMINDER: needed because `ResponseList` returns metadata as string, not as Record 24 - const schema = tbBuildResponseList.extend({ 25 - metadata: z.record(z.string(), z.unknown()).nullable(), 26 - }); 27 - 28 16 interface DataTableRowActionsProps<TData> { 29 17 row: Row<TData>; 30 18 } ··· 32 20 export function DataTableRowActions<TData>({ 33 21 row, 34 22 }: DataTableRowActionsProps<TData>) { 35 - const ping = schema.parse(row.original); 23 + const ping = tbBuildResponseList.parse(row.original); 36 24 return ( 37 - <Dialog> 38 - <DropdownMenu> 39 - <DropdownMenuTrigger asChild> 40 - <Button 41 - variant="ghost" 42 - className="data-[state=open]:bg-accent h-8 w-8 p-0" 43 - > 44 - <span className="sr-only">Open menu</span> 45 - <MoreHorizontal className="h-4 w-4" /> 46 - </Button> 47 - </DropdownMenuTrigger> 48 - <DropdownMenuContent align="end"> 49 - <DropdownMenuLabel>Actions</DropdownMenuLabel> 50 - <DialogTrigger asChild> 51 - <DropdownMenuItem>View Metadata</DropdownMenuItem> 52 - </DialogTrigger> 53 - </DropdownMenuContent> 54 - </DropdownMenu> 55 - <DialogContent> 56 - <DialogHeader> 57 - <DialogTitle>Metadata</DialogTitle> 58 - <DialogDescription> 59 - Additional informations to your Response like... 60 - </DialogDescription> 61 - </DialogHeader> 62 - <div className="border-border rounded-lg border border-dashed p-4"> 63 - {!Boolean(Object.keys(ping.metadata || {}).length) ? ( 64 - <ul className="grid gap-1"> 65 - {Object.keys(ping.metadata || {}).map((key) => { 66 - return ( 67 - <li key={key} className="text-sm"> 68 - <p> 69 - <code> 70 - {`"${key}"`}: {`${ping.metadata?.[key] || null}`} 71 - </code> 72 - </p> 73 - </li> 74 - ); 75 - })} 76 - </ul> 77 - ) : ( 78 - <p className="text-sm">No data collected</p> 79 - )} 80 - </div> 81 - </DialogContent> 82 - </Dialog> 25 + <DropdownMenu> 26 + <DropdownMenuTrigger asChild> 27 + <Button 28 + variant="ghost" 29 + className="data-[state=open]:bg-accent h-8 w-8 p-0" 30 + > 31 + <span className="sr-only">Open menu</span> 32 + <MoreHorizontal className="h-4 w-4" /> 33 + </Button> 34 + </DropdownMenuTrigger> 35 + <DropdownMenuContent align="end"> 36 + <Link 37 + href={`./details?monitorId=${ping.monitorId}&cronTimestamp=${ping.cronTimestamp}&region=${ping.region}`} 38 + > 39 + <DropdownMenuItem>Details</DropdownMenuItem> 40 + </Link> 41 + </DropdownMenuContent> 42 + </DropdownMenu> 83 43 ); 84 44 }
+62
apps/web/src/components/play/card.tsx
··· 1 + import Link from "next/link"; 2 + import type { LucideIcon } from "lucide-react"; 3 + 4 + import { Button } from "@openstatus/ui"; 5 + 6 + import { Shell } from "@/components/dashboard/shell"; 7 + import { cn } from "@/lib/utils"; 8 + 9 + interface CardProps extends React.HTMLAttributes<HTMLDivElement> { 10 + href: string; 11 + title: string; 12 + description: string; 13 + icon?: LucideIcon; 14 + variant?: "default" | "primary"; 15 + } 16 + 17 + // TODO: unify the cards of playground, oss-friends and external monitors 18 + 19 + function Card({ 20 + title, 21 + description, 22 + href, 23 + variant = "default", 24 + icon, 25 + className, 26 + ...props 27 + }: CardProps) { 28 + const buttonVariant = variant === "default" ? "outline" : "default"; 29 + const shellClassName = 30 + variant === "default" ? "" : "bg-accent text-accent-foreground"; 31 + 32 + const isExternal = href.startsWith("http"); 33 + const externalProps = isExternal 34 + ? { target: "_blank", rel: "noreferrer" } 35 + : {}; 36 + 37 + return ( 38 + <Shell 39 + className={cn( 40 + "group flex flex-col gap-3 hover:shadow", 41 + shellClassName, 42 + className, 43 + )} 44 + {...props} 45 + > 46 + <div className="flex-1 space-y-2"> 47 + <h2 className={cn("font-cal text-xl")}>{title}</h2> 48 + <p className="text-muted-foreground">{description}</p> 49 + </div> 50 + <div className="flex items-center justify-between"> 51 + <Button variant={buttonVariant} className="rounded-full" asChild> 52 + <Link href={href} {...externalProps}> 53 + Learn more 54 + </Link> 55 + </Button> 56 + <div className="border-border bg-background rounded-full border p-2 transition-transform duration-200 group-hover:-rotate-12"> 57 + {icon ? icon({ className: "text-muted-foreground h-5 w-5" }) : null} 58 + </div> 59 + </div> 60 + </Shell> 61 + ); 62 + }
+14
apps/web/src/lib/tb.ts
··· 1 1 import type { 2 2 HomeStatsParams, 3 3 MonitorListParams, 4 + ResponseDetailsParams, 4 5 ResponseGraphParams, 5 6 ResponseListParams, 6 7 } from "@openstatus/tinybird"; ··· 8 9 getHomeMonitorList, 9 10 getHomeStats, 10 11 getMonitorList, 12 + getResponseDetails, 11 13 getResponseGraph, 12 14 getResponseList, 13 15 Tinybird, ··· 21 23 export async function getResponseListData(props: Partial<ResponseListParams>) { 22 24 try { 23 25 const res = await getResponseList(tb)(props); 26 + return res.data; 27 + } catch (e) { 28 + console.error(e); 29 + } 30 + return; 31 + } 32 + 33 + export async function getResponseDetailsData( 34 + props: Partial<ResponseDetailsParams>, 35 + ) { 36 + try { 37 + const res = await getResponseDetails(tb)(props); 24 38 return res.data; 25 39 } catch (e) { 26 40 console.error(e);
+1 -1
packages/tinybird/pipes/home_stats.pipe
··· 5 5 6 6 % 7 7 SELECT COUNT(*) as count 8 - FROM ping_response__v5 8 + FROM ping_response__v7 9 9 {% if defined(period) %} 10 10 {% if String(period) == "1h" %} 11 11 WHERE cronTimestamp > toUnixTimestamp(now() - INTERVAL 1 HOUR) * 1000
+14
packages/tinybird/pipes/response_details.pipe
··· 1 + VERSION 0 2 + 3 + NODE response_graph_0 4 + SQL > 5 + 6 + % 7 + SELECT * 8 + FROM ping_response__v7 9 + WHERE 10 + monitorId = {{ String(monitorId, '1') }} 11 + AND cronTimestamp = {{ Int64(cronTimestamp, 1706467215188) }} 12 + AND region = {{ String(region, 'ams') }} 13 + 14 +
+1 -1
packages/tinybird/pipes/response_graph.pipe
··· 16 16 round(quantile(0.9)(latency)) as p90Latency, 17 17 round(quantile(0.95)(latency)) as p95Latency, 18 18 round(quantile(0.99)(latency)) as p99Latency 19 - FROM materialized_view_ping_response_3d 19 + FROM ping_response__v7 20 20 WHERE 21 21 monitorId = {{ String(monitorId, '1') }} 22 22 {% if defined(fromDate) %} AND timestamp >= {{ Int64(fromDate) }} {% end %}
+1 -1
packages/tinybird/pipes/response_list.pipe
··· 5 5 6 6 % 7 7 SELECT latency, monitorId, region, statusCode, timestamp, url, workspaceId, cronTimestamp, message 8 - FROM ping_response__v5 8 + FROM ping_response__v7 9 9 WHERE monitorId = {{ String(monitorId, 'openstatusPing') }} 10 10 {% if defined(region) %} 11 11 AND region = {{ String(region) }}
+1 -1
packages/tinybird/pipes/status_timezone.pipe
··· 14 14 toStartOfDay(with_timezone) as start_of_day, 15 15 statusCode, 16 16 latency 17 - FROM ping_response__v5 17 + FROM ping_response__v7 18 18 WHERE monitorId = {{ String(monitorId, '1') }} 19 19 -- by default, we only query the last 45 days 20 20 AND cronTimestamp >= toUnixTimestamp64Milli(
+13
packages/tinybird/src/client.ts
··· 4 4 tbBuildHomeStats, 5 5 tbBuildMonitorList, 6 6 tbBuildPublicStatus, 7 + tbBuildResponseDetails, 7 8 tbBuildResponseGraph, 8 9 tbBuildResponseList, 9 10 tbIngestPingResponse, 10 11 tbParameterHomeStats, 11 12 tbParameterMonitorList, 12 13 tbParameterPublicStatus, 14 + tbParameterResponseDetails, 13 15 tbParameterResponseGraph, 14 16 tbParameterResponseList, 15 17 } from "./validation"; ··· 30 32 opts: { 31 33 // cache: "default", 32 34 revalidate: 600, // 10 min cache 35 + }, 36 + }); 37 + } 38 + 39 + export function getResponseDetails(tb: Tinybird) { 40 + return tb.buildPipe({ 41 + pipe: "response_details__v0", 42 + parameters: tbParameterResponseDetails, 43 + data: tbBuildResponseDetails, 44 + opts: { 45 + cache: "force-cache", 33 46 }, 34 47 }); 35 48 }
+57 -5
packages/tinybird/src/validation.ts
··· 1 1 import * as z from "zod"; 2 2 3 - import { availableRegions } from "@openstatus/utils"; 3 + import { flyRegions } from "@openstatus/utils"; 4 4 5 5 /** 6 6 * Values for the datasource ping_response ··· 44 44 latency: z.number().int(), // in ms 45 45 cronTimestamp: z.number().int().nullable().default(Date.now()), 46 46 url: z.string().url(), 47 - region: z.enum(availableRegions), 47 + region: z.enum(flyRegions), 48 48 message: z.string().nullable().optional(), 49 49 }); 50 50 ··· 56 56 fromDate: z.number().int().default(0), // always start from a date 57 57 toDate: z.number().int().optional(), 58 58 limit: z.number().int().optional().default(7500), // one day has 2448 pings (17 (regions) * 6 (per hour) * 24) * 3 days for historical data 59 - region: z.enum(availableRegions).optional(), 59 + region: z.enum(flyRegions).optional(), 60 60 cronTimestamp: z.number().int().optional(), 61 61 }); 62 62 63 63 /** 64 + * Params for pipe response_details 65 + */ 66 + export const tbParameterResponseDetails = tbParameterResponseList.pick({ 67 + monitorId: true, 68 + cronTimestamp: true, 69 + region: true, 70 + }); 71 + 72 + export const responseHeadersSchema = z.record(z.string(), z.string()); 73 + export const responseTimingSchema = z.object({ 74 + dnsStart: z.number(), 75 + dnsDone: z.number(), 76 + connectStart: z.number(), 77 + connectDone: z.number(), 78 + tlsHandshakeStart: z.number(), 79 + tlsHandshakeDone: z.number(), 80 + firstByteStart: z.number(), 81 + firstByteDone: z.number(), 82 + transferStart: z.number(), 83 + transferDone: z.number(), 84 + }); 85 + 86 + /** 87 + * Values from the pipe response_details 88 + */ 89 + export const tbBuildResponseDetails = tbBuildResponseList.extend({ 90 + message: z.string().nullable().optional(), 91 + headers: z 92 + .string() 93 + .nullable() 94 + .optional() 95 + .transform((val) => { 96 + if (!val) return null; 97 + const value = responseHeadersSchema.safeParse(JSON.parse(val)); 98 + if (value.success) return value.data; 99 + return null; 100 + }), 101 + timing: z 102 + .string() 103 + .nullable() 104 + .optional() 105 + .transform((val) => { 106 + if (!val) return null; 107 + const value = responseTimingSchema.safeParse(JSON.parse(val)); 108 + if (value.success) return value.data; 109 + return null; 110 + }), 111 + }); 112 + 113 + /** 64 114 * Values from pipe response_graph 65 115 */ 66 116 export const tbBuildResponseGraph = z.object({ 67 - region: z.enum(availableRegions), 117 + region: z.enum(flyRegions), 68 118 timestamp: z.number().int(), 69 119 avgLatency: z.number().int(), 70 120 p75Latency: z.number().int(), ··· 142 192 }); 143 193 144 194 export type Ping = z.infer<typeof tbBuildResponseList>; 145 - export type Region = (typeof availableRegions)[number]; // TODO: rename type AvailabeRegion 195 + export type Region = (typeof flyRegions)[number]; // TODO: rename type AvailabeRegion 146 196 export type Monitor = z.infer<typeof tbBuildMonitorList>; 147 197 export type HomeStats = z.infer<typeof tbBuildHomeStats>; 148 198 export type ResponseGraph = z.infer<typeof tbBuildResponseGraph>; // TODO: rename to ResponseQuantileChart ··· 150 200 export type ResponseGraphParams = z.infer<typeof tbParameterResponseGraph>; 151 201 export type MonitorListParams = z.infer<typeof tbParameterMonitorList>; 152 202 export type HomeStatsParams = z.infer<typeof tbParameterHomeStats>; 203 + export type ResponseDetails = z.infer<typeof tbBuildResponseDetails>; 204 + export type ResponseDetailsParams = z.infer<typeof tbParameterResponseDetails>;