Openstatus www.openstatus.dev

feat: improve og images (#602)

* chore: improve opengraph images

* fix: rename layout component

* chore: add border

* chore: add background gradient to border

* chore: add footer text to search params

* fix: curve type monotone

* fix: chart equidistant x axis

* fix: tw warning

authored by

Maximilian Kaske and committed by
GitHub
138dc16e dda921ed

+812 -255
+1 -1
apps/web/package.json
··· 34 34 "@t3-oss/env-nextjs": "0.7.0", 35 35 "@tailwindcss/typography": "0.5.10", 36 36 "@tanstack/react-table": "8.10.3", 37 - "@tremor/react": "3.8.2", 37 + "@tremor/react": "3.13.3", 38 38 "@trpc/client": "10.38.5", 39 39 "@trpc/next": "10.38.5", 40 40 "@trpc/react-query": "10.38.5",
apps/web/public/assets/og/dashboard.png

This is a binary file and will not be displayed.

+36
apps/web/src/app/api/og/_components/background.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + 3 + export function Background({ 4 + children, 5 + tw, 6 + }: { 7 + children: React.ReactNode; 8 + tw?: string; 9 + }) { 10 + return ( 11 + <div 12 + tw={cn( 13 + "relative flex flex-col bg-white items-center justify-center w-full h-full", 14 + tw, 15 + )} 16 + > 17 + <div 18 + tw="flex w-full h-full absolute inset-0" 19 + // not every css variable is supported 20 + style={{ 21 + backgroundImage: "radial-gradient(#cbd5e1 10%, transparent 10%)", 22 + backgroundSize: "16px 16px", 23 + }} 24 + /> 25 + <div 26 + tw="flex w-full h-full absolute inset-0 opacity-70" 27 + style={{ 28 + backgroundColor: "white", 29 + backgroundImage: 30 + "radial-gradient(farthest-corner at 100px 100px, #cbd5e1, white 80%)", // tbd: switch color position 31 + }} 32 + /> 33 + {children} 34 + </div> 35 + ); 36 + }
+37
apps/web/src/app/api/og/_components/basic-layout.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + import { Background } from "./background"; 3 + 4 + export function BasicLayout({ 5 + title, 6 + description, 7 + children, 8 + tw, 9 + }: { 10 + title: string; 11 + description?: string; 12 + children?: React.ReactNode; 13 + tw?: string; 14 + }) { 15 + return ( 16 + <Background> 17 + <div tw="flex flex-col h-full w-full px-24"> 18 + <div tw="flex flex-col flex-1 justify-end"> 19 + <div tw="flex flex-col px-12"> 20 + <h1 style={{ fontFamily: "Cal" }} tw="text-6xl"> 21 + {title} 22 + </h1> 23 + <p tw="text-slate-600 text-3xl">{description}</p> 24 + </div> 25 + </div> 26 + <div 27 + tw={cn( 28 + "flex flex-col justify-center shadow-2xl mt-1 bg-white rounded-t-lg border-t-2 border-r-2 border-l-2 border-slate-200 px-12", 29 + tw, 30 + )} 31 + > 32 + {children} 33 + </div> 34 + </div> 35 + </Background> 36 + ); 37 + }
+104
apps/web/src/app/api/og/_components/status-check.tsx
··· 1 + import type { StatusVariant } from "@/lib/tracker"; 2 + import { cn } from "@/lib/utils"; 3 + 4 + export function StatusCheck({ 5 + variant, 6 + }: { 7 + variant: StatusVariant | "incident"; 8 + }) { 9 + function getVariant() { 10 + switch (variant) { 11 + case "down": 12 + return { 13 + color: "bg-red-500", 14 + label: "Major Outage", 15 + icon: Minus, 16 + }; 17 + case "degraded": 18 + return { 19 + color: "bg-yellow-500", 20 + label: "Systems Degraded", 21 + icon: Minus, 22 + }; 23 + case "incident": 24 + return { 25 + color: "bg-yellow-500", 26 + label: "Incident Ongoing", 27 + icon: Alert, 28 + }; 29 + default: 30 + return { 31 + color: "bg-green-500", 32 + label: "All Systems Operational", 33 + icon: Check, 34 + }; 35 + } 36 + } 37 + 38 + const { icon, color, label } = getVariant(); 39 + 40 + return ( 41 + <div tw="flex flex-col justify-center items-center gap-2 w-full"> 42 + <div tw={cn("flex text-white rounded-full p-3", color)}>{icon()}</div> 43 + <p style={{ fontFamily: "Cal" }} tw="text-4xl"> 44 + {label} 45 + </p> 46 + </div> 47 + ); 48 + } 49 + 50 + function Check() { 51 + return ( 52 + <svg 53 + xmlns="http://www.w3.org/2000/svg" 54 + width="40" 55 + height="40" 56 + viewBox="0 0 24 24" 57 + fill="none" 58 + stroke="currentColor" 59 + stroke-width="2" 60 + stroke-linecap="round" 61 + stroke-linejoin="round" 62 + > 63 + <path d="M20 6 9 17l-5-5" /> 64 + </svg> 65 + ); 66 + } 67 + 68 + function Minus() { 69 + return ( 70 + <svg 71 + xmlns="http://www.w3.org/2000/svg" 72 + width="40" 73 + height="40" 74 + viewBox="0 0 24 24" 75 + fill="none" 76 + stroke="currentColor" 77 + stroke-width="2" 78 + stroke-linecap="round" 79 + stroke-linejoin="round" 80 + > 81 + <path d="M5 12h14" /> 82 + </svg> 83 + ); 84 + } 85 + 86 + function Alert() { 87 + return ( 88 + <svg 89 + xmlns="http://www.w3.org/2000/svg" 90 + width="40" 91 + height="40" 92 + viewBox="0 0 24 24" 93 + fill="none" 94 + stroke="currentColor" 95 + stroke-width="2" 96 + stroke-linecap="round" 97 + stroke-linejoin="round" 98 + > 99 + <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /> 100 + <path d="M12 9v4" /> 101 + <path d="M12 17h.01" /> 102 + </svg> 103 + ); 104 + }
+55
apps/web/src/app/api/og/_components/tracker.tsx
··· 1 + import type { Monitor } from "@openstatus/tinybird"; 2 + 3 + import { 4 + addBlackListInfo, 5 + getStatus, 6 + getTotalUptimeString, 7 + } from "@/lib/tracker"; 8 + import { cn, formatDate } from "@/lib/utils"; 9 + 10 + export function Tracker({ data }: { data: Monitor[] }) { 11 + const _data = addBlackListInfo(data); 12 + const uptime = getTotalUptimeString(data); 13 + 14 + return ( 15 + <div tw="flex flex-col w-full my-12"> 16 + <div tw="flex flex-col mx-auto"> 17 + <div tw="flex flex-row items-center justify-between -mb-1 text-black font-light"> 18 + <p></p> 19 + <p tw="font-medium">{uptime}</p> 20 + </div> 21 + {/* Empty State */} 22 + <div tw="flex flex-row relative"> 23 + {new Array(data.length).fill(null).map((_, i) => { 24 + return <div key={i} tw="h-16 w-3 rounded-full mr-1 bg-black/20" />; 25 + })} 26 + <div tw="flex flex-row-reverse absolute left-0"> 27 + {_data.map((item, i) => { 28 + const { variant } = getStatus(item.ok / item.count); 29 + const isBlackListed = Boolean(item.blacklist); 30 + if (isBlackListed) { 31 + return ( 32 + <div key={i} tw="h-16 w-3 rounded-full mr-1 bg-green-400" /> 33 + ); 34 + } 35 + return ( 36 + <div 37 + key={i} 38 + tw={cn("h-16 w-3 rounded-full mr-1", { 39 + "bg-green-500": variant === "up", 40 + "bg-red-500": variant === "down", 41 + "bg-yellow-500": variant === "degraded", 42 + })} 43 + /> 44 + ); 45 + })} 46 + </div> 47 + </div> 48 + <div tw="flex flex-row items-center justify-between -mt-3 text-slate-500 text-sm"> 49 + <p tw="">{data.length} days ago</p> 50 + <p tw="mr-1">{formatDate(new Date())}</p> 51 + </div> 52 + </div> 53 + </div> 54 + ); 55 + }
+173
apps/web/src/app/api/og/checker/route.tsx
··· 1 + import { ImageResponse } from "next/server"; 2 + 3 + import { 4 + getCheckerDataById, 5 + regionFormatter, 6 + timestampFormatter, 7 + } from "@/app/play/checker/[id]/utils"; 8 + import { cn } from "@/lib/utils"; 9 + import { BasicLayout } from "../_components/basic-layout"; 10 + import { 11 + calSemiBold, 12 + interLight, 13 + interMedium, 14 + interRegular, 15 + SIZE, 16 + } from "../utils"; 17 + 18 + export const runtime = "edge"; 19 + 20 + export async function GET(req: Request) { 21 + const interMediumData = await interMedium; 22 + const interRegularData = await interRegular; 23 + const interLightData = await interLight; 24 + const calSemiBoldData = await calSemiBold; 25 + 26 + const { searchParams } = new URL(req.url); 27 + 28 + const id = searchParams.has("id") ? searchParams.get("id") : undefined; 29 + 30 + const data = id ? await getCheckerDataById(id) : undefined; 31 + 32 + function getMinMax() { 33 + if (!data?.checks?.length) return; 34 + let min = data.checks[0]; 35 + let max = data.checks[0]; 36 + for (const check of data.checks) { 37 + if (check.latency < min.latency) min = check; 38 + if (check.latency > max.latency) max = check; 39 + } 40 + return { min, max }; 41 + } 42 + 43 + const { min, max } = getMinMax() || {}; 44 + 45 + function getStatusColor(statusCode: number) { 46 + const green = String(statusCode).startsWith("2"); 47 + if (green) return "border-green-300 bg-green-50 text-green-700"; 48 + const blue = String(statusCode).startsWith("3"); 49 + if (blue) return "border-blue-300 bg-blue-50 text-blue-700"; 50 + const red = 51 + String(statusCode).startsWith("4") || String(statusCode).startsWith("5"); 52 + if (red) return "border-red-300 bg-red-50 text-red-700"; 53 + return "border-gray-300 bg-gray-50 text-gray-700"; 54 + } 55 + 56 + return new ImageResponse( 57 + ( 58 + <BasicLayout 59 + title="Speed Checker" 60 + description="Experience the performance of your application from around the different continents." 61 + tw="pt-4 pb-8" 62 + > 63 + <h2 64 + style={{ 65 + width: (SIZE.width * 3) / 4, 66 + lineClamp: 2, 67 + textOverflow: "ellipsis", 68 + overflow: "hidden", 69 + whiteSpace: "nowrap", 70 + }} 71 + tw="text-3xl text-left font-medium mb-0" 72 + > 73 + {data?.url} 74 + </h2> 75 + <p tw="text-slate-500 text-right">{timestampFormatter(data!.time)}</p> 76 + <div tw="flex"> 77 + <div tw="flex flex-col flex-1"> 78 + <p tw="text-slate-600 mb-1">Min. Request</p> 79 + </div> 80 + <div tw="flex flex-col flex-1"> 81 + <p tw="text-slate-600 mb-1">Max. Request</p> 82 + </div> 83 + </div> 84 + <div tw="flex w-full h-px bg-slate-200" /> 85 + <div tw="flex"> 86 + <div tw="flex flex-col flex-1"> 87 + <div tw="flex items-center"> 88 + <p tw="text-slate-600 font-medium text-lg mr-2 w-24 mb-2"> 89 + Status 90 + </p> 91 + <p 92 + tw={cn( 93 + "text-lg border rounded-full px-3 mb-2", 94 + getStatusColor(min!.status), 95 + )} 96 + > 97 + {min?.status} 98 + </p> 99 + </div> 100 + <div tw="flex items-center"> 101 + <p tw="text-slate-600 font-medium text-lg mr-2 w-24 mb-2"> 102 + Region 103 + </p> 104 + <p tw="text-black text-xl mb-2">{regionFormatter(min!.region)}</p> 105 + </div> 106 + <div tw="flex items-center"> 107 + <p tw="text-slate-600 font-medium text-lg mr-2 w-24 mb-2"> 108 + Latency 109 + </p> 110 + <p tw="text-black text-xl font-mono mb-2">{min?.latency}ms</p> 111 + </div> 112 + </div> 113 + <div tw="flex flex-col flex-1"> 114 + <div tw="flex items-center"> 115 + <p tw="text-slate-600 font-medium text-lg mr-2 w-24 mb-2"> 116 + Status 117 + </p> 118 + <p 119 + tw={cn( 120 + "text-lg border rounded-full px-3 mb-2", 121 + getStatusColor(max!.status), 122 + )} 123 + > 124 + {max?.status} 125 + </p> 126 + </div> 127 + <div tw="flex items-center"> 128 + <p tw="text-slate-600 font-medium text-lg mr-2 w-24 mb-2"> 129 + Region 130 + </p> 131 + <p tw="text-black text-xl mb-2">{regionFormatter(max!.region)}</p> 132 + </div> 133 + <div tw="flex items-center"> 134 + <p tw="text-slate-600 font-medium text-lg mr-2 w-24 mb-2"> 135 + Latency 136 + </p> 137 + <p tw="text-black text-xl font-mono mb-2">{max?.latency}ms</p> 138 + </div> 139 + </div> 140 + </div> 141 + </BasicLayout> 142 + ), 143 + { 144 + ...SIZE, 145 + fonts: [ 146 + { 147 + name: "Inter", 148 + data: interMediumData, 149 + style: "normal", 150 + weight: 500, 151 + }, 152 + { 153 + name: "Inter", 154 + data: interRegularData, 155 + style: "normal", 156 + weight: 400, 157 + }, 158 + { 159 + name: "Inter", 160 + data: interLightData, 161 + style: "normal", 162 + weight: 300, 163 + }, 164 + { 165 + name: "Cal", 166 + data: calSemiBoldData, 167 + style: "normal", 168 + weight: 600, 169 + }, 170 + ], 171 + }, 172 + ); 173 + }
+73
apps/web/src/app/api/og/monitor/route.tsx
··· 1 + import { ImageResponse } from "next/server"; 2 + 3 + import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 4 + import { getMonitorListData } from "@/lib/tb"; 5 + import { convertTimezoneToGMT } from "@/lib/timezone"; 6 + import { BasicLayout } from "../_components/basic-layout"; 7 + import { Tracker } from "../_components/tracker"; 8 + import { calSemiBold, interLight, interRegular, SIZE } from "../utils"; 9 + 10 + export const runtime = "edge"; 11 + 12 + export async function GET(req: Request) { 13 + const interRegularData = await interRegular; 14 + const interLightData = await interLight; 15 + const calSemiBoldData = await calSemiBold; 16 + 17 + const { searchParams } = new URL(req.url); 18 + 19 + const title = 20 + (searchParams.has("title") && searchParams.get("title")) || TITLE; 21 + 22 + const description = 23 + (searchParams.has("description") && searchParams.get("description")) || 24 + DESCRIPTION; 25 + 26 + const monitorId = 27 + (searchParams.has("id") && searchParams.get("id")) || undefined; 28 + 29 + const timezone = convertTimezoneToGMT(); 30 + 31 + const data = 32 + (monitorId && 33 + (await getMonitorListData({ 34 + monitorId, 35 + timezone, 36 + }))) || 37 + []; 38 + 39 + return new ImageResponse( 40 + ( 41 + <BasicLayout 42 + title={title} 43 + description={description} 44 + tw={data.length === 0 ? "mt-32" : undefined} 45 + > 46 + {Boolean(data.length) ? <Tracker data={data} /> : null} 47 + </BasicLayout> 48 + ), 49 + { 50 + ...SIZE, 51 + fonts: [ 52 + { 53 + name: "Inter", 54 + data: interRegularData, 55 + style: "normal", 56 + weight: 400, 57 + }, 58 + { 59 + name: "Inter", 60 + data: interLightData, 61 + style: "normal", 62 + weight: 300, 63 + }, 64 + { 65 + name: "Cal", 66 + data: calSemiBoldData, 67 + style: "normal", 68 + weight: 600, 69 + }, 70 + ], 71 + }, 72 + ); 73 + }
+75
apps/web/src/app/api/og/page/route.tsx
··· 1 + import { ImageResponse } from "next/server"; 2 + 3 + import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 4 + import { getResponseListData } from "@/lib/tb"; 5 + import { calcStatus } from "@/lib/tracker"; 6 + import { notEmpty } from "@/lib/utils"; 7 + import { api } from "@/trpc/server"; 8 + import { BasicLayout } from "../_components/basic-layout"; 9 + import { StatusCheck } from "../_components/status-check"; 10 + import { calSemiBold, interLight, interRegular, SIZE } from "../utils"; 11 + 12 + export const runtime = "edge"; 13 + 14 + export async function GET(req: Request) { 15 + const interRegularData = await interRegular; 16 + const interLightData = await interLight; 17 + const calSemiBoldData = await calSemiBold; 18 + 19 + const { searchParams } = new URL(req.url); 20 + 21 + const slug = searchParams.has("slug") ? searchParams.get("slug") : undefined; 22 + 23 + const page = await api.page.getPageBySlug.query({ slug: slug || "" }); 24 + 25 + const title = page ? page.title : TITLE; 26 + const description = page ? "" : DESCRIPTION; 27 + 28 + const isIncident = page?.statusReports.some( 29 + (incident) => !["monitoring", "resolved"].includes(incident.status), 30 + ); 31 + 32 + const monitorsData = ( 33 + await Promise.all( 34 + page?.monitors.map((monitor) => { 35 + return getResponseListData({ 36 + monitorId: String(monitor.id), 37 + limit: 10, 38 + }); 39 + }) || [], 40 + ) 41 + ).filter(notEmpty); 42 + 43 + const status = calcStatus(monitorsData); 44 + 45 + return new ImageResponse( 46 + ( 47 + <BasicLayout title={title} description={description} tw="py-24 px-24"> 48 + <StatusCheck variant={isIncident ? "incident" : status.variant} /> 49 + </BasicLayout> 50 + ), 51 + { 52 + ...SIZE, 53 + fonts: [ 54 + { 55 + name: "Inter", 56 + data: interRegularData, 57 + style: "normal", 58 + weight: 400, 59 + }, 60 + { 61 + name: "Inter", 62 + data: interLightData, 63 + style: "normal", 64 + weight: 300, 65 + }, 66 + { 67 + name: "Cal", 68 + data: calSemiBoldData, 69 + style: "normal", 70 + weight: 600, 71 + }, 72 + ], 73 + }, 74 + ); 75 + }
+24 -70
apps/web/src/app/api/og/post/route.tsx
··· 2 2 import { ImageResponse } from "next/server"; 3 3 4 4 import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 5 + import { BasicLayout } from "../_components/basic-layout"; 6 + import { 7 + calSemiBold, 8 + DEFAULT_URL, 9 + interLight, 10 + interRegular, 11 + SIZE, 12 + } from "../utils"; 5 13 6 14 export const runtime = "edge"; 7 15 8 - const DEFAULT_URL = process.env.VERCEL_URL 9 - ? `https://${process.env.VERCEL_URL}` 10 - : "http://localhost:3000"; 11 - 12 - const size = { 13 - width: 1200, 14 - height: 630, 15 - }; 16 - 17 - const interRegular = fetch( 18 - new URL("../../../../public/fonts/Inter-Regular.ttf", import.meta.url), 19 - ).then((res) => res.arrayBuffer()); 20 - 21 - const interLight = fetch( 22 - new URL("../../../../public/fonts/Inter-Light.ttf", import.meta.url), 23 - ).then((res) => res.arrayBuffer()); 24 - 25 - const calSemiBold = fetch( 26 - new URL("../../../../public/fonts/CalSans-SemiBold.ttf", import.meta.url), 27 - ).then((res) => res.arrayBuffer()); 28 - 29 - // TODO: add publishedAt date 30 - // Think of instead having the image, preferably display author... 31 - 32 16 export async function GET(req: Request) { 33 17 const interRegularData = await interRegular; 34 18 const interLightData = await interLight; ··· 36 20 37 21 const { searchParams } = new URL(req.url); 38 22 39 - const title = searchParams.has("title") ? searchParams.get("title") : TITLE; 40 - const description = searchParams.has("description") 41 - ? searchParams.get("description") 42 - : DESCRIPTION; 23 + const title = 24 + (searchParams.has("title") && searchParams.get("title")) || TITLE; 25 + const description = 26 + (searchParams.has("description") && searchParams.get("description")) || 27 + DESCRIPTION; 43 28 const image = searchParams.has("image") 44 29 ? searchParams.get("image") 45 30 : undefined; 46 31 47 32 return new ImageResponse( 48 33 ( 49 - <div tw="relative flex flex-col bg-white items-center justify-center w-full h-full"> 50 - <div 51 - tw="flex w-full h-full absolute inset-0" 52 - // not every css variable is supported 53 - style={{ 54 - backgroundImage: "radial-gradient(#cbd5e1 10%, transparent 10%)", 55 - backgroundSize: "24px 24px", 56 - }} 57 - /> 58 - <div 59 - tw="flex w-full h-full absolute inset-0 opacity-70" 60 - style={{ 61 - backgroundColor: "white", 62 - backgroundImage: 63 - "radial-gradient(farthest-corner at 100px 100px, #cbd5e1, white 80%)", // tbd: switch color position 64 - }} 65 - /> 66 - <div tw="flex flex-col h-full justify-between px-24"> 67 - <div tw="flex flex-col flex-1 justify-end"> 68 - <div tw="flex flex-col px-12"> 69 - {/* lineClamp not working... */} 70 - <h1 style={{ fontFamily: "Cal", lineClamp: "2" }} tw="text-6xl"> 71 - {title} 72 - </h1> 73 - <p style={{ lineClamp: "2" }} tw="text-slate-600 text-3xl"> 74 - {description} 75 - </p> 76 - </div> 77 - </div> 78 - {image ? ( 79 - <div tw="flex justify-center shadow-2xl mt-1"> 80 - <img 81 - alt="" 82 - style={{ objectFit: "cover", height: 350 }} // h-80 = 320px 83 - tw="flex w-full border-2 rounded-xl" 84 - src={new URL(image, DEFAULT_URL).toString()} 85 - /> 86 - </div> 87 - ) : null} 88 - </div> 89 - </div> 34 + <BasicLayout title={title} description={description}> 35 + {image ? ( 36 + <img 37 + alt="" 38 + style={{ objectFit: "cover", height: 350 }} // h-80 = 320px 39 + tw="flex w-full" 40 + src={new URL(image, DEFAULT_URL).toString()} 41 + /> 42 + ) : null} 43 + </BasicLayout> 90 44 ), 91 45 { 92 - ...size, 46 + ...SIZE, 93 47 fonts: [ 94 48 { 95 49 name: "Inter",
+64 -103
apps/web/src/app/api/og/route.tsx
··· 1 + /* eslint-disable @next/next/no-img-element */ 1 2 import { ImageResponse } from "next/server"; 2 3 3 4 import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 4 - import { getMonitorListData } from "@/lib/tb"; 5 - import { convertTimezoneToGMT } from "@/lib/timezone"; 5 + import { Background } from "./_components/background"; 6 6 import { 7 - addBlackListInfo, 8 - getStatus, 9 - getTotalUptimeString, 10 - } from "@/lib/tracker"; 11 - import { cn, formatDate } from "@/lib/utils"; 7 + calSemiBold, 8 + DEFAULT_URL, 9 + interLight, 10 + interMedium, 11 + interRegular, 12 + SIZE, 13 + } from "./utils"; 12 14 13 15 export const runtime = "edge"; 14 16 15 - const size = { 16 - width: 1200, 17 - height: 630, 18 - }; 19 - 20 - const interRegular = fetch( 21 - new URL("../../../public/fonts/Inter-Regular.ttf", import.meta.url), 22 - ).then((res) => res.arrayBuffer()); 23 - 24 - const interLight = fetch( 25 - new URL("../../../public/fonts/Inter-Light.ttf", import.meta.url), 26 - ).then((res) => res.arrayBuffer()); 27 - 28 - const calSemiBold = fetch( 29 - new URL("../../../public/fonts/CalSans-SemiBold.ttf", import.meta.url), 30 - ).then((res) => res.arrayBuffer()); 17 + // const TITLE = "A better way to monitor your services."; 18 + // const DESCRIPTION = "Reduce alert fatigue by triggering only relevant alerts when your services experience downtime."; 19 + const IMAGE = "assets/og/dashboard.png"; 20 + const FOOTER = "openstatus.dev"; 31 21 32 22 export async function GET(req: Request) { 23 + const interMediumData = await interMedium; 33 24 const interRegularData = await interRegular; 34 25 const interLightData = await interLight; 35 26 const calSemiBoldData = await calSemiBold; 36 27 37 28 const { searchParams } = new URL(req.url); 38 29 39 - const title = searchParams.has("title") ? searchParams.get("title") : TITLE; 40 - const description = searchParams.has("description") 41 - ? searchParams.get("description") 42 - : DESCRIPTION; 43 - const monitorId = searchParams.has("monitorId") 44 - ? searchParams.get("monitorId") 45 - : undefined; 30 + const title = 31 + (searchParams.has("title") && searchParams.get("title")) || TITLE; 46 32 47 - const timezone = convertTimezoneToGMT(); 33 + const description = 34 + (searchParams.has("description") && searchParams.get("description")) || 35 + DESCRIPTION; 48 36 49 - // currently, we only show the tracker for a single(!) monitor 50 - const data = 51 - (monitorId && 52 - (await getMonitorListData({ 53 - monitorId, 54 - timezone, 55 - }))) || 56 - []; 37 + const image = 38 + (searchParams.has("image") && searchParams.get("image")) || IMAGE; 57 39 58 - const _data = addBlackListInfo(data); 59 - const uptime = getTotalUptimeString(data); 40 + const footer = 41 + (searchParams.has("footer") && searchParams.get("footer")) || FOOTER; 60 42 61 43 return new ImageResponse( 62 44 ( 63 - <div tw="relative flex flex-col bg-white items-center justify-center w-full h-full"> 45 + <Background tw="justify-start items-start"> 64 46 <div 65 - tw="flex w-full h-full absolute inset-0" 66 - // not every css variable is supported 67 - style={{ 68 - backgroundImage: "radial-gradient(#cbd5e1 10%, transparent 10%)", 69 - backgroundSize: "24px 24px", 70 - }} 71 - /> 47 + style={{ clipPath: "polygon(90% 0%, 200% 0%, 200% 200%, -30% 200%)" }} 48 + tw="flex absolute h-full w-full bg-slate-200" 49 + > 50 + <img 51 + alt="" 52 + style={{ objectFit: "cover" }} 53 + tw="flex w-full h-full" 54 + src={new URL(image, DEFAULT_URL).toString()} 55 + /> 56 + </div> 57 + {/* adds a border to the mask element */} 72 58 <div 73 - tw="flex w-full h-full absolute inset-0 opacity-70" 74 59 style={{ 75 - backgroundColor: "white", 60 + clipPath: "polygon(90% 0%, 170% 0%, -30% 200%, -29% 200%)", 61 + // from-slate-100 to-slate-300 76 62 backgroundImage: 77 - "radial-gradient(farthest-corner at 100px 100px, #cbd5e1, white 80%)", // tbd: switch color position 63 + "linear-gradient(to bottom left, #f1f5f9, #cbd5e1)", 78 64 }} 65 + tw="flex absolute h-full w-full" // bg-slate-200 79 66 /> 80 - <div tw="max-w-4xl relative flex flex-col"> 81 - <h1 style={{ fontFamily: "Cal" }} tw="text-6xl"> 82 - {title} 83 - </h1> 84 - <p tw="text-slate-600 text-3xl">{description}</p> 85 - {Boolean(data.length) && Boolean(_data.length) ? ( 86 - <div tw="flex flex-col w-full mt-6"> 87 - <div tw="flex flex-row items-center justify-between -mb-1 text-black font-light"> 88 - <p tw="">{formatDate(new Date())}</p> 89 - <p tw="mr-1">{uptime}</p> 90 - </div> 91 - <div tw="flex flex-row relative"> 92 - {/* Empty State */} 93 - {new Array(data.length).fill(null).map((_, i) => { 94 - return ( 95 - <div key={i} tw="h-16 w-3 rounded-full mr-1 bg-black/20" /> 96 - ); 97 - })} 98 - <div tw="flex flex-row-reverse absolute right-0"> 99 - {_data.map((item, i) => { 100 - const { variant } = getStatus(item.ok / item.count); 101 - const isBlackListed = Boolean(item.blacklist); 102 - if (isBlackListed) { 103 - return ( 104 - <div 105 - key={i} 106 - tw="h-16 w-3 rounded-full mr-1 bg-green-400" 107 - /> 108 - ); 109 - } 110 - return ( 111 - <div 112 - key={i} 113 - tw={cn("h-16 w-3 rounded-full mr-1", { 114 - "bg-green-500": variant === "up", 115 - "bg-red-500": variant === "down", 116 - "bg-yellow-500": variant === "degraded", 117 - })} 118 - /> 119 - ); 120 - })} 121 - </div> 122 - </div> 123 - <div tw="flex flex-row items-center justify-between -mt-3 text-slate-500 text-sm"> 124 - <p tw="">{data.length} days ago</p> 125 - <p tw="mr-1">today</p> 126 - </div> 127 - </div> 128 - ) : null} 67 + <div tw="flex flex-col justify-between h-full flex-1 py-24 px-24"> 68 + <div tw="flex flex-col h-full flex-1 justify-center"> 69 + <h1 70 + style={{ fontFamily: "Cal", width: 700 }} 71 + tw="text-6xl text-black" 72 + > 73 + {title} 74 + </h1> 75 + <p style={{ width: 580 }} tw="text-4xl text-slate-700"> 76 + {description} 77 + </p> 78 + </div> 79 + <div tw="flex w-full"> 80 + <p style={{ width: 450 }} tw="font-medium text-xl"> 81 + {footer} 82 + </p> 83 + </div> 129 84 </div> 130 - </div> 85 + </Background> 131 86 ), 132 87 { 133 - ...size, 88 + ...SIZE, 134 89 fonts: [ 90 + { 91 + name: "Inter", 92 + data: interMediumData, 93 + style: "normal", 94 + weight: 500, 95 + }, 135 96 { 136 97 name: "Inter", 137 98 data: interRegularData,
+24
apps/web/src/app/api/og/utils.ts
··· 1 + export const SIZE = { 2 + width: 1200, 3 + height: 630, 4 + }; 5 + 6 + export const DEFAULT_URL = process.env.VERCEL_URL 7 + ? `https://${process.env.VERCEL_URL}` 8 + : "http://localhost:3000"; 9 + 10 + export const interMedium = fetch( 11 + new URL("../../../public/fonts/Inter-Medium.ttf", import.meta.url), 12 + ).then((res) => res.arrayBuffer()); 13 + 14 + export const interRegular = fetch( 15 + new URL("../../../public/fonts/Inter-Regular.ttf", import.meta.url), 16 + ).then((res) => res.arrayBuffer()); 17 + 18 + export const interLight = fetch( 19 + new URL("../../../public/fonts/Inter-Light.ttf", import.meta.url), 20 + ).then((res) => res.arrayBuffer()); 21 + 22 + export const calSemiBold = fetch( 23 + new URL("../../../public/fonts/CalSans-SemiBold.ttf", import.meta.url), 24 + ).then((res) => res.arrayBuffer());
+30 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/_components/chart.tsx
··· 1 1 "use client"; 2 2 3 + import type { CustomTooltipProps } from "@tremor/react"; 3 4 import { Card, LineChart, Title } from "@tremor/react"; 4 5 5 6 const dataFormatter = (number: number) => ··· 40 41 ]} 41 42 onValueChange={(v) => void 0} // that prop makes the chart interactive 42 43 valueFormatter={dataFormatter} 43 - curveType="natural" 44 + curveType="monotone" 45 + intervalType="equidistantPreserveStart" 46 + // customTooltip={customTooltip} 44 47 /> 45 48 </Card> 46 49 ); 47 50 } 51 + 52 + // TBD: add custom tooltip 53 + const customTooltip = ({ payload, active }: CustomTooltipProps) => { 54 + if (!active || !payload) return null; 55 + return ( 56 + <div className="rounded-tremor-default text-tremor-default bg-tremor-background shadow-tremor-dropdown border-tremor-border w-36 border p-2"> 57 + <div className="flex flex-col gap-3"> 58 + {payload 59 + .filter((category) => category.type !== undefined) // tremor adds additional data to the payload, we don't want that 60 + .map((category, idx) => ( 61 + <div key={idx} className="flex flex-1 gap-2"> 62 + <div 63 + className={`flex w-1 flex-col bg-${category.color}-500 rounded`} 64 + /> 65 + <div className="flex gap-1"> 66 + <p className="text-tremor-content">{category.dataKey}</p> 67 + <p className="text-tremor-content-emphasis font-mono font-medium"> 68 + {dataFormatter(category.value as number)} 69 + </p> 70 + </div> 71 + </div> 72 + ))} 73 + </div> 74 + </div> 75 + ); 76 + };
+33 -5
apps/web/src/app/play/checker/[id]/page.tsx
··· 1 + import type { Metadata } from "next"; 1 2 import Link from "next/link"; 2 3 import { redirect } from "next/navigation"; 3 4 import * as z from "zod"; ··· 5 6 import { monitorFlyRegionSchema } from "@openstatus/db/src/schema"; 6 7 import { Separator } from "@openstatus/ui"; 7 8 9 + import { 10 + defaultMetadata, 11 + ogMetadata, 12 + twitterMetadata, 13 + } from "@/app/shared-metadata"; 8 14 import { Shell } from "@/components/dashboard/shell"; 9 15 import { BackButton } from "@/components/layout/back-button"; 10 16 import { CopyLinkButton } from "./_components/copy-link-button"; ··· 22 28 region: monitorFlyRegionSchema.optional(), 23 29 }); 24 30 25 - export default async function CheckPage({ 26 - params, 27 - searchParams, 28 - }: { 31 + interface Props { 29 32 params: { id: string }; 30 33 searchParams: { [key: string]: string | string[] | undefined }; 31 - }) { 34 + } 35 + 36 + export default async function CheckPage({ params, searchParams }: Props) { 32 37 const search = searchParamsSchema.safeParse(searchParams); 33 38 34 39 const selectedRegion = search.success ? search.data.region : undefined; ··· 90 95 </> 91 96 ); 92 97 } 98 + 99 + export async function generateMetadata({ params }: Props): Promise<Metadata> { 100 + const title = "Speed Checker"; 101 + const description = 102 + "Get speed insights for your api, website from multiple regions."; 103 + return { 104 + ...defaultMetadata, 105 + title, 106 + description, 107 + twitter: { 108 + ...twitterMetadata, 109 + title, 110 + description, 111 + images: [`/api/og/checker?id=${params?.id}`], 112 + }, 113 + openGraph: { 114 + ...ogMetadata, 115 + title, 116 + description, 117 + images: [`/api/og/checker?id=${params?.id}`], 118 + }, 119 + }; 120 + }
+1 -1
apps/web/src/app/shared-metadata.ts
··· 2 2 3 3 export const TITLE = "OpenStatus"; 4 4 export const DESCRIPTION = 5 - "A better way to monitor your services. Don't let your down time ruin your day."; 5 + "A better way to monitor your services. Don't let your downtime ruin your day."; 6 6 7 7 export const defaultMetadata: Metadata = { 8 8 title: {
+2 -11
apps/web/src/app/status-page/[domain]/layout.tsx
··· 93 93 94 94 export async function generateMetadata({ params }: Props): Promise<Metadata> { 95 95 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 96 - const firstMonitor = page?.monitors?.[0]; // temporary solution 97 96 98 97 return { 99 98 ...defaultMetadata, ··· 102 101 icons: page?.icon, 103 102 twitter: { 104 103 ...twitterMetadata, 105 - images: [ 106 - `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 107 - page?.description || `The ${page?.title} status page` 108 - }`, 109 - ], 104 + images: [`/api/og/page?slug=${page?.slug}`], 110 105 title: page?.title, 111 106 description: page?.description, 112 107 }, 113 108 openGraph: { 114 109 ...ogMetadata, 115 - images: [ 116 - `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 117 - page?.description || `The ${page?.title} status page` 118 - }`, 119 - ], 110 + images: [`/api/og/page?slug=${page?.slug}`], 120 111 title: page?.title, 121 112 description: page?.description, 122 113 },
+2 -15
apps/web/src/components/status-page/status-check.tsx
··· 8 8 9 9 import { getResponseListData } from "@/lib/tb"; 10 10 import type { StatusVariant } from "@/lib/tracker"; 11 - import { getStatus } from "@/lib/tracker"; 11 + import { calcStatus } from "@/lib/tracker"; 12 12 import { cn, notEmpty } from "@/lib/utils"; 13 13 import { Icons } from "../icons"; 14 14 ··· 49 49 ) 50 50 ).filter(notEmpty); 51 51 52 - function calcStatus() { 53 - const { count, ok } = monitorsData.flat(1).reduce( 54 - (prev, curr) => { 55 - if (!curr.statusCode) return prev; // TODO: handle this better 56 - const isOk = curr.statusCode <= 299 && curr.statusCode >= 200; 57 - return { count: prev.count + 1, ok: prev.ok + (isOk ? 1 : 0) }; 58 - }, 59 - { count: 0, ok: 0 }, 60 - ); 61 - const ratio = ok / count; 62 - if (isNaN(ratio)) return getStatus(1); // outsmart caching issue 63 - return getStatus(ratio); 64 - } 52 + const status = calcStatus(monitorsData); 65 53 66 - const status = calcStatus(); 67 54 const incident = { 68 55 label: "Incident", 69 56 variant: "incident",
+3 -1
apps/web/src/components/workspace/select-workspace.tsx
··· 50 50 <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 51 51 </Button> 52 52 </DropdownMenuTrigger> 53 - <DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]"> 53 + <DropdownMenuContent 54 + style={{ width: "var(--radix-dropdown-menu-trigger-width)" }} 55 + > 54 56 <DropdownMenuLabel>Workspaces</DropdownMenuLabel> 55 57 <DropdownMenuSeparator /> 56 58 {workspaces.map((workspace) => (
+18 -1
apps/web/src/lib/tracker.ts
··· 1 - import type { Monitor } from "@openstatus/tinybird"; 1 + import type { Monitor, Ping } from "@openstatus/tinybird"; 2 2 3 3 export type StatusVariant = "up" | "degraded" | "down" | "empty"; 4 4 ··· 88 88 areDatesEqualByDayMonthYear(new Date(date), new Date(timestamp)), 89 89 ); 90 90 return el ? blacklistDates[el] : undefined; 91 + } 92 + 93 + /** 94 + * Calculate the overall status of a page based on all the monitor data 95 + */ 96 + export function calcStatus(data: Ping[][]) { 97 + const { count, ok } = data.flat(1).reduce( 98 + (prev, curr) => { 99 + if (!curr.statusCode) return prev; // TODO: handle this better 100 + const isOk = curr.statusCode <= 299 && curr.statusCode >= 200; 101 + return { count: prev.count + 1, ok: prev.ok + (isOk ? 1 : 0) }; 102 + }, 103 + { count: 0, ok: 0 }, 104 + ); 105 + const ratio = ok / count; 106 + if (isNaN(ratio)) return getStatus(1); // outsmart caching issue 107 + return getStatus(ratio); 91 108 } 92 109 93 110 /**
apps/web/src/public/fonts/Inter-Medium.ttf

This is a binary file and will not be displayed.

+57 -46
pnpm-lock.yaml
··· 177 177 specifier: 8.10.3 178 178 version: 8.10.3(react-dom@18.2.0)(react@18.2.0) 179 179 '@tremor/react': 180 - specifier: 3.8.2 181 - version: 3.8.2(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2) 180 + specifier: 3.13.3 181 + version: 3.13.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2) 182 182 '@trpc/client': 183 183 specifier: 10.38.5 184 184 version: 10.38.5(@trpc/server@10.38.5) ··· 2238 2238 react-dom: 18.2.0(react@18.2.0) 2239 2239 dev: false 2240 2240 2241 - /@headlessui/tailwindcss@0.1.3(tailwindcss@3.3.2): 2242 - resolution: {integrity: sha512-3aMdDyYZx9A15euRehpppSyQnb2gIw2s/Uccn2ELIoLQ9oDy0+9oRygNWNjXCD5Dt+w1pxo7C+XoiYvGcqA4Kg==} 2241 + /@headlessui/react@1.7.18(react-dom@18.2.0)(react@18.2.0): 2242 + resolution: {integrity: sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==} 2243 + engines: {node: '>=10'} 2244 + peerDependencies: 2245 + react: ^16 || ^17 || ^18 2246 + react-dom: ^16 || ^17 || ^18 2247 + dependencies: 2248 + '@tanstack/react-virtual': 3.0.2(react-dom@18.2.0)(react@18.2.0) 2249 + client-only: 0.0.1 2250 + react: 18.2.0 2251 + react-dom: 18.2.0(react@18.2.0) 2252 + dev: false 2253 + 2254 + /@headlessui/tailwindcss@0.2.0(tailwindcss@3.3.2): 2255 + resolution: {integrity: sha512-fpL830Fln1SykOCboExsWr3JIVeQKieLJ3XytLe/tt1A0XzqUthOftDmjcCYLW62w7mQI7wXcoPXr3tZ9QfGxw==} 2243 2256 engines: {node: '>=10'} 2244 2257 peerDependencies: 2245 2258 tailwindcss: ^3.0 ··· 4869 4882 react-dom: 18.2.0(react@18.2.0) 4870 4883 dev: false 4871 4884 4885 + /@tanstack/react-virtual@3.0.2(react-dom@18.2.0)(react@18.2.0): 4886 + resolution: {integrity: sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA==} 4887 + peerDependencies: 4888 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 4889 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 4890 + dependencies: 4891 + '@tanstack/virtual-core': 3.0.0 4892 + react: 18.2.0 4893 + react-dom: 18.2.0(react@18.2.0) 4894 + dev: false 4895 + 4872 4896 /@tanstack/table-core@8.10.3: 4873 4897 resolution: {integrity: sha512-hJ55YfJlWbfzRROfcyA/kC1aZr/shsLA8XNAwN8jXylhYWGLnPmiJJISrUfj4dMMWRiFi0xBlnlC7MLH+zSrcw==} 4874 4898 engines: {node: '>=12'} 4875 4899 dev: false 4876 4900 4901 + /@tanstack/virtual-core@3.0.0: 4902 + resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} 4903 + dev: false 4904 + 4877 4905 /@tootallnate/once@2.0.0: 4878 4906 resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} 4879 4907 engines: {node: '>= 10'} ··· 4883 4911 resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} 4884 4912 dev: true 4885 4913 4886 - /@tremor/react@3.8.2(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2): 4887 - resolution: {integrity: sha512-gZ6GTBIiL915U2Tikreooh8nllWEgKd1wnZ3ru0Cm9qmq6TbW+jHikCpHy1KQ2PQkpdKrXi5R2s6f8N4Fj5Imw==} 4914 + /@tremor/react@3.13.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.2): 4915 + resolution: {integrity: sha512-v0JTAhZr1VTj67nmrb5WF/vI5Mq3Fj7LigPYwqFZcYwrF1UXkUwv5mEt8V5GR5QVMmprmYx7A6m8baImt99IQQ==} 4888 4916 peerDependencies: 4889 4917 react: ^18.0.0 4890 4918 react-dom: '>=16.6.0' 4891 4919 dependencies: 4892 4920 '@floating-ui/react': 0.19.2(react-dom@18.2.0)(react@18.2.0) 4893 - '@headlessui/react': 1.7.17(react-dom@18.2.0)(react@18.2.0) 4894 - '@headlessui/tailwindcss': 0.1.3(tailwindcss@3.3.2) 4921 + '@headlessui/react': 1.7.18(react-dom@18.2.0)(react@18.2.0) 4922 + '@headlessui/tailwindcss': 0.2.0(tailwindcss@3.3.2) 4895 4923 date-fns: 2.30.0 4896 4924 react: 18.2.0 4897 - react-day-picker: 8.8.2(date-fns@2.30.0)(react@18.2.0) 4925 + react-day-picker: 8.10.0(date-fns@2.30.0)(react@18.2.0) 4898 4926 react-dom: 18.2.0(react@18.2.0) 4899 - react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) 4900 - recharts: 2.9.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) 4927 + react-transition-state: 2.1.1(react-dom@18.2.0)(react@18.2.0) 4928 + recharts: 2.11.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) 4901 4929 tailwind-merge: 1.14.0 4902 4930 transitivePeerDependencies: 4903 4931 - prop-types ··· 6172 6200 clsx: 2.0.0 6173 6201 dev: false 6174 6202 6175 - /classnames@2.3.2: 6176 - resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} 6177 - dev: false 6178 - 6179 6203 /clean-stack@2.2.0: 6180 6204 resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} 6181 6205 engines: {node: '>=6'} ··· 6774 6798 resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} 6775 6799 dependencies: 6776 6800 '@babel/runtime': 7.23.2 6777 - dev: false 6778 - 6779 - /dom-helpers@5.2.1: 6780 - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} 6781 - dependencies: 6782 - '@babel/runtime': 7.23.2 6783 - csstype: 3.1.2 6784 6801 dev: false 6785 6802 6786 6803 /dom-serializer@2.0.0: ··· 11136 11153 minimist: 1.2.8 11137 11154 strip-json-comments: 2.0.1 11138 11155 11156 + /react-day-picker@8.10.0(date-fns@2.30.0)(react@18.2.0): 11157 + resolution: {integrity: sha512-mz+qeyrOM7++1NCb1ARXmkjMkzWVh2GL9YiPbRjKe0zHccvekk4HE+0MPOZOrosn8r8zTHIIeOUXTmXRqmkRmg==} 11158 + peerDependencies: 11159 + date-fns: ^2.28.0 || ^3.0.0 11160 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 11161 + dependencies: 11162 + date-fns: 2.30.0 11163 + react: 18.2.0 11164 + dev: false 11165 + 11139 11166 /react-day-picker@8.8.2(date-fns@2.30.0)(react@18.2.0): 11140 11167 resolution: {integrity: sha512-sK5M5PNZaLiszmACUKUpVu1eX3eFDVV+WLdWQ3BxTPbEC9jhuawmlgpbSXX5dIIQQwJpZ4wwP5+vsMVOwa1IRw==} 11141 11168 peerDependencies: ··· 11257 11284 tslib: 2.6.2 11258 11285 use-callback-ref: 1.3.0(@types/react@18.2.24)(react@18.2.0) 11259 11286 use-sidecar: 1.1.2(@types/react@18.2.24)(react@18.2.0) 11260 - dev: false 11261 - 11262 - /react-resize-detector@8.1.0(react-dom@18.2.0)(react@18.2.0): 11263 - resolution: {integrity: sha512-S7szxlaIuiy5UqLhLL1KY3aoyGHbZzsTpYal9eYMwCyKqoqoVLCmIgAgNyIM1FhnP2KyBygASJxdhejrzjMb+w==} 11264 - peerDependencies: 11265 - react: ^16.0.0 || ^17.0.0 || ^18.0.0 11266 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 11267 - dependencies: 11268 - lodash: 4.17.21 11269 - react: 18.2.0 11270 - react-dom: 18.2.0(react@18.2.0) 11271 11287 dev: false 11272 11288 11273 11289 /react-smooth@2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): ··· 11323 11339 react-lifecycles-compat: 3.0.4 11324 11340 dev: false 11325 11341 11326 - /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): 11327 - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} 11342 + /react-transition-state@2.1.1(react-dom@18.2.0)(react@18.2.0): 11343 + resolution: {integrity: sha512-kQx5g1FVu9knoz1T1WkapjUgFz08qQ/g1OmuWGi3/AoEFfS0kStxrPlZx81urjCXdz2d+1DqLpU6TyLW/Ro04Q==} 11328 11344 peerDependencies: 11329 - react: '>=16.6.0' 11330 - react-dom: '>=16.6.0' 11345 + react: '>=16.8.0' 11346 + react-dom: '>=16.8.0' 11331 11347 dependencies: 11332 - '@babel/runtime': 7.23.2 11333 - dom-helpers: 5.2.1 11334 - loose-envify: 1.4.0 11335 - prop-types: 15.8.1 11336 11348 react: 18.2.0 11337 11349 react-dom: 18.2.0(react@18.2.0) 11338 11350 dev: false ··· 11415 11427 decimal.js-light: 2.5.1 11416 11428 dev: false 11417 11429 11418 - /recharts@2.9.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): 11419 - resolution: {integrity: sha512-cVgiAU3W5UrA8nRRV/N0JrudgZzY/vjkzrlShbH+EFo1vs4nMlXgshZWLI0DfDLmn4/p4pF7Lq7F5PU+K94Ipg==} 11420 - engines: {node: '>=12'} 11430 + /recharts@2.11.0(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): 11431 + resolution: {integrity: sha512-5s+u1m5Hwxb2nh0LABkE3TS/lFqFHyWl7FnPbQhHobbQQia4ih1t3o3+ikPYr31Ns+kYe4FASIthKeKi/YYvMg==} 11432 + engines: {node: '>=14'} 11421 11433 peerDependencies: 11422 11434 prop-types: ^15.6.0 11423 11435 react: ^16.0.0 || ^17.0.0 || ^18.0.0 11424 11436 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 11425 11437 dependencies: 11426 - classnames: 2.3.2 11438 + clsx: 2.0.0 11427 11439 eventemitter3: 4.0.7 11428 11440 lodash: 4.17.21 11429 11441 prop-types: 15.8.1 11430 11442 react: 18.2.0 11431 11443 react-dom: 18.2.0(react@18.2.0) 11432 11444 react-is: 16.13.1 11433 - react-resize-detector: 8.1.0(react-dom@18.2.0)(react@18.2.0) 11434 11445 react-smooth: 2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) 11435 11446 recharts-scale: 0.4.5 11436 11447 tiny-invariant: 1.3.1