Openstatus www.openstatus.dev

feat: improve og image and status-page (#68)

* chore: og and status page

* chore: adjust styles

* chore: update og

* refactor: name siteId to monitorId

* fix: ignore route

authored by

Maximilian Kaske and committed by
GitHub
7010014a 1ea73633

+202 -45
+88 -5
apps/web/src/app/api/og/route.tsx
··· 1 1 import { ImageResponse } from "next/server"; 2 2 3 + import { getMonitorListData } from "@/lib/tb"; 4 + import { cn, formatDate } from "@/lib/utils"; 5 + 3 6 export const runtime = "edge"; 4 7 5 8 const size = { ··· 9 12 10 13 const TITLE = "Open Status"; 11 14 const DESCRIPTION = "An Open Source Alternative for your next Status Page"; 15 + const LIMIT = 40; 12 16 13 17 const interRegular = fetch( 14 18 new URL("../../../public/fonts/Inter-Regular.ttf", import.meta.url), 15 19 ).then((res) => res.arrayBuffer()); 16 20 21 + const interLight = fetch( 22 + new URL("../../../public/fonts/Inter-Light.ttf", import.meta.url), 23 + ).then((res) => res.arrayBuffer()); 24 + 17 25 const calSemiBold = fetch( 18 26 new URL("../../../public/fonts/CalSans-SemiBold.ttf", import.meta.url), 19 27 ).then((res) => res.arrayBuffer()); 20 28 21 29 export async function GET(req: Request) { 22 30 const interRegularData = await interRegular; 31 + const interLightData = await interLight; 23 32 const calSemiBoldData = await calSemiBold; 24 33 25 34 const { searchParams } = new URL(req.url); 35 + 26 36 const title = searchParams.has("title") ? searchParams.get("title") : TITLE; 27 37 const description = searchParams.has("description") 28 38 ? searchParams.get("description") 29 39 : DESCRIPTION; 40 + const monitorId = searchParams.has("monitorId") 41 + ? searchParams.get("monitorId") 42 + : undefined; 43 + 44 + const data = 45 + (monitorId && 46 + (await getMonitorListData({ 47 + siteId: monitorId, 48 + groupBy: "day", 49 + limit: LIMIT, 50 + }))) || 51 + []; 52 + 53 + const uptime = data?.reduce( 54 + (prev, curr) => { 55 + prev.ok += curr.ok; 56 + prev.count += curr.count; 57 + return prev; 58 + }, 59 + { ok: 0, count: 0 }, 60 + ); 30 61 31 62 return new ImageResponse( 32 63 ( ··· 36 67 // not every css variable is supported 37 68 style={{ 38 69 backgroundImage: "radial-gradient(#cbd5e1 10%, transparent 10%)", 39 - backgroundSize: "32px 32px", 40 - filter: "blur(1px)", // to be discussed... couldn't put it inside the content container 70 + backgroundSize: "24px 24px", 71 + }} 72 + ></div> 73 + <div 74 + tw="flex w-full h-full absolute inset-0 opacity-70" 75 + style={{ 76 + backgroundColor: "white", 77 + backgroundImage: 78 + "radial-gradient(farthest-corner at 100px 100px, #64748b, white 70%)", // tbd: switch color position 41 79 }} 42 80 ></div> 43 - <div tw="max-w-2xl relative flex flex-col items-center rounded-lg border border-slate-200 p-6 overflow-hidden bg-white bg-opacity-80"> 44 - <h1 style={{ fontFamily: "Cal" }} tw="text-6xl text-center"> 81 + <div tw="max-w-4xl relative flex flex-col"> 82 + <h1 style={{ fontFamily: "Cal" }} tw="text-6xl"> 45 83 {title} 46 84 </h1> 47 - <p tw="text-slate-600 text-3xl text-center">{description}</p> 85 + <p tw="text-slate-600 text-3xl">{description}</p> 86 + {data && data.length > 0 ? ( 87 + <div tw="flex flex-col w-full mt-6"> 88 + <div tw="flex flex-row items-center justify-between -mb-1 text-black font-light"> 89 + <p tw="">{formatDate(new Date())}</p> 90 + <p tw="mr-1"> 91 + {((uptime.ok / uptime.count) * 100).toFixed(2)}% uptime 92 + </p> 93 + </div> 94 + <div tw="flex flex-row relative"> 95 + {/* Empty State */} 96 + {new Array(LIMIT).fill(null).map((_, i) => { 97 + return ( 98 + <div 99 + key={i} 100 + tw="h-16 w-2.5 rounded-full mr-1 bg-black/20" 101 + ></div> 102 + ); 103 + })} 104 + <div tw="flex flex-row absolute right-0"> 105 + {data.map((item, i) => { 106 + const status = item.ok / item.count === 1 ? "up" : "down"; // needs to be better defined! 107 + return ( 108 + <div 109 + key={i} 110 + tw={cn("h-16 w-2.5 rounded-full mr-1", { 111 + "bg-green-600": status === "up", 112 + "bg-red-600": status === "down", 113 + })} 114 + ></div> 115 + ); 116 + })} 117 + </div> 118 + </div> 119 + <div tw="flex flex-row items-center justify-between -mt-3 text-slate-500 text-sm"> 120 + <p tw="">{LIMIT} days ago</p> 121 + <p tw="mr-1">today</p> 122 + </div> 123 + </div> 124 + ) : null} 48 125 </div> 49 126 </div> 50 127 ), ··· 56 133 data: interRegularData, 57 134 style: "normal", 58 135 weight: 400, 136 + }, 137 + { 138 + name: "Inter", 139 + data: interLightData, 140 + style: "normal", 141 + weight: 300, 59 142 }, 60 143 { 61 144 name: "Cal",
+10
apps/web/src/app/play/layout.tsx
··· 1 1 import * as React from "react"; 2 + import type { Metadata } from "next"; 2 3 3 4 import { BackButton } from "@/components/layout/back-button"; 4 5 import { Footer } from "@/components/layout/footer"; 6 + 7 + export const metadata: Metadata = { 8 + twitter: { 9 + images: [`/api/og?monitorId=openstatus`], 10 + }, 11 + openGraph: { 12 + images: [`/api/og?monitorId=openstatu`], 13 + }, 14 + }; 5 15 6 16 export default function PlayLayout({ 7 17 children,
+18 -5
apps/web/src/app/status-page/[domain]/layout.tsx
··· 4 4 children: React.ReactNode; 5 5 }) { 6 6 return ( 7 - <main className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 8 - <div className="flex flex-1 flex-col items-center justify-center gap-8"> 9 - <div className="mx-auto max-w-xl text-center">{children}</div> 10 - </div> 11 - </main> 7 + <div className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 8 + <main className="flex flex-1 flex-col items-center justify-center gap-8"> 9 + <div className="mx-auto w-full max-w-xl text-center">{children}</div> 10 + </main> 11 + <footer className="z-10"> 12 + <p className="text-muted-foreground text-center text-sm"> 13 + powered by{" "} 14 + <a 15 + href="https://openstatus.dev" 16 + target="_blank" 17 + rel="noreferrer" 18 + className="text-foreground underline underline-offset-4 hover:no-underline" 19 + > 20 + openstatus.dev 21 + </a> 22 + </p> 23 + </footer> 24 + </div> 12 25 ); 13 26 }
+17
apps/web/src/app/status-page/[domain]/loading.tsx
··· 1 + import { Container } from "@/components/dashboard/container"; 2 + import { Header } from "@/components/dashboard/header"; 3 + import { Shell } from "@/components/dashboard/shell"; 4 + 5 + export default function StatusPageLoading() { 6 + return ( 7 + <Shell> 8 + <div className="grid gap-6"> 9 + <Header.Skeleton /> 10 + <div className="grid gap-4"> 11 + <Container.Skeleton /> 12 + <Container.Skeleton /> 13 + </div> 14 + </div> 15 + </Shell> 16 + ); 17 + }
+6 -2
apps/web/src/app/status-page/[domain]/page.tsx
··· 16 16 17 17 return ( 18 18 <Shell> 19 - <div className="grid gap-4"> 20 - <Header title={page.title} description={page.description} /> 19 + <div className="grid gap-6"> 20 + <Header 21 + title={page.title} 22 + description={page.description} 23 + className="max-w-lg" 24 + /> 21 25 <MonitorList monitors={page.monitors} /> 22 26 </div> 23 27 </Shell>
+44 -19
apps/web/src/components/monitor/tracker.tsx
··· 37 37 interface TrackerProps { 38 38 data: Monitor[]; 39 39 url: string; 40 - id: string; 40 + id: string | number; 41 41 name: string; 42 42 /** 43 43 * Maximium length of the data array 44 44 */ 45 45 maxSize?: number; 46 + context?: "play" | "status-page"; // TODO: we might need to extract those two different use cases - for now it's ok I'd say. 46 47 } 47 48 48 - // TODO: discusss to move data fetching inside of Tracker 49 - export function Tracker({ data, url, id, name, maxSize = 35 }: TrackerProps) { 49 + export function Tracker({ 50 + data, 51 + url, 52 + id, 53 + name, 54 + maxSize = 35, 55 + context = "play", 56 + }: TrackerProps) { 50 57 const slicedData = data.slice(0, maxSize).reverse(); 51 58 const placeholderData: null[] = Array(maxSize).fill(null); 52 59 ··· 69 76 <div className="mb-1 flex justify-between text-sm sm:mb-2"> 70 77 <div className="flex items-center gap-2"> 71 78 <p className="text-foreground font-semibold">{name}</p> 72 - <MoreInfo {...{ url, id }} /> 79 + <MoreInfo {...{ url, id, context }} /> 73 80 </div> 74 81 <p className="text-muted-foreground font-light"> 75 82 {`${totalUptime}%`} uptime ··· 83 90 </div> 84 91 <div className="absolute right-0 top-0 flex gap-0.5"> 85 92 {slicedData.map((props) => { 86 - return <Bar key={props.cronTimestamp} {...props} />; 93 + return ( 94 + <Bar key={props.cronTimestamp} context={context} {...props} /> 95 + ); 87 96 })} 88 97 </div> 89 98 </div> ··· 91 100 ); 92 101 } 93 102 94 - const MoreInfo = ({ url, id }: Record<"id" | "url", string>) => { 103 + const MoreInfo = ({ 104 + url, 105 + id, 106 + context, 107 + }: Pick<TrackerProps, "url" | "id" | "context">) => { 95 108 const [open, setOpen] = React.useState(false); 96 109 const formattedURL = new URL(url); 110 + const link = `${formattedURL.host}${formattedURL.pathname}`; 97 111 return ( 98 112 <TooltipProvider> 99 113 <Tooltip open={open} onOpenChange={setOpen}> ··· 101 115 <Info className="h-4 w-4" /> 102 116 </TooltipTrigger> 103 117 <TooltipContent> 104 - <Link 105 - href={`/monitor/${id}`} 106 - className="text-muted-foreground hover:text-foreground" 107 - > 108 - {`${formattedURL.host}${formattedURL.pathname}`} 109 - </Link> 118 + <p className="text-muted-foreground"> 119 + {context === "play" ? ( 120 + <Link href={`/monitor/${id}`} className="hover:text-foreground"> 121 + {link} 122 + </Link> 123 + ) : ( 124 + link 125 + )} 126 + </p> 110 127 </TooltipContent> 111 128 </Tooltip> 112 129 </TooltipProvider> 113 130 ); 114 131 }; 115 132 116 - const Bar = ({ count, ok, avgLatency, cronTimestamp }: Monitor) => { 133 + const Bar = ({ 134 + count, 135 + ok, 136 + avgLatency, 137 + cronTimestamp, 138 + context, 139 + }: Monitor & Pick<TrackerProps, "context">) => { 117 140 const [open, setOpen] = React.useState(false); 118 141 const ratio = ok / count; 119 142 const isOk = ratio === 1; // TODO: when operational, downtime, degraded ··· 139 162 <p className="text-sm font-semibold"> 140 163 {isOk ? "Operational" : "Downtime"} 141 164 </p> 142 - <Link 143 - href={`/monitor/openstatus?fromDate=${cronTimestamp}&toDate=${toDate}`} 144 - className="text-muted-foreground hover:text-foreground" 145 - > 146 - <Eye className="h-4 w-4" /> 147 - </Link> 165 + {context === "play" ? ( 166 + <Link 167 + href={`/monitor/openstatus?fromDate=${cronTimestamp}&toDate=${toDate}`} 168 + className="text-muted-foreground hover:text-foreground" 169 + > 170 + <Eye className="h-4 w-4" /> 171 + </Link> 172 + ) : null} 148 173 </div> 149 174 <div className="flex justify-between"> 150 175 <p className="text-xs font-light">
+1 -1
apps/web/src/components/status-page/monitor-list.tsx
··· 10 10 monitors: z.infer<typeof selectMonitorSchema>[]; 11 11 }) => { 12 12 return ( 13 - <div> 13 + <div className="grid gap-4"> 14 14 {monitors.map((monitor, index) => ( 15 15 <div key={index}> 16 16 {/* Fetch tracker and data */}
+12 -12
apps/web/src/components/status-page/monitor.tsx
··· 10 10 }: { 11 11 monitor: z.infer<typeof selectMonitorSchema>; 12 12 }) => { 13 - // fix this we should update our tinybird to fetch with pageId and monitorId 14 - // const data = await getMonitorListData({ siteId: String(monitor.pageId) }); 15 - const data = await getMonitorListData({ siteId: "openstatus" }); 13 + const data = await getMonitorListData({ 14 + siteId: "openstatus", // TODO: use proper id 15 + groupBy: "day", 16 + }); 16 17 17 18 if (!data) return <div>Something went wrong</div>; 18 19 19 20 return ( 20 - <div className="border-border rounded-lg border p-8"> 21 - <h1 className="font-cal mb-3 text-center text-2xl">Status</h1> 22 - <Tracker 23 - data={data} 24 - id="openstatus" 25 - name="Ping" 26 - url="https://openstatus.dev/api/ping" 27 - /> 28 - </div> 21 + <Tracker 22 + data={data} 23 + id={monitor.id} 24 + name="Ping" 25 + url="https://openstatus.dev/api/ping" 26 + context="status-page" 27 + maxSize={40} 28 + /> 29 29 ); 30 30 };
+5
apps/web/src/lib/utils.ts
··· 1 1 import { clsx } from "clsx"; 2 2 import type { ClassValue } from "clsx"; 3 + import { format } from "date-fns"; 3 4 import { twMerge } from "tailwind-merge"; 4 5 5 6 export function cn(...inputs: ClassValue[]) { ··· 9 10 export function wait(ms: number) { 10 11 return new Promise((resolve) => setTimeout(resolve, ms)); 11 12 } 13 + 14 + export function formatDate(date: Date) { 15 + return format(date, "dd/MM/yyyy"); 16 + }
+1 -1
apps/web/src/middleware.ts
··· 58 58 "/api/checker/regions/(.*)", 59 59 "/api/checker/cron/10m", 60 60 ], 61 - 61 + ignoredRoutes: ["/api/og"], // FIXME: we should check the `publicRoutes` 62 62 beforeAuth: before, 63 63 async afterAuth(auth, req, evt) { 64 64 // handle users who aren't authenticated
apps/web/src/public/fonts/Inter-Light.ttf

This is a binary file and will not be displayed.