Openstatus www.openstatus.dev

chore: improve status page (#540)

* chore: improve status page

* feat: update changelog

* fix: filled tb data

* fix: og image

* wip:

* chore: add reminder todo

authored by

Maximilian Kaske and committed by
GitHub
0270b4ce f07e881f

+479 -313
apps/web/public/assets/changelog/individual-status-report-page.png

This is a binary file and will not be displayed.

apps/web/public/assets/changelog/status-update-subscriber.png

This is a binary file and will not be displayed.

+9 -4
apps/web/src/app/api/og/route.tsx
··· 3 3 4 4 import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 5 5 import { getMonitorListData } from "@/lib/tb"; 6 - import { cleanData, getStatus } from "@/lib/tracker"; 6 + import { 7 + addBlackListInfo, 8 + getStatus, 9 + getTotalUptimeString, 10 + } from "@/lib/tracker"; 7 11 import { cn, formatDate } from "@/lib/utils"; 8 12 9 13 export const runtime = "edge"; ··· 56 60 }))) || 57 61 []; 58 62 59 - const { bars, uptime } = cleanData(data, LIMIT); 63 + const _data = addBlackListInfo(data); 64 + const uptime = getTotalUptimeString(data); 60 65 61 66 return new ImageResponse( 62 67 ( ··· 82 87 {title} 83 88 </h1> 84 89 <p tw="text-slate-600 text-3xl">{description}</p> 85 - {Boolean(data.length) && Boolean(bars.length) ? ( 90 + {Boolean(data.length) && Boolean(_data.length) ? ( 86 91 <div tw="flex flex-col w-full mt-6"> 87 92 <div tw="flex flex-row items-center justify-between -mb-1 text-black font-light"> 88 93 <p tw="">{formatDate(new Date())}</p> ··· 99 104 ); 100 105 })} 101 106 <div tw="flex flex-row-reverse absolute right-0"> 102 - {bars.map((item, i) => { 107 + {_data.map((item, i) => { 103 108 const { variant } = getStatus(item.ok / item.count); 104 109 const isBlackListed = Boolean(item.blacklist); 105 110 if (isBlackListed) {
+4 -4
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/general/_components/CopyToClipboardButton.tsx apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/general/_components/copy-to-clipboard-button.tsx
··· 6 6 import type { ButtonProps } from "@openstatus/ui"; 7 7 8 8 import { Icons } from "@/components/icons"; 9 - import { copyToClipboard } from "@/lib/utils"; 9 + import { cn, copyToClipboard } from "@/lib/utils"; 10 10 11 11 export function CopyToClipboardButton({ 12 12 children, ··· 26 26 return ( 27 27 <Button 28 28 onClick={(e) => { 29 - copyToClipboard(children?.toString() ?? ""); 29 + copyToClipboard(children?.toString() || ""); 30 30 setHasCopied(true); 31 31 onClick?.(e); 32 32 }} ··· 34 34 > 35 35 {children} 36 36 {!hasCopied ? ( 37 - <Icons.copy className="ml-2 h-4 w-4" /> 37 + <Icons.copy className={cn("h-4 w-4", children && "ml-2")} /> 38 38 ) : ( 39 - <Icons.check className="ml-2 h-4 w-4" /> 39 + <Icons.check className={cn("h-4 w-4", children && "ml-2")} /> 40 40 )} 41 41 </Button> 42 42 );
+1 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/general/page.tsx
··· 2 2 3 3 import { WorkspaceForm } from "@/components/forms/workspace-form"; 4 4 import { api } from "@/trpc/server"; 5 - import { CopyToClipboardButton } from "./_components/CopyToClipboardButton"; 5 + import { CopyToClipboardButton } from "./_components/copy-to-clipboard-button"; 6 6 7 7 export default async function GeneralPage() { 8 8 const data = await api.workspace.getWorkspace.query();
+2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/_components/action-button.tsx
··· 68 68 <DropdownMenuItem>Edit</DropdownMenuItem> 69 69 </Link> 70 70 <Link 71 + // TODO: it would be create to extract this logic and include custom domains if they are set 72 + // similar to `setPrefixUrl` 71 73 href={ 72 74 process.env.NODE_ENV === "production" 73 75 ? `https://${page.slug}.openstatus.dev`
+33
apps/web/src/app/status-page/[domain]/_components/navbar.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import { useSelectedLayoutSegment } from "next/navigation"; 5 + 6 + import { Button } from "@openstatus/ui"; 7 + 8 + type Props = { 9 + navigation: { label: string; href: string; segment: string | null }[]; 10 + }; 11 + 12 + export function Navbar({ navigation }: Props) { 13 + const selectedSegment = useSelectedLayoutSegment(); 14 + 15 + return ( 16 + <ul className="flex items-center gap-2"> 17 + {navigation.map(({ label, href, segment }) => { 18 + const isActive = segment === selectedSegment; 19 + return ( 20 + <li key={segment}> 21 + <Button 22 + variant={isActive ? "secondary" : "ghost"} 23 + size="sm" 24 + asChild 25 + > 26 + <Link href={href}>{label}</Link> 27 + </Button> 28 + </li> 29 + ); 30 + })} 31 + </ul> 32 + ); 33 + }
-42
apps/web/src/app/status-page/[domain]/_components/navigation-link.tsx
··· 1 - "use client"; 2 - 3 - import Link from "next/link"; 4 - import { usePathname, useSelectedLayoutSegment } from "next/navigation"; 5 - 6 - import { Button } from "@openstatus/ui"; 7 - 8 - export default function NavigationLink({ 9 - slug, 10 - children, 11 - }: { 12 - slug: string | null; 13 - children: React.ReactNode; 14 - }) { 15 - const pathname = usePathname(); 16 - const segment = useSelectedLayoutSegment(); 17 - const isActive = slug === segment; 18 - 19 - // REMINDER: `/status-page/${params.domain}/${slug}` won't work for subdomain 20 - let href = pathname; 21 - 22 - if (!isActive) { 23 - if (segment && slug) { 24 - href = `${pathname.replace(segment, slug)}`; 25 - } else if (segment) { 26 - href = `${pathname.replace(segment, "")}`; 27 - } else { 28 - href = `${pathname}${pathname.endsWith("/") ? "" : "/"}${slug}`; 29 - } 30 - } 31 - 32 - return ( 33 - <Button 34 - asChild 35 - variant={isActive ? "secondary" : "ghost"} 36 - className={isActive ? "font-bold" : ""} 37 - size="sm" 38 - > 39 - <Link href={href}>{children}</Link> 40 - </Button> 41 - ); 42 - }
+47
apps/web/src/app/status-page/[domain]/incidents/[id]/page.tsx
··· 1 + import Link from "next/link"; 2 + import { notFound } from "next/navigation"; 3 + import { ChevronLeft } from "lucide-react"; 4 + 5 + import { Button, Separator } from "@openstatus/ui"; 6 + 7 + import { Events } from "@/components/status-update/events"; 8 + import { Summary } from "@/components/status-update/summary"; 9 + import { api } from "@/trpc/server"; 10 + import { setPrefixUrl } from "../../utils"; 11 + import { CopyLinkButton } from "./_components/copy-link-button"; 12 + 13 + export default async function IncidentPage({ 14 + params, 15 + }: { 16 + params: { domain: string; id: string }; 17 + }) { 18 + const report = await api.statusReport.getPublicStatusReportById.query({ 19 + slug: params.domain, 20 + id: Number(params.id), 21 + }); 22 + 23 + if (!report) return notFound(); 24 + 25 + const affectedMonitors = report.monitorsToStatusReports.map( 26 + ({ monitor }) => monitor, 27 + ); 28 + 29 + return ( 30 + <div className="grid gap-4 text-left"> 31 + <div> 32 + <Button variant="link" size="sm" className="px-0" asChild> 33 + <Link href={setPrefixUrl("/", params)}> 34 + <ChevronLeft className="h-4 w-4" /> Back 35 + </Link> 36 + </Button> 37 + </div> 38 + <div className="flex items-center gap-1"> 39 + <h3 className="text-xl font-semibold">{report.title}</h3> 40 + <CopyLinkButton /> 41 + </div> 42 + <Summary report={report} monitors={affectedMonitors} /> 43 + <Separator /> 44 + <Events statusReportUpdates={report.statusReportUpdates} /> 45 + </div> 46 + ); 47 + }
-32
apps/web/src/app/status-page/[domain]/incidents/page.tsx
··· 32 32 </div> 33 33 ); 34 34 } 35 - 36 - export async function generateMetadata({ params }: Props): Promise<Metadata> { 37 - const page = await api.page.getPageBySlug.query({ slug: params.domain }); 38 - const firstMonitor = page?.monitors?.[0]; // temporary solution 39 - 40 - return { 41 - ...defaultMetadata, 42 - title: page?.title, 43 - description: page?.description, 44 - icons: page?.icon, 45 - twitter: { 46 - ...twitterMetadata, 47 - images: [ 48 - `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 49 - page?.description || `The ${page?.title} status page` 50 - }`, 51 - ], 52 - title: page?.title, 53 - description: page?.description, 54 - }, 55 - openGraph: { 56 - ...ogMetadata, 57 - images: [ 58 - `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 59 - page?.description || `The ${page?.title} status page` 60 - }`, 61 - ], 62 - title: page?.title, 63 - description: page?.description, 64 - }, 65 - }; 66 - }
+71 -6
apps/web/src/app/status-page/[domain]/layout.tsx
··· 1 + import type { Metadata } from "next"; 2 + import Image from "next/image"; 1 3 import { notFound } from "next/navigation"; 2 4 5 + import { Avatar, AvatarFallback, AvatarImage } from "@openstatus/ui"; 6 + 7 + import { 8 + defaultMetadata, 9 + ogMetadata, 10 + twitterMetadata, 11 + } from "@/app/shared-metadata"; 3 12 import { Shell } from "@/components/dashboard/shell"; 4 13 import { ThemeToggle } from "@/components/theme-toggle"; 5 14 import { api } from "@/trpc/server"; 6 - import NavigationLink from "./_components/navigation-link"; 15 + import { Navbar } from "./_components/navbar"; 7 16 import { SubscribeButton } from "./_components/subscribe-button"; 17 + import { setPrefixUrl } from "./utils"; 8 18 9 19 type Props = { 10 20 params: { domain: string }; ··· 15 25 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 16 26 if (!page) return notFound(); 17 27 28 + const navigation = [ 29 + { 30 + label: "Status", 31 + segment: null, 32 + href: setPrefixUrl("/", params), 33 + }, 34 + { 35 + label: "Incidents", 36 + segment: "incidents", 37 + href: setPrefixUrl("/incidents", params), 38 + }, 39 + ]; 40 + 18 41 return ( 19 42 <div className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 20 43 <header className="mx-auto w-full max-w-xl"> 21 44 <Shell className="mx-auto flex items-center justify-between gap-4 p-2 px-2 md:p-3"> 22 - <div className="hidden w-[100px] md:block" /> 23 - <div className="flex items-center gap-2"> 24 - <NavigationLink slug={null}>Status</NavigationLink> 25 - <NavigationLink slug="incidents">Incidents</NavigationLink> 45 + <div className="relative sm:w-[100px]"> 46 + {page?.icon ? ( 47 + <div className="bg-muted border-border h-10 w-10 overflow-hidden rounded-full border"> 48 + <Image 49 + height={40} 50 + width={40} 51 + src={page.icon} 52 + alt={page.title} 53 + objectFit="cover" 54 + className="rounded-full" 55 + /> 56 + </div> 57 + ) : null} 26 58 </div> 27 - <div className="w-[100px] text-end"> 59 + <Navbar navigation={navigation} /> 60 + <div className="text-end sm:w-[100px]"> 28 61 {page.workspacePlan !== "free" ? ( 29 62 <SubscribeButton slug={params.domain} /> 30 63 ) : null} ··· 54 87 </div> 55 88 ); 56 89 } 90 + 91 + export async function generateMetadata({ params }: Props): Promise<Metadata> { 92 + const page = await api.page.getPageBySlug.query({ slug: params.domain }); 93 + const firstMonitor = page?.monitors?.[0]; // temporary solution 94 + 95 + return { 96 + ...defaultMetadata, 97 + title: page?.title, 98 + description: page?.description, 99 + icons: page?.icon, 100 + twitter: { 101 + ...twitterMetadata, 102 + images: [ 103 + `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 104 + page?.description || `The ${page?.title} status page` 105 + }`, 106 + ], 107 + title: page?.title, 108 + description: page?.description, 109 + }, 110 + openGraph: { 111 + ...ogMetadata, 112 + images: [ 113 + `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 114 + page?.description || `The ${page?.title} status page` 115 + }`, 116 + ], 117 + title: page?.title, 118 + description: page?.description, 119 + }, 120 + }; 121 + }
+5 -40
apps/web/src/app/status-page/[domain]/page.tsx
··· 1 - import type { Metadata } from "next"; 2 1 import Link from "next/link"; 3 2 import { notFound } from "next/navigation"; 4 3 5 4 import { Button } from "@openstatus/ui"; 6 5 7 - import { 8 - defaultMetadata, 9 - ogMetadata, 10 - twitterMetadata, 11 - } from "@/app/shared-metadata"; 12 6 import { EmptyState } from "@/components/dashboard/empty-state"; 13 7 import { Header } from "@/components/dashboard/header"; 14 8 import { IncidentList } from "@/components/status-page/incident-list"; ··· 26 20 searchParams: { [key: string]: string | string[] | undefined }; 27 21 }; 28 22 29 - export const revalidate = "600"; 23 + export const revalidate = 600; 30 24 31 25 export default async function Page({ params }: Props) { 32 26 const page = await api.page.getPageBySlug.query({ slug: params.domain }); ··· 59 53 statusReports={page.statusReports} 60 54 monitors={page.monitors} 61 55 /> 62 - <MonitorList monitors={page.monitors} /> 56 + <MonitorList 57 + monitors={page.monitors} 58 + statusReports={page.statusReports} 59 + /> 63 60 {/* TODO: rename to StatusReportList */} 64 61 <IncidentList 65 62 incidents={page.statusReports} ··· 71 68 </div> 72 69 ); 73 70 } 74 - 75 - export async function generateMetadata({ params }: Props): Promise<Metadata> { 76 - const page = await api.page.getPageBySlug.query({ slug: params.domain }); 77 - const firstMonitor = page?.monitors?.[0]; // temporary solution 78 - 79 - return { 80 - ...defaultMetadata, 81 - title: page?.title, 82 - description: page?.description, 83 - icons: page?.icon, 84 - twitter: { 85 - ...twitterMetadata, 86 - images: [ 87 - `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 88 - page?.description || `The ${page?.title} status page` 89 - }`, 90 - ], 91 - title: page?.title, 92 - description: page?.description, 93 - }, 94 - openGraph: { 95 - ...ogMetadata, 96 - images: [ 97 - `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 98 - page?.description || `The ${page?.title} status page` 99 - }`, 100 - ], 101 - title: page?.title, 102 - description: page?.description, 103 - }, 104 - }; 105 - }
+10
apps/web/src/app/status-page/[domain]/utils.tsx
··· 1 + /** 2 + * While developing, we can also access the status page via /status-page/:domain 3 + */ 4 + export function setPrefixUrl(value: string, params: { domain: string }) { 5 + const suffix = value.startsWith("/") ? value : `/${value}`; 6 + if (process.env.NODE_ENV === "development") { 7 + return `/status-page/${params.domain}${suffix}`; 8 + } 9 + return suffix; 10 + }
+7 -3
apps/web/src/components/content/timeline.tsx
··· 41 41 }: ArticleProps) { 42 42 return ( 43 43 <article className="grid grid-cols-1 gap-4 md:grid-cols-5 md:gap-6"> 44 - <time className="text-muted-foreground order-2 font-mono text-sm md:order-1 md:col-span-1"> 45 - {formatDate(new Date(publishedAt))} 46 - </time> 44 + <div className="relative row-span-2"> 45 + <div className="sticky top-2"> 46 + <time className="text-muted-foreground order-2 font-mono text-sm md:order-1 md:col-span-1"> 47 + {formatDate(new Date(publishedAt))} 48 + </time> 49 + </div> 50 + </div> 47 51 <div className="relative order-1 h-64 w-full md:order-2 md:col-span-4"> 48 52 <Link href={href}> 49 53 <Image
+1 -1
apps/web/src/components/data-table/status-page/columns.tsx
··· 61 61 header: "Favicon", 62 62 cell: ({ row }) => { 63 63 if (!row.getValue("icon")) { 64 - return <span className="text-muted-foreground">Missing</span>; 64 + return <span className="text-muted-foreground">-</span>; 65 65 } 66 66 return ( 67 67 <Image
+51 -28
apps/web/src/components/status-page/incident-list.tsx
··· 1 1 "use client"; 2 2 3 + import Link from "next/link"; 4 + import { useParams } from "next/navigation"; 5 + import { ChevronRight } from "lucide-react"; 3 6 import type { z } from "zod"; 4 7 5 8 import type { 6 9 selectPublicMonitorSchema, 7 10 selectStatusReportPageSchema, 8 11 } from "@openstatus/db/src/schema"; 9 - import { Separator } from "@openstatus/ui"; 12 + import { Button, Separator } from "@openstatus/ui"; 10 13 14 + import { setPrefixUrl } from "@/app/status-page/[domain]/utils"; 11 15 import { notEmpty } from "@/lib/utils"; 12 16 import { Events } from "../status-update/events"; 13 17 import { Summary } from "../status-update/summary"; ··· 23 27 monitors: z.infer<typeof selectPublicMonitorSchema>[]; 24 28 context?: "all" | "latest"; // latest 7 days 25 29 }) => { 30 + const params = useParams<{ domain: string }>(); 26 31 const lastWeek = Date.now() - 1000 * 60 * 60 * 24 * 7; 27 32 28 33 function getLastWeeksIncidents() { ··· 35 40 36 41 const _incidents = context === "all" ? incidents : getLastWeeksIncidents(); 37 42 43 + _incidents.sort((a, b) => { 44 + if (a.updatedAt == undefined) return 1; 45 + if (b.updatedAt == undefined) return -1; 46 + return b.updatedAt.getTime() - a.updatedAt.getTime(); 47 + }); 48 + 38 49 return ( 39 50 <> 40 - {_incidents.sort((a, b) => { 41 - if (a.updatedAt == undefined) return 1; 42 - if (b.updatedAt == undefined) return -1; 43 - return b.updatedAt.getTime() - a.updatedAt.getTime(); 44 - })?.length > 0 ? ( 45 - <div className="grid gap-4"> 46 - <h2 className="text-muted-foreground text-lg font-light"> 51 + {_incidents?.length > 0 ? ( 52 + <div className="grid gap-3"> 53 + <p className="text-muted-foreground text-sm font-light"> 47 54 {context === "all" ? "All incidents" : "Latest incidents"} 48 - </h2> 49 - {_incidents.map((incident) => { 50 - const affectedMonitors = incident.monitorsToStatusReports 51 - .map(({ monitorId }) => { 52 - const monitor = monitors.find(({ id }) => monitorId === id); 53 - return monitor || undefined; 54 - }) 55 - .filter(notEmpty); 56 - return ( 57 - <div key={incident.id} className="grid gap-4 text-left"> 58 - <div className="max-w-3xl font-semibold">{incident.title}</div> 59 - <Summary report={incident} monitors={affectedMonitors} /> 60 - <Separator /> 61 - <Events 62 - statusReportUpdates={incident.statusReportUpdates} 63 - collabsible 64 - /> 65 - </div> 66 - ); 67 - })} 55 + </p> 56 + <div className="grid gap-8"> 57 + {_incidents.map((incident) => { 58 + const affectedMonitors = incident.monitorsToStatusReports 59 + .map(({ monitorId }) => { 60 + const monitor = monitors.find(({ id }) => monitorId === id); 61 + return monitor || undefined; 62 + }) 63 + .filter(notEmpty); 64 + return ( 65 + <div key={incident.id} className="group grid gap-4 text-left"> 66 + <div className="flex items-center gap-1"> 67 + <h3 className="text-xl font-semibold">{incident.title}</h3> 68 + <Button 69 + variant="ghost" 70 + size="icon" 71 + className="hidden h-7 w-7 group-hover:inline-flex" 72 + asChild 73 + > 74 + <Link 75 + href={setPrefixUrl(`/incidents/${incident.id}`, params)} 76 + > 77 + <ChevronRight className="h-4 w-4" /> 78 + </Link> 79 + </Button> 80 + </div> 81 + <Summary report={incident} monitors={affectedMonitors} /> 82 + <Separator /> 83 + <Events 84 + statusReportUpdates={incident.statusReportUpdates} 85 + collabsible 86 + /> 87 + </div> 88 + ); 89 + })} 90 + </div> 68 91 </div> 69 92 ) : ( 70 93 <p className="text-muted-foreground text-center text-sm font-light">
+20 -7
apps/web/src/components/status-page/monitor-list.tsx
··· 1 1 import type { z } from "zod"; 2 2 3 - import type { selectPublicMonitorSchema } from "@openstatus/db/src/schema"; 3 + import type { 4 + selectPublicMonitorSchema, 5 + selectPublicStatusReportSchemaWithRelation, 6 + } from "@openstatus/db/src/schema"; 4 7 5 8 import { Monitor } from "./monitor"; 6 9 7 10 export const MonitorList = ({ 8 11 monitors, 12 + statusReports, 9 13 }: { 10 14 monitors: z.infer<typeof selectPublicMonitorSchema>[]; 15 + statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 11 16 }) => { 12 17 return ( 13 18 <div className="grid gap-4"> 14 - {monitors.map((monitor, index) => ( 15 - <div key={index}> 16 - {/* Fetch tracker and data */} 17 - <Monitor monitor={monitor} /> 18 - </div> 19 - ))} 19 + {monitors.map((monitor, index) => { 20 + const monitorStatusReport = statusReports.filter((statusReport) => 21 + statusReport.monitorsToStatusReports.some( 22 + (i) => i.monitor.id === monitor.id, 23 + ), 24 + ); 25 + return ( 26 + <Monitor 27 + key={index} 28 + monitor={monitor} 29 + statusReports={monitorStatusReport} 30 + /> 31 + ); 32 + })} 20 33 </div> 21 34 ); 22 35 };
+10 -10
apps/web/src/components/status-page/monitor.tsx
··· 1 1 import { headers } from "next/headers"; 2 2 import type { z } from "zod"; 3 3 4 - import type { selectPublicMonitorSchema } from "@openstatus/db/src/schema"; 4 + import type { 5 + selectPublicMonitorSchema, 6 + selectPublicStatusReportSchemaWithRelation, 7 + } from "@openstatus/db/src/schema"; 5 8 6 9 import { getMonitorListData } from "@/lib/tb"; 7 10 import { Tracker } from "../tracker"; ··· 10 13 11 14 export const Monitor = async ({ 12 15 monitor, 16 + statusReports, 13 17 }: { 14 18 monitor: z.infer<typeof selectPublicMonitorSchema>; 19 + statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 15 20 }) => { 16 21 const headersList = headers(); 17 22 const timezone = headersList.get("x-vercel-ip-timezone") || currentTimezone; ··· 20 25 monitorId: String(monitor.id), 21 26 timezone, 22 27 }); 28 + 29 + // TODO: we could handle the `statusReports` here instead of passing it down to the tracker 30 + 23 31 if (!data) return <div>Something went wrong</div>; 24 32 25 - return ( 26 - <Tracker 27 - data={data} 28 - id={monitor.id} 29 - name={monitor.name} 30 - url={monitor.url} 31 - description={monitor.description} 32 - /> 33 - ); 33 + return <Tracker data={data} reports={statusReports} {...monitor} />; 34 34 };
+4 -3
apps/web/src/components/status-page/status-check.tsx
··· 12 12 import { cn, notEmpty } from "@/lib/utils"; 13 13 import { Icons } from "../icons"; 14 14 15 - const check = cva("border-border rounded-full border p-2", { 15 + const check = cva("border-border rounded-full border p-1.5", { 16 16 variants: { 17 17 variant: { 18 18 up: "bg-green-500/80 border-green-500", ··· 58 58 }, 59 59 { count: 0, ok: 0 }, 60 60 ); 61 - const status = getStatus(ok / count); 62 - return status; 61 + const ratio = ok / count; 62 + if (isNaN(ratio)) return getStatus(1); // outsmart caching issue 63 + return getStatus(ratio); 63 64 } 64 65 65 66 const status = calcStatus();
+62 -16
apps/web/src/components/tracker.tsx
··· 4 4 import Link from "next/link"; 5 5 import { cva } from "class-variance-authority"; 6 6 import { format } from "date-fns"; 7 - import { Eye, Info } from "lucide-react"; 7 + import { ChevronRight, Eye, Info } from "lucide-react"; 8 8 9 + import type { 10 + StatusReport, 11 + StatusReportUpdate, 12 + } from "@openstatus/db/src/schema"; 9 13 import type { Monitor } from "@openstatus/tinybird"; 10 14 import { 11 15 HoverCard, ··· 19 23 } from "@openstatus/ui"; 20 24 21 25 import useWindowSize from "@/hooks/use-window-size"; 22 - import type { CleanMonitor } from "@/lib/tracker"; 23 - import { cleanData, getStatus } from "@/lib/tracker"; 26 + import { 27 + addBlackListInfo, 28 + getStatus, 29 + getTotalUptimeString, 30 + } from "@/lib/tracker"; 24 31 25 32 // What would be cool is tracker that turn from green to red depending on the number of errors 26 33 const tracker = cva("h-10 rounded-full flex-1", { ··· 32 39 empty: "bg-muted-foreground/20 data-[state=open]:bg-muted-foreground/30", 33 40 blacklist: "bg-green-500/80 data-[state=open]:bg-green-500", 34 41 }, 42 + report: { 43 + 0: "", 44 + 30: "bg-gradient-to-t from-blue-500/90 hover:from-blue-500 from-30% to-transparent to-30%", 45 + }, 35 46 }, 36 47 defaultVariants: { 37 48 variant: "empty", 49 + report: 0, 38 50 }, 39 51 }); 40 52 ··· 46 58 description?: string; 47 59 context?: "play" | "status-page"; // TODO: we might need to extract those two different use cases - for now it's ok I'd say. 48 60 timezone?: string; 61 + reports?: (StatusReport & { statusReportUpdates: StatusReportUpdate[] })[]; 49 62 } 50 63 51 64 export function Tracker({ ··· 55 68 name, 56 69 context = "status-page", 57 70 description, 58 - timezone, 71 + reports, 59 72 }: TrackerProps) { 73 + // TODO: remove the isMobile, maxSize, and _placeholder as status_timezone__v0 already returns the last 45 days 60 74 const { isMobile } = useWindowSize(); 61 - // TODO: it is better than how it was currently, but creates a small content shift on first render 62 75 const maxSize = React.useMemo(() => (isMobile ? 35 : 45), [isMobile]); 63 - const { bars, uptime } = cleanData(data, maxSize, timezone); 76 + const uptime = getTotalUptimeString(data); 77 + 78 + const _data = addBlackListInfo(data); 79 + const _placeholder = Array.from({ length: maxSize - _data.length }); 64 80 65 81 return ( 66 82 <div className="flex flex-col"> ··· 74 90 <p className="text-muted-foreground shrink-0 font-light">{uptime}</p> 75 91 </div> 76 92 <div className="relative h-full w-full"> 77 - <div className="flex flex-row-reverse gap-0.5"> 78 - {bars.map((props) => { 93 + <div className="flex flex-row-reverse gap-px sm:gap-0.5"> 94 + {_data.map((props, i) => { 95 + const dateReports = reports?.filter((report) => { 96 + const firstStatusReportUpdate = report.statusReportUpdates.sort( 97 + (a, b) => a.date.getTime() - b.date.getTime(), 98 + )?.[0]; 99 + 100 + if (!firstStatusReportUpdate) return false; 101 + const d = firstStatusReportUpdate.date; 102 + d.setHours(0, 0, 0); // set date to midnight as cronTimestamp is midnight 103 + return d.getTime() === new Date(props.day).getTime(); 104 + }); 79 105 return ( 80 - <Bar key={props.cronTimestamp} context={context} {...props} /> 106 + <Bar key={i} context={context} reports={dateReports} {...props} /> 81 107 ); 108 + })} 109 + {_placeholder.map((_, i) => { 110 + return <div key={i} className={tracker({ variant: "empty" })} />; 82 111 })} 83 112 </div> 84 113 </div> ··· 122 151 ); 123 152 }; 124 153 154 + type BarProps = Monitor & { blacklist?: string } & Pick< 155 + TrackerProps, 156 + "context" | "reports" 157 + >; 158 + 125 159 const Bar = ({ 126 160 count, 127 161 ok, 128 162 avgLatency, 129 - cronTimestamp, 163 + day, 130 164 blacklist, 131 165 context, 132 - }: CleanMonitor & Pick<TrackerProps, "context">) => { 166 + reports, 167 + }: BarProps) => { 133 168 const [open, setOpen] = React.useState(false); 134 169 const ratio = ok / count; 170 + const cronTimestamp = new Date(day).getTime(); 135 171 const date = new Date(cronTimestamp); 136 172 const toDate = date.setDate(date.getDate() + 1); 137 173 const dateFormat = "dd/MM/yy"; 138 174 139 175 const className = tracker({ 176 + report: reports && reports.length > 0 ? 30 : undefined, 140 177 variant: blacklist ? "blacklist" : getStatus(ratio).variant, 141 178 }); 142 - // console.log({ className, blacklist }); 143 179 144 180 return ( 145 181 <HoverCard ··· 149 185 onOpenChange={setOpen} 150 186 > 151 187 <HoverCardTrigger onClick={() => setOpen(true)} asChild> 152 - <div 153 - // suppressHydrationWarning 154 - className={className} 155 - /> 188 + <div className={className} /> 156 189 </HoverCardTrigger> 157 190 <HoverCardContent side="top" className="w-64"> 158 191 {blacklist ? ( ··· 170 203 </Link> 171 204 ) : null} 172 205 </div> 206 + <ul className="my-1.5"> 207 + {reports?.map((report) => ( 208 + <li key={report.id} className="text-muted-foreground text-sm"> 209 + <Link 210 + href={`./incidents/${report.id}`} 211 + className="hover:text-foreground group flex items-center justify-between gap-2" 212 + > 213 + <span className="truncate">{report.title}</span> 214 + <ChevronRight className="h-4 w-4" /> 215 + </Link> 216 + </li> 217 + ))} 218 + </ul> 173 219 <div className="flex justify-between"> 174 220 <p className="text-xs font-light"> 175 221 {format(new Date(cronTimestamp), dateFormat)}
+9
apps/web/src/content/changelog/individual-status-report-page.mdx
··· 1 + --- 2 + title: Invidivual Status Report Page 3 + description: Every created status report now has its own page. 4 + publishedAt: 2023-12-22 5 + image: /assets/changelog/individual-status-report-page.png 6 + --- 7 + 8 + Every created status report now has its own page. That will make it easier to 9 + share the status report with your team and across your users.
+11
apps/web/src/content/changelog/status-update-subscriber.mdx
··· 1 + --- 2 + title: Status Update Subscriber 3 + description: You can now subscribe to pro plan status updates via email. 4 + publishedAt: 2023-12-11 5 + image: /assets/changelog/status-update-subscriber.png 6 + --- 7 + 8 + You can now subscribe to pro plan status updates via email. 9 + 10 + As a subscriber, we require you to verify your email address to avoid spam. You 11 + can do this by clicking the link in the email we send you after you subscribe.
+5 -101
apps/web/src/lib/tracker.ts
··· 36 36 37 37 // TODO: move into Class component sharing the same `data` 38 38 39 - // FIXME: name TrackerMonitor 40 - 41 - export type CleanMonitor = { 42 - count: number; 43 - ok: number; 44 - avgLatency: number; 45 - cronTimestamp: number; 46 - blacklist?: string; 47 - }; 48 - 49 - /** 50 - * Clean the data to show only the last X days 51 - * @param data array of monitors 52 - * @param last number of days to show 53 - * @param timeZone timezone of the monitor 54 - * @returns 55 - */ 56 - export function cleanData(data: Monitor[], last: number, timeZone?: string) { 57 - const today = new Date(new Date().toLocaleString("en-US", { timeZone })); 58 - 59 - const currentDay = new Date(today); 60 - currentDay.setDate(today.getDate()); 61 - currentDay.setHours(0, 0, 0, 0); 62 - 63 - const lastDay = new Date(today); 64 - lastDay.setDate(today.getDate() - last); 65 - lastDay.setHours(0, 0, 0, 0); 66 - 67 - const dateSequence = generateDateSequence(lastDay, currentDay); 68 - 69 - const filledData = fillEmptyData(data, dateSequence); 70 - 71 - const uptime = getTotalUptimeString(filledData); 72 - 73 - return { bars: filledData, uptime }; // possibly only return filledData? 74 - } 75 - 76 - function fillEmptyData(data: Monitor[], dateSequence: Date[]) { 77 - const filledData: CleanMonitor[] = []; 78 - let dataIndex = 0; 79 - 80 - for (const date of dateSequence) { 81 - const timestamp = date.getTime(); 82 - const cronTimestamp = 83 - dataIndex < data.length 84 - ? new Date(data[dataIndex].day).getTime() 85 - : undefined; 86 - 87 - if ( 88 - cronTimestamp && 89 - areDatesEqualByDayMonthYear(date, new Date(cronTimestamp)) 90 - ) { 91 - const blacklist = isInBlacklist(cronTimestamp); 92 - 93 - /** 94 - * automatically remove the data from the array to avoid wrong uptime 95 - * that provides time to remove cursed logs from tinybird via mv migration 96 - */ 97 - if (blacklist) { 98 - filledData.push({ 99 - ...emptyData(cronTimestamp), 100 - blacklist, 101 - }); 102 - } else { 103 - const { day, ...props } = data[dataIndex]; 104 - filledData.push({ ...props, cronTimestamp }); 105 - } 106 - dataIndex++; 107 - } else { 108 - filledData.push(emptyData(timestamp)); 109 - } 110 - } 111 - 112 - return filledData; 113 - } 114 - 115 - function emptyData(cronTimestamp: number) { 116 - return { 117 - count: 0, 118 - ok: 0, 119 - avgLatency: 0, 120 - cronTimestamp, 121 - }; 122 - } 123 - 124 39 /** 125 40 * equal days - fixes issue with daylight saving 126 41 * @param date1 ··· 137 52 return date1.toUTCString() === date2.toUTCString(); 138 53 } 139 54 140 - /** 141 - * 142 - * @param startDate 143 - * @param endDate 144 - * @returns 145 - */ 146 - export function generateDateSequence(startDate: Date, endDate: Date): Date[] { 147 - const dateSequence: Date[] = []; 148 - const currentDate = new Date(startDate); 149 - 150 - while (currentDate <= endDate) { 151 - dateSequence.push(new Date(currentDate)); 152 - currentDate.setDate(currentDate.getDate() + 1); 153 - } 154 - 155 - return dateSequence.reverse(); 55 + export function addBlackListInfo(data: Monitor[]) { 56 + return data.map((monitor) => { 57 + const blacklist = isInBlacklist(new Date(monitor.day).getTime()); 58 + return { ...monitor, blacklist }; 59 + }); 156 60 } 157 61 158 62 export function getTotalUptime(data: { ok: number; count: number }[]) {
+1 -1
packages/api/src/router/page.ts
··· 219 219 where: or(inArray(statusReport.id, statusReportIds)), 220 220 with: { 221 221 statusReportUpdates: true, 222 - monitorsToStatusReports: true, 222 + monitorsToStatusReports: { with: { monitor: true } }, 223 223 pagesToStatusReports: true, 224 224 }, 225 225 })
+30 -4
packages/api/src/router/statusReport.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { and, eq, inArray, isNotNull } from "@openstatus/db"; 3 + import { and, eq, inArray, isNotNull, sql } from "@openstatus/db"; 4 4 import { 5 5 insertStatusReportSchema, 6 6 insertStatusReportUpdateSchema, ··· 9 9 pagesToStatusReports, 10 10 pageSubscriber, 11 11 selectMonitorSchema, 12 + selectPublicStatusReportSchemaWithRelation, 12 13 selectStatusReportSchema, 13 14 selectStatusReportUpdateSchema, 14 15 statusReport, ··· 18 19 } from "@openstatus/db/src/schema"; 19 20 import { sendEmailHtml } from "@openstatus/emails/emails/send"; 20 21 21 - import { createTRPCRouter, protectedProcedure } from "../trpc"; 22 + import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 22 23 23 24 export const statusReportRouter = createTRPCRouter({ 24 25 createStatusReport: protectedProcedure ··· 306 307 getStatusReportById: protectedProcedure 307 308 .input(z.object({ id: z.number() })) 308 309 .query(async (opts) => { 309 - const selectStatusReportSchemaWithRelation = 310 + const selectPublicStatusReportSchemaWithRelation = 310 311 selectStatusReportSchema.extend({ 311 312 status: statusReportStatusSchema.default("investigating"), // TODO: remove! 312 313 monitorsToStatusReports: z ··· 341 342 }, 342 343 }); 343 344 344 - return selectStatusReportSchemaWithRelation.parse(data); 345 + return selectPublicStatusReportSchemaWithRelation.parse(data); 345 346 }), 346 347 347 348 getStatusReportUpdateById: protectedProcedure ··· 384 385 console.log(result); 385 386 return z.array(selectStatusSchemaWithRelation).parse(result); 386 387 }), 388 + 389 + getPublicStatusReportById: publicProcedure 390 + .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 391 + .query(async (opts) => { 392 + const result = await opts.ctx.db.query.page.findFirst({ 393 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 394 + }); 395 + 396 + if (!result) return; 397 + 398 + const _statusReport = await opts.ctx.db.query.statusReport.findFirst({ 399 + where: and( 400 + eq(statusReport.id, opts.input.id), 401 + eq(statusReport.workspaceId, result.id), 402 + ), 403 + with: { 404 + monitorsToStatusReports: { with: { monitor: true } }, 405 + statusReportUpdates: true, 406 + }, 407 + }); 408 + 409 + if (!_statusReport) return; 410 + 411 + return selectPublicStatusReportSchemaWithRelation.parse(_statusReport); 412 + }), 387 413 });
+23 -9
packages/db/src/schema/shared.ts
··· 8 8 } from "./status_reports"; 9 9 import { workspacePlanSchema } from "./workspaces"; 10 10 11 - // FIXME: delete this file! 11 + // TODO: create a 'public-status' schema with all the different types and validations 12 + 13 + export const selectPublicMonitorSchema = selectMonitorSchema.omit({ 14 + body: true, 15 + headers: true, 16 + regions: true, 17 + method: true, 18 + }); 12 19 13 20 export const selectStatusReportPageSchema = z.array( 14 21 selectStatusReportSchema.extend({ 15 22 statusReportUpdates: z.array(selectStatusReportUpdateSchema).default([]), 16 23 monitorsToStatusReports: z 17 - .array(z.object({ monitorId: z.number(), statusReportId: z.number() })) 24 + .array( 25 + z.object({ 26 + monitorId: z.number(), 27 + statusReportId: z.number(), 28 + monitor: selectPublicMonitorSchema, 29 + }), 30 + ) 18 31 .default([]), 19 32 }), 20 33 ); 21 34 export const selectPageSchemaWithRelation = selectPageSchema.extend({ 22 35 monitors: z.array(selectMonitorSchema), 23 36 statusReports: selectStatusReportPageSchema, 24 - }); 25 - 26 - export const selectPublicMonitorSchema = selectMonitorSchema.omit({ 27 - body: true, 28 - headers: true, 29 - regions: true, 30 - method: true, 31 37 }); 32 38 33 39 export const selectPublicPageSchemaWithRelation = selectPageSchema ··· 43 49 workspaceId: true, 44 50 id: true, 45 51 }); 52 + 53 + export const selectPublicStatusReportSchemaWithRelation = 54 + selectStatusReportSchema.extend({ 55 + monitorsToStatusReports: z 56 + .array(z.object({ monitor: selectPublicMonitorSchema })) 57 + .default([]), 58 + statusReportUpdates: z.array(selectStatusReportUpdateSchema), 59 + });
+8 -1
packages/tinybird/pipes/status_timezone.pipe
··· 41 41 round(avg(avgLatency)) as avgLatency 42 42 FROM group_by_cronTimestamp 43 43 GROUP BY start_of_day 44 - ORDER BY start_of_day DESC 44 + -- ORDER BY start_of_day DESC WITH FILL STEP INTERVAL -1 DAY 45 + ORDER BY start_of_day 46 + WITH FILL 47 + FROM 48 + toStartOfDay(toTimezone(now(), {{ String(timezone, 'Europe/Berlin') }})) 49 + TO toStartOfDay( 50 + toTimezone(date_sub(DAY, 46, now()), {{ String(timezone, 'Europe/Berlin') }}) 51 + ) STEP INTERVAL -1 DAY 45 52 LIMIT {{ Int32(limit, 100) }} 46 53 47 54