Openstatus www.openstatus.dev

feat: tremor charts incl. small fixes (#361)

* wip:

* wip:

* status filter ui component update (#327)

* status filter ui component update

* DataTableFacetedFilter updated

* pnpm format 💅

* feat: filter status by code prefix

---------

Co-authored-by: mxkaske <maximilian@kaske.org>

* fix: small issues

* fix: mobile overflow and remove periods

* wip: blog timeline, metadata, marketing menu

* fix: image src

---------

Co-authored-by: hrutik7 <62372737+hrutik7@users.noreply.github.com>

authored by

Maximilian Kaske
mxkaske
hrutik7
and committed by
GitHub
07e51763 50cf285f

+908 -149
+2
apps/web/package.json
··· 11 11 }, 12 12 "dependencies": { 13 13 "@clerk/nextjs": "4.25.1", 14 + "@headlessui/react": "^1.7.17", 14 15 "@hookform/resolvers": "3.3.1", 15 16 "@openstatus/analytics": "workspace:*", 16 17 "@openstatus/api": "workspace:*", ··· 28 29 "@t3-oss/env-nextjs": "0.6.1", 29 30 "@tailwindcss/typography": "0.5.10", 30 31 "@tanstack/react-table": "8.10.3", 32 + "@tremor/react": "^3.8.2", 31 33 "@trpc/client": "10.38.5", 32 34 "@trpc/next": "10.38.5", 33 35 "@trpc/react-query": "10.38.5",
apps/web/public/assets/changelog/response-time-charts.png

This is a binary file and will not be displayed.

+45 -9
apps/web/src/app/(content)/blog/page.tsx
··· 1 + import type { Metadata } from "next"; 2 + import Link from "next/link"; 1 3 import { allPosts } from "contentlayer/generated"; 2 4 3 - import { Thumbnail } from "@/components/content/thumbnail"; 5 + import { Button } from "@openstatus/ui"; 6 + 7 + import { 8 + defaultMetadata, 9 + ogMetadata, 10 + twitterMetadata, 11 + } from "@/app/shared-metadata"; 12 + import { Timeline } from "@/components/content/timeline"; 4 13 import { Shell } from "@/components/dashboard/shell"; 5 14 15 + export const metadata: Metadata = { 16 + ...defaultMetadata, 17 + title: "Blog", 18 + openGraph: { 19 + ...ogMetadata, 20 + title: "Blog | OpenStatus", 21 + }, 22 + twitter: { 23 + ...twitterMetadata, 24 + title: "Blog | OpenStatus", 25 + }, 26 + }; 27 + 6 28 export default async function Post() { 7 29 const posts = allPosts.sort( 8 30 (a, b) => ··· 11 33 12 34 return ( 13 35 <Shell> 14 - <div className="grid gap-8"> 15 - <h1 className="text-foreground font-cal text-4xl">Blog</h1> 16 - <div className="space-y-4"> 17 - {posts.map((post) => ( 18 - <Thumbnail key={post._id} post={post} /> 19 - ))} 20 - </div> 21 - </div> 36 + <Timeline 37 + title="Blog" 38 + description="All the latest articles and news from OpenStatus." 39 + > 40 + {posts.map((post) => ( 41 + <Timeline.Article 42 + key={post.slug} 43 + publishedAt={post.publishedAt} 44 + imageSrc={post.image} 45 + title={post.title} 46 + > 47 + <div className="prose"> 48 + <p>{post.description}</p> 49 + </div> 50 + <div> 51 + <Button variant="outline" className="rounded-full" asChild> 52 + <Link href={`./blog/${post.slug}`}>Read more</Link> 53 + </Button> 54 + </div> 55 + </Timeline.Article> 56 + ))} 57 + </Timeline> 22 58 </Shell> 23 59 ); 24 60 }
+31 -34
apps/web/src/app/(content)/changelog/page.tsx
··· 1 - import Image from "next/image"; 1 + import type { Metadata } from "next"; 2 2 import { allChangelogs } from "contentlayer/generated"; 3 3 4 + import { 5 + defaultMetadata, 6 + ogMetadata, 7 + twitterMetadata, 8 + } from "@/app/shared-metadata"; 4 9 import { Mdx } from "@/components/content/mdx"; 10 + import { Timeline } from "@/components/content/timeline"; 5 11 import { Shell } from "@/components/dashboard/shell"; 6 - import { formatDate } from "@/lib/utils"; 7 12 8 - export default async function Post() { 13 + export const metadata: Metadata = { 14 + ...defaultMetadata, 15 + title: "Changelog", 16 + openGraph: { 17 + ...ogMetadata, 18 + title: "Changelog | OpenStatus", 19 + }, 20 + twitter: { 21 + ...twitterMetadata, 22 + title: "Changelog | OpenStatus", 23 + }, 24 + }; 25 + export default async function Changelog() { 9 26 const posts = allChangelogs.sort( 10 27 (a, b) => 11 28 new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(), ··· 13 30 14 31 return ( 15 32 <Shell> 16 - <div className="grid gap-8"> 17 - <div className="grid gap-4 md:grid-cols-5 md:gap-8"> 18 - <div className="md:col-span-1" /> 19 - <div className="grid gap-4 md:col-span-4"> 20 - <h1 className="text-foreground font-cal text-4xl">Changelog</h1> 21 - <p className="text-muted-foreground"> 22 - All the latest features, fixes and work to OpenStatus. 23 - </p> 24 - </div> 25 - </div> 33 + <Timeline 34 + title="Changelog" 35 + description="All the latest features, fixes and work to OpenStatus." 36 + > 26 37 {posts.map((post) => ( 27 - <article 38 + <Timeline.Article 28 39 key={post.slug} 29 - className="grid gap-4 md:grid-cols-5 md:gap-6" 40 + publishedAt={post.publishedAt} 41 + imageSrc={post.image} 42 + title={post.title} 30 43 > 31 - <time className="text-muted-foreground order-2 font-mono text-sm md:order-1 md:col-span-1"> 32 - {formatDate(new Date(post.publishedAt))} 33 - </time> 34 - <div className="relative order-1 h-64 w-full md:order-2 md:col-span-4"> 35 - <Image 36 - src={post.image} 37 - fill={true} 38 - alt={post.title} 39 - className="border-border rounded-md border object-cover" 40 - /> 41 - </div> 42 - <h3 className="text-foreground font-cal order-3 text-2xl md:col-span-4 md:col-start-2"> 43 - {post.title} 44 - </h3> 45 - <div className="order-4 md:col-span-4 md:col-start-2"> 46 - <Mdx code={post.body.code} /> 47 - </div> 48 - </article> 44 + <Mdx code={post.body.code} /> 45 + </Timeline.Article> 49 46 ))} 50 - </div> 47 + </Timeline> 51 48 </Shell> 52 49 ); 53 50 }
+1 -1
apps/web/src/app/(content)/layout.tsx
··· 2 2 3 3 import { MarketingLayout } from "@/components/layout/marketing-layout"; 4 4 5 - export default function BlogLayout({ 5 + export default function ContentLayout({ 6 6 children, 7 7 }: { 8 8 children: React.ReactNode;
+2 -3
apps/web/src/app/api/og/post/route.tsx
··· 1 1 /* eslint-disable @next/next/no-img-element */ 2 2 import { ImageResponse } from "next/server"; 3 3 4 + import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 5 + 4 6 export const runtime = "edge"; 5 7 6 8 const DEFAULT_URL = process.env.VERCEL_URL ··· 11 13 width: 1200, 12 14 height: 630, 13 15 }; 14 - 15 - const TITLE = "OpenStatus"; 16 - const DESCRIPTION = "An Open Source Alternative for your next Status Page"; 17 16 18 17 const interRegular = fetch( 19 18 new URL("../../../../public/fonts/Inter-Regular.ttf", import.meta.url),
+1 -2
apps/web/src/app/api/og/route.tsx
··· 1 1 import { ImageResponse } from "next/server"; 2 2 3 + import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 3 4 import { getMonitorListData } from "@/lib/tb"; 4 5 import { blacklistDates, getMonitorList, getStatus } from "@/lib/tracker"; 5 6 import { cn, formatDate } from "@/lib/utils"; ··· 11 12 height: 630, 12 13 }; 13 14 14 - const TITLE = "OpenStatus"; 15 - const DESCRIPTION = "An Open Source monitoring platform for serverless"; 16 15 const LIMIT = 40; 17 16 18 17 const interRegular = fetch(
+70
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/_components/chart-wrapper.tsx
··· 1 + /** NOT USING THE CLIENT TO CALCULATE THE GROUPED DATA */ 2 + import type { Ping, Region } from "@openstatus/tinybird"; 3 + 4 + import type { Period } from "../utils"; 5 + import { Chart } from "./chart"; 6 + 7 + export function ChartWrapper({ 8 + data, 9 + period, 10 + }: { 11 + data: Ping[]; 12 + period: Period; 13 + }) { 14 + const group = groupDataByTimestamp(data, period); 15 + return <Chart data={group.data} regions={group.regions} />; 16 + } 17 + /** 18 + * 19 + * @param data expects to be sorted by cronTimestamp 20 + * @param period 21 + * @returns 22 + */ 23 + function groupDataByTimestamp(data: Ping[], period: Period) { 24 + let currentTimestamp = 0; 25 + const regions: Partial<Record<Region, null>> = {}; 26 + const _data = data.reduce( 27 + (acc, curr) => { 28 + const { cronTimestamp, latency, region } = curr; 29 + regions[region] = null; // to get the region keys 30 + if (cronTimestamp === currentTimestamp) { 31 + // overwrite last object in acc 32 + const last = acc.pop(); 33 + if (last) { 34 + acc.push({ 35 + ...last, 36 + [region]: latency, 37 + }); 38 + } 39 + } else if (cronTimestamp) { 40 + currentTimestamp = cronTimestamp; 41 + // create new object in acc 42 + acc.push({ 43 + timestamp: renderTimestamp(cronTimestamp, period), 44 + [region]: latency, 45 + }); 46 + } 47 + return acc; 48 + }, 49 + [] as (Partial<Record<Region, string>> & { timestamp: string })[], 50 + ); 51 + return { regions: Object.keys(regions) as Region[], data: _data.reverse() }; 52 + } 53 + 54 + /** 55 + * in case we need to change the format of the timestamp 56 + * based on the period 57 + * @param timestamp 58 + * @param period 59 + * @returns 60 + */ 61 + function renderTimestamp(timestamp: number, period: Period) { 62 + const isInDay = ["hour", "day"].includes(period); 63 + return new Date(timestamp).toLocaleString("en-US", { 64 + year: !isInDay ? "numeric" : undefined, 65 + month: !isInDay ? "numeric" : undefined, 66 + day: !isInDay ? "numeric" : undefined, 67 + hour: "2-digit", 68 + minute: "2-digit", 69 + }); 70 + }
+48
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/_components/chart.tsx
··· 1 + "use client"; 2 + 3 + import { Card, LineChart, Title } from "@tremor/react"; 4 + 5 + import type { Region } from "@openstatus/tinybird"; 6 + 7 + const dataFormatter = (number: number) => 8 + `${Intl.NumberFormat("us").format(number).toString()}ms`; 9 + 10 + interface ChartProps { 11 + data: (Partial<Record<Region, string>> & { timestamp: string })[]; 12 + regions: Region[]; 13 + } 14 + 15 + export function Chart({ data, regions }: ChartProps) { 16 + return ( 17 + <Card> 18 + <Title>Response Time</Title> 19 + <LineChart 20 + data={data} 21 + yAxisWidth={64} 22 + index="timestamp" 23 + categories={regions} 24 + colors={[ 25 + "blue", 26 + "amber", 27 + "cyan", 28 + "yellow", 29 + "red", 30 + "indigo", 31 + "lime", 32 + "purple", 33 + "green", 34 + "orange", 35 + "sky", 36 + "rose", 37 + "violet", 38 + "teal", 39 + "fuchsia", 40 + "pink", 41 + "emerald", 42 + ]} 43 + onValueChange={(v) => void 0} // that prop makes the chart interactive 44 + valueFormatter={dataFormatter} 45 + /> 46 + </Card> 47 + ); 48 + }
+66
apps/web/src/app/app/(dashboard)/[workspaceSlug]/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 + }
+7 -5
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/[id]/data/loading.tsx
··· 1 1 import { Skeleton } from "@openstatus/ui"; 2 2 3 - import { Container } from "@/components/dashboard/container"; 4 3 import { Header } from "@/components/dashboard/header"; 5 4 import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 6 5 7 6 export default function Loading() { 8 7 return ( 9 8 <div className="grid gap-6 md:gap-8"> 10 - <Header.Skeleton /> 9 + <Header.Skeleton> 10 + <Skeleton className="h-9 w-32" /> 11 + </Header.Skeleton> 12 + <Skeleton className="h-[396px] w-full" /> 11 13 <div className="grid gap-3"> 12 - <div className="flex items-center justify-between gap-3"> 13 - <Skeleton className="h-8 w-64" /> 14 - <Skeleton className="h-8 w-64" /> 14 + <div className="flex items-center gap-3"> 15 + <Skeleton className="h-8 w-32" /> 16 + <Skeleton className="h-8 w-16" /> 15 17 </div> 16 18 <DataTableSkeleton rows={7} /> 17 19 </div>
+32 -6
apps/web/src/app/app/(dashboard)/[workspaceSlug]/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"; 3 4 import * as z from "zod"; 4 5 5 6 import { availableRegions } from "@openstatus/tinybird"; ··· 9 10 import { DataTable } from "@/components/data-table/data-table"; 10 11 import { getResponseListData } from "@/lib/tb"; 11 12 import { api } from "@/trpc/server"; 13 + import { ChartWrapper } from "./_components/chart-wrapper"; 14 + import { DatePickerPreset } from "./_components/date-picker-preset"; 15 + import { getPeriodDate, periods } from "./utils"; 12 16 13 - export const revalidate = 0; // revalidate this page every 10 minutes 17 + export const revalidate = 0; 14 18 15 19 /** 16 20 * allowed URL search params ··· 19 23 statusCode: z.coerce.number().optional(), 20 24 region: z.enum(availableRegions).optional(), 21 25 cronTimestamp: z.coerce.number().optional(), 22 - fromDate: z.coerce.number().optional(), 23 - toDate: z.coerce.number().optional(), 26 + fromDate: z.coerce 27 + .number() 28 + .optional() 29 + .default(startOfDay(new Date()).getTime()), 30 + toDate: z.coerce.number().optional().default(endOfDay(new Date()).getTime()), 31 + period: z.enum(periods).optional().default("hour"), 24 32 }); 25 33 26 34 export default async function Page({ ··· 41 49 return notFound(); 42 50 } 43 51 52 + const date = getPeriodDate(search.data.period); 53 + 44 54 const data = await getResponseListData({ 45 55 monitorId: id, 46 56 ...search.data, 57 + /** 58 + * We are overwriting the `fromDate` and `toDate` 59 + * to only support presets from the `period` 60 + */ 61 + fromDate: date.from.getTime(), 62 + toDate: date.to.getTime(), 47 63 }); 48 64 49 65 return ( 50 - <div className="grid gap-6 md:gap-8"> 51 - <Header title={monitor.name} description={monitor.url} /> 52 - {data && <DataTable columns={columns} data={data} />} 66 + // overflow-x-scroll needed for the chart. 67 + <div className="grid grid-cols-1 gap-6 md:gap-8"> 68 + <Header 69 + title={monitor.name} 70 + description={monitor.url} 71 + actions={<DatePickerPreset period={search.data.period} />} 72 + /> 73 + {data ? ( 74 + <> 75 + <ChartWrapper period={search.data.period} data={data} /> 76 + <DataTable columns={columns} data={data} /> 77 + </> 78 + ) : null} 53 79 </div> 54 80 ); 55 81 }
+29
apps/web/src/app/app/(dashboard)/[workspaceSlug]/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 + }
+11 -6
apps/web/src/app/shared-metadata.ts
··· 1 - const TITLE = "OpenStatus"; 2 - const DESCRIPTION = 1 + import type { Metadata } from "next"; 2 + 3 + export const TITLE = "OpenStatus"; 4 + export const DESCRIPTION = 3 5 "Open-Source synthetic monitoring with incident management."; 4 6 5 - export const defaultMetadata = { 6 - title: TITLE, 7 + export const defaultMetadata: Metadata = { 8 + title: { 9 + template: `%s | ${TITLE}`, 10 + default: TITLE, 11 + }, 7 12 description: DESCRIPTION, 8 13 metadataBase: new URL("https://www.openstatus.dev"), 9 14 }; 10 15 11 - export const twitterMetadata = { 16 + export const twitterMetadata: Metadata["twitter"] = { 12 17 title: TITLE, 13 18 description: DESCRIPTION, 14 19 card: "summary_large_image", 15 20 images: [`/api/og`], 16 21 }; 17 22 18 - export const ogMetadata = { 23 + export const ogMetadata: Metadata["openGraph"] = { 19 24 title: TITLE, 20 25 description: DESCRIPTION, 21 26 type: "website",
+1 -1
apps/web/src/components/content/article.tsx
··· 54 54 </div> 55 55 </div> 56 56 </div> 57 - <div className="prose-pre:overflow-y-auto prose-pre:max-w-xs md:prose-pre:max-w-none mx-auto max-w-prose "> 57 + <div className="prose-pre:overflow-y-auto prose-pre:max-w-xs md:prose-pre:max-w-none mx-auto max-w-prose"> 58 58 <Mdx code={post.body.code} /> 59 59 </div> 60 60 </article>
-24
apps/web/src/components/content/thumbnail.tsx
··· 1 - import Link from "next/link"; 2 - import type { Post } from "contentlayer/generated"; 3 - 4 - import { formatDate } from "@/lib/utils"; 5 - 6 - export function Thumbnail({ post }: { post: Post }) { 7 - return ( 8 - <div key={post.slug}> 9 - <Link href={`/blog/${post.slug}`}> 10 - <section> 11 - <p className="text-foreground font-cal text-2xl">{post.title}</p> 12 - <p className="text-muted-foreground">{post.description}</p> 13 - <p className="text-muted-foreground mt-1 text-xs"> 14 - {post.author.name} 15 - <span className="text-muted-foreground/70 mx-1">&bull;</span> 16 - {formatDate(new Date(post.publishedAt))} 17 - <span className="text-muted-foreground/70 mx-1">&bull;</span> 18 - {post.readingTime} 19 - </p> 20 - </section> 21 - </Link> 22 - </div> 23 - ); 24 - }
+55
apps/web/src/components/content/timeline.tsx
··· 1 + import Image from "next/image"; 2 + 3 + import { formatDate } from "@/lib/utils"; 4 + 5 + interface TimelineProps { 6 + title: string; 7 + description: string; 8 + children?: React.ReactNode; 9 + } 10 + 11 + export function Timeline({ title, description, children }: TimelineProps) { 12 + return ( 13 + <div className="grid gap-8"> 14 + <div className="grid gap-4 md:grid-cols-5 md:gap-8"> 15 + <div className="md:col-span-1" /> 16 + <div className="grid gap-4 md:col-span-4"> 17 + <h1 className="text-foreground font-cal text-4xl">{title}</h1> 18 + <p className="text-muted-foreground">{description}</p> 19 + </div> 20 + </div> 21 + {children} 22 + </div> 23 + ); 24 + } 25 + 26 + interface ArticleProps { 27 + publishedAt: string; 28 + imageSrc: string; 29 + title: string; 30 + children?: React.ReactNode; 31 + } 32 + 33 + function Article({ publishedAt, imageSrc, title, children }: ArticleProps) { 34 + return ( 35 + <article className="grid grid-cols-1 gap-4 md:grid-cols-5 md:gap-6"> 36 + <time className="text-muted-foreground order-2 font-mono text-sm md:order-1 md:col-span-1"> 37 + {formatDate(new Date(publishedAt))} 38 + </time> 39 + <div className="relative order-1 h-64 w-full md:order-2 md:col-span-4"> 40 + <Image 41 + src={imageSrc} 42 + fill={true} 43 + alt={title} 44 + className="border-border rounded-md border object-cover" 45 + /> 46 + </div> 47 + <div className="order-3 grid grid-cols-1 gap-4 md:col-span-4 md:col-start-2"> 48 + <h3 className="text-foreground font-cal text-2xl">{title}</h3> 49 + {children} 50 + </div> 51 + </article> 52 + ); 53 + } 54 + 55 + Timeline.Article = Article;
+4 -4
apps/web/src/components/dashboard/header.tsx
··· 19 19 className, 20 20 )} 21 21 > 22 - <div className="grid w-full gap-1"> 23 - <h1 className="font-cal text-3xl">{title}</h1> 22 + <div className="flex w-full flex-col gap-1"> 23 + <h1 className="font-cal truncate text-3xl">{title}</h1> 24 24 {description ? ( 25 - <p className="text-muted-foreground">{description}</p> 25 + <p className="text-muted-foreground truncate">{description}</p> 26 26 ) : null} 27 27 </div> 28 28 {actions ? ( 29 - <div className="flex items-center gap-2">{actions}</div> 29 + <div className="flex flex-1 items-center gap-2">{actions}</div> 30 30 ) : null} 31 31 </div> 32 32 );
+10 -6
apps/web/src/components/data-table/columns.tsx
··· 34 34 return <DataTableStatusBadge {...{ statusCode }} />; 35 35 }, 36 36 filterFn: (row, id, value) => { 37 - // needed because value is number, not string 38 - return `${row.getValue(id)}`.includes(`${value}`); 37 + // get the first digit of the status code 38 + return value.includes(Number(String(row.getValue(id)).charAt(0))); 39 39 }, 40 40 }, 41 41 { ··· 50 50 <DataTableColumnHeader column={column} title="Region" /> 51 51 ), 52 52 cell: ({ row }) => { 53 - const region = String(row.getValue("region")); 54 - // eslint-disable-next-line @typescript-eslint/ban-ts-comment 55 - // @ts-expect-error 56 - return <div>{regionsDict[region]?.location}</div>; 53 + return ( 54 + <div> 55 + <span className="font-mono">{String(row.getValue("region"))} </span> 56 + <span className="text-muted-foreground text-xs"> 57 + {regionsDict[row.original.region]?.location} 58 + </span> 59 + </div> 60 + ); 57 61 }, 58 62 filterFn: (row, id, value) => { 59 63 return value.includes(row.getValue(id));
+2 -19
apps/web/src/components/data-table/data-table-date-ranger-picker.tsx
··· 2 2 3 3 import * as React from "react"; 4 4 import { usePathname, useRouter, useSearchParams } from "next/navigation"; 5 - import { addDays, format } from "date-fns"; 5 + import { format } from "date-fns"; 6 6 import { Calendar as CalendarIcon } from "lucide-react"; 7 7 import type { DateRange } from "react-day-picker"; 8 8 ··· 15 15 } from "@openstatus/ui"; 16 16 17 17 import useUpdateSearchParams from "@/hooks/use-update-search-params"; 18 - import { cn } from "@/lib/utils"; 18 + import { cn, manipulateDate } from "@/lib/utils"; 19 19 20 20 type DataTableDateRangePicker = React.HTMLAttributes<HTMLDivElement>; 21 21 ··· 91 91 </div> 92 92 ); 93 93 } 94 - 95 - /** 96 - * Whenever you select a date, it will use the midnight timestamp of that date. 97 - * We need to add a day minus one second to include the whole day. 98 - */ 99 - function manipulateDate(date?: DateRange | null) { 100 - const isToDateMidnight = String(date?.to?.getTime()).endsWith("00000"); 101 - 102 - const addOneDayToDate = date?.to 103 - ? addDays(new Date(date.to), 1).getTime() - 1 104 - : null; 105 - 106 - return { 107 - fromDate: date?.from?.getTime() || null, 108 - toDate: isToDateMidnight ? addOneDayToDate : date?.to?.getTime() || null, 109 - }; 110 - }
+4 -2
apps/web/src/components/data-table/data-table-faceted-filter.tsx
··· 25 25 title?: string; 26 26 options: { 27 27 label: string; 28 - value: string; 28 + value: string | number; 29 29 icon?: React.ComponentType<{ className?: string }>; 30 30 }[]; 31 31 } ··· 36 36 options, 37 37 }: DataTableFacetedFilter<TData, TValue>) { 38 38 const facets = column?.getFacetedUniqueValues(); 39 - const selectedValues = new Set(column?.getFilterValue() as string[]); 39 + const selectedValues = new Set( 40 + column?.getFilterValue() as (string | number)[], 41 + ); 40 42 41 43 return ( 42 44 <Popover>
+15 -2
apps/web/src/components/data-table/data-table-toolbar.tsx
··· 5 5 6 6 import { Button } from "@openstatus/ui"; 7 7 8 + import { codesDict } from "@/data/code-dictionary"; 8 9 import { regionsDict } from "@/data/regions-dictionary"; 9 10 import { DataTableDateRangePicker } from "./data-table-date-ranger-picker"; 10 11 import { DataTableFacetedFilter } from "./data-table-faceted-filter"; ··· 22 23 return ( 23 24 <div className="flex flex-wrap items-center justify-between gap-3"> 24 25 <div className="flex flex-1 items-center gap-2"> 25 - <DataTableFilterInput table={table} /> 26 + {table.getColumn("statusCode") && ( 27 + <DataTableFacetedFilter 28 + column={table.getColumn("statusCode")} 29 + title="Status Code" 30 + options={Object.keys(codesDict).map((key) => { 31 + const typedKey = key as keyof typeof codesDict; 32 + return { 33 + label: codesDict[typedKey].label, 34 + value: codesDict[typedKey].prefix, 35 + }; 36 + })} 37 + /> 38 + )} 26 39 {table.getColumn("region") && ( 27 40 <DataTableFacetedFilter 28 41 column={table.getColumn("region")} ··· 47 60 </Button> 48 61 )} 49 62 </div> 50 - <DataTableDateRangePicker /> 63 + {/* <DataTableDateRangePicker /> */} 51 64 </div> 52 65 ); 53 66 }
+9 -3
apps/web/src/components/data-table/monitor/columns.tsx
··· 1 1 "use client"; 2 2 3 + import Link from "next/link"; 3 4 import type { ColumnDef } from "@tanstack/react-table"; 4 5 import * as z from "zod"; 5 6 ··· 19 20 const active = row.getValue("active"); 20 21 return ( 21 22 // TODO: add Link on click when we have a better details page 22 - <div className="flex items-center gap-2"> 23 + <Link 24 + href={`./monitors/${row.original.id}/data`} 25 + className="group flex items-center gap-2" 26 + > 23 27 {active ? ( 24 28 <span className="relative flex h-2 w-2"> 25 29 <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75 duration-1000" /> ··· 30 34 <span className="absolute inline-flex h-2 w-2 rounded-full bg-red-500" /> 31 35 </span> 32 36 )} 33 - <span className="max-w-[125px] truncate">{row.getValue("name")}</span> 37 + <span className="max-w-[125px] truncate group-hover:underline"> 38 + {row.getValue("name")} 39 + </span> 34 40 {!active ? <Badge variant="outline">paused</Badge> : null} 35 - </div> 41 + </Link> 36 42 ); 37 43 }, 38 44 },
+8 -8
apps/web/src/components/layout/marketing-header.tsx
··· 19 19 20 20 return ( 21 21 <header 22 - className={cn("grid w-full grid-cols-2 gap-2 md:grid-cols-5", className)} 22 + className={cn("grid w-full grid-cols-2 gap-2 sm:grid-cols-5", className)} 23 23 > 24 - <div className="flex items-center md:col-span-1"> 24 + <div className="flex items-center sm:col-span-1"> 25 25 <BrandName /> 26 26 </div> 27 - <div className="hidden items-center justify-center md:col-span-3 md:flex md:gap-3"> 28 - <Button variant="link" asChild className="md:mr-3"> 27 + <div className="hidden items-center justify-center sm:col-span-3 sm:flex sm:gap-3"> 28 + <Button variant="link" asChild> 29 29 <Link href="/blog">Blog</Link> 30 30 </Button> 31 - <Button variant="link" asChild className="md:mr-3"> 31 + <Button variant="link" asChild> 32 32 <Link href="/changelog">Changelog</Link> 33 33 </Button> 34 - <Button variant="link" asChild className="md:mr-3"> 34 + <Button variant="link" asChild> 35 35 <Link href="https://docs.openstatus.dev" target="_blank"> 36 36 Docs 37 37 <ArrowUpRight className="ml-1 h-4 w-4 flex-shrink-0" /> 38 38 </Link> 39 39 </Button> 40 40 </div> 41 - <div className="flex items-center justify-end gap-3 md:col-span-1"> 42 - <div className="block md:hidden"> 41 + <div className="flex items-center justify-end gap-3 sm:col-span-1"> 42 + <div className="block sm:hidden"> 43 43 <MarketingMenu /> 44 44 </div> 45 45 <Button asChild className="rounded-full">
+15 -4
apps/web/src/components/layout/marketing-menu.tsx
··· 3 3 import * as React from "react"; 4 4 import Link from "next/link"; 5 5 import { usePathname, useSearchParams } from "next/navigation"; 6 - import { Menu } from "lucide-react"; 6 + import { ArrowUpRight, Menu } from "lucide-react"; 7 7 8 8 import { 9 9 Button, ··· 40 40 </SheetTrigger> 41 41 <SheetContent> 42 42 <SheetHeader> 43 - <SheetTitle className="text-left">Navigation</SheetTitle> 43 + <SheetTitle className="ml-2 text-left">Menu</SheetTitle> 44 44 </SheetHeader> 45 45 <ul className="mt-4 grid gap-1"> 46 46 {pages.map(({ href, title }) => { 47 47 const isActive = pathname?.startsWith(href); 48 + const isExternal = href.startsWith("http"); 49 + const externalProps = isExternal 50 + ? { 51 + target: "_blank", 52 + rel: "noreferrer", 53 + } 54 + : {}; 48 55 return ( 49 - <li key="title" className="w-full"> 56 + <li key={href} className="-ml-1 w-full"> 50 57 <Link 51 58 href={href} 52 59 className={cn( 53 - "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", 60 + "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", 54 61 isActive && "bg-muted/50 border-border text-foreground", 55 62 )} 63 + {...externalProps} 56 64 > 57 65 {title} 66 + {isExternal ? ( 67 + <ArrowUpRight className="ml-1 h-4 w-4 flex-shrink-0" /> 68 + ) : null} 58 69 </Link> 59 70 </li> 60 71 );
+10
apps/web/src/content/changelog/response-time-charts.mdx
··· 1 + --- 2 + title: Response Time Charts 3 + publishedAt: 2023-10-08 4 + image: /assets/changelog/response-time-charts.png 5 + --- 6 + 7 + You can now assess your monitor's response time using the new charts and get a 8 + better overview of your monitor's performance. 9 + 10 + Powered by [tremor.so](https://tremor.so).
+27
apps/web/src/data/code-dictionary.ts
··· 1 + export const codesDict = { 2 + "1xx": { 3 + prefix: 1, 4 + label: "1xx", 5 + name: "Informational", 6 + }, 7 + "2xx": { 8 + prefix: 2, 9 + label: "2xx", 10 + name: "Successfull", 11 + }, 12 + "3xx": { 13 + prefix: 3, 14 + label: "3xx", 15 + name: "Redirection", 16 + }, 17 + "4xx": { 18 + prefix: 4, 19 + label: "4xx", 20 + name: "Client Error", 21 + }, 22 + "5xx": { 23 + prefix: 5, 24 + label: "5xx", 25 + name: "Server Error", 26 + }, 27 + } as const;
+1 -5
apps/web/src/lib/tb.ts
··· 16 16 const tb = new Tinybird({ token: env.TINY_BIRD_API_KEY }); 17 17 18 18 // TODO: add security layer 19 - export async function getResponseListData( 20 - props: Partial< 21 - Pick<ResponseListParams, "region" | "cronTimestamp" | "limit" | "monitorId"> 22 - >, 23 - ) { 19 + export async function getResponseListData(props: Partial<ResponseListParams>) { 24 20 try { 25 21 const res = await getResponseList(tb)(props); 26 22 return res.data;
+24 -1
apps/web/src/lib/utils.ts
··· 1 1 import type { ClassValue } from "clsx"; 2 2 import { clsx } from "clsx"; 3 - import { format } from "date-fns"; 3 + import { addDays, format } from "date-fns"; 4 4 import { twMerge } from "tailwind-merge"; 5 5 6 6 export function cn(...inputs: ClassValue[]) { ··· 36 36 const formatter = Intl.NumberFormat("en", { notation: "compact" }); 37 37 return formatter.format(value); 38 38 } 39 + 40 + /** 41 + * Whenever you select a date, it will use the midnight timestamp of that date. 42 + * We need to add a day minus one second to include the whole day. 43 + */ 44 + export function manipulateDate( 45 + date?: { 46 + from: Date | undefined; 47 + to?: Date | undefined; 48 + } | null, 49 + ) { 50 + const isToDateMidnight = String(date?.to?.getTime()).endsWith("00000"); 51 + 52 + // We might wanna use `endOfDay(new Date(date.to))` here 53 + const addOneDayToDate = date?.to 54 + ? addDays(new Date(date.to), 1).getTime() - 1 55 + : null; 56 + 57 + return { 58 + fromDate: date?.from?.getTime() || null, 59 + toDate: isToDateMidnight ? addOneDayToDate : date?.to?.getTime() || null, 60 + }; 61 + }
+136 -2
apps/web/tailwind.config.ts
··· 1 1 const { fontFamily } = require("tailwindcss/defaultTheme"); 2 2 3 + /** 4 + * TODO: is there a way to merge `tremor` with `shadcn`? At least the colors 5 + */ 6 + 3 7 /** @type {import('tailwindcss').Config} */ 4 8 module.exports = { 5 9 darkMode: ["class"], 6 10 content: [ 7 11 "src/**/*.{ts,tsx}", 8 - // for vercel integration 12 + /* tremor */ 13 + "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}", 14 + // our vercel integration 9 15 "../../packages/integrations/**/*.{ts,tsx}", 10 16 "../../packages/ui/**/*.{ts,tsx}", 11 17 ], 12 18 theme: { 19 + /* Tremor */ 20 + transparent: "transparent", 21 + current: "currentColor", 22 + /* */ 13 23 container: { 14 24 center: true, 15 25 padding: "2rem", ··· 56 66 DEFAULT: "hsl(var(--card))", 57 67 foreground: "hsl(var(--card-foreground))", 58 68 }, 69 + /* Tremor */ 70 + // light mode 71 + tremor: { 72 + brand: { 73 + faint: "#eff6ff", // blue-50 74 + muted: "#bfdbfe", // blue-200 75 + subtle: "#60a5fa", // blue-400 76 + DEFAULT: "#3b82f6", // blue-500 77 + emphasis: "#1d4ed8", // blue-700 78 + inverted: "#ffffff", // white 79 + }, 80 + background: { 81 + muted: "#f9fafb", // gray-50 82 + subtle: "#f3f4f6", // gray-100 83 + DEFAULT: "#ffffff", // white 84 + emphasis: "#374151", // gray-700 85 + }, 86 + border: { 87 + DEFAULT: "#e5e7eb", // gray-200 88 + }, 89 + ring: { 90 + DEFAULT: "#e5e7eb", // gray-200 91 + }, 92 + content: { 93 + subtle: "#9ca3af", // gray-400 94 + DEFAULT: "#6b7280", // gray-500 95 + emphasis: "#374151", // gray-700 96 + strong: "#111827", // gray-900 97 + inverted: "#ffffff", // white 98 + }, 99 + }, 100 + // dark mode 101 + "dark-tremor": { 102 + brand: { 103 + faint: "#0B1229", // custom 104 + muted: "#172554", // blue-950 105 + subtle: "#1e40af", // blue-800 106 + DEFAULT: "#3b82f6", // blue-500 107 + emphasis: "#60a5fa", // blue-400 108 + inverted: "#030712", // gray-950 109 + }, 110 + background: { 111 + muted: "#131A2B", // custom 112 + subtle: "#1f2937", // gray-800 113 + DEFAULT: "#111827", // gray-900 114 + emphasis: "#d1d5db", // gray-300 115 + }, 116 + border: { 117 + DEFAULT: "#1f2937", // gray-800 118 + }, 119 + ring: { 120 + DEFAULT: "#1f2937", // gray-800 121 + }, 122 + content: { 123 + subtle: "#4b5563", // gray-600 124 + DEFAULT: "#6b7280", // gray-600 125 + emphasis: "#e5e7eb", // gray-200 126 + strong: "#f9fafb", // gray-50 127 + inverted: "#000000", // black 128 + }, 129 + }, 130 + /* */ 59 131 }, 60 132 borderRadius: { 61 133 lg: `var(--radius)`, 62 134 md: `calc(var(--radius) - 2px)`, 63 135 sm: "calc(var(--radius) - 4px)", 136 + /* Tremor */ 137 + "tremor-small": "0.375rem", 138 + "tremor-default": "0.5rem", 139 + "tremor-full": "9999px", 140 + /* */ 64 141 }, 142 + /* Tremor */ 143 + boxShadow: { 144 + // light 145 + "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", 146 + "tremor-card": 147 + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", 148 + "tremor-dropdown": 149 + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", 150 + // dark 151 + "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", 152 + "dark-tremor-card": 153 + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", 154 + "dark-tremor-dropdown": 155 + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", 156 + }, 157 + fontSize: { 158 + "tremor-label": ["0.75rem"], 159 + "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }], 160 + "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }], 161 + "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }], 162 + }, 163 + /* */ 65 164 fontFamily: { 66 165 sans: ["var(--font-sans)", ...fontFamily.sans], 67 166 cal: ["var(--font-calsans)"], ··· 82 181 }, 83 182 }, 84 183 }, 85 - plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], 184 + /* Tremor */ 185 + safelist: [ 186 + { 187 + pattern: 188 + /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 189 + variants: ["hover", "ui-selected"], 190 + }, 191 + { 192 + pattern: 193 + /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 194 + variants: ["hover", "ui-selected"], 195 + }, 196 + { 197 + pattern: 198 + /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 199 + variants: ["hover", "ui-selected"], 200 + }, 201 + { 202 + pattern: 203 + /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 204 + }, 205 + { 206 + pattern: 207 + /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 208 + }, 209 + { 210 + pattern: 211 + /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 212 + }, 213 + ], 214 + /* */ 215 + plugins: [ 216 + require("tailwindcss-animate"), 217 + require("@tailwindcss/typography"), 218 + require("@headlessui/tailwindcss"), 219 + ], 86 220 };
+1 -2
packages/tinybird/src/validation.ts
··· 69 69 monitorId: z.string().default(""), // REMINDER: remove default once alpha 70 70 fromDate: z.number().int().default(0), // always start from a date 71 71 toDate: z.number().int().optional(), 72 - limit: z.number().int().optional().default(2500), // one day has 2448 pings (17 (regions) * 6 (per hour) * 24) 72 + limit: z.number().int().optional().default(7500), // one day has 2448 pings (17 (regions) * 6 (per hour) * 24) * 3 days for historical data 73 73 region: z.enum(availableRegions).optional(), 74 74 cronTimestamp: z.number().int().optional(), 75 75 }); ··· 85 85 * Params for pipe monitor_list__v0 86 86 */ 87 87 export const tbParameterMonitorList = z.object({ 88 - siteId: z.string().optional().default("openstatus"), // REMINDER: remove default once alpha 89 88 monitorId: z.string().optional().default(""), // REMINDER: remove default once alpha 90 89 limit: z.number().int().optional().default(2500), // one day has 2448 pings (17 (regions) * 6 (per hour) * 24) 91 90 cronTimestamp: z.number().int().optional(),
+241
pnpm-lock.yaml
··· 112 112 '@clerk/nextjs': 113 113 specifier: 4.25.1 114 114 version: 4.25.1(next@13.5.3)(react-dom@18.2.0)(react@18.2.0) 115 + '@headlessui/react': 116 + specifier: ^1.7.17 117 + version: 1.7.17(react-dom@18.2.0)(react@18.2.0) 115 118 '@hookform/resolvers': 116 119 specifier: 3.3.1 117 120 version: 3.3.1(react-hook-form@7.47.0) ··· 163 166 '@tanstack/react-table': 164 167 specifier: 8.10.3 165 168 version: 8.10.3(react-dom@18.2.0)(react@18.2.0) 169 + '@tremor/react': 170 + specifier: ^3.8.2 171 + version: 3.8.2(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2) 166 172 '@trpc/client': 167 173 specifier: 10.38.5 168 174 version: 10.38.5(@trpc/server@10.38.5) ··· 2010 2016 '@floating-ui/utils': 0.1.6 2011 2017 dev: false 2012 2018 2019 + /@floating-ui/react-dom@1.3.0(react-dom@18.2.0)(react@18.2.0): 2020 + resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==} 2021 + peerDependencies: 2022 + react: '>=16.8.0' 2023 + react-dom: '>=16.8.0' 2024 + dependencies: 2025 + '@floating-ui/dom': 1.5.3 2026 + react: 18.2.0 2027 + react-dom: 18.2.0(react@18.2.0) 2028 + dev: false 2029 + 2013 2030 /@floating-ui/react-dom@2.0.2(react-dom@18.2.0)(react@18.2.0): 2014 2031 resolution: {integrity: sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==} 2015 2032 peerDependencies: ··· 2021 2038 react-dom: 18.2.0(react@18.2.0) 2022 2039 dev: false 2023 2040 2041 + /@floating-ui/react@0.19.2(react-dom@18.2.0)(react@18.2.0): 2042 + resolution: {integrity: sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==} 2043 + peerDependencies: 2044 + react: '>=16.8.0' 2045 + react-dom: '>=16.8.0' 2046 + dependencies: 2047 + '@floating-ui/react-dom': 1.3.0(react-dom@18.2.0)(react@18.2.0) 2048 + aria-hidden: 1.2.3 2049 + react: 18.2.0 2050 + react-dom: 18.2.0(react@18.2.0) 2051 + tabbable: 6.2.0 2052 + dev: false 2053 + 2024 2054 /@floating-ui/utils@0.1.6: 2025 2055 resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} 2026 2056 dev: false ··· 2054 2084 client-only: 0.0.1 2055 2085 react: 18.2.0 2056 2086 react-dom: 18.2.0(react@18.2.0) 2087 + dev: false 2088 + 2089 + /@headlessui/tailwindcss@0.1.3(tailwindcss@3.3.2): 2090 + resolution: {integrity: sha512-3aMdDyYZx9A15euRehpppSyQnb2gIw2s/Uccn2ELIoLQ9oDy0+9oRygNWNjXCD5Dt+w1pxo7C+XoiYvGcqA4Kg==} 2091 + engines: {node: '>=10'} 2092 + peerDependencies: 2093 + tailwindcss: ^3.0 2094 + dependencies: 2095 + tailwindcss: 3.3.2 2057 2096 dev: false 2058 2097 2059 2098 /@hono/zod-openapi@0.7.1(hono@3.7.3)(zod@3.22.2): ··· 5095 5134 resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} 5096 5135 dev: true 5097 5136 5137 + /@tremor/react@3.8.2(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2): 5138 + resolution: {integrity: sha512-gZ6GTBIiL915U2Tikreooh8nllWEgKd1wnZ3ru0Cm9qmq6TbW+jHikCpHy1KQ2PQkpdKrXi5R2s6f8N4Fj5Imw==} 5139 + peerDependencies: 5140 + react: ^18.0.0 5141 + react-dom: '>=16.6.0' 5142 + dependencies: 5143 + '@floating-ui/react': 0.19.2(react-dom@18.2.0)(react@18.2.0) 5144 + '@headlessui/react': 1.7.17(react-dom@18.2.0)(react@18.2.0) 5145 + '@headlessui/tailwindcss': 0.1.3(tailwindcss@3.3.2) 5146 + date-fns: 2.30.0 5147 + react: 18.2.0 5148 + react-day-picker: 8.8.2(date-fns@2.30.0)(react@18.2.0) 5149 + react-dom: 18.2.0(react@18.2.0) 5150 + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) 5151 + recharts: 2.8.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) 5152 + tailwind-merge: 1.14.0 5153 + transitivePeerDependencies: 5154 + - prop-types 5155 + - tailwindcss 5156 + dev: false 5157 + 5098 5158 /@trpc/client@10.38.5(@trpc/server@10.38.5): 5099 5159 resolution: {integrity: sha512-tpGUsoAP+3CD/1KRqMdWZ+zebvB68/86SaVPAYHaEDozTFLQdNqTe98DS/T0S4hfh7WCKbMSObj40SCzE8amKQ==} 5100 5160 peerDependencies: ··· 5221 5281 '@types/node': 20.8.0 5222 5282 dev: false 5223 5283 5284 + /@types/d3-array@3.0.8: 5285 + resolution: {integrity: sha512-2xAVyAUgaXHX9fubjcCbGAUOqYfRJN1em1EKR2HfzWBpObZhwfnZKvofTN4TplMqJdFQao61I+NVSai/vnBvDQ==} 5286 + dev: false 5287 + 5288 + /@types/d3-color@3.1.1: 5289 + resolution: {integrity: sha512-CSAVrHAtM9wfuLJ2tpvvwCU/F22sm7rMHNN+yh9D6O6hyAms3+O0cgMpC1pm6UEUMOntuZC8bMt74PteiDUdCg==} 5290 + dev: false 5291 + 5292 + /@types/d3-ease@3.0.0: 5293 + resolution: {integrity: sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==} 5294 + dev: false 5295 + 5296 + /@types/d3-interpolate@3.0.2: 5297 + resolution: {integrity: sha512-zAbCj9lTqW9J9PlF4FwnvEjXZUy75NQqPm7DMHZXuxCFTpuTrdK2NMYGQekf4hlasL78fCYOLu4EE3/tXElwow==} 5298 + dependencies: 5299 + '@types/d3-color': 3.1.1 5300 + dev: false 5301 + 5302 + /@types/d3-path@3.0.0: 5303 + resolution: {integrity: sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==} 5304 + dev: false 5305 + 5224 5306 /@types/d3-scale-chromatic@3.0.0: 5225 5307 resolution: {integrity: sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==} 5226 5308 dev: false ··· 5231 5313 '@types/d3-time': 3.0.0 5232 5314 dev: false 5233 5315 5316 + /@types/d3-shape@3.1.3: 5317 + resolution: {integrity: sha512-cHMdIq+rhF5IVwAV7t61pcEXfEHsEsrbBUPkFGBwTXuxtTAkBBrnrNA8++6OWm3jwVsXoZYQM8NEekg6CPJ3zw==} 5318 + dependencies: 5319 + '@types/d3-path': 3.0.0 5320 + dev: false 5321 + 5234 5322 /@types/d3-time@3.0.0: 5235 5323 resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} 5236 5324 dev: false 5237 5325 5326 + /@types/d3-timer@3.0.0: 5327 + resolution: {integrity: sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==} 5328 + dev: false 5329 + 5238 5330 /@types/debug@4.1.8: 5239 5331 resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} 5240 5332 dependencies: ··· 6785 6877 resolution: {integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==} 6786 6878 dev: false 6787 6879 6880 + /css-unit-converter@1.1.2: 6881 + resolution: {integrity: sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA==} 6882 + dev: false 6883 + 6788 6884 /css.escape@1.5.1: 6789 6885 resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} 6790 6886 dev: false ··· 7183 7279 dependencies: 7184 7280 ms: 2.1.2 7185 7281 7282 + /decimal.js-light@2.5.1: 7283 + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} 7284 + dev: false 7285 + 7186 7286 /decimal.js@10.4.3: 7187 7287 resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} 7188 7288 dev: false ··· 7374 7474 engines: {node: '>=6.0.0'} 7375 7475 dependencies: 7376 7476 esutils: 2.0.3 7477 + 7478 + /dom-helpers@3.4.0: 7479 + resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} 7480 + dependencies: 7481 + '@babel/runtime': 7.23.1 7482 + dev: false 7483 + 7484 + /dom-helpers@5.2.1: 7485 + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} 7486 + dependencies: 7487 + '@babel/runtime': 7.23.1 7488 + csstype: 3.1.2 7489 + dev: false 7377 7490 7378 7491 /dom-serializer@2.0.0: 7379 7492 resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} ··· 8231 8344 es5-ext: 0.10.62 8232 8345 dev: true 8233 8346 8347 + /eventemitter3@4.0.7: 8348 + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} 8349 + dev: false 8350 + 8234 8351 /execa@0.8.0: 8235 8352 resolution: {integrity: sha512-zDWS+Rb1E8BlqqhALSt9kUhss8Qq4nN3iof3gsOdyINksElaPyNBtKUMTR62qhvgVWR0CqCX7sdnKe4MnUbFEA==} 8236 8353 engines: {node: '>=4'} ··· 8292 8409 /fast-deep-equal@3.1.3: 8293 8410 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 8294 8411 8412 + /fast-equals@5.0.1: 8413 + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} 8414 + engines: {node: '>=6.0.0'} 8415 + dev: false 8416 + 8295 8417 /fast-folder-size@1.6.1: 8296 8418 resolution: {integrity: sha512-F3tRpfkAzb7TT2JNKaJUglyuRjRa+jelQD94s9OSqkfEeytLmupCqQiD+H2KoIXGtp4pB5m4zNmv5m2Ktcr+LA==} 8297 8419 hasBin: true ··· 12173 12295 cssesc: 3.0.0 12174 12296 util-deprecate: 1.0.2 12175 12297 12298 + /postcss-value-parser@3.3.1: 12299 + resolution: {integrity: sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==} 12300 + dev: false 12301 + 12176 12302 /postcss-value-parser@4.2.0: 12177 12303 resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} 12178 12304 ··· 12611 12737 /react-is@18.2.0: 12612 12738 resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} 12613 12739 12740 + /react-lifecycles-compat@3.0.4: 12741 + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} 12742 + dev: false 12743 + 12614 12744 /react-property@2.0.0: 12615 12745 resolution: {integrity: sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==} 12616 12746 dev: false ··· 12702 12832 use-sidecar: 1.1.2(@types/react@18.2.24)(react@18.2.0) 12703 12833 dev: false 12704 12834 12835 + /react-resize-detector@8.1.0(react-dom@18.2.0)(react@18.2.0): 12836 + resolution: {integrity: sha512-S7szxlaIuiy5UqLhLL1KY3aoyGHbZzsTpYal9eYMwCyKqoqoVLCmIgAgNyIM1FhnP2KyBygASJxdhejrzjMb+w==} 12837 + peerDependencies: 12838 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 12839 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 12840 + dependencies: 12841 + lodash: 4.17.21 12842 + react: 18.2.0 12843 + react-dom: 18.2.0(react@18.2.0) 12844 + dev: false 12845 + 12846 + /react-smooth@2.0.4(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): 12847 + resolution: {integrity: sha512-OkFsrrMBTvQUwEJthE1KXSOj79z57yvEWeFefeXPib+RmQEI9B1Ub1PgzlzzUyBOvl/TjXt5nF2hmD4NsgAh8A==} 12848 + peerDependencies: 12849 + prop-types: ^15.6.0 12850 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 12851 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 12852 + dependencies: 12853 + fast-equals: 5.0.1 12854 + prop-types: 15.8.1 12855 + react: 18.2.0 12856 + react-dom: 18.2.0(react@18.2.0) 12857 + react-transition-group: 2.9.0(react-dom@18.2.0)(react@18.2.0) 12858 + dev: false 12859 + 12705 12860 /react-ssr-prepass@1.5.0(react@18.2.0): 12706 12861 resolution: {integrity: sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==} 12707 12862 peerDependencies: ··· 12740 12895 refractor: 3.6.0 12741 12896 dev: false 12742 12897 12898 + /react-transition-group@2.9.0(react-dom@18.2.0)(react@18.2.0): 12899 + resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} 12900 + peerDependencies: 12901 + react: '>=15.0.0' 12902 + react-dom: '>=15.0.0' 12903 + dependencies: 12904 + dom-helpers: 3.4.0 12905 + loose-envify: 1.4.0 12906 + prop-types: 15.8.1 12907 + react: 18.2.0 12908 + react-dom: 18.2.0(react@18.2.0) 12909 + react-lifecycles-compat: 3.0.4 12910 + dev: false 12911 + 12912 + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): 12913 + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} 12914 + peerDependencies: 12915 + react: '>=16.6.0' 12916 + react-dom: '>=16.6.0' 12917 + dependencies: 12918 + '@babel/runtime': 7.23.1 12919 + dom-helpers: 5.2.1 12920 + loose-envify: 1.4.0 12921 + prop-types: 15.8.1 12922 + react: 18.2.0 12923 + react-dom: 18.2.0(react@18.2.0) 12924 + dev: false 12925 + 12743 12926 /react-tweet@3.1.1(react-dom@18.2.0)(react@18.2.0): 12744 12927 resolution: {integrity: sha512-8GQLa5y0G56kvGQkN7OiaKkjFAhWYVdyFq62ioY2qVtpMrjchVU+3KnqneCyp0+BemOQZkg6WWp/qoCNeEMH6A==} 12745 12928 peerDependencies: ··· 12815 12998 resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} 12816 12999 dev: false 12817 13000 13001 + /recharts-scale@0.4.5: 13002 + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} 13003 + dependencies: 13004 + decimal.js-light: 2.5.1 13005 + dev: false 13006 + 13007 + /recharts@2.8.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): 13008 + resolution: {integrity: sha512-nciXqQDh3aW8abhwUlA4EBOBusRHLNiKHfpRZiG/yjups1x+auHb2zWPuEcTn/IMiN47vVMMuF8Sr+vcQJtsmw==} 13009 + engines: {node: '>=12'} 13010 + peerDependencies: 13011 + prop-types: ^15.6.0 13012 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 13013 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 13014 + dependencies: 13015 + classnames: 2.3.2 13016 + eventemitter3: 4.0.7 13017 + lodash: 4.17.21 13018 + prop-types: 15.8.1 13019 + react: 18.2.0 13020 + react-dom: 18.2.0(react@18.2.0) 13021 + react-is: 16.13.1 13022 + react-resize-detector: 8.1.0(react-dom@18.2.0)(react@18.2.0) 13023 + react-smooth: 2.0.4(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) 13024 + recharts-scale: 0.4.5 13025 + reduce-css-calc: 2.1.8 13026 + victory-vendor: 36.6.11 13027 + dev: false 13028 + 12818 13029 /rechoir@0.6.2: 12819 13030 resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} 12820 13031 engines: {node: '>= 0.10'} ··· 12822 13033 resolve: 1.22.6 12823 13034 dev: false 12824 13035 13036 + /reduce-css-calc@2.1.8: 13037 + resolution: {integrity: sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==} 13038 + dependencies: 13039 + css-unit-converter: 1.1.2 13040 + postcss-value-parser: 3.3.1 13041 + dev: false 13042 + 12825 13043 /redux-immutable@4.0.0(immutable@3.8.2): 12826 13044 resolution: {integrity: sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==} 12827 13045 peerDependencies: ··· 13873 14091 resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} 13874 14092 dev: false 13875 14093 14094 + /tabbable@6.2.0: 14095 + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} 14096 + dev: false 14097 + 13876 14098 /tailwind-merge@1.14.0: 13877 14099 resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==} 13878 14100 dev: false ··· 14882 15104 '@types/unist': 3.0.0 14883 15105 unist-util-stringify-position: 4.0.0 14884 15106 vfile-message: 4.0.2 15107 + dev: false 15108 + 15109 + /victory-vendor@36.6.11: 15110 + resolution: {integrity: sha512-nT8kCiJp8dQh8g991J/R5w5eE2KnO8EAIP0xocWlh9l2okngMWglOPoMZzJvek8Q1KUc4XE/mJxTZnvOB1sTYg==} 15111 + dependencies: 15112 + '@types/d3-array': 3.0.8 15113 + '@types/d3-ease': 3.0.0 15114 + '@types/d3-interpolate': 3.0.2 15115 + '@types/d3-scale': 4.0.4 15116 + '@types/d3-shape': 3.1.3 15117 + '@types/d3-time': 3.0.0 15118 + '@types/d3-timer': 3.0.0 15119 + d3-array: 3.2.4 15120 + d3-ease: 3.0.1 15121 + d3-interpolate: 3.0.1 15122 + d3-scale: 4.0.2 15123 + d3-shape: 3.2.0 15124 + d3-time: 3.1.0 15125 + d3-timer: 3.0.1 14885 15126 dev: false 14886 15127 14887 15128 /vite-node@0.34.6(@types/node@20.8.0):