Openstatus www.openstatus.dev
6
fork

Configure Feed

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

chore: improve layout and monitor chart (#551)

* chore: improve layout and monitor chart

* chore: sheet content side bottom

* fix: missing import type

* feat: add changelog

* refactor: wording

* fix: search param id on monitor form

* wip: menu from top

authored by

Maximilian Kaske and committed by
GitHub
62319860 63ab0a78

+962 -698
apps/web/public/assets/changelog/latency-quantiles.png

This is a binary file and will not be displayed.

+38
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/layout.tsx
··· 1 + import * as React from "react"; 2 + import Link from "next/link"; 3 + 4 + import { ButtonWithDisableTooltip } from "@openstatus/ui"; 5 + 6 + import { Header } from "@/components/dashboard/header"; 7 + import { HelpCallout } from "@/components/dashboard/help-callout"; 8 + import { api } from "@/trpc/server"; 9 + 10 + export default async function Layout({ 11 + children, 12 + }: { 13 + children: React.ReactNode; 14 + }) { 15 + const isLimitReached = await api.monitor.isMonitorLimitReached.query(); 16 + 17 + return ( 18 + <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-2 md:gap-8"> 19 + <Header 20 + title="Monitors" 21 + description="Overview of all your monitors." 22 + actions={ 23 + <ButtonWithDisableTooltip 24 + tooltip="You reached the limits" 25 + asChild={!isLimitReached} 26 + disabled={isLimitReached} 27 + > 28 + <Link href="./monitors/new">Create</Link> 29 + </ButtonWithDisableTooltip> 30 + } 31 + /> 32 + <div className="col-span-full">{children}</div> 33 + <div className="mt-8 md:mt-12"> 34 + <HelpCallout /> 35 + </div> 36 + </div> 37 + ); 38 + }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/loading.tsx
··· 1 + import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 + 3 + export default function Loading() { 4 + return <DataTableSkeleton />; 5 + }
+36
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/page.tsx
··· 1 + import * as React from "react"; 2 + import Link from "next/link"; 3 + 4 + import { Button } from "@openstatus/ui"; 5 + 6 + import { EmptyState } from "@/components/dashboard/empty-state"; 7 + import { Limit } from "@/components/dashboard/limit"; 8 + import { columns } from "@/components/data-table/monitor/columns"; 9 + import { DataTable } from "@/components/data-table/monitor/data-table"; 10 + import { api } from "@/trpc/server"; 11 + 12 + export default async function MonitorPage() { 13 + const monitors = await api.monitor.getMonitorsByWorkspace.query(); 14 + const isLimitReached = await api.monitor.isMonitorLimitReached.query(); 15 + 16 + if (monitors?.length === 0) 17 + return ( 18 + <EmptyState 19 + icon="activity" 20 + title="No monitors" 21 + description="Create your first monitor" 22 + action={ 23 + <Button asChild> 24 + <Link href="./monitors/new">Create</Link> 25 + </Button> 26 + } 27 + /> 28 + ); 29 + 30 + return ( 31 + <> 32 + <DataTable columns={columns} data={monitors} /> 33 + <div className="mt-3">{isLimitReached ? <Limit /> : null}</div> 34 + </> 35 + ); 36 + }
+53
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/_components/date-picker-preset.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { usePathname, useRouter } from "next/navigation"; 5 + import { CalendarIcon } from "lucide-react"; 6 + 7 + import { 8 + Select, 9 + SelectContent, 10 + SelectItem, 11 + SelectTrigger, 12 + SelectValue, 13 + } from "@openstatus/ui"; 14 + 15 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 16 + import { periods } from "../utils"; 17 + import type { Period } from "../utils"; 18 + 19 + export function DatePickerPreset({ period }: { period: Period }) { 20 + const router = useRouter(); 21 + const pathname = usePathname(); 22 + const updateSearchParams = useUpdateSearchParams(); 23 + 24 + function onSelect(value: Period) { 25 + const searchParams = updateSearchParams({ period: value }); 26 + router.replace(`${pathname}?${searchParams}`); 27 + } 28 + 29 + function renderLabel(period?: Period) { 30 + if (period === "1h") return "Last hour"; 31 + if (period === "1d") return "Last day"; 32 + if (period === "3d") return "Last 3 days"; 33 + return "Pick a range"; 34 + } 35 + 36 + return ( 37 + <Select defaultValue={period} onValueChange={onSelect}> 38 + <SelectTrigger className="w-[150px] text-left"> 39 + <span className="flex items-center gap-2"> 40 + <CalendarIcon className="h-4 w-4" /> 41 + <SelectValue placeholder="Pick a range" /> 42 + </span> 43 + </SelectTrigger> 44 + <SelectContent> 45 + {periods.map((period) => ( 46 + <SelectItem key={period} value={period}> 47 + {renderLabel(period)} 48 + </SelectItem> 49 + ))} 50 + </SelectContent> 51 + </Select> 52 + ); 53 + }
+46
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/_components/interval-preset.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { usePathname, useRouter } from "next/navigation"; 5 + import { Hourglass } from "lucide-react"; 6 + 7 + import { 8 + Select, 9 + SelectContent, 10 + SelectItem, 11 + SelectTrigger, 12 + SelectValue, 13 + } from "@openstatus/ui"; 14 + 15 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 16 + import { intervals } from "../utils"; 17 + import type { Interval } from "../utils"; 18 + 19 + export function IntervalPreset({ interval }: { interval: Interval }) { 20 + const router = useRouter(); 21 + const pathname = usePathname(); 22 + const updateSearchParams = useUpdateSearchParams(); 23 + 24 + function onSelect(value: Interval) { 25 + const searchParams = updateSearchParams({ interval: value }); 26 + router.replace(`${pathname}?${searchParams}`); 27 + } 28 + 29 + return ( 30 + <Select onValueChange={onSelect} defaultValue={interval}> 31 + <SelectTrigger className="w-[100px]"> 32 + <span className="flex items-center gap-2"> 33 + <Hourglass className="h-4 w-4" /> 34 + <SelectValue /> 35 + </span> 36 + </SelectTrigger> 37 + <SelectContent> 38 + {intervals.map((interval) => ( 39 + <SelectItem key={interval} value={interval}> 40 + {interval} 41 + </SelectItem> 42 + ))} 43 + </SelectContent> 44 + </Select> 45 + ); 46 + }
+57
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/_components/pause-button.tsx
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { PauseCircle, PlayCircle } from "lucide-react"; 6 + 7 + import type { Monitor } from "@openstatus/db/src/schema"; 8 + import { Button } from "@openstatus/ui"; 9 + 10 + import { LoadingAnimation } from "@/components/loading-animation"; 11 + import { useToastAction } from "@/hooks/use-toast-action"; 12 + import { api } from "@/trpc/client"; 13 + 14 + export function PauseButton({ monitor }: { monitor: Monitor }) { 15 + const [isPending, startTransition] = useTransition(); 16 + const router = useRouter(); 17 + const { toast } = useToastAction(); 18 + 19 + async function toggle() { 20 + startTransition(async () => { 21 + try { 22 + await api.monitor.update.mutate({ 23 + ...monitor, 24 + active: !monitor.active, 25 + }); 26 + toast("success"); 27 + router.refresh(); 28 + } catch { 29 + toast("error"); 30 + } 31 + }); 32 + } 33 + 34 + return ( 35 + <Button 36 + variant={monitor.active ? "outline" : "default"} 37 + onClick={toggle} 38 + disabled={isPending} 39 + > 40 + {isPending ? ( 41 + <LoadingAnimation variant={monitor.active ? "inverse" : "default"} /> 42 + ) : ( 43 + <> 44 + {monitor.active ? ( 45 + <> 46 + <PauseCircle className="mr-2 h-4 w-4" /> Pause 47 + </> 48 + ) : ( 49 + <> 50 + <PlayCircle className="mr-2 h-4 w-4" /> Resume 51 + </> 52 + )} 53 + </> 54 + )} 55 + </Button> 56 + ); 57 + }
+52
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/_components/quantile-preset.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { usePathname, useRouter } from "next/navigation"; 5 + import { CandlestickChart } from "lucide-react"; 6 + 7 + import { 8 + Select, 9 + SelectContent, 10 + SelectItem, 11 + SelectSeparator, 12 + SelectTrigger, 13 + SelectValue, 14 + } from "@openstatus/ui"; 15 + 16 + import useUpdateSearchParams from "@/hooks/use-update-search-params"; 17 + import { quantiles } from "../utils"; 18 + import type { Quantile } from "../utils"; 19 + 20 + export function QuantilePreset({ quantile }: { quantile: Quantile }) { 21 + const router = useRouter(); 22 + const pathname = usePathname(); 23 + const updateSearchParams = useUpdateSearchParams(); 24 + 25 + function onSelect(value: Quantile) { 26 + const searchParams = updateSearchParams({ quantile: value }); 27 + router.replace(`${pathname}?${searchParams}`); 28 + } 29 + 30 + return ( 31 + <Select onValueChange={onSelect} defaultValue={quantile}> 32 + <SelectTrigger className="w-[100px] uppercase"> 33 + <span className="flex items-center gap-2"> 34 + <CandlestickChart className="h-4 w-4" /> 35 + <SelectValue /> 36 + </span> 37 + </SelectTrigger> 38 + <SelectContent> 39 + {quantiles.map((quantile) => { 40 + return ( 41 + <React.Fragment key={quantile}> 42 + {quantile === "avg" && <SelectSeparator />} 43 + <SelectItem value={quantile} className="uppercase"> 44 + {quantile} 45 + </SelectItem> 46 + </React.Fragment> 47 + ); 48 + })} 49 + </SelectContent> 50 + </Select> 51 + ); 52 + }
+17 -10
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/chart-wrapper.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/_components/chart-wrapper.tsx
··· 1 1 "use client"; 2 2 3 - import type { Ping, Region } from "@openstatus/tinybird"; 3 + import type { Region, ResponseGraph } from "@openstatus/tinybird"; 4 4 import { regionsDict } from "@openstatus/utils"; 5 5 6 - import type { Period } from "../utils"; 6 + import type { Period, Quantile } from "../../utils"; 7 7 import { Chart } from "./chart"; 8 8 9 9 export function ChartWrapper({ 10 10 data, 11 11 period, 12 + quantile, 12 13 }: { 13 - data: Ping[]; 14 + data: ResponseGraph[]; 14 15 period: Period; 16 + quantile: Quantile; 15 17 }) { 16 - const group = groupDataByTimestamp(data, period); 18 + const group = groupDataByTimestamp(data, period, quantile); 17 19 return <Chart data={group.data} regions={group.regions} />; 18 20 } 19 21 /** ··· 22 24 * @param period 23 25 * @returns 24 26 */ 25 - function groupDataByTimestamp(data: Ping[], period: Period) { 27 + function groupDataByTimestamp( 28 + data: ResponseGraph[], 29 + period: Period, 30 + quantile: Quantile, 31 + ) { 26 32 let currentTimestamp = 0; 27 33 const regions: Record<string, null> = {}; 28 34 const _data = data.reduce( 29 35 (acc, curr) => { 30 - const { cronTimestamp, latency, region } = curr; 36 + const { timestamp, region } = curr; 37 + const latency = curr[`${quantile}Latency`]; 31 38 const { flag, code } = regionsDict[region]; 32 39 const fullNameRegion = `${flag} ${code}`; 33 40 regions[fullNameRegion] = null; // to get the region keys 34 - if (cronTimestamp === currentTimestamp) { 41 + if (timestamp === currentTimestamp) { 35 42 // overwrite last object in acc 36 43 const last = acc.pop(); 37 44 if (last) { ··· 40 47 [fullNameRegion]: latency, 41 48 }); 42 49 } 43 - } else if (cronTimestamp) { 44 - currentTimestamp = cronTimestamp; 50 + } else if (timestamp) { 51 + currentTimestamp = timestamp; 45 52 // create new object in acc 46 53 acc.push({ 47 - timestamp: renderTimestamp(cronTimestamp, period), 54 + timestamp: renderTimestamp(timestamp, period), 48 55 [fullNameRegion]: latency, 49 56 }); 50 57 }
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/chart.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/_components/chart.tsx
-66
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/date-picker-preset.tsx
··· 1 - "use client"; 2 - 3 - import * as React from "react"; 4 - import { usePathname, useRouter } from "next/navigation"; 5 - import { CalendarIcon } from "lucide-react"; 6 - 7 - import { 8 - Button, 9 - DropdownMenu, 10 - DropdownMenuContent, 11 - DropdownMenuItem, 12 - DropdownMenuTrigger, 13 - } from "@openstatus/ui"; 14 - 15 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 16 - import { cn } from "@/lib/utils"; 17 - import type { Period } from "../utils"; 18 - 19 - export function DatePickerPreset({ period }: { period: Period }) { 20 - const router = useRouter(); 21 - const pathname = usePathname(); 22 - const updateSearchParams = useUpdateSearchParams(); 23 - 24 - function onSelect(value: Period) { 25 - const searchParams = updateSearchParams({ 26 - period: value, 27 - }); 28 - router.replace(`${pathname}?${searchParams}`); 29 - } 30 - 31 - function renderLabel() { 32 - if (period === "hour") return "Last hour"; 33 - if (period === "day") return "Today"; 34 - if (period === "3d") return "Last 3 days"; 35 - return "Pick a range"; 36 - } 37 - 38 - return ( 39 - <DropdownMenu> 40 - <DropdownMenuTrigger asChild> 41 - <Button 42 - id="date" 43 - variant={"outline"} 44 - className={cn( 45 - "w-[140px] justify-start text-left font-normal", 46 - !period && "text-muted-foreground", 47 - )} 48 - > 49 - <CalendarIcon className="mr-2 h-4 w-4" /> 50 - {renderLabel()} 51 - </Button> 52 - </DropdownMenuTrigger> 53 - <DropdownMenuContent> 54 - <DropdownMenuItem onClick={() => onSelect("hour")}> 55 - Last hour 56 - </DropdownMenuItem> 57 - <DropdownMenuItem onClick={() => onSelect("day")}> 58 - Today 59 - </DropdownMenuItem> 60 - <DropdownMenuItem onClick={() => onSelect("3d")}> 61 - Last 3 days 62 - </DropdownMenuItem> 63 - </DropdownMenuContent> 64 - </DropdownMenu> 65 - ); 66 - }
+5 -7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/loading.tsx
··· 1 1 import { Skeleton } from "@openstatus/ui"; 2 2 3 - import { Header } from "@/components/dashboard/header"; 4 3 import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 5 4 6 5 export default function Loading() { 7 6 return ( 8 - <div className="grid gap-6 md:gap-8"> 9 - <Header.Skeleton> 10 - <Skeleton className="h-9 w-32" /> 11 - </Header.Skeleton> 12 - <Skeleton className="h-[396px] w-full" /> 7 + <div className="grid gap-4"> 8 + <div className="flex flex-wrap items-center gap-2 sm:justify-end"> 9 + <Skeleton className="h-10 w-32" /> 10 + </div> 13 11 <div className="grid gap-3"> 14 - <div className="flex items-center gap-3"> 12 + <div className="flex items-center gap-2"> 15 13 <Skeleton className="h-8 w-32" /> 16 14 <Skeleton className="h-8 w-16" /> 17 15 </div>
+11 -34
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/page.tsx
··· 1 1 import * as React from "react"; 2 2 import { notFound } from "next/navigation"; 3 - import { endOfDay, startOfDay } from "date-fns"; 4 3 import * as z from "zod"; 5 4 6 - import { Header } from "@/components/dashboard/header"; 7 5 import { columns } from "@/components/data-table/columns"; 8 6 import { DataTable } from "@/components/data-table/data-table"; 9 7 import { getResponseListData } from "@/lib/tb"; 10 8 import { api } from "@/trpc/server"; 11 - import { ChartWrapper } from "./_components/chart-wrapper"; 12 - import { DatePickerPreset } from "./_components/date-picker-preset"; 13 - import { getPeriodDate, periods } from "./utils"; 14 - 15 - export const revalidate = 0; 9 + import { DatePickerPreset } from "../_components/date-picker-preset"; 10 + import { getDateByPeriod, periods } from "../utils"; 16 11 17 12 /** 18 13 * allowed URL search params 19 14 */ 20 15 const searchParamsSchema = z.object({ 21 - statusCode: z.coerce.number().optional(), 22 - cronTimestamp: z.coerce.number().optional(), 23 - fromDate: z.coerce 24 - .number() 25 - .optional() 26 - .default(startOfDay(new Date()).getTime()), 27 - toDate: z.coerce.number().optional().default(endOfDay(new Date()).getTime()), 28 - period: z.enum(periods).optional().default("hour"), 16 + period: z.enum(periods).optional().default("1h"), 29 17 }); 30 18 31 19 export default async function Page({ ··· 46 34 return notFound(); 47 35 } 48 36 49 - const date = getPeriodDate(search.data.period); 37 + const date = getDateByPeriod(search.data.period); 50 38 51 39 const data = await getResponseListData({ 52 40 monitorId: id, 53 - ...search.data, 54 - /** 55 - * We are overwriting the `fromDate` and `toDate` 56 - * to only support presets from the `period` 57 - */ 58 41 fromDate: date.from.getTime(), 59 42 toDate: date.to.getTime(), 60 43 }); 61 44 45 + if (!data) return null; 46 + 62 47 return ( 63 - // overflow-x-scroll needed for the chart. 64 - <div className="grid grid-cols-1 gap-6 md:gap-8"> 65 - <Header 66 - title={monitor.name} 67 - description={monitor.url} 68 - actions={<DatePickerPreset period={search.data.period} />} 69 - /> 70 - {data ? ( 71 - <> 72 - <ChartWrapper period={search.data.period} data={data} /> 73 - <DataTable columns={columns} data={data} /> 74 - </> 75 - ) : null} 48 + <div className="grid gap-4"> 49 + <div className="flex flex-wrap items-center gap-2 sm:justify-end"> 50 + <DatePickerPreset period={search.data.period} /> 51 + </div> 52 + <DataTable columns={columns} data={data} /> 76 53 </div> 77 54 ); 78 55 }
-29
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/utils.ts
··· 1 - import { endOfDay, startOfDay, subDays, subHours, subMonths } from "date-fns"; 2 - 3 - export const periods = ["hour", "day", "3d", "7d", "30d"] as const; 4 - 5 - export type Period = (typeof periods)[number]; 6 - 7 - export function getPeriodDate(period: Period) { 8 - if (period === "hour") 9 - return { from: subHours(new Date(), 1), to: new Date() }; 10 - if (period === "day") 11 - return { from: startOfDay(new Date()), to: endOfDay(new Date()) }; 12 - if (period === "3d") 13 - return { 14 - from: subDays(startOfDay(new Date()), 3), 15 - to: endOfDay(new Date()), 16 - }; 17 - if (period === "7d") 18 - return { 19 - from: subDays(startOfDay(new Date()), 7), 20 - to: endOfDay(new Date()), 21 - }; 22 - if (period === "30d") 23 - return { 24 - from: subMonths(startOfDay(new Date()), 1), 25 - to: endOfDay(new Date()), 26 - }; 27 - // default to today 28 - return { from: startOfDay(new Date()), to: endOfDay(new Date()) }; 29 - }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/loading.tsx
··· 1 + import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 + 3 + export default function Loading() { 4 + return <SkeletonForm />; 5 + }
+29
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/page.tsx
··· 1 + import { MonitorForm } from "@/components/forms/monitor-form"; 2 + import { api } from "@/trpc/server"; 3 + 4 + export default async function EditPage({ 5 + params, 6 + }: { 7 + params: { workspaceSlug: string; id: string }; 8 + }) { 9 + const id = Number(params.id); 10 + const monitor = await api.monitor.getMonitorById.query({ id }); 11 + const workspace = await api.workspace.getWorkspace.query(); 12 + 13 + const monitorNotifications = 14 + await api.monitor.getAllNotificationsForMonitor.query({ id }); 15 + 16 + const notifications = 17 + await api.notification.getNotificationsByWorkspace.query(); 18 + 19 + return ( 20 + <MonitorForm 21 + defaultValues={{ 22 + ...monitor, 23 + notifications: monitorNotifications?.map(({ id }) => id), 24 + }} 25 + plan={workspace?.plan} 26 + notifications={notifications} 27 + /> 28 + ); 29 + }
+54
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/layout.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + 3 + import { Header } from "@/components/dashboard/header"; 4 + import { Navbar } from "@/components/dashboard/navbar"; 5 + import { api } from "@/trpc/server"; 6 + import { PauseButton } from "./_components/pause-button"; 7 + 8 + export default async function Layout({ 9 + children, 10 + params, 11 + }: { 12 + children: React.ReactNode; 13 + params: { workspaceSlug: string; id: string }; 14 + }) { 15 + const id = params.id; 16 + 17 + const monitor = await api.monitor.getMonitorById.query({ 18 + id: Number(id), 19 + }); 20 + 21 + if (!monitor) { 22 + return notFound(); 23 + } 24 + 25 + const navigation = [ 26 + { 27 + label: "Overview", 28 + href: `/app/${params.workspaceSlug}/monitors/${id}/overview`, 29 + segment: "overview", 30 + }, 31 + { 32 + label: "Data Table", 33 + href: `/app/${params.workspaceSlug}/monitors/${id}/data`, 34 + segment: "data", 35 + }, 36 + { 37 + label: "Settings", 38 + href: `/app/${params.workspaceSlug}/monitors/${id}/edit`, 39 + segment: "edit", 40 + }, 41 + ]; 42 + 43 + return ( 44 + <div className="grid grid-cols-1 gap-6 md:gap-8"> 45 + <Header 46 + title={monitor.name} 47 + description={monitor.url} 48 + actions={<PauseButton monitor={monitor} />} 49 + /> 50 + <Navbar className="col-span-full" navigation={navigation} /> 51 + {children} 52 + </div> 53 + ); 54 + }
+14
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/loading.tsx
··· 1 + import { Skeleton } from "@openstatus/ui"; 2 + 3 + export default function Loading() { 4 + return ( 5 + <div className="grid gap-4"> 6 + <Skeleton className="h-5 w-40" /> 7 + <div className="flex flex-wrap items-center gap-2 sm:justify-end"> 8 + <Skeleton className="h-10 w-32" /> 9 + <Skeleton className="h-10 w-24" /> 10 + </div> 11 + <Skeleton className="h-[396px] w-full" /> 12 + </div> 13 + ); 14 + }
+102
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/page.tsx
··· 1 + import * as React from "react"; 2 + import { notFound } from "next/navigation"; 3 + import { endOfDay, startOfDay } from "date-fns"; 4 + import * as z from "zod"; 5 + 6 + import { StatusDot } from "@/components/monitor/status-dot"; 7 + import { getResponseGraphData } from "@/lib/tb"; 8 + import { api } from "@/trpc/server"; 9 + import { DatePickerPreset } from "../_components/date-picker-preset"; 10 + import { IntervalPreset } from "../_components/interval-preset"; 11 + import { QuantilePreset } from "../_components/quantile-preset"; 12 + import { 13 + getDateByPeriod, 14 + getMinutesByInterval, 15 + intervals, 16 + periods, 17 + quantiles, 18 + } from "../utils"; 19 + import { ChartWrapper } from "./_components/chart-wrapper"; 20 + 21 + /** 22 + * allowed URL search params 23 + */ 24 + const searchParamsSchema = z.object({ 25 + statusCode: z.coerce.number().optional(), 26 + cronTimestamp: z.coerce.number().optional(), 27 + quantile: z.enum(quantiles).optional().default("p95"), 28 + interval: z.enum(intervals).optional().default("30m"), 29 + period: z.enum(periods).optional().default("1d"), 30 + fromDate: z.coerce 31 + .number() 32 + .optional() 33 + .default(startOfDay(new Date()).getTime()), 34 + toDate: z.coerce.number().optional().default(endOfDay(new Date()).getTime()), 35 + }); 36 + 37 + export default async function Page({ 38 + params, 39 + searchParams, 40 + }: { 41 + params: { workspaceSlug: string; id: string }; 42 + searchParams: { [key: string]: string | string[] | undefined }; 43 + }) { 44 + const id = params.id; 45 + const search = searchParamsSchema.safeParse(searchParams); 46 + 47 + const monitor = await api.monitor.getMonitorById.query({ 48 + id: Number(id), 49 + }); 50 + 51 + if (!monitor || !search.success) { 52 + return notFound(); 53 + } 54 + 55 + const date = getDateByPeriod(search.data.period); 56 + const minutes = getMinutesByInterval(search.data.interval); 57 + 58 + const data = await getResponseGraphData({ 59 + monitorId: id, 60 + ...search.data, 61 + /** 62 + * 63 + */ 64 + fromDate: date.from.getTime(), 65 + toDate: date.to.getTime(), 66 + interval: minutes, 67 + }); 68 + 69 + if (!data) return null; 70 + 71 + const { period, quantile, interval } = search.data; 72 + 73 + return ( 74 + <div className="grid gap-4"> 75 + <div> 76 + <p className="text-muted-foreground inline-flex items-center gap-2 text-sm"> 77 + <StatusDot status={monitor.status} active={monitor.active} /> 78 + <span> 79 + {monitor.active 80 + ? monitor.status === "active" 81 + ? "up" 82 + : "down" 83 + : "pause"}{" "} 84 + · checked every{" "} 85 + <code className="text-foreground">{monitor.periodicity}</code> 86 + </span> 87 + </p> 88 + </div> 89 + <div className="flex flex-wrap items-center gap-2 sm:justify-end"> 90 + {/* IDEA: add tooltip for description */} 91 + <DatePickerPreset period={period} /> 92 + <QuantilePreset quantile={quantile} /> 93 + <IntervalPreset interval={interval} /> 94 + </div> 95 + <ChartWrapper period={period} quantile={quantile} data={data} /> 96 + <p className="text-muted-foreground text-center text-xs"> 97 + Select your preferred time range, percentile for insights, and time 98 + interval for granular analysis. 99 + </p> 100 + </div> 101 + ); 102 + }
+9
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + 3 + export default function Page({ 4 + params, 5 + }: { 6 + params: { workspaceSlug: string; id: string }; 7 + }) { 8 + return redirect(`./${params.id}/overview`); 9 + }
+55
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/utils.ts
··· 1 + import { 2 + endOfDay, 3 + endOfHour, 4 + startOfDay, 5 + startOfHour, 6 + subDays, 7 + subHours, 8 + } from "date-fns"; 9 + 10 + export const periods = ["1h", "1d", "3d"] as const; // If neeeded (e.g. Pro plans), "7d", "30d" 11 + export const quantiles = ["p99", "p95", "p90", "p75", "avg"] as const; 12 + export const intervals = ["1m", "10m", "30m", "1h"] as const; 13 + 14 + export type Period = (typeof periods)[number]; 15 + export type Quantile = (typeof quantiles)[number]; 16 + export type Interval = (typeof intervals)[number]; 17 + 18 + export function getDateByPeriod(period: Period) { 19 + switch (period) { 20 + case "1h": 21 + return { 22 + from: subHours(startOfHour(new Date()), 1), 23 + to: endOfHour(new Date()), 24 + }; 25 + case "1d": 26 + return { 27 + from: subDays(startOfHour(new Date()), 1), 28 + to: endOfDay(new Date()), 29 + }; 30 + case "3d": 31 + return { 32 + from: subDays(startOfDay(new Date()), 3), 33 + to: endOfDay(new Date()), 34 + }; 35 + default: 36 + const _exhaustiveCheck: never = period; 37 + throw new Error(`Unhandled period: ${_exhaustiveCheck}`); 38 + } 39 + } 40 + 41 + export function getMinutesByInterval(interval: Interval) { 42 + switch (interval) { 43 + case "1m": 44 + return 1; 45 + case "10m": 46 + return 10; 47 + case "30m": 48 + return 30; 49 + case "1h": 50 + return 60; 51 + default: 52 + const _exhaustiveCheck: never = interval; 53 + throw new Error(`Unhandled interval: ${_exhaustiveCheck}`); 54 + } 55 + }
-122
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/_components/action-button.tsx
··· 1 - "use client"; 2 - 3 - import * as React from "react"; 4 - import Link from "next/link"; 5 - import { useRouter } from "next/navigation"; 6 - import { MoreVertical } from "lucide-react"; 7 - import type * as z from "zod"; 8 - 9 - import type { insertMonitorSchema } from "@openstatus/db/src/schema"; 10 - import { 11 - AlertDialog, 12 - AlertDialogAction, 13 - AlertDialogCancel, 14 - AlertDialogContent, 15 - AlertDialogDescription, 16 - AlertDialogFooter, 17 - AlertDialogHeader, 18 - AlertDialogTitle, 19 - AlertDialogTrigger, 20 - Button, 21 - DropdownMenu, 22 - DropdownMenuContent, 23 - DropdownMenuItem, 24 - DropdownMenuTrigger, 25 - } from "@openstatus/ui"; 26 - 27 - import { LoadingAnimation } from "@/components/loading-animation"; 28 - import { useToastAction } from "@/hooks/use-toast-action"; 29 - import { api } from "@/trpc/client"; 30 - 31 - type Schema = z.infer<typeof insertMonitorSchema>; 32 - 33 - export function ActionButton(props: Schema & { workspaceSlug: string }) { 34 - const router = useRouter(); 35 - const { toast } = useToastAction(); 36 - const [alertOpen, setAlertOpen] = React.useState(false); 37 - const [isPending, startTransition] = React.useTransition(); 38 - 39 - async function onDelete() { 40 - startTransition(async () => { 41 - try { 42 - if (!props.id) return; 43 - await api.monitor.delete.mutate({ id: props.id }); 44 - toast("deleted"); 45 - router.refresh(); 46 - setAlertOpen(false); 47 - } catch { 48 - toast("error"); 49 - } 50 - }); 51 - } 52 - 53 - async function onTest() { 54 - startTransition(async () => { 55 - const { url, body, method, headers } = props; 56 - const res = await fetch(`/api/checker/test`, { 57 - method: "POST", 58 - headers: new Headers({ 59 - "Content-Type": "application/json", 60 - }), 61 - body: JSON.stringify({ url, body, method, headers }), 62 - }); 63 - if (res.ok) { 64 - toast("test-success"); 65 - } else { 66 - toast("test-error"); 67 - } 68 - }); 69 - } 70 - 71 - return ( 72 - <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 73 - <DropdownMenu> 74 - <DropdownMenuTrigger asChild> 75 - <Button 76 - variant="ghost" 77 - className="data-[state=open]:bg-accent h-8 w-8 p-0" 78 - > 79 - <span className="sr-only">Open menu</span> 80 - <MoreVertical className="h-4 w-4" /> 81 - </Button> 82 - </DropdownMenuTrigger> 83 - <DropdownMenuContent align="end"> 84 - <Link href={`./monitors/edit?id=${props.id}`}> 85 - <DropdownMenuItem>Edit</DropdownMenuItem> 86 - </Link> 87 - <Link href={`/app/${props.workspaceSlug}/monitors/${props.id}/data`}> 88 - <DropdownMenuItem>View data</DropdownMenuItem> 89 - </Link> 90 - <DropdownMenuItem onClick={onTest}>Test endpoint</DropdownMenuItem> 91 - <AlertDialogTrigger asChild> 92 - <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 93 - Delete 94 - </DropdownMenuItem> 95 - </AlertDialogTrigger> 96 - </DropdownMenuContent> 97 - </DropdownMenu> 98 - <AlertDialogContent> 99 - <AlertDialogHeader> 100 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 101 - <AlertDialogDescription> 102 - This action cannot be undone. This will permanently delete the 103 - monitor. 104 - </AlertDialogDescription> 105 - </AlertDialogHeader> 106 - <AlertDialogFooter> 107 - <AlertDialogCancel>Cancel</AlertDialogCancel> 108 - <AlertDialogAction 109 - onClick={(e) => { 110 - e.preventDefault(); 111 - onDelete(); 112 - }} 113 - disabled={isPending} 114 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 115 - > 116 - {!isPending ? "Delete" : <LoadingAnimation />} 117 - </AlertDialogAction> 118 - </AlertDialogFooter> 119 - </AlertDialogContent> 120 - </AlertDialog> 121 - ); 122 - }
-20
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/_components/empty-state.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import { Button } from "@openstatus/ui"; 4 - 5 - import { EmptyState as DefaultEmptyState } from "@/components/dashboard/empty-state"; 6 - 7 - export function EmptyState() { 8 - return ( 9 - <DefaultEmptyState 10 - icon="activity" 11 - title="No monitors" 12 - description="Create your first monitor" 13 - action={ 14 - <Button asChild> 15 - <Link href="./monitors/edit">Create</Link> 16 - </Button> 17 - } 18 - /> 19 - ); 20 - }
-19
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/edit/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 5 - 6 - export default function Loading() { 7 - return ( 8 - <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 9 - <div className="col-span-full flex w-full justify-between"> 10 - <Header.Skeleton> 11 - <Skeleton className="h-9 w-20" /> 12 - </Header.Skeleton> 13 - </div> 14 - <div className="col-span-full"> 15 - <SkeletonForm /> 16 - </div> 17 - </div> 18 - ); 19 - }
-66
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/edit/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - import * as z from "zod"; 3 - 4 - import { Header } from "@/components/dashboard/header"; 5 - import { MonitorForm } from "@/components/forms/monitor-form"; 6 - import { api } from "@/trpc/server"; 7 - 8 - /** 9 - * allowed URL search params 10 - */ 11 - const searchParamsSchema = z.object({ 12 - id: z.coerce.number().optional(), 13 - }); 14 - 15 - export default async function EditPage({ 16 - params, 17 - searchParams, 18 - }: { 19 - params: { workspaceSlug: string }; 20 - searchParams: { [key: string]: string | string[] | undefined }; 21 - }) { 22 - const search = searchParamsSchema.safeParse(searchParams); 23 - 24 - if (!search.success) { 25 - return notFound(); 26 - } 27 - 28 - const { id } = search.data; 29 - 30 - const monitor = id 31 - ? await api.monitor.getMonitorById.query({ id }) 32 - : undefined; 33 - const workspace = await api.workspace.getWorkspace.query(); 34 - 35 - const monitorNotifications = id 36 - ? await api.monitor.getAllNotificationsForMonitor.query({ 37 - id, 38 - }) 39 - : []; 40 - 41 - const notifications = 42 - await api.notification.getNotificationsByWorkspace.query(); 43 - 44 - return ( 45 - <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 46 - <Header 47 - title="Monitor" 48 - description={monitor ? "Update your monitor" : "Create your monitor"} 49 - /> 50 - <div className="col-span-full"> 51 - <MonitorForm 52 - defaultValues={ 53 - monitor 54 - ? { 55 - ...monitor, 56 - notifications: monitorNotifications?.map(({ id }) => id), 57 - } 58 - : undefined 59 - } 60 - plan={workspace?.plan} 61 - notifications={notifications} 62 - /> 63 - </div> 64 - </div> 65 - ); 66 - }
-19
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 5 - 6 - export default function Loading() { 7 - return ( 8 - <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 9 - <div className="col-span-full flex w-full justify-between"> 10 - <Header.Skeleton> 11 - <Skeleton className="h-9 w-20" /> 12 - </Header.Skeleton> 13 - </div> 14 - <div className="col-span-full w-full"> 15 - <DataTableSkeleton /> 16 - </div> 17 - </div> 18 - ); 19 - }
+14
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/new/layout.tsx
··· 1 + import { Header } from "@/components/dashboard/header"; 2 + 3 + export default async function Layout({ 4 + children, 5 + }: { 6 + children: React.ReactNode; 7 + }) { 8 + return ( 9 + <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 10 + <Header title="Monitor" description="Create your monitor" /> 11 + <div className="col-span-full">{children}</div> 12 + </div> 13 + ); 14 + }
+5
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/new/loading.tsx
··· 1 + import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 + 3 + export default function Loading() { 4 + return <SkeletonForm />; 5 + }
+16
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/new/page.tsx
··· 1 + import { MonitorForm } from "@/components/forms/monitor-form"; 2 + import { api } from "@/trpc/server"; 3 + 4 + export default async function Page() { 5 + const workspace = await api.workspace.getWorkspace.query(); 6 + const notifications = 7 + await api.notification.getNotificationsByWorkspace.query(); 8 + 9 + return ( 10 + <MonitorForm 11 + plan={workspace?.plan} 12 + notifications={notifications} 13 + nextUrl="./" // back to the overview page 14 + /> 15 + ); 16 + }
-84
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/page.tsx
··· 1 - import * as React from "react"; 2 - import Link from "next/link"; 3 - 4 - import { allPlans } from "@openstatus/plans"; 5 - import { ButtonWithDisableTooltip } from "@openstatus/ui"; 6 - 7 - import { Header } from "@/components/dashboard/header"; 8 - import { HelpCallout } from "@/components/dashboard/help-callout"; 9 - import { Limit } from "@/components/dashboard/limit"; 10 - import { columns } from "@/components/data-table/monitor/columns"; 11 - import { DataTable } from "@/components/data-table/monitor/data-table"; 12 - import { getResponseListData } from "@/lib/tb"; 13 - import { api } from "@/trpc/server"; 14 - import { EmptyState } from "./_components/empty-state"; 15 - 16 - export default async function MonitorPage({ 17 - params, 18 - }: { 19 - params: { workspaceSlug: string }; 20 - }) { 21 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 22 - const workspace = await api.workspace.getWorkspace.query(); 23 - 24 - const isLimit = 25 - (monitors?.length || 0) >= 26 - allPlans[workspace?.plan || "free"].limits.monitors; 27 - 28 - async function getMonitorLastStatusCode(monitorId: string) { 29 - const ping = await getResponseListData({ 30 - monitorId, 31 - limit: 1, 32 - }); 33 - const lastStatusCode = 34 - ping && ping.length > 0 ? ping[0].statusCode : undefined; 35 - return lastStatusCode; 36 - } 37 - 38 - const lastStatusCodes = ( 39 - await Promise.allSettled( 40 - monitors?.map((monitor) => 41 - getMonitorLastStatusCode(String(monitor.id)), 42 - ) || [], 43 - ) 44 - ).map((code) => (code.status === "fulfilled" ? code.value : undefined)); 45 - 46 - return ( 47 - <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-2 md:gap-8"> 48 - <Header 49 - title="Monitors" 50 - description="Overview of all your monitors." 51 - actions={ 52 - <ButtonWithDisableTooltip 53 - tooltip="You reached the limits" 54 - asChild={!isLimit} 55 - disabled={isLimit} 56 - > 57 - <Link href="./monitors/edit">Create</Link> 58 - </ButtonWithDisableTooltip> 59 - } 60 - /> 61 - {Boolean(monitors?.length) ? ( 62 - <div className="col-span-full"> 63 - {monitors && ( 64 - <DataTable 65 - columns={columns} 66 - data={monitors.map((_, i) => ({ 67 - ..._, 68 - lastStatusCode: lastStatusCodes[i], 69 - }))} 70 - /> 71 - )} 72 - </div> 73 - ) : ( 74 - <div className="col-span-full"> 75 - <EmptyState /> 76 - <div className="mt-3">{isLimit ? <Limit /> : null}</div> 77 - </div> 78 - )} 79 - <div className="mt-8 md:mt-12"> 80 - <HelpCallout /> 81 - </div> 82 - </div> 83 - ); 84 - }
+30 -43
apps/web/src/app/app/[workspaceSlug]/onboarding/page.tsx
··· 1 1 import Link from "next/link"; 2 - import { notFound } from "next/navigation"; 3 - import * as z from "zod"; 2 + import { redirect } from "next/navigation"; 4 3 5 4 import { Button } from "@openstatus/ui"; 6 5 ··· 9 8 import { StatusPageForm } from "@/components/forms/status-page-form"; 10 9 import { api } from "@/trpc/server"; 11 10 import { Description } from "./_components/description"; 12 - 13 - /** 14 - * allowed URL search params 15 - */ 16 - const searchParamsSchema = z.object({ 17 - id: z.coerce.number().optional(), // monitorId 18 - }); 19 11 20 12 export default async function Onboarding({ 21 13 params, 22 - searchParams, 23 14 }: { 24 15 params: { workspaceSlug: string }; 25 - searchParams: { [key: string]: string | string[] | undefined }; 26 16 }) { 27 - const search = searchParamsSchema.safeParse(searchParams); 28 17 const { workspaceSlug } = params; 29 18 30 - if (!search.success) { 31 - return notFound(); 32 - } 33 - 34 - // Instead of having the workspaceSlug in the search params, we can get it from the auth user 35 - const { id: monitorId } = search.data; 36 - 37 19 const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 20 + const allPages = await api.page.getPagesByWorkspace.query(); 38 21 const allNotifications = 39 22 await api.notification.getNotificationsByWorkspace.query(); 40 23 41 - if (!monitorId) { 24 + if (allMonitors.length === 0) { 42 25 return ( 43 26 <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 44 27 <Header ··· 62 45 ); 63 46 } 64 47 65 - return ( 66 - <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 67 - <Header 68 - title="Get Started" 69 - description="Create your first status page." 70 - actions={ 71 - <Button variant="link" className="text-muted-foreground"> 72 - <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 73 - </Button> 74 - } 75 - /> 76 - <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 77 - <div className="md:col-span-2"> 78 - <StatusPageForm 79 - {...{ workspaceSlug, allMonitors }} 80 - nextUrl={`/app/${workspaceSlug}/status-pages`} 81 - checkAllMonitors 82 - /> 83 - </div> 84 - <div className="hidden h-full md:col-span-1 md:block"> 85 - <Description step="status-page" /> 48 + if (allPages.length === 0) { 49 + return ( 50 + <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 51 + <Header 52 + title="Get Started" 53 + description="Create your first status page." 54 + actions={ 55 + <Button variant="link" className="text-muted-foreground"> 56 + <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 57 + </Button> 58 + } 59 + /> 60 + <div className="grid h-full w-full gap-6 md:grid-cols-3 md:gap-8"> 61 + <div className="md:col-span-2"> 62 + <StatusPageForm 63 + {...{ workspaceSlug, allMonitors }} 64 + nextUrl={`/app/${workspaceSlug}/status-pages`} 65 + checkAllMonitors 66 + /> 67 + </div> 68 + <div className="hidden h-full md:col-span-1 md:block"> 69 + <Description step="status-page" /> 70 + </div> 86 71 </div> 87 72 </div> 88 - </div> 89 - ); 73 + ); 74 + } 75 + 76 + return redirect(`/app/${workspaceSlug}/monitors`); 90 77 }
+3 -15
apps/web/src/app/play/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 1 import * as z from "zod"; 3 2 4 3 import { Label } from "@openstatus/ui"; ··· 7 6 import { getHomeMonitorListData } from "@/lib/tb"; 8 7 import { convertTimezoneToGMT, getRequestHeaderTimezone } from "@/lib/timezone"; 9 8 import { TimezoneCombobox } from "./_components/timezone-combobox"; 10 - 11 - const supportedTimezones = Intl.supportedValuesOf("timeZone"); 12 9 13 10 /** 14 11 * allowed URL search params ··· 25 22 const search = searchParamsSchema.safeParse(searchParams); 26 23 const requestTimezone = getRequestHeaderTimezone(); 27 24 28 - if (!search.success) { 29 - return notFound(); 30 - } 31 - 32 - if ( 33 - search.data.timezone && 34 - !supportedTimezones.includes(search.data.timezone) 35 - ) { 36 - return notFound(); 37 - } 25 + const timezone = search.success ? search.data.timezone : undefined; 38 26 39 - const gmt = convertTimezoneToGMT(search.data.timezone); 27 + const gmt = convertTimezoneToGMT(timezone); 40 28 41 29 const data = await getHomeMonitorListData({ timezone: gmt }); 42 30 ··· 63 51 <div className="grid items-center gap-1"> 64 52 <Label className="text-muted-foreground text-xs">Timezone</Label> 65 53 <TimezoneCombobox 66 - defaultValue={search.data.timezone || requestTimezone || undefined} 54 + defaultValue={timezone || requestTimezone || undefined} 67 55 /> 68 56 </div> 69 57 </div>
+1
apps/web/src/components/dashboard/navbar.tsx
··· 9 9 import { cn } from "@/lib/utils"; 10 10 11 11 type Props = { 12 + // TODO: add disabled state for pro/hobby plan users 12 13 navigation: { label: string; href: string; segment: string | null }[]; 13 14 className?: string; 14 15 };
+20 -36
apps/web/src/components/data-table/monitor/columns.tsx
··· 2 2 3 3 import Link from "next/link"; 4 4 import type { ColumnDef } from "@tanstack/react-table"; 5 - import * as z from "zod"; 6 5 7 6 import type { Monitor } from "@openstatus/db/src/schema"; 8 7 import { Badge } from "@openstatus/ui"; 9 8 10 - import { DataTableStatusBadge } from "../data-table-status-badge"; 9 + import { StatusDot } from "@/components/monitor/status-dot"; 11 10 import { DataTableRowActions } from "./data-table-row-actions"; 12 11 13 - export const columns: ColumnDef< 14 - Monitor & { lastStatusCode?: number | null } 15 - >[] = [ 12 + export const columns: ColumnDef<Monitor>[] = [ 16 13 { 17 14 accessorKey: "name", 18 15 header: "Name", 19 16 cell: ({ row }) => { 20 - const active = row.getValue("active"); 17 + const { active, status } = row.original; 21 18 return ( 22 - // TODO: add Link on click when we have a better details page 23 19 <Link 24 - href={`./monitors/${row.original.id}/data`} 20 + href={`./monitors/${row.original.id}/overview`} 25 21 className="group flex items-center gap-2" 26 22 > 27 - {active ? ( 28 - <span className="relative flex h-2 w-2"> 29 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500/80 opacity-75 duration-1000" /> 30 - <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" /> 31 - </span> 32 - ) : ( 33 - <span className="relative flex h-2 w-2"> 34 - <span className="absolute inline-flex h-2 w-2 rounded-full bg-red-500" /> 35 - </span> 36 - )} 23 + <StatusDot active={active} status={status} /> 37 24 <span className="max-w-[125px] truncate group-hover:underline"> 38 25 {row.getValue("name")} 39 26 </span> 40 - {!active ? <Badge variant="outline">paused</Badge> : null} 41 27 </Link> 42 28 ); 43 29 }, ··· 56 42 }, 57 43 }, 58 44 { 59 - // hidden by `columnVisibility` in `data-table.tsx` but used via row.getValue("active") 60 - accessorKey: "active", 61 - header: "Active", 45 + accessorKey: "description", 46 + header: "Description", 62 47 cell: ({ row }) => { 63 - const active = row.getValue("active"); 64 - if (!active) { 65 - return <span className="text-muted-foreground">paused</span>; 66 - } 67 - return <span>running</span>; 48 + return ( 49 + <div className="flex"> 50 + <span className="text-muted-foreground max-w-[150px] truncate sm:max-w-[200px] lg:max-w-[250px] xl:max-w-[350px]"> 51 + {row.getValue("description") || "-"} 52 + </span> 53 + </div> 54 + ); 68 55 }, 69 56 }, 70 57 { 71 - accessorKey: "lastStatusCode", 72 - header: "Last Status", 58 + accessorKey: "status", 59 + header: "Status", 73 60 cell: ({ row }) => { 74 - const lastStatusCode = row.getValue("lastStatusCode"); 75 - const statusCode = z.number().nullable().optional().parse(lastStatusCode); 76 - 77 - if (statusCode === undefined) { 78 - return <span className="text-muted-foreground">Missing</span>; 79 - } 61 + const { active, status } = row.original; 80 62 81 - return <DataTableStatusBadge {...{ statusCode }} />; 63 + if (!active) return <Badge variant="secondary">pause</Badge>; 64 + if (status === "error") return <Badge variant="destructive">down</Badge>; 65 + return <Badge variant="outline">up</Badge>; 82 66 }, 83 67 }, 84 68 {
+2 -2
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 104 104 </Button> 105 105 </DropdownMenuTrigger> 106 106 <DropdownMenuContent align="end"> 107 - <Link href={`./monitors/edit?id=${monitor.id}`}> 107 + <Link href={`./monitors/${monitor.id}/edit`}> 108 108 <DropdownMenuItem>Edit</DropdownMenuItem> 109 109 </Link> 110 - <Link href={`./monitors/${monitor.id}/data`}> 110 + <Link href={`./monitors/${monitor.id}/overview`}> 111 111 <DropdownMenuItem>Details</DropdownMenuItem> 112 112 </Link> 113 113 <DropdownMenuSeparator />
+3 -30
apps/web/src/components/data-table/monitor/data-table.tsx
··· 1 1 "use client"; 2 2 3 3 import * as React from "react"; 4 - import type { ColumnDef, VisibilityState } from "@tanstack/react-table"; 4 + import type { ColumnDef } from "@tanstack/react-table"; 5 5 import { 6 6 flexRender, 7 7 getCoreRowModel, ··· 29 29 columns, 30 30 data, 31 31 }: DataTableProps<TData, TValue>) { 32 - const [columnVisibility, setColumnVisibility] = 33 - React.useState<VisibilityState>({ active: false }); 34 32 const table = useReactTable({ 35 33 data, 36 34 columns, 37 - state: { columnVisibility }, 38 - onColumnVisibilityChange: setColumnVisibility, 39 35 getCoreRowModel: getCoreRowModel(), 40 - // defaultColumn: { 41 - // minSize: 0, 42 - // size: Number.MAX_SAFE_INTEGER, 43 - // maxSize: Number.MAX_SAFE_INTEGER, 44 - // }, 45 36 }); 46 37 47 38 return ( ··· 52 43 <TableRow key={headerGroup.id} className="hover:bg-transparent"> 53 44 {headerGroup.headers.map((header) => { 54 45 return ( 55 - <TableHead 56 - key={header.id} 57 - // className="max-w-0 truncate sm:max-w-full" 58 - // style={{ 59 - // width: 60 - // header.getSize() === Number.MAX_SAFE_INTEGER 61 - // ? "auto" 62 - // : header.getSize(), 63 - // }} 64 - > 46 + <TableHead key={header.id}> 65 47 {header.isPlaceholder 66 48 ? null 67 49 : flexRender( ··· 82 64 data-state={row.getIsSelected() && "selected"} 83 65 > 84 66 {row.getVisibleCells().map((cell) => ( 85 - <TableCell 86 - key={cell.id} 87 - // className="max-w-0 truncate md:max-w-full" 88 - // style={{ 89 - // width: 90 - // cell.column.getSize() === Number.MAX_SAFE_INTEGER 91 - // ? "auto" 92 - // : cell.column.getSize(), 93 - // }} 94 - > 67 + <TableCell key={cell.id}> 95 68 {flexRender(cell.column.columnDef.cell, cell.getContext())} 96 69 </TableCell> 97 70 ))}
+6 -5
apps/web/src/components/forms/monitor-form.tsx
··· 65 65 import { LoadingAnimation } from "@/components/loading-animation"; 66 66 import { FailedPingAlertConfirmation } from "@/components/modals/failed-ping-alert-confirmation"; 67 67 import { useToastAction } from "@/hooks/use-toast-action"; 68 - import useUpdateSearchParams from "@/hooks/use-update-search-params"; 69 68 import { cn } from "@/lib/utils"; 70 69 import { api } from "@/trpc/client"; 71 70 import type { Writeable } from "@/types/utils"; ··· 83 82 defaultValues?: InsertMonitor; 84 83 plan?: WorkspacePlan; 85 84 notifications?: Notification[]; 85 + nextUrl?: string; 86 86 } 87 87 88 88 export function MonitorForm({ 89 89 defaultValues, 90 90 plan = "free", 91 91 notifications, 92 + nextUrl, 92 93 }: Props) { 93 94 const form = useForm<InsertMonitor>({ 94 95 resolver: zodResolver(insertMonitorSchema), ··· 116 117 const [openDialog, setOpenDialog] = React.useState(false); 117 118 const { toast } = useToastAction(); 118 119 const watchMethod = form.watch("method"); 119 - const updateSearchParams = useUpdateSearchParams(); 120 120 121 121 const { fields, append, remove } = useFieldArray({ 122 122 name: "headers", ··· 128 128 if (defaultValues) { 129 129 await api.monitor.update.mutate(props); 130 130 } else { 131 - const monitor = await api.monitor.create.mutate(props); 132 - const id = monitor?.id || null; 133 - router.replace(`?${updateSearchParams({ id })}`); 131 + await api.monitor.create.mutate(props); 132 + } 133 + if (nextUrl) { 134 + router.push(nextUrl); 134 135 } 135 136 router.refresh(); 136 137 toast("saved");
+62
apps/web/src/components/layout/app-link.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import type { LinkProps } from "next/link"; 5 + import { useSelectedLayoutSegment } from "next/navigation"; 6 + import { cva } from "class-variance-authority"; 7 + 8 + import { cn } from "@/lib/utils"; 9 + import type { ValidIcon } from "../icons"; 10 + import { Icons } from "../icons"; 11 + 12 + const linkVariants = cva( 13 + "text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border px-3 py-1", 14 + { 15 + variants: { 16 + variant: { 17 + default: "hover:bg-muted/50 hover:text-foreground border-transparent", 18 + active: "bg-muted/50 border-border text-foreground", 19 + disabled: "pointer-events-none opacity-60", 20 + }, 21 + }, 22 + defaultVariants: { 23 + variant: "default", 24 + }, 25 + }, 26 + ); 27 + 28 + interface AppLinkProps extends LinkProps { 29 + label: string; 30 + segment?: string | null; 31 + icon?: ValidIcon; 32 + className?: string; 33 + disabled?: boolean; 34 + } 35 + 36 + export function AppLink({ 37 + label, 38 + href, 39 + icon, 40 + disabled, 41 + className, 42 + segment, 43 + ...props 44 + }: AppLinkProps) { 45 + const selectedSegment = useSelectedLayoutSegment(); 46 + const Icon = icon && Icons[icon]; 47 + 48 + const isActive = segment === selectedSegment; 49 + const variant = disabled ? "disabled" : isActive ? "active" : "default"; 50 + 51 + return ( 52 + <Link 53 + href={href} 54 + className={cn(linkVariants({ variant, className }))} 55 + aria-disabled={disabled} 56 + {...props} 57 + > 58 + {Icon ? <Icon className={cn("mr-2 h-4 w-4")} /> : null} 59 + {label} 60 + </Link> 61 + ); 62 + }
+5 -3
apps/web/src/components/layout/app-menu.tsx
··· 31 31 <Menu className="h-6 w-6" /> 32 32 </Button> 33 33 </SheetTrigger> 34 - <SheetContent> 34 + <SheetContent side="top" className="flex flex-col"> 35 35 <SheetHeader> 36 - <SheetTitle className="text-left">Navigation</SheetTitle> 36 + <SheetTitle className="ml-2 text-left">Menu</SheetTitle> 37 37 </SheetHeader> 38 - <AppSidebar /> 38 + <div className="flex-1"> 39 + <AppSidebar /> 40 + </div> 39 41 </SheetContent> 40 42 </Sheet> 41 43 );
+10 -21
apps/web/src/components/layout/app-sidebar.tsx
··· 1 1 "use client"; 2 2 3 - import Link from "next/link"; 4 - import { useParams, usePathname } from "next/navigation"; 5 - 6 - import type { Workspace } from "@openstatus/db/src/schema"; 3 + import { useParams } from "next/navigation"; 7 4 8 5 import { pagesConfig } from "@/config/pages"; 9 - import { cn } from "@/lib/utils"; 10 6 import { ProBanner } from "../billing/pro-banner"; 11 - import { Icons } from "../icons"; 12 7 import { SelectWorkspace } from "../workspace/select-workspace"; 8 + import { AppLink } from "./app-link"; 13 9 14 10 export function AppSidebar() { 15 - const pathname = usePathname(); 16 11 const params = useParams(); 17 12 18 13 return ( 19 14 <div className="flex h-full flex-col justify-between gap-6"> 20 15 <ul className="grid gap-1"> 21 16 {pagesConfig.map(({ title, href, icon, disabled }) => { 22 - const Icon = Icons[icon]; 23 - const link = `/app/${params?.workspaceSlug}${href}`; 24 - const isActive = pathname?.startsWith(link); 25 17 return ( 26 18 <li key={title} className="w-full"> 27 - <Link 28 - href={link} 29 - className={cn( 30 - "hover:bg-muted/50 hover:text-foreground text-muted-foreground group flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 31 - isActive && "bg-muted/50 border-border text-foreground", // font-semibold 32 - disabled && "pointer-events-none opacity-60", 33 - )} 34 - > 35 - <Icon className={cn("mr-2 h-4 w-4")} /> 36 - {title} 37 - </Link> 19 + <AppLink 20 + label={title} 21 + href={`/app/${params?.workspaceSlug}${href}`} 22 + disabled={disabled} 23 + segment={href.replace("/", "")} 24 + icon={icon} 25 + /> 38 26 </li> 39 27 ); 40 28 })} 41 29 </ul> 42 30 <ul className="grid gap-2"> 31 + {/* <li className="w-full">Help & Support</li> */} 43 32 <li className="w-full"> 44 33 <ProBanner /> 45 34 </li>
+32 -36
apps/web/src/components/layout/marketing-menu.tsx
··· 1 1 "use client"; 2 2 3 3 import * as React from "react"; 4 - import Link from "next/link"; 5 4 import { usePathname, useSearchParams } from "next/navigation"; 6 - import { ArrowUpRight, Menu } from "lucide-react"; 5 + import { Menu } from "lucide-react"; 7 6 8 7 import { 9 8 Button, ··· 14 13 SheetTrigger, 15 14 } from "@openstatus/ui"; 16 15 17 - import { cn } from "@/lib/utils"; 16 + import { AppLink } from "./app-link"; 18 17 19 18 const pages = [ 20 - { href: "/changelog", title: "Changelog" }, 21 - { href: "/blog", title: "Blog" }, 22 - { href: "https://docs.openstatus.dev", title: "Docs" }, 19 + { href: "/changelog", label: "Changelog", segment: "changelog" }, 20 + { href: "/blog", label: "Blog", segment: "blog" }, 21 + { href: "https://docs.openstatus.dev", label: "Documentation" }, 23 22 ]; 24 23 25 24 export function MarketingMenu() { ··· 43 42 <Menu className="h-6 w-6" /> 44 43 </Button> 45 44 </SheetTrigger> 46 - <SheetContent> 45 + <SheetContent side="top" className="flex flex-col"> 47 46 <SheetHeader> 48 47 <SheetTitle className="ml-2 text-left">Menu</SheetTitle> 49 48 </SheetHeader> 50 - <ul className="mt-4 grid gap-1"> 51 - {pages.map(({ href, title }) => { 52 - const isActive = pathname?.startsWith(href); 53 - const isExternal = href.startsWith("http"); 54 - const externalProps = isExternal 55 - ? { 56 - target: "_blank", 57 - rel: "noreferrer", 58 - } 59 - : {}; 60 - return ( 61 - <li key={href} className="-ml-1 w-full"> 62 - <Link 63 - href={href} 64 - className={cn( 65 - "hover:bg-muted/50 hover:text-foreground text-muted-foreground group inline-flex w-full min-w-[200px] items-center rounded-md border border-transparent px-3 py-1", 66 - isActive && "bg-muted/50 border-border text-foreground", 67 - )} 68 - {...externalProps} 69 - > 70 - {title} 71 - {isExternal ? ( 72 - <ArrowUpRight className="ml-1 h-4 w-4 flex-shrink-0" /> 73 - ) : null} 74 - </Link> 75 - </li> 76 - ); 77 - })} 78 - </ul> 49 + <div className="flex flex-1 flex-col justify-between gap-4"> 50 + <ul className="grid gap-1"> 51 + {pages.map(({ href, label, segment }) => { 52 + const isExternal = href.startsWith("http"); 53 + const externalProps = isExternal ? { target: "_blank" } : {}; 54 + return ( 55 + <li key={href} className="w-full"> 56 + <AppLink 57 + href={href} 58 + label={label} 59 + segment={segment} 60 + {...externalProps} 61 + /> 62 + </li> 63 + ); 64 + })} 65 + </ul> 66 + <ul className="grid gap-1"> 67 + <li className="w-full"> 68 + <AppLink href="/github" label="GitHub" icon="github" /> 69 + </li> 70 + <li className="w-full"> 71 + <AppLink href="/discord" label="Discord" icon="discord" /> 72 + </li> 73 + </ul> 74 + </div> 79 75 </SheetContent> 80 76 </Sheet> 81 77 );
+26
apps/web/src/components/monitor/status-dot.tsx
··· 1 + import type { Monitor } from "@openstatus/db/src/schema"; 2 + 3 + export function StatusDot({ 4 + active, 5 + status, 6 + }: Pick<Monitor, "active" | "status">) { 7 + if (!active) { 8 + return ( 9 + <span className="relative flex h-2 w-2"> 10 + <span className="absolute inline-flex h-2 w-2 rounded-full bg-orange-500" /> 11 + </span> 12 + ); 13 + } 14 + 15 + return status === "active" ? ( 16 + <span className="relative flex h-2 w-2"> 17 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500/80 opacity-75 duration-1000" /> 18 + <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" /> 19 + </span> 20 + ) : ( 21 + <span className="relative flex h-2 w-2"> 22 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500/80 opacity-75 duration-1000" /> 23 + <span className="absolute inline-flex h-2 w-2 rounded-full bg-red-500" /> 24 + </span> 25 + ); 26 + }
+5 -3
apps/web/src/components/tracker.tsx
··· 154 154 count, 155 155 ok, 156 156 avgLatency, 157 + p95Latency, 157 158 day, 158 159 blacklist, 159 160 context, ··· 212 213 ))} 213 214 </ul> 214 215 <div className="flex justify-between"> 215 - <p className="text-xs font-light"> 216 + <p className="text-xs"> 216 217 {format(new Date(cronTimestamp), dateFormat)} 217 218 </p> 218 - <p className="text-muted-foreground text-xs"> 219 - avg. <span className="font-mono">{avgLatency}ms</span> 219 + <p className="text-muted-foreground text-xs font-light"> 220 + p95{" "} 221 + <span className="font-mono font-medium">{p95Latency}ms</span> 220 222 </p> 221 223 </div> 222 224 <Separator className="my-1.5" />
+16
apps/web/src/content/changelog/latency-quantiles.mdx
··· 1 + --- 2 + title: Latency quantiles 3 + description: Refine response time analysis with precision using quantiles. 4 + image: /assets/changelog/latency-quantiles.png 5 + publishedAt: 2024-01-04 6 + --- 7 + 8 + You can now refine your latency data more effectively with quantiles: `p99`, 9 + `p95`, `p90`, and `p75`. The average (`avg`) has been retained. 10 + 11 + The `<Tracker />` data on your status pages now displays the `p95` quantile 12 + instead of the average. 13 + 14 + Furthermore, the `/monitors` layout has been updated. The data table has its 15 + dedicated tab to prevent loading excessive data at once, along with the edit 16 + form. The pause button is also now more quickly accessible.
+14
apps/web/src/lib/tb.ts
··· 1 1 import type { 2 2 HomeStatsParams, 3 3 MonitorListParams, 4 + ResponseGraphParams, 4 5 ResponseListParams, 5 6 } from "@openstatus/tinybird"; 6 7 import { 7 8 getHomeMonitorList, 8 9 getHomeStats, 9 10 getMonitorList, 11 + getResponseGraph, 10 12 getResponseList, 11 13 Tinybird, 12 14 } from "@openstatus/tinybird"; ··· 58 60 } 59 61 return; 60 62 } 63 + 64 + export async function getResponseGraphData( 65 + props: Partial<ResponseGraphParams>, 66 + ) { 67 + try { 68 + const res = await getResponseGraph(tb)(props); 69 + return res.data; 70 + } catch (e) { 71 + console.error(e); 72 + } 73 + return; 74 + }
+6 -4
apps/web/src/lib/timezone.ts
··· 12 12 return requestTimezone; 13 13 } 14 14 15 - export function convertTimezoneToGMT(timezone?: string) { 15 + export function convertTimezoneToGMT(defaultTimezone?: string) { 16 16 const requestTimezone = getRequestHeaderTimezone(); 17 17 18 18 /** ··· 20 20 */ 21 21 const clientTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 22 22 23 - const msOffset = getTimezoneOffset( 24 - timezone || requestTimezone || clientTimezone, 25 - ); 23 + const timezone = defaultTimezone || requestTimezone || clientTimezone; 24 + 25 + if (!supportedTimezones.includes(timezone)) return "Etc/UTC"; 26 + 27 + const msOffset = getTimezoneOffset(timezone); 26 28 27 29 if (isNaN(msOffset)) return "Etc/UTC"; 28 30
+11
packages/api/src/router/monitor.ts
··· 309 309 .all(); 310 310 return data.map((d) => selectNotificationSchema.parse(d.notification)); 311 311 }), 312 + 313 + isMonitorLimitReached: protectedProcedure.query(async (opts) => { 314 + const monitorLimit = allPlans[opts.ctx.workspace.plan].limits.monitors; 315 + const monitorNumbers = ( 316 + await opts.ctx.db.query.monitor.findMany({ 317 + where: eq(monitor.workspaceId, opts.ctx.workspace.id), 318 + }) 319 + ).length; 320 + 321 + return monitorNumbers >= monitorLimit; 322 + }), 312 323 });
+27
packages/tinybird/pipes/response_graph.pipe
··· 1 + VERSION 0 2 + 3 + NODE response_graph_0 4 + SQL > 5 + 6 + % 7 + SELECT 8 + region, 9 + toStartOfInterval( 10 + toDateTime(cronTimestamp / 1000), 11 + INTERVAL {{ Int64(interval, 30) }} MINUTE 12 + ) as h, 13 + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, 14 + round(avg(latency)) as avgLatency, 15 + round(quantile(0.75)(latency)) as p75Latency, 16 + round(quantile(0.9)(latency)) as p90Latency, 17 + round(quantile(0.95)(latency)) as p95Latency, 18 + round(quantile(0.99)(latency)) as p99Latency 19 + FROM materialized_view_ping_response_3d 20 + WHERE 21 + monitorId = {{ String(monitorId, '1') }} 22 + {% if defined(fromDate) %} AND timestamp >= {{ Int64(fromDate) }} {% end %} 23 + {% if defined(toDate) %} AND timestamp <= {{ Int64(toDate) }} {% end %} 24 + GROUP BY h, region 25 + ORDER BY h DESC 26 + 27 +
+15 -21
packages/tinybird/pipes/status_timezone.pipe
··· 1 - VERSION 0 1 + VERSION 1 2 2 3 3 DESCRIPTION > 4 4 TODO: descripe what it is for! ··· 10 10 % 11 11 SELECT 12 12 toDateTime(cronTimestamp / 1000, 'UTC') AS day, 13 - -- only for debugging purposes 14 - toTimezone(day, {{ String(timezone, 'Europe/Berlin') }}) as with_timezone, 13 + toTimezone(day, {{ String(timezone, 'Europe/London') }}) as with_timezone, 15 14 toStartOfDay(with_timezone) as start_of_day, 16 - avg(latency) AS avgLatency, 17 - count() AS count, 18 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok 15 + statusCode, 16 + latency 19 17 FROM ping_response__v5 20 - WHERE 21 - (day IS NOT NULL) 22 - AND (day != 0) 23 - AND monitorId = {{ String(monitorId, '1') }} 24 - -- By default, we only only query the last 45 days 18 + WHERE monitorId = {{ String(monitorId, '1') }} 19 + -- by default, we only query the last 45 days 25 20 AND cronTimestamp >= toUnixTimestamp64Milli( 26 21 toDateTime64(toStartOfDay(date_sub(DAY, 45, now())), 3) 27 22 ) 28 - GROUP BY cronTimestamp, monitorId 29 - ORDER BY day DESC 30 - 31 23 32 24 33 25 NODE group_by_day ··· 36 28 % 37 29 SELECT 38 30 start_of_day as day, 39 - sum(count) as count, 40 - sum(ok) as ok, 41 - round(avg(avgLatency)) as avgLatency 31 + count() AS count, 32 + count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 33 + round(avg(latency)) as avgLatency, 34 + round(quantile(0.75)(latency)) as p75Latency, 35 + round(quantile(0.9)(latency)) as p90Latency, 36 + round(quantile(0.95)(latency)) as p95Latency, 37 + round(quantile(0.99)(latency)) as p99Latency 42 38 FROM group_by_cronTimestamp 43 39 GROUP BY start_of_day 44 - -- ORDER BY start_of_day DESC WITH FILL STEP INTERVAL -1 DAY 45 40 ORDER BY start_of_day 46 41 WITH FILL 47 42 FROM 48 - toStartOfDay(toTimezone(now(), {{ String(timezone, 'Europe/Berlin') }})) 43 + toStartOfDay(toTimezone(now(), {{ String(timezone, 'Europe/London') }})) 49 44 TO toStartOfDay( 50 - toTimezone(date_sub(DAY, 46, now()), {{ String(timezone, 'Europe/Berlin') }}) 45 + toTimezone(date_sub(DAY, 46, now()), {{ String(timezone, 'Europe/London') }}) 51 46 ) STEP INTERVAL -1 DAY 52 - LIMIT {{ Int32(limit, 100) }} 53 47 54 48
+16 -3
packages/tinybird/src/client.ts
··· 4 4 tbBuildHomeStats, 5 5 tbBuildMonitorList, 6 6 tbBuildPublicStatus, 7 + tbBuildResponseGraph, 7 8 tbBuildResponseList, 8 9 tbIngestPingResponse, 9 10 tbParameterHomeStats, 10 11 tbParameterMonitorList, 11 12 tbParameterPublicStatus, 13 + tbParameterResponseGraph, 12 14 tbParameterResponseList, 13 15 } from "./validation"; 14 16 ··· 26 28 parameters: tbParameterResponseList, 27 29 data: tbBuildResponseList, 28 30 opts: { 29 - // cache: "no-store", 31 + // cache: "default", 30 32 revalidate: 600, // 10 min cache 31 33 }, 32 34 }); 33 35 } 34 36 37 + export function getResponseGraph(tb: Tinybird) { 38 + return tb.buildPipe({ 39 + pipe: "response_graph__v0", 40 + parameters: tbParameterResponseGraph, 41 + data: tbBuildResponseGraph, 42 + opts: { 43 + revalidate: 60, // 1 min cache 44 + }, 45 + }); 46 + } 47 + 35 48 export function getMonitorList(tb: Tinybird) { 36 49 return tb.buildPipe({ 37 - pipe: "status_timezone__v0", 50 + pipe: "status_timezone__v1", 38 51 parameters: tbParameterMonitorList, 39 52 data: tbBuildMonitorList, 40 53 opts: { ··· 51 64 */ 52 65 export function getHomeMonitorList(tb: Tinybird) { 53 66 return tb.buildPipe({ 54 - pipe: "status_timezone__v0", 67 + pipe: "status_timezone__v1", 55 68 parameters: tbParameterMonitorList, 56 69 data: tbBuildMonitorList, 57 70 opts: {
+29
packages/tinybird/src/validation.ts
··· 45 45 }); 46 46 47 47 /** 48 + * Values from pipe response_graph 49 + */ 50 + export const tbBuildResponseGraph = z.object({ 51 + region: z.enum(availableRegions), 52 + timestamp: z.number().int(), 53 + avgLatency: z.number().int(), 54 + p75Latency: z.number().int(), 55 + p90Latency: z.number().int(), 56 + p95Latency: z.number().int(), 57 + p99Latency: z.number().int(), 58 + }); 59 + 60 + /** 61 + * Params for pipe response_graph 62 + */ 63 + export const tbParameterResponseGraph = z.object({ 64 + monitorId: z.string().default(""), 65 + interval: z.number().int().default(10), 66 + fromDate: z.number().int().default(0), 67 + toDate: z.number().int().optional(), 68 + }); 69 + 70 + /** 48 71 * Params for pipe status_timezone 49 72 */ 50 73 export const tbParameterMonitorList = z.object({ ··· 60 83 count: z.number().int(), 61 84 ok: z.number().int(), 62 85 avgLatency: z.number().int(), 86 + p75Latency: z.number().int(), 87 + p90Latency: z.number().int(), 88 + p95Latency: z.number().int(), 89 + p99Latency: z.number().int(), 63 90 day: z.string().transform((val) => { 64 91 // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) 65 92 return new Date(`${val} GMT`).toISOString(); ··· 102 129 export type Region = (typeof availableRegions)[number]; // TODO: rename type AvailabeRegion 103 130 export type Monitor = z.infer<typeof tbBuildMonitorList>; 104 131 export type HomeStats = z.infer<typeof tbBuildHomeStats>; 132 + export type ResponseGraph = z.infer<typeof tbBuildResponseGraph>; // TODO: rename to ResponseQuantileChart 105 133 export type ResponseListParams = z.infer<typeof tbParameterResponseList>; 134 + export type ResponseGraphParams = z.infer<typeof tbParameterResponseGraph>; 106 135 export type MonitorListParams = z.infer<typeof tbParameterMonitorList>; 107 136 export type HomeStatsParams = z.infer<typeof tbParameterHomeStats>;