Openstatus www.openstatus.dev

feat: status page v2 (#1354)

* wip:

* wip:

* fix: build

* chore: use pathname for vercel domain

* fix: format

* fix: stuff

* chore: empty state

* fix: resolved variant

* chore: tb query in public procedure

* chore: add log

* fix: type

* wip: tb status pipes v2

* wip: no data

* chore: uptime

* chore: small stuff

* wip: page subscription

* chore: subscription token verification

* chore: password protection

* fix: legacy public page schema

* fix: color and order

* chore: status tracker events

* wip:

* fix: change

* wip: manual type

* wip: manual type

* fix: link

* chore: page status

* fix: lint

* chore: keyboard navigation

* chore: duration skip seconds

* chore: pinned opacity

* fix: stuff

* refactor: events

* fix: tsc

* fix: focus style

* fix: lint

* chore: loading state

* fix: status tracker content dominant

* fix: restrict events

* wip:

* fix: hover card

* fix: more stuff

* chore: include metadata

* refactor: move logic from front to back

* chore: disabled types

* fix: store

* fix: manual type

* fix: type

* wip:

* wip: status banner

* fix: format

* wip: status banner

* wip: status banner

* fix: status content

* fix: empty state and status banner

* fix: avoid fetching from tb on manual mode

* fix: floating button

* chore: small stuff

* fix: empty store

* fix: test

* fix: format

* fix: tb mock

authored by

Maximilian Kaske and committed by
GitHub
d35821fe fac4ad89

+4836 -1593
+4 -4
apps/server/src/libs/test/preload.ts
··· 13 13 14 14 mock.module("@openstatus/tinybird", () => ({ 15 15 OSTinybird: class { 16 - httpStatus45d() { 17 - return Promise.resolve({ data: [] }); 16 + get legacy_httpStatus45d() { 17 + return () => Promise.resolve({ data: [] }); 18 18 } 19 - tcpStatus45d() { 20 - return Promise.resolve({ data: [] }); 19 + get legacy_tcpStatus45d() { 20 + return () => Promise.resolve({ data: [] }); 21 21 } 22 22 }, 23 23 }));
+2 -2
apps/server/src/routes/v1/monitors/summary/get.ts
··· 77 77 console.log("fetching from tinybird"); 78 78 const res = 79 79 _monitor.jobType === "http" 80 - ? await tb.httpStatus45d({ monitorId: id }) 81 - : await tb.tcpStatus45d({ monitorId: id }); 80 + ? await tb.legacy_httpStatus45d({ monitorId: id }) 81 + : await tb.legacy_tcpStatus45d({ monitorId: id }); 82 82 83 83 await redis.set(`${id}-daily-stats`, res.data, { ex: 600 }); 84 84
+139 -1
apps/status-page/src/app/(public)/page.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + Section, 5 + SectionDescription, 6 + SectionGroup, 7 + SectionHeader, 8 + SectionTitle, 9 + } from "@/components/content/section"; 10 + import { THEMES } from "@/components/status-page/community-themes"; 11 + import { COMMUNITY_THEME } from "@/components/status-page/floating-button"; 12 + import { 13 + Status, 14 + StatusContent, 15 + StatusDescription, 16 + StatusHeader, 17 + StatusTitle, 18 + } from "@/components/status-page/status"; 19 + import { StatusBanner } from "@/components/status-page/status-banner"; 20 + import { StatusMonitor } from "@/components/status-page/status-monitor"; 21 + import { monitors } from "@/data/monitors"; 22 + import { useTRPC } from "@/lib/trpc/client"; 23 + import { cn } from "@/lib/utils"; 24 + import { useQuery } from "@tanstack/react-query"; 25 + 1 26 export default function Page() { 2 - return <div>Status Page</div>; 27 + return ( 28 + <SectionGroup> 29 + <Section> 30 + <SectionHeader> 31 + <SectionTitle>Status Page Themes</SectionTitle> 32 + <SectionDescription> 33 + View all the current themes you can use. Or contribute your own one. 34 + </SectionDescription> 35 + </SectionHeader> 36 + <div className="flex flex-col gap-4"> 37 + {COMMUNITY_THEME.filter((theme) => theme !== "default").map( 38 + (theme) => { 39 + const t = THEMES[theme]; 40 + return ( 41 + <div key={theme} className="flex flex-col gap-2"> 42 + <ThemeHeader> 43 + <ThemeTitle>{t.name}</ThemeTitle> 44 + <ThemeAuthor> 45 + by{" "} 46 + <a 47 + href={t.author.url} 48 + target="_blank" 49 + rel="noopener noreferrer" 50 + > 51 + {t.author.name} 52 + </a> 53 + </ThemeAuthor> 54 + </ThemeHeader> 55 + <ThemeGroup> 56 + <ThemeCard theme={theme} mode="light" /> 57 + <ThemeCard theme={theme} mode="dark" /> 58 + </ThemeGroup> 59 + </div> 60 + ); 61 + }, 62 + )} 63 + </div> 64 + </Section> 65 + </SectionGroup> 66 + ); 67 + } 68 + 69 + // TODO: the status-tracker hover card is mounted on the body and looses the theme style context 70 + 71 + function ThemeCard({ 72 + theme, 73 + mode, 74 + }: { 75 + theme: keyof typeof THEMES; 76 + mode: "dark" | "light"; 77 + }) { 78 + const t = THEMES[theme][mode]; 79 + const trpc = useTRPC(); 80 + const { data: uptimeData, isLoading } = useQuery( 81 + trpc.statusPage.getNoopUptime.queryOptions(), 82 + ); 83 + return ( 84 + <div className="group/theme-card overflow-hidden rounded-lg border"> 85 + <div 86 + style={{ 87 + ...t, 88 + }} 89 + className="h-full w-full bg-background" 90 + > 91 + {/* NOTE: we use pointer-events-none to prevent the hover card or tooltip from being interactive - the Portal container is document body and we loose the styles */} 92 + <div className="pointer-events-none scale-85 bg-background text-foreground transition-all duration-300 group-hover/theme-card:scale-90"> 93 + <Status variant="success"> 94 + <StatusHeader> 95 + <StatusTitle>Acme Inc.</StatusTitle> 96 + <StatusDescription> 97 + Get informed about our services. 98 + </StatusDescription> 99 + </StatusHeader> 100 + <StatusBanner status="success" /> 101 + <StatusContent> 102 + {/* TODO: create mock data */} 103 + <StatusMonitor 104 + status="success" 105 + data={uptimeData?.data || []} 106 + monitor={monitors[0]} 107 + showUptime={true} 108 + uptime={uptimeData?.uptime} 109 + isLoading={isLoading} 110 + /> 111 + </StatusContent> 112 + </Status> 113 + </div> 114 + </div> 115 + </div> 116 + ); 117 + } 118 + 119 + function ThemeGroup({ children, className }: React.ComponentProps<"div">) { 120 + return ( 121 + <div className={cn("grid grid-cols-1 gap-4 sm:grid-cols-2", className)}> 122 + {children} 123 + </div> 124 + ); 125 + } 126 + 127 + function ThemeHeader({ children, className }: React.ComponentProps<"div">) { 128 + return <div className={cn("flex flex-col", className)}>{children}</div>; 129 + } 130 + 131 + function ThemeTitle({ children, className }: React.ComponentProps<"div">) { 132 + return <div className={cn("font-bold text-base", className)}>{children}</div>; 133 + } 134 + 135 + function ThemeAuthor({ children, className }: React.ComponentProps<"div">) { 136 + return ( 137 + <div className={cn("font-mono text-muted-foreground text-xs", className)}> 138 + {children} 139 + </div> 140 + ); 3 141 }
+42
apps/status-page/src/app/(status-page)/[domain]/(private)/layout.tsx
··· 1 + import { Footer } from "@/components/nav/footer"; 2 + import { 3 + FloatingButton, 4 + StatusPageProvider, 5 + } from "@/components/status-page/floating-button"; 6 + import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 7 + 8 + export default function Layout({ 9 + children, 10 + params, 11 + }: { 12 + children: React.ReactNode; 13 + params: Promise<{ domain: string }>; 14 + }) { 15 + return ( 16 + <Hydrate params={params}> 17 + <StatusPageProvider> 18 + <div className="flex min-h-screen flex-col gap-4"> 19 + <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 20 + {children} 21 + </main> 22 + <Footer className="w-full border-t" /> 23 + </div> 24 + <FloatingButton /> 25 + </StatusPageProvider> 26 + </Hydrate> 27 + ); 28 + } 29 + 30 + async function Hydrate({ 31 + children, 32 + params, 33 + }: { 34 + children: React.ReactNode; 35 + params: Promise<{ domain: string }>; 36 + }) { 37 + const queryClient = getQueryClient(); 38 + await queryClient.prefetchQuery( 39 + trpc.statusPage.get.queryOptions({ slug: (await params).domain }), 40 + ); 41 + return <HydrateClient>{children}</HydrateClient>; 42 + }
+56
apps/status-page/src/app/(status-page)/[domain]/(private)/protected/page.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + Section, 5 + SectionDescription, 6 + SectionHeader, 7 + SectionTitle, 8 + } from "@/components/content/section"; 9 + import { FormPassword } from "@/components/forms/form-password"; 10 + import { Button } from "@/components/ui/button"; 11 + import { useCookieState } from "@/hooks/use-cookie-state"; 12 + import { createProtectedCookieKey } from "@/lib/protected"; 13 + import { useTRPC } from "@/lib/trpc/client"; 14 + import { useMutation } from "@tanstack/react-query"; 15 + import { useParams, useRouter, useSearchParams } from "next/navigation"; 16 + 17 + export default function PrivatePage() { 18 + const { domain } = useParams<{ domain: string }>(); 19 + const searchParams = useSearchParams(); 20 + const trpc = useTRPC(); 21 + const [_, setPassword] = useCookieState(createProtectedCookieKey(domain)); 22 + const router = useRouter(); 23 + const verifyPasswordMutation = useMutation( 24 + trpc.statusPage.verifyPassword.mutationOptions({}), 25 + ); 26 + 27 + return ( 28 + <Section className="m-auto w-full max-w-lg rounded-lg border bg-card p-4"> 29 + <SectionHeader> 30 + <SectionTitle>Protected Page</SectionTitle> 31 + <SectionDescription> 32 + Enter the password to access the status page. 33 + </SectionDescription> 34 + </SectionHeader> 35 + <div className="flex flex-col gap-2"> 36 + <FormPassword 37 + id="password-form" 38 + onSubmit={async (values) => { 39 + const result = await verifyPasswordMutation.mutateAsync({ 40 + slug: domain, 41 + password: values.password, 42 + }); 43 + if (result) { 44 + setPassword(values.password); 45 + const redirect = searchParams.get("redirect"); 46 + router.push(redirect ?? "/"); 47 + } 48 + }} 49 + /> 50 + <Button type="submit" form="password-form"> 51 + Submit 52 + </Button> 53 + </div> 54 + </Section> 55 + ); 56 + }
+142
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + StatusEvent, 5 + StatusEventAffected, 6 + StatusEventAside, 7 + StatusEventContent, 8 + StatusEventTimelineMaintenance, 9 + StatusEventTimelineReport, 10 + StatusEventTitle, 11 + } from "@/components/status-page/status-events"; 12 + import { useTRPC } from "@/lib/trpc/client"; 13 + import { useQuery } from "@tanstack/react-query"; 14 + import { useParams } from "next/navigation"; 15 + 16 + import { 17 + StatusEmptyState, 18 + StatusEmptyStateDescription, 19 + StatusEmptyStateTitle, 20 + } from "@/components/status-page/status"; 21 + import { Badge } from "@/components/ui/badge"; 22 + import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 23 + import { formatDate } from "@/lib/formatter"; 24 + import Link from "next/link"; 25 + 26 + // TODO: include ?filter=maintenance/reports 27 + 28 + export default function Page() { 29 + const { domain } = useParams<{ domain: string }>(); 30 + const trpc = useTRPC(); 31 + const { data: page } = useQuery( 32 + trpc.statusPage.get.queryOptions({ slug: domain }), 33 + ); 34 + 35 + if (!page) return null; 36 + 37 + const { statusReports, maintenances } = page; 38 + 39 + return ( 40 + <Tabs defaultValue="reports" className="gap-4"> 41 + <TabsList> 42 + <TabsTrigger value="reports">Reports</TabsTrigger> 43 + <TabsTrigger value="maintenances">Maintenances</TabsTrigger> 44 + </TabsList> 45 + <TabsContent value="reports" className="flex flex-col gap-4"> 46 + {statusReports.length > 0 ? ( 47 + statusReports.map((report) => { 48 + const startedAt = report.statusReportUpdates[0].date; 49 + return ( 50 + <StatusEvent key={report.id}> 51 + <StatusEventAside> 52 + <span className="font-medium text-foreground/80"> 53 + {formatDate(startedAt, { month: "short" })} 54 + </span> 55 + </StatusEventAside> 56 + <Link 57 + href={`./events/report/${report.id}`} 58 + className="rounded-lg" 59 + > 60 + <StatusEventContent> 61 + <StatusEventTitle>{report.title}</StatusEventTitle> 62 + {report.monitorsToStatusReports.length > 0 ? ( 63 + <StatusEventAffected className="flex flex-wrap gap-1"> 64 + {report.monitorsToStatusReports.map((affected) => ( 65 + <Badge 66 + key={affected.monitor.id} 67 + variant="outline" 68 + className="text-[10px]" 69 + > 70 + {affected.monitor.name} 71 + </Badge> 72 + ))} 73 + </StatusEventAffected> 74 + ) : null} 75 + <StatusEventTimelineReport 76 + updates={report.statusReportUpdates} 77 + /> 78 + </StatusEventContent> 79 + </Link> 80 + </StatusEvent> 81 + ); 82 + }) 83 + ) : ( 84 + <StatusEmptyState> 85 + <StatusEmptyStateTitle>No reports found</StatusEmptyStateTitle> 86 + <StatusEmptyStateDescription> 87 + No reports found for this status page. 88 + </StatusEmptyStateDescription> 89 + </StatusEmptyState> 90 + )} 91 + </TabsContent> 92 + <TabsContent value="maintenances" className="flex flex-col gap-4"> 93 + {maintenances.length > 0 ? ( 94 + maintenances.map((maintenance) => { 95 + const isFuture = maintenance.from > new Date(); 96 + return ( 97 + <StatusEvent key={maintenance.id}> 98 + <StatusEventAside> 99 + <span className="font-medium text-foreground/80"> 100 + {formatDate(maintenance.from, { month: "short" })} 101 + </span> 102 + {isFuture ? ( 103 + <span className="text-info text-sm">Upcoming</span> 104 + ) : null} 105 + </StatusEventAside> 106 + <Link 107 + href={`./events/maintenance/${maintenance.id}`} 108 + className="rounded-lg" 109 + > 110 + <StatusEventContent> 111 + <StatusEventTitle>{maintenance.title}</StatusEventTitle> 112 + {maintenance.maintenancesToMonitors.length > 0 ? ( 113 + <StatusEventAffected className="flex flex-wrap gap-1"> 114 + {maintenance.maintenancesToMonitors.map((affected) => ( 115 + <Badge 116 + key={affected.monitor.id} 117 + variant="outline" 118 + className="text-[10px]" 119 + > 120 + {affected.monitor.name} 121 + </Badge> 122 + ))} 123 + </StatusEventAffected> 124 + ) : null} 125 + <StatusEventTimelineMaintenance maintenance={maintenance} /> 126 + </StatusEventContent> 127 + </Link> 128 + </StatusEvent> 129 + ); 130 + }) 131 + ) : ( 132 + <StatusEmptyState> 133 + <StatusEmptyStateTitle>No maintenances found</StatusEmptyStateTitle> 134 + <StatusEmptyStateDescription> 135 + No maintenances found for this status page. 136 + </StatusEmptyStateDescription> 137 + </StatusEmptyState> 138 + )} 139 + </TabsContent> 140 + </Tabs> 141 + ); 142 + }
+19
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/layout.tsx
··· 1 + import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 2 + 3 + export default async function Layout({ 4 + children, 5 + params, 6 + }: { 7 + children: React.ReactNode; 8 + params: Promise<{ id: string; domain: string }>; 9 + }) { 10 + const { id, domain } = await params; 11 + const queryClient = getQueryClient(); 12 + await queryClient.prefetchQuery( 13 + trpc.statusPage.getMaintenance.queryOptions({ 14 + id: Number(id), 15 + slug: domain, 16 + }), 17 + ); 18 + return <HydrateClient>{children}</HydrateClient>; 19 + }
+67
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/page.tsx
··· 1 + "use client"; 2 + 3 + import { useTRPC } from "@/lib/trpc/client"; 4 + import { useQuery } from "@tanstack/react-query"; 5 + 6 + import { formatDate } from "@/lib/formatter"; 7 + 8 + import { ButtonBack } from "@/components/button/button-back"; 9 + import { ButtonCopyLink } from "@/components/button/button-copy-link"; 10 + import { 11 + StatusEvent, 12 + StatusEventAffected, 13 + StatusEventAside, 14 + StatusEventContent, 15 + StatusEventTimelineMaintenance, 16 + StatusEventTitle, 17 + } from "@/components/status-page/status-events"; 18 + import { Badge } from "@/components/ui/badge"; 19 + import { useParams } from "next/navigation"; 20 + 21 + export default function MaintenancePage() { 22 + const trpc = useTRPC(); 23 + const { id, domain } = useParams<{ id: string; domain: string }>(); 24 + const { data: maintenance } = useQuery( 25 + trpc.statusPage.getMaintenance.queryOptions({ 26 + id: Number(id), 27 + slug: domain, 28 + }), 29 + ); 30 + 31 + if (!maintenance) return null; 32 + 33 + const isFuture = maintenance.from > new Date(); 34 + return ( 35 + <div className="flex flex-col gap-4"> 36 + <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> 37 + <ButtonBack href="../" /> 38 + <ButtonCopyLink /> 39 + </div> 40 + <StatusEvent> 41 + <StatusEventAside> 42 + <span className="font-medium text-foreground/80"> 43 + {formatDate(maintenance.from, { month: "short" })} 44 + </span> 45 + {isFuture ? ( 46 + <span className="text-info text-sm">Upcoming</span> 47 + ) : null} 48 + </StatusEventAside> 49 + <StatusEventContent hoverable={false}> 50 + <StatusEventTitle>{maintenance.title}</StatusEventTitle> 51 + <StatusEventAffected className="flex flex-wrap gap-1"> 52 + {maintenance.maintenancesToMonitors.map((affected) => ( 53 + <Badge 54 + key={affected.monitor.id} 55 + variant="outline" 56 + className="text-[10px]" 57 + > 58 + {affected.monitor.name} 59 + </Badge> 60 + ))} 61 + </StatusEventAffected> 62 + <StatusEventTimelineMaintenance maintenance={maintenance} /> 63 + </StatusEventContent> 64 + </StatusEvent> 65 + </div> 66 + ); 67 + }
+19
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/layout.tsx
··· 1 + import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 2 + 3 + export default async function Layout({ 4 + children, 5 + params, 6 + }: { 7 + children: React.ReactNode; 8 + params: Promise<{ id: string; domain: string }>; 9 + }) { 10 + const { id, domain } = await params; 11 + const queryClient = getQueryClient(); 12 + await queryClient.prefetchQuery( 13 + trpc.statusPage.getReport.queryOptions({ 14 + id: Number(id), 15 + slug: domain, 16 + }), 17 + ); 18 + return <HydrateClient>{children}</HydrateClient>; 19 + }
+63
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx
··· 1 + "use client"; 2 + 3 + import { formatDate } from "@/lib/formatter"; 4 + 5 + import { ButtonBack } from "@/components/button/button-back"; 6 + import { ButtonCopyLink } from "@/components/button/button-copy-link"; 7 + import { 8 + StatusEvent, 9 + StatusEventAffected, 10 + StatusEventAside, 11 + StatusEventContent, 12 + StatusEventTimelineReport, 13 + StatusEventTitle, 14 + } from "@/components/status-page/status-events"; 15 + import { Badge } from "@/components/ui/badge"; 16 + import { useTRPC } from "@/lib/trpc/client"; 17 + import { useQuery } from "@tanstack/react-query"; 18 + import { useParams } from "next/navigation"; 19 + 20 + export default function ReportPage() { 21 + const trpc = useTRPC(); 22 + const { id, domain } = useParams<{ id: string; domain: string }>(); 23 + const { data: report } = useQuery( 24 + trpc.statusPage.getReport.queryOptions({ id: Number(id), slug: domain }), 25 + ); 26 + 27 + if (!report) return null; 28 + 29 + const startedAt = report.statusReportUpdates[0].date; 30 + 31 + return ( 32 + <div className="flex flex-col gap-4"> 33 + <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> 34 + <ButtonBack href="../" /> 35 + <ButtonCopyLink /> 36 + </div> 37 + <StatusEvent> 38 + <StatusEventAside> 39 + <span className="font-medium text-foreground/80"> 40 + {formatDate(startedAt, { month: "short" })} 41 + </span> 42 + </StatusEventAside> 43 + <StatusEventContent hoverable={false}> 44 + <StatusEventTitle>{report.title}</StatusEventTitle> 45 + {report.monitorsToStatusReports.length > 0 ? ( 46 + <StatusEventAffected className="flex flex-wrap gap-1"> 47 + {report.monitorsToStatusReports.map((affected) => ( 48 + <Badge 49 + key={affected.monitor.id} 50 + variant="outline" 51 + className="text-[10px]" 52 + > 53 + {affected.monitor.name} 54 + </Badge> 55 + ))} 56 + </StatusEventAffected> 57 + ) : null} 58 + <StatusEventTimelineReport updates={report.statusReportUpdates} /> 59 + </StatusEventContent> 60 + </StatusEvent> 61 + </div> 62 + ); 63 + }
+38
apps/status-page/src/app/(status-page)/[domain]/(public)/events/layout.tsx
··· 1 + "use client"; 2 + 3 + import { useStatusPage } from "@/components/status-page/floating-button"; 4 + import { 5 + Status, 6 + StatusContent, 7 + StatusDescription, 8 + StatusHeader, 9 + StatusTitle, 10 + } from "@/components/status-page/status"; 11 + import { useTRPC } from "@/lib/trpc/client"; 12 + import { useQuery } from "@tanstack/react-query"; 13 + import { useParams } from "next/navigation"; 14 + 15 + export default function EventLayout({ 16 + children, 17 + }: { 18 + children: React.ReactNode; 19 + }) { 20 + const { variant } = useStatusPage(); 21 + const { domain } = useParams<{ domain: string }>(); 22 + const trpc = useTRPC(); 23 + const { data: page } = useQuery( 24 + trpc.statusPage.get.queryOptions({ slug: domain }), 25 + ); 26 + 27 + if (!page) return null; 28 + 29 + return ( 30 + <Status variant={variant}> 31 + <StatusHeader> 32 + <StatusTitle>{page.title}</StatusTitle> 33 + <StatusDescription>{page.description}</StatusDescription> 34 + </StatusHeader> 35 + <StatusContent>{children}</StatusContent> 36 + </Status> 37 + ); 38 + }
+92
apps/status-page/src/app/(status-page)/[domain]/(public)/layout.tsx
··· 1 + import { defaultMetadata, ogMetadata, twitterMetadata } from "@/app/metadata"; 2 + import { Footer } from "@/components/nav/footer"; 3 + import { Header } from "@/components/nav/header"; 4 + import { 5 + FloatingButton, 6 + StatusPageProvider, 7 + } from "@/components/status-page/floating-button"; 8 + import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; 9 + import type { Metadata } from "next"; 10 + import { notFound } from "next/navigation"; 11 + 12 + export default function Layout({ 13 + children, 14 + params, 15 + }: { 16 + children: React.ReactNode; 17 + params: Promise<{ domain: string }>; 18 + }) { 19 + return ( 20 + <Hydrate params={params}> 21 + <StatusPageProvider> 22 + <div className="flex min-h-screen flex-col gap-4"> 23 + <Header className="w-full border-b" /> 24 + <main className="mx-auto flex w-full max-w-2xl flex-1 flex-col px-3 py-2"> 25 + {children} 26 + </main> 27 + <Footer className="w-full border-t" /> 28 + </div> 29 + <FloatingButton /> 30 + </StatusPageProvider> 31 + </Hydrate> 32 + ); 33 + } 34 + 35 + async function Hydrate({ 36 + children, 37 + params, 38 + }: { 39 + children: React.ReactNode; 40 + params: Promise<{ domain: string }>; 41 + }) { 42 + const queryClient = getQueryClient(); 43 + await queryClient.prefetchQuery( 44 + trpc.statusPage.get.queryOptions({ slug: (await params).domain }), 45 + ); 46 + return <HydrateClient>{children}</HydrateClient>; 47 + } 48 + 49 + export async function generateMetadata({ 50 + params, 51 + }: { 52 + params: Promise<{ domain: string }>; 53 + }): Promise<Metadata> { 54 + const queryClient = getQueryClient(); 55 + const { domain } = await params; 56 + const page = await queryClient.fetchQuery( 57 + trpc.statusPage.get.queryOptions({ slug: domain }), 58 + ); 59 + 60 + if (!page) return notFound(); 61 + 62 + return { 63 + ...defaultMetadata, 64 + title: { 65 + template: `%s | ${page.title}`, 66 + default: page?.title, 67 + }, 68 + description: page?.description, 69 + icons: page?.icon, 70 + alternates: { 71 + canonical: page?.customDomain 72 + ? `https://${page.customDomain}` 73 + : `https://${page.slug}.openstatus.dev`, 74 + }, 75 + twitter: { 76 + ...twitterMetadata, 77 + images: [ 78 + `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, 79 + ], 80 + title: page?.title, 81 + description: page?.description, 82 + }, 83 + openGraph: { 84 + ...ogMetadata, 85 + images: [ 86 + `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, 87 + ], 88 + title: page?.title, 89 + description: page?.description, 90 + }, 91 + }; 92 + }
+329
apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/[id]/page.tsx
··· 1 + "use client"; 2 + 3 + import { ButtonBack } from "@/components/button/button-back"; 4 + import { ButtonCopyLink } from "@/components/button/button-copy-link"; 5 + import { 6 + ChartAreaPercentiles, 7 + ChartAreaPercentilesSkeleton, 8 + } from "@/components/chart/chart-area-percentiles"; 9 + import { 10 + ChartBarUptime, 11 + ChartBarUptimeSkeleton, 12 + } from "@/components/chart/chart-bar-uptime"; 13 + import { 14 + ChartLineRegions, 15 + ChartLineRegionsSkeleton, 16 + } from "@/components/chart/chart-line-regions"; 17 + import { PopoverQuantile } from "@/components/popover/popover-quantile"; 18 + import { 19 + Status, 20 + StatusContent, 21 + StatusDescription, 22 + StatusHeader, 23 + StatusTitle, 24 + } from "@/components/status-page/status"; 25 + import { 26 + StatusChartContent, 27 + StatusChartDescription, 28 + StatusChartHeader, 29 + StatusChartTitle, 30 + } from "@/components/status-page/status-charts"; 31 + import { 32 + StatusMonitorTabs, 33 + StatusMonitorTabsContent, 34 + StatusMonitorTabsList, 35 + StatusMonitorTabsTrigger, 36 + StatusMonitorTabsTriggerLabel, 37 + StatusMonitorTabsTriggerValue, 38 + StatusMonitorTabsTriggerValueSkeleton, 39 + } from "@/components/status-page/status-monitor-tabs"; 40 + import { Badge } from "@/components/ui/badge"; 41 + import { 42 + formatMillisecondsRange, 43 + formatNumber, 44 + formatPercentage, 45 + } from "@/lib/formatter"; 46 + import { useTRPC } from "@/lib/trpc/client"; 47 + import { useQuery } from "@tanstack/react-query"; 48 + import { TrendingUp } from "lucide-react"; 49 + import { useParams } from "next/navigation"; 50 + import { useMemo } from "react"; 51 + 52 + export default function Page() { 53 + const trpc = useTRPC(); 54 + const { id, domain } = useParams<{ id: string; domain: string }>(); 55 + const { data: page } = useQuery( 56 + trpc.statusPage.get.queryOptions({ slug: domain }), 57 + ); 58 + 59 + const tempMonitor = useMemo(() => { 60 + return page?.monitors.find((monitor) => monitor.id === Number(id)); 61 + }, [page, id]); 62 + 63 + if (!page) return null; 64 + 65 + const { data: monitor, isLoading } = useQuery( 66 + trpc.statusPage.getMonitor.queryOptions({ id: Number(id), slug: domain }), 67 + ); 68 + 69 + const globalLatencyData = useMemo(() => { 70 + if (!monitor?.data.latency?.data) return []; 71 + 72 + return monitor.data.latency.data 73 + .sort((a, b) => a.timestamp - b.timestamp) 74 + .map((item) => ({ 75 + ...item, 76 + timestamp: new Date(item.timestamp).toLocaleString("default", { 77 + day: "numeric", 78 + month: "short", 79 + hour: "numeric", 80 + minute: "numeric", 81 + timeZoneName: "short", 82 + }), 83 + })); 84 + }, [monitor?.data.latency?.data]); 85 + 86 + const regionLatencyData = useMemo(() => { 87 + if (!monitor?.data.regions?.data) return []; 88 + 89 + const grouped = monitor.data.regions.data 90 + .sort((a, b) => a.timestamp - b.timestamp) 91 + .reduce( 92 + (acc, item) => { 93 + const timestamp = new Date(item.timestamp).toLocaleString("default", { 94 + day: "numeric", 95 + month: "short", 96 + hour: "numeric", 97 + minute: "numeric", 98 + timeZoneName: "short", 99 + }); 100 + 101 + if (!acc[timestamp]) { 102 + acc[timestamp] = { timestamp }; 103 + } 104 + acc[timestamp][item.region] = item.p75Latency; 105 + return acc; 106 + }, 107 + {} as Record< 108 + string, 109 + { timestamp: string; [region: string]: number | string | null } 110 + >, 111 + ); 112 + 113 + return Object.values(grouped); 114 + }, [monitor?.data.regions?.data]); 115 + 116 + const uptimeData = useMemo(() => { 117 + if (!monitor?.data.uptime?.data) return []; 118 + return monitor.data.uptime.data 119 + .sort((a, b) => a.interval.getTime() - b.interval.getTime()) 120 + .map((item) => ({ 121 + timestamp: item.interval.toLocaleString("default", { 122 + day: "numeric", 123 + month: "short", 124 + hour: "numeric", 125 + minute: "numeric", 126 + timeZoneName: "short", 127 + }), 128 + ...item, 129 + })); 130 + }, [monitor?.data.uptime?.data]); 131 + 132 + const { totalChecks, uptimePercentage, slowestRegion, p75Range } = 133 + useMemo(() => { 134 + const p75Range = globalLatencyData.reduce( 135 + (acc, item) => ({ 136 + min: Math.min(acc.min, item.p75Latency), 137 + max: Math.max(acc.max, item.p75Latency), 138 + }), 139 + { 140 + min: Number.POSITIVE_INFINITY, 141 + max: Number.NEGATIVE_INFINITY, 142 + }, 143 + ); 144 + 145 + const uptimeStats = uptimeData.reduce( 146 + (acc, item) => { 147 + return { 148 + total: acc.total + item.success + item.degraded + item.error, 149 + success: acc.success + item.success, 150 + degraded: acc.degraded + item.degraded, 151 + error: acc.error + item.error, 152 + }; 153 + }, 154 + { total: 0, success: 0, degraded: 0, error: 0 }, 155 + ); 156 + 157 + const uptimePercentage = 158 + uptimeStats.total > 0 159 + ? (uptimeStats.success + uptimeStats.degraded) / uptimeStats.total 160 + : 0; 161 + 162 + const regionAverages = regionLatencyData.reduce( 163 + (acc, item) => { 164 + Object.keys(item).forEach((key) => { 165 + if (key !== "timestamp" && typeof item[key] === "number") { 166 + if (!acc[key]) { 167 + acc[key] = { sum: 0, count: 0 }; 168 + } 169 + acc[key].sum += item[key] as number; 170 + acc[key].count += 1; 171 + } 172 + }); 173 + return acc; 174 + }, 175 + {} as Record<string, { sum: number; count: number }>, 176 + ); 177 + 178 + const slowestRegion = Object.entries(regionAverages) 179 + .map(([region, stats]) => ({ 180 + region, 181 + avgLatency: stats.count > 0 ? stats.sum / stats.count : 0, 182 + })) 183 + .sort((a, b) => b.avgLatency - a.avgLatency)[0]; 184 + 185 + return { 186 + totalChecks: formatNumber(uptimeStats.total, { 187 + notation: "compact", 188 + compactDisplay: "short", 189 + }).replace("K", "k"), 190 + uptimePercentage: 191 + uptimeStats.total > 0 ? formatPercentage(uptimePercentage) : "N/A", 192 + slowestRegion: slowestRegion?.region || "N/A", 193 + p75Range: 194 + p75Range.min !== Number.POSITIVE_INFINITY || 195 + p75Range.max !== Number.NEGATIVE_INFINITY 196 + ? formatMillisecondsRange(p75Range.min, p75Range.max) 197 + : "N/A", 198 + }; 199 + }, [uptimeData, regionLatencyData, globalLatencyData]); 200 + 201 + return ( 202 + <Status> 203 + <StatusHeader> 204 + <StatusTitle>{tempMonitor?.name}</StatusTitle> 205 + <StatusDescription>{tempMonitor?.description}</StatusDescription> 206 + </StatusHeader> 207 + <StatusContent className="flex flex-col gap-6"> 208 + <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> 209 + <ButtonBack href="./" /> 210 + <ButtonCopyLink /> 211 + </div> 212 + <StatusMonitorTabs defaultValue="global"> 213 + <StatusMonitorTabsList className="grid grid-cols-3"> 214 + <StatusMonitorTabsTrigger value="global"> 215 + <StatusMonitorTabsTriggerLabel> 216 + Global Latency 217 + </StatusMonitorTabsTriggerLabel> 218 + {isLoading ? ( 219 + <StatusMonitorTabsTriggerValueSkeleton /> 220 + ) : ( 221 + <StatusMonitorTabsTriggerValue> 222 + {p75Range}{" "} 223 + <Badge variant="outline" className="py-px text-[10px]"> 224 + p75 225 + </Badge> 226 + </StatusMonitorTabsTriggerValue> 227 + )} 228 + </StatusMonitorTabsTrigger> 229 + <StatusMonitorTabsTrigger value="region"> 230 + <StatusMonitorTabsTriggerLabel> 231 + Region Latency 232 + </StatusMonitorTabsTriggerLabel> 233 + {isLoading ? ( 234 + <StatusMonitorTabsTriggerValueSkeleton /> 235 + ) : ( 236 + <StatusMonitorTabsTriggerValue> 237 + {tempMonitor?.regions.length} regions{" "} 238 + <Badge 239 + variant="outline" 240 + className="py-px font-mono text-[10px]" 241 + > 242 + {slowestRegion} <TrendingUp className="size-3" /> 243 + </Badge> 244 + </StatusMonitorTabsTriggerValue> 245 + )} 246 + </StatusMonitorTabsTrigger> 247 + <StatusMonitorTabsTrigger value="uptime"> 248 + <StatusMonitorTabsTriggerLabel> 249 + Uptime 250 + </StatusMonitorTabsTriggerLabel> 251 + {isLoading ? ( 252 + <StatusMonitorTabsTriggerValueSkeleton /> 253 + ) : ( 254 + <StatusMonitorTabsTriggerValue> 255 + {uptimePercentage}{" "} 256 + <Badge variant="outline" className="py-px text-[10px]"> 257 + {totalChecks} checks 258 + </Badge> 259 + </StatusMonitorTabsTriggerValue> 260 + )} 261 + </StatusMonitorTabsTrigger> 262 + </StatusMonitorTabsList> 263 + <StatusMonitorTabsContent value="global"> 264 + <StatusChartContent> 265 + <StatusChartHeader> 266 + <StatusChartTitle>Global Latency</StatusChartTitle> 267 + <StatusChartDescription> 268 + The aggregated latency from all active regions based on 269 + different <PopoverQuantile>quantiles</PopoverQuantile>. 270 + </StatusChartDescription> 271 + </StatusChartHeader> 272 + {isLoading ? ( 273 + <ChartAreaPercentilesSkeleton className="h-[250px]" /> 274 + ) : ( 275 + <ChartAreaPercentiles 276 + className="h-[250px]" 277 + legendClassName="justify-start pt-1 ps-1" 278 + legendVerticalAlign="top" 279 + xAxisHide={false} 280 + data={globalLatencyData} 281 + yAxisDomain={[0, "dataMax"]} 282 + /> 283 + )} 284 + </StatusChartContent> 285 + </StatusMonitorTabsContent> 286 + <StatusMonitorTabsContent value="region"> 287 + <StatusChartContent> 288 + <StatusChartHeader> 289 + <StatusChartTitle>Latency by Region</StatusChartTitle> 290 + <StatusChartDescription> 291 + {/* TODO: we could add an information to p95 that it takes the highest selected global latency percentile */} 292 + Region latency per{" "} 293 + <code className="font-medium text-foreground">p75</code>{" "} 294 + <PopoverQuantile>quantile</PopoverQuantile>, sorted by slowest 295 + region. Compare up to{" "} 296 + <code className="font-medium text-foreground">6</code>{" "} 297 + regions. 298 + </StatusChartDescription> 299 + </StatusChartHeader> 300 + {isLoading ? ( 301 + <ChartLineRegionsSkeleton className="h-[250px]" /> 302 + ) : ( 303 + <ChartLineRegions 304 + className="h-[250px]" 305 + data={regionLatencyData} 306 + /> 307 + )} 308 + </StatusChartContent> 309 + </StatusMonitorTabsContent> 310 + <StatusMonitorTabsContent value="uptime"> 311 + <StatusChartContent> 312 + <StatusChartHeader> 313 + <StatusChartTitle>Total Uptime</StatusChartTitle> 314 + <StatusChartDescription> 315 + Main values of uptime and availability, transparent. 316 + </StatusChartDescription> 317 + </StatusChartHeader> 318 + {isLoading ? ( 319 + <ChartBarUptimeSkeleton className="h-[250px]" /> 320 + ) : ( 321 + <ChartBarUptime className="h-[250px]" data={uptimeData} /> 322 + )} 323 + </StatusChartContent> 324 + </StatusMonitorTabsContent> 325 + </StatusMonitorTabs> 326 + </StatusContent> 327 + </Status> 328 + ); 329 + }
+107
apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/page.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + ChartAreaPercentiles, 5 + ChartAreaPercentilesSkeleton, 6 + } from "@/components/chart/chart-area-percentiles"; 7 + import { 8 + EmptyStateContainer, 9 + EmptyStateDescription, 10 + EmptyStateTitle, 11 + } from "@/components/content/empty-state"; 12 + import { useStatusPage } from "@/components/status-page/floating-button"; 13 + import { 14 + Status, 15 + StatusContent, 16 + StatusDescription, 17 + StatusHeader, 18 + StatusTitle, 19 + } from "@/components/status-page/status"; 20 + import { StatusMonitorTitle } from "@/components/status-page/status-monitor"; 21 + import { StatusMonitorDescription } from "@/components/status-page/status-monitor"; 22 + import { useTRPC } from "@/lib/trpc/client"; 23 + import { useQuery } from "@tanstack/react-query"; 24 + import Link from "next/link"; 25 + import { useParams } from "next/navigation"; 26 + 27 + export default function Page() { 28 + const { variant } = useStatusPage(); 29 + const { domain } = useParams<{ domain: string }>(); 30 + const trpc = useTRPC(); 31 + const { data: page } = useQuery( 32 + trpc.statusPage.get.queryOptions({ slug: domain }), 33 + ); 34 + const { data: monitors, isLoading } = useQuery( 35 + trpc.statusPage.getMonitors.queryOptions({ slug: domain }), 36 + ); 37 + 38 + if (!page) return null; 39 + 40 + return ( 41 + <Status variant={variant}> 42 + <StatusHeader> 43 + <StatusTitle>{page.title}</StatusTitle> 44 + <StatusDescription>{page.description}</StatusDescription> 45 + </StatusHeader> 46 + <StatusContent className="flex flex-col gap-6"> 47 + {page.monitors.length > 0 ? ( 48 + page.monitors 49 + .filter((monitor) => monitor.public) 50 + .map((monitor) => { 51 + const data = 52 + monitors 53 + ?.find((item) => item.id === monitor.id) 54 + ?.data?.map((item) => ({ 55 + ...item, 56 + // TODO: create formatter 57 + timestamp: new Date(item.timestamp).toLocaleString( 58 + "default", 59 + { 60 + day: "numeric", 61 + month: "short", 62 + hour: "numeric", 63 + minute: "numeric", 64 + timeZoneName: "short", 65 + }, 66 + ), 67 + })) ?? []; 68 + 69 + return ( 70 + <Link 71 + key={monitor.id} 72 + href={`./monitors/${monitor.id}`} 73 + className="rounded-lg" 74 + > 75 + <div className="group -mx-3 -my-2 flex flex-col gap-2 rounded-lg border border-transparent px-3 py-2 hover:border-border/50 hover:bg-muted/50"> 76 + <div className="flex flex-row items-center gap-2"> 77 + <StatusMonitorTitle>{monitor.name}</StatusMonitorTitle> 78 + <StatusMonitorDescription> 79 + {monitor.description} 80 + </StatusMonitorDescription> 81 + </div> 82 + {isLoading ? ( 83 + <ChartAreaPercentilesSkeleton className="h-[80px]" /> 84 + ) : ( 85 + <ChartAreaPercentiles 86 + className="h-[80px]" 87 + legendClassName="pb-1" 88 + data={data} 89 + singleSeries 90 + /> 91 + )} 92 + </div> 93 + </Link> 94 + ); 95 + }) 96 + ) : ( 97 + <EmptyStateContainer> 98 + <EmptyStateTitle>No public monitors</EmptyStateTitle> 99 + <EmptyStateDescription> 100 + No public monitors have been added to this page. 101 + </EmptyStateDescription> 102 + </EmptyStateContainer> 103 + )} 104 + </StatusContent> 105 + </Status> 106 + ); 107 + }
+146
apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx
··· 1 + "use client"; 2 + 3 + import { useStatusPage } from "@/components/status-page/floating-button"; 4 + import { 5 + Status, 6 + StatusContent, 7 + StatusDescription, 8 + StatusHeader, 9 + StatusTitle, 10 + } from "@/components/status-page/status"; 11 + import { 12 + StatusBanner, 13 + StatusBannerContainer, 14 + StatusBannerContent, 15 + StatusBannerTitle, 16 + } from "@/components/status-page/status-banner"; 17 + import { 18 + StatusEventTimelineMaintenance, 19 + StatusEventTimelineReport, 20 + } from "@/components/status-page/status-events"; 21 + import { StatusFeed } from "@/components/status-page/status-feed"; 22 + import { StatusMonitor } from "@/components/status-page/status-monitor"; 23 + import { Separator } from "@/components/ui/separator"; 24 + import { useTRPC } from "@/lib/trpc/client"; 25 + import { useQuery } from "@tanstack/react-query"; 26 + import { useParams } from "next/navigation"; 27 + 28 + export default function Page() { 29 + const { domain } = useParams<{ domain: string }>(); 30 + const { cardType, barType, showUptime } = useStatusPage(); 31 + const trpc = useTRPC(); 32 + const { data: page } = useQuery( 33 + trpc.statusPage.get.queryOptions({ slug: domain }), 34 + ); 35 + // NOTE: we can prefetch that to avoid loading state 36 + const { data: uptimeData, isLoading } = useQuery( 37 + trpc.statusPage.getUptime.queryOptions({ 38 + slug: domain, 39 + monitorIds: page?.monitors?.map((monitor) => monitor.id.toString()) || [], 40 + // NOTE: this will be moved to db config 41 + cardType, 42 + barType, 43 + }), 44 + ); 45 + 46 + if (!page) return null; 47 + 48 + return ( 49 + <div className="flex flex-col gap-6"> 50 + <Status variant={page.status}> 51 + <StatusHeader> 52 + <StatusTitle>{page.title}</StatusTitle> 53 + <StatusDescription>{page.description}</StatusDescription> 54 + </StatusHeader> 55 + {page.openEvents.length > 0 ? ( 56 + <StatusContent> 57 + {page.openEvents.map((e) => { 58 + if (e.type === "maintenance") { 59 + const maintenance = page.maintenances.find( 60 + (maintenance) => maintenance.id === e.id, 61 + ); 62 + if (!maintenance) return null; 63 + return ( 64 + <StatusBannerContainer key={e.id} status={e.status}> 65 + <StatusBannerTitle>{e.name}</StatusBannerTitle> 66 + <StatusBannerContent> 67 + <StatusEventTimelineMaintenance 68 + maintenance={maintenance} 69 + withDot={false} 70 + /> 71 + </StatusBannerContent> 72 + </StatusBannerContainer> 73 + ); 74 + } 75 + if (e.type === "report") { 76 + const report = page.statusReports.find( 77 + (report) => report.id === e.id, 78 + ); 79 + if (!report) return null; 80 + return ( 81 + <StatusBannerContainer key={e.id} status={e.status}> 82 + <StatusBannerTitle>{e.name}</StatusBannerTitle> 83 + <StatusBannerContent> 84 + <StatusEventTimelineReport 85 + updates={report.statusReportUpdates} 86 + withDot={false} 87 + /> 88 + </StatusBannerContent> 89 + </StatusBannerContainer> 90 + ); 91 + } 92 + return null; 93 + })} 94 + </StatusContent> 95 + ) : ( 96 + <StatusBanner status={page.status} /> 97 + )} 98 + {/* TODO: check how to display current events */} 99 + <StatusContent> 100 + {page.monitors.map((monitor) => { 101 + const { data, uptime } = 102 + uptimeData?.find((m) => m.id === monitor.id) ?? {}; 103 + return ( 104 + <StatusMonitor 105 + key={monitor.id} 106 + status={monitor.status} 107 + data={data} 108 + monitor={monitor} 109 + uptime={uptime} 110 + showUptime={showUptime} 111 + isLoading={isLoading} 112 + /> 113 + ); 114 + })} 115 + </StatusContent> 116 + <Separator /> 117 + <StatusContent> 118 + <StatusTitle>Recent Events</StatusTitle> 119 + <StatusFeed 120 + statusReports={page.statusReports 121 + .filter((report) => 122 + page.lastEvents.some((event) => event.id === report.id), 123 + ) 124 + .map((report) => ({ 125 + ...report, 126 + affected: report.monitorsToStatusReports.map( 127 + (monitor) => monitor.monitor.name, 128 + ), 129 + updates: report.statusReportUpdates, 130 + }))} 131 + maintenances={page.maintenances 132 + .filter((maintenance) => 133 + page.lastEvents.some((event) => event.id === maintenance.id), 134 + ) 135 + .map((maintenance) => ({ 136 + ...maintenance, 137 + affected: maintenance.maintenancesToMonitors.map( 138 + (monitor) => monitor.monitor.name, 139 + ), 140 + }))} 141 + /> 142 + </StatusContent> 143 + </Status> 144 + </div> 145 + ); 146 + }
+66
apps/status-page/src/app/(status-page)/[domain]/(public)/verify/[token]/page.tsx
··· 1 + "use client"; 2 + 3 + import { ButtonBack } from "@/components/button/button-back"; 4 + import { 5 + Status, 6 + StatusHeader, 7 + StatusTitle, 8 + } from "@/components/status-page/status"; 9 + import { useTRPC } from "@/lib/trpc/client"; 10 + import { cn } from "@/lib/utils"; 11 + import { useMutation, useQuery } from "@tanstack/react-query"; 12 + import { useParams } from "next/navigation"; 13 + import { useEffect } from "react"; 14 + 15 + export default function VerifyPage() { 16 + const trpc = useTRPC(); 17 + const { token, domain } = useParams<{ token: string; domain: string }>(); 18 + const { data: page } = useQuery( 19 + trpc.statusPage.get.queryOptions({ slug: domain }), 20 + ); 21 + 22 + const verifyEmailMutation = useMutation( 23 + trpc.statusPage.verifyEmail.mutationOptions({}), 24 + ); 25 + 26 + // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> 27 + useEffect(() => { 28 + verifyEmailMutation.mutate({ slug: domain, token }); 29 + }, [domain, token]); 30 + 31 + if (!page) return null; 32 + 33 + return ( 34 + <Status className="my-auto text-center"> 35 + <StatusHeader className="space-y-2 font-mono"> 36 + {verifyEmailMutation.isSuccess ? ( 37 + <StatusTitle> 38 + All set to receive updates from to {verifyEmailMutation.data?.email} 39 + </StatusTitle> 40 + ) : verifyEmailMutation.isError ? ( 41 + <StatusTitle 42 + className={cn( 43 + verifyEmailMutation.error?.data?.code === "NOT_FOUND" 44 + ? "text-destructive" 45 + : "", 46 + )} 47 + > 48 + {verifyEmailMutation.error?.message} 49 + </StatusTitle> 50 + ) : ( 51 + <StatusTitle> 52 + Hang tight - we're confirming your subscription 53 + </StatusTitle> 54 + )} 55 + <ButtonBack 56 + href="../" 57 + className={cn( 58 + verifyEmailMutation.isSuccess || verifyEmailMutation.isError 59 + ? "visible" 60 + : "invisible", 61 + )} 62 + /> 63 + </StatusHeader> 64 + </Status> 65 + ); 66 + }
-7
apps/status-page/src/app/(status-page)/[domain]/events/(list)/page.tsx
··· 1 - "use client"; 2 - 3 - import { StatusEventsTabs } from "@/components/status-page/status-events"; 4 - 5 - export default function Page() { 6 - return <StatusEventsTabs />; 7 - }
-98
apps/status-page/src/app/(status-page)/[domain]/events/(view)/maintenance/page.tsx
··· 1 - "use client"; 2 - 3 - import { formatDate } from "@/lib/formatter"; 4 - 5 - import { 6 - StatusEvent, 7 - StatusEventAffected, 8 - StatusEventAside, 9 - StatusEventContent, 10 - StatusEventTimelineMaintenance, 11 - StatusEventTitle, 12 - } from "@/components/status-page/status-events"; 13 - import { Badge } from "@/components/ui/badge"; 14 - import { Button } from "@/components/ui/button"; 15 - import { maintenances } from "@/data/maintenances"; 16 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 17 - import { cn } from "@/lib/utils"; 18 - import { ArrowLeft, Check, Copy } from "lucide-react"; 19 - import Link from "next/link"; 20 - 21 - const maintenance = maintenances[0]; 22 - 23 - export default function EventPage() { 24 - const isFuture = maintenance.startDate > new Date(); 25 - return ( 26 - <div className="flex flex-col gap-4"> 27 - <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> 28 - <BackButton /> 29 - <CopyButton /> 30 - </div> 31 - <StatusEvent> 32 - <StatusEventAside> 33 - <span className="font-medium text-foreground/80"> 34 - {formatDate(maintenance.startDate, { month: "short" })} 35 - </span> 36 - {isFuture ? ( 37 - <span className="text-info text-sm">Upcoming</span> 38 - ) : null} 39 - </StatusEventAside> 40 - <StatusEventContent hoverable={false}> 41 - <StatusEventTitle>{maintenance.title}</StatusEventTitle> 42 - <StatusEventAffected className="flex flex-wrap gap-1"> 43 - {maintenance.affected.map((affected) => ( 44 - <Badge key={affected} variant="outline" className="text-[10px]"> 45 - {affected} 46 - </Badge> 47 - ))} 48 - </StatusEventAffected> 49 - <StatusEventTimelineMaintenance maintenance={maintenance} /> 50 - </StatusEventContent> 51 - </StatusEvent> 52 - </div> 53 - ); 54 - } 55 - 56 - function BackButton({ 57 - className, 58 - ...props 59 - }: React.ComponentProps<typeof Button>) { 60 - return ( 61 - <Button 62 - variant="ghost" 63 - size="sm" 64 - className={cn("text-muted-foreground", className)} 65 - asChild 66 - {...props} 67 - > 68 - <Link href="/status-page/events"> 69 - <ArrowLeft /> 70 - Back 71 - </Link> 72 - </Button> 73 - ); 74 - } 75 - 76 - function CopyButton({ 77 - className, 78 - ...props 79 - }: React.ComponentProps<typeof Button>) { 80 - const { copy, isCopied } = useCopyToClipboard(); 81 - 82 - return ( 83 - <Button 84 - variant="outline" 85 - size="icon" 86 - onClick={() => 87 - copy(window.location.href, { 88 - successMessage: "Link copied to clipboard", 89 - }) 90 - } 91 - className={cn("size-8", className)} 92 - {...props} 93 - > 94 - {isCopied ? <Check /> : <Copy />} 95 - <span className="sr-only">Copy Link</span> 96 - </Button> 97 - ); 98 - }
-95
apps/status-page/src/app/(status-page)/[domain]/events/(view)/report/page.tsx
··· 1 - "use client"; 2 - 3 - import { formatDate } from "@/lib/formatter"; 4 - 5 - import { 6 - StatusEvent, 7 - StatusEventAffected, 8 - StatusEventAside, 9 - StatusEventContent, 10 - StatusEventTimelineReport, 11 - StatusEventTitle, 12 - } from "@/components/status-page/status-events"; 13 - import { Badge } from "@/components/ui/badge"; 14 - import { Button } from "@/components/ui/button"; 15 - import { statusReports } from "@/data/status-reports"; 16 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 17 - import { cn } from "@/lib/utils"; 18 - import { ArrowLeft, Check, Copy } from "lucide-react"; 19 - import Link from "next/link"; 20 - 21 - const report = statusReports[1]; 22 - 23 - export default function EventPage() { 24 - return ( 25 - <div className="flex flex-col gap-4"> 26 - <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> 27 - <BackButton /> 28 - <CopyButton /> 29 - </div> 30 - <StatusEvent> 31 - <StatusEventAside> 32 - <span className="font-medium text-foreground/80"> 33 - {formatDate(report.startedAt, { month: "short" })} 34 - </span> 35 - </StatusEventAside> 36 - <StatusEventContent hoverable={false}> 37 - <StatusEventTitle>{report.name}</StatusEventTitle> 38 - <StatusEventAffected className="flex flex-wrap gap-1"> 39 - {report.affected.map((affected) => ( 40 - // TODO: use StatusEventAffectedBadge component 41 - <Badge key={affected} variant="outline" className="text-[10px]"> 42 - {affected} 43 - </Badge> 44 - ))} 45 - </StatusEventAffected> 46 - <StatusEventTimelineReport updates={report.updates} /> 47 - </StatusEventContent> 48 - </StatusEvent> 49 - </div> 50 - ); 51 - } 52 - 53 - function BackButton({ 54 - className, 55 - ...props 56 - }: React.ComponentProps<typeof Button>) { 57 - return ( 58 - <Button 59 - variant="ghost" 60 - size="sm" 61 - className={cn("text-muted-foreground", className)} 62 - asChild 63 - {...props} 64 - > 65 - <Link href="/status-page/events"> 66 - <ArrowLeft /> 67 - Back 68 - </Link> 69 - </Button> 70 - ); 71 - } 72 - 73 - function CopyButton({ 74 - className, 75 - ...props 76 - }: React.ComponentProps<typeof Button>) { 77 - const { copy, isCopied } = useCopyToClipboard(); 78 - 79 - return ( 80 - <Button 81 - variant="outline" 82 - size="icon" 83 - onClick={() => 84 - copy(window.location.href, { 85 - successMessage: "Link copied to clipboard", 86 - }) 87 - } 88 - className={cn("size-8", className)} 89 - {...props} 90 - > 91 - {isCopied ? <Check /> : <Copy />} 92 - <span className="sr-only">Copy Link</span> 93 - </Button> 94 - ); 95 - }
-27
apps/status-page/src/app/(status-page)/[domain]/events/layout.tsx
··· 1 - "use client"; 2 - 3 - import { useStatusPage } from "@/components/status-page/floating-button"; 4 - import { 5 - Status, 6 - StatusContent, 7 - StatusDescription, 8 - StatusHeader, 9 - StatusTitle, 10 - } from "@/components/status-page/status"; 11 - 12 - export default function EventLayout({ 13 - children, 14 - }: { 15 - children: React.ReactNode; 16 - }) { 17 - const { variant } = useStatusPage(); 18 - return ( 19 - <Status variant={variant}> 20 - <StatusHeader> 21 - <StatusTitle>Craft</StatusTitle> 22 - <StatusDescription>Stay informed about the stability</StatusDescription> 23 - </StatusHeader> 24 - <StatusContent>{children}</StatusContent> 25 - </Status> 26 - ); 27 - }
-149
apps/status-page/src/app/(status-page)/[domain]/layout.tsx
··· 1 - "use client"; 2 - 3 - /** 4 - * TODO: 5 - * - add different header 6 - * - add different chart/tracker 7 - * - add subscription popover (choose which one you'd like to allow) 8 - * - use the '@/components/status-page` for the components 9 - */ 10 - 11 - import { Link } from "@/components/common/link"; 12 - import { 13 - FloatingButton, 14 - StatusPageProvider, 15 - } from "@/components/status-page/floating-button"; 16 - import { StatusUpdates } from "@/components/status-page/status-updates"; 17 - import { Button } from "@/components/ui/button"; 18 - import { 19 - Sheet, 20 - SheetContent, 21 - SheetHeader, 22 - SheetTitle, 23 - SheetTrigger, 24 - } from "@/components/ui/sheet"; 25 - import { cn } from "@/lib/utils"; 26 - import { Menu } from "lucide-react"; 27 - import NextLink from "next/link"; 28 - import { usePathname } from "next/navigation"; 29 - import { useState } from "react"; 30 - const nav = [ 31 - { label: "Status", href: "/status-page" }, 32 - { label: "Events", href: "/status-page/events" }, 33 - { label: "Monitors", href: "/status-page/monitors" }, 34 - ]; 35 - 36 - export default function Layout({ children }: { children: React.ReactNode }) { 37 - return ( 38 - <StatusPageProvider> 39 - <div className="flex min-h-screen flex-col gap-4"> 40 - <header className="w-full border-b"> 41 - <nav className="mx-auto flex max-w-2xl items-center justify-between gap-3 px-3 py-2"> 42 - {/* NOTE: same width as the `StatusUpdates` button */} 43 - <div className="w-[105px] shrink-0"> 44 - <Link href="/"> 45 - <img 46 - src="https://www.openstatus.dev/icon.png" 47 - alt="Craft" 48 - className="size-8 rounded-full border" 49 - /> 50 - </Link> 51 - </div> 52 - <NavDesktop className="hidden md:flex" /> 53 - <StatusUpdates className="hidden md:block" /> 54 - <div className="flex gap-3 md:hidden"> 55 - <NavMobile /> 56 - <StatusUpdates /> 57 - </div> 58 - </nav> 59 - </header> 60 - <main className="mx-auto w-full max-w-2xl flex-1 px-3 py-2"> 61 - {children} 62 - </main> 63 - <footer className="w-full border-t"> 64 - <div className="mx-auto max-w-2xl px-3 py-2"> 65 - <p className="text-center text-muted-foreground"> 66 - Powered by <Link href="#">OpenStatus</Link> 67 - </p> 68 - </div> 69 - </footer> 70 - </div> 71 - <FloatingButton /> 72 - </StatusPageProvider> 73 - ); 74 - } 75 - 76 - function NavDesktop({ className, ...props }: React.ComponentProps<"ul">) { 77 - const pathname = usePathname(); 78 - return ( 79 - <ul className={cn("flex flex-row gap-2", className)} {...props}> 80 - {nav.map((item) => { 81 - const isActive = 82 - item.href === "/status-page" 83 - ? pathname === item.href 84 - : pathname.startsWith(item.href); 85 - return ( 86 - <li key={item.label}> 87 - <Button 88 - variant={isActive ? "secondary" : "ghost"} 89 - size="sm" 90 - asChild 91 - > 92 - <NextLink href={item.href}>{item.label}</NextLink> 93 - </Button> 94 - </li> 95 - ); 96 - })} 97 - </ul> 98 - ); 99 - } 100 - 101 - function NavMobile({ 102 - className, 103 - ...props 104 - }: React.ComponentProps<typeof Button>) { 105 - const pathname = usePathname(); 106 - const [open, setOpen] = useState(false); 107 - return ( 108 - <Sheet open={open} onOpenChange={setOpen}> 109 - <SheetTrigger asChild> 110 - <Button 111 - variant="secondary" 112 - size="sm" 113 - className={cn("size-8", className)} 114 - {...props} 115 - > 116 - <Menu /> 117 - </Button> 118 - </SheetTrigger> 119 - <SheetContent side="top"> 120 - <SheetHeader className="border-b"> 121 - <SheetTitle>Menu</SheetTitle> 122 - </SheetHeader> 123 - <div className="px-1 pb-4"> 124 - <ul className="flex flex-col gap-1"> 125 - {nav.map((item) => { 126 - const isActive = 127 - item.href === "/status-page" 128 - ? pathname === item.href 129 - : pathname.startsWith(item.href); 130 - return ( 131 - <li key={item.label} className="w-full"> 132 - <Button 133 - variant={isActive ? "secondary" : "ghost"} 134 - onClick={() => setOpen(false)} 135 - className="w-full justify-start" 136 - size="sm" 137 - asChild 138 - > 139 - <NextLink href={item.href}>{item.label}</NextLink> 140 - </Button> 141 - </li> 142 - ); 143 - })} 144 - </ul> 145 - </div> 146 - </SheetContent> 147 - </Sheet> 148 - ); 149 - }
-53
apps/status-page/src/app/(status-page)/[domain]/monitors/page.tsx
··· 1 - "use client"; 2 - 3 - import { ChartAreaPercentiles } from "@/components/chart/chart-area-percentiles"; 4 - import { useStatusPage } from "@/components/status-page/floating-button"; 5 - import { 6 - Status, 7 - StatusContent, 8 - StatusDescription, 9 - StatusHeader, 10 - StatusTitle, 11 - } from "@/components/status-page/status"; 12 - import { StatusMonitorTitle } from "@/components/status-page/status-monitor"; 13 - import { StatusMonitorDescription } from "@/components/status-page/status-monitor"; 14 - import { monitors } from "@/data/monitors"; 15 - import Link from "next/link"; 16 - 17 - export default function Page() { 18 - const { variant } = useStatusPage(); 19 - return ( 20 - <Status variant={variant}> 21 - <StatusHeader> 22 - <StatusTitle>Craft</StatusTitle> 23 - <StatusDescription>Stay informed about the stability</StatusDescription> 24 - </StatusHeader> 25 - {/* TODO: create components */} 26 - <StatusContent className="flex flex-col gap-6"> 27 - {monitors 28 - .filter((monitor) => monitor.public) 29 - .map((monitor) => ( 30 - <Link 31 - key={monitor.id} 32 - href="/status-page/monitors/view" 33 - className="rounded-lg" 34 - > 35 - <div className="group -mx-3 -my-2 flex flex-col gap-2 rounded-lg border border-transparent px-3 py-2 hover:border-border/50 hover:bg-muted/50"> 36 - <div className="flex flex-row items-center gap-2"> 37 - <StatusMonitorTitle>{monitor.name}</StatusMonitorTitle> 38 - <StatusMonitorDescription> 39 - {monitor.description} 40 - </StatusMonitorDescription> 41 - </div> 42 - <ChartAreaPercentiles 43 - className="h-[80px]" 44 - legendClassName="pb-1" 45 - singleSeries 46 - /> 47 - </div> 48 - </Link> 49 - ))} 50 - </StatusContent> 51 - </Status> 52 - ); 53 - }
-370
apps/status-page/src/app/(status-page)/[domain]/monitors/view/page.tsx
··· 1 - "use client"; 2 - 3 - import { ChartAreaPercentiles } from "@/components/chart/chart-area-percentiles"; 4 - import { ChartLineRegions } from "@/components/chart/chart-line-regions"; 5 - import { 6 - MetricCard, 7 - MetricCardGroup, 8 - MetricCardHeader, 9 - MetricCardTitle, 10 - MetricCardValue, 11 - } from "@/components/content/metric-card"; 12 - import { 13 - Status, 14 - StatusContent, 15 - StatusDescription, 16 - StatusHeader, 17 - StatusTitle, 18 - } from "@/components/status-page/status"; 19 - import { 20 - StatusChartContent, 21 - StatusChartDescription, 22 - StatusChartHeader, 23 - StatusChartTitle, 24 - } from "@/components/status-page/status-charts"; 25 - import { StatusMonitor } from "@/components/status-page/status-monitor"; 26 - import { chartData } from "@/components/status-page/utils"; 27 - import { Badge } from "@/components/ui/badge"; 28 - import { Button } from "@/components/ui/button"; 29 - import { 30 - DropdownMenu, 31 - DropdownMenuContent, 32 - DropdownMenuGroup, 33 - DropdownMenuItem, 34 - DropdownMenuLabel, 35 - DropdownMenuTrigger, 36 - } from "@/components/ui/dropdown-menu"; 37 - import { 38 - Popover, 39 - PopoverContent, 40 - PopoverTrigger, 41 - } from "@/components/ui/popover"; 42 - import { Separator } from "@/components/ui/separator"; 43 - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 44 - import { monitors } from "@/data/monitors"; 45 - import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 46 - import { formatNumber } from "@/lib/formatter"; 47 - import { cn } from "@/lib/utils"; 48 - import { Check, Copy, TrendingUp } from "lucide-react"; 49 - import { useState } from "react"; 50 - 51 - // TODO: add error range on ChartAreaLatency 52 - // TODO: add timerange (1d, 7d, 14d) or leave as is and have 7d default? 53 - // TODO: how to deal with the latency by region percentiles + interval/resolution 54 - 55 - const metrics = [ 56 - { 57 - label: "UPTIME", 58 - value: "99.99%", 59 - variant: "success" as const, 60 - }, 61 - { 62 - label: "FAILS", 63 - value: "3", 64 - variant: "destructive" as const, 65 - }, 66 - { 67 - label: "DEGRADED", 68 - value: "0", 69 - variant: "warning" as const, 70 - }, 71 - { 72 - label: "CHECKS", 73 - value: "5.102", 74 - variant: "ghost" as const, 75 - }, 76 - ]; 77 - 78 - export default function Page() { 79 - return ( 80 - <Status> 81 - <StatusHeader> 82 - <StatusTitle>OpenStatus 418</StatusTitle> 83 - <StatusDescription> 84 - I&apos;m a teapot - Just random values 85 - </StatusDescription> 86 - </StatusHeader> 87 - <StatusContent className="flex flex-col gap-6"> 88 - <div className="flex w-full flex-row items-center justify-between gap-2 py-0.5"> 89 - <DropdownPeriod /> 90 - <CopyButton /> 91 - </div> 92 - <StatusMonitorTabs defaultValue="global"> 93 - <StatusMonitorTabsList className="grid grid-cols-3"> 94 - <StatusMonitorTabsTrigger value="global"> 95 - <StatusMonitorTabsTriggerLabel> 96 - Global Latency 97 - </StatusMonitorTabsTriggerLabel> 98 - <StatusMonitorTabsTriggerValue> 99 - 287 - 568ms{" "} 100 - <Badge variant="outline" className="py-px text-[10px]"> 101 - p75 102 - </Badge> 103 - </StatusMonitorTabsTriggerValue> 104 - </StatusMonitorTabsTrigger> 105 - <StatusMonitorTabsTrigger value="region"> 106 - <StatusMonitorTabsTriggerLabel> 107 - Region Latency 108 - </StatusMonitorTabsTriggerLabel> 109 - <StatusMonitorTabsTriggerValue> 110 - 7 regions{" "} 111 - <Badge 112 - variant="outline" 113 - className="py-px font-mono text-[10px]" 114 - > 115 - arn <TrendingUp className="size-3" /> 116 - </Badge> 117 - </StatusMonitorTabsTriggerValue> 118 - </StatusMonitorTabsTrigger> 119 - <StatusMonitorTabsTrigger value="uptime"> 120 - <StatusMonitorTabsTriggerLabel> 121 - Uptime 122 - </StatusMonitorTabsTriggerLabel> 123 - <StatusMonitorTabsTriggerValue> 124 - 99.99%{" "} 125 - <Badge variant="outline" className="py-px text-[10px]"> 126 - {formatNumber(5102, { 127 - notation: "compact", 128 - compactDisplay: "short", 129 - }).replace("K", "k")}{" "} 130 - checks 131 - </Badge> 132 - </StatusMonitorTabsTriggerValue> 133 - </StatusMonitorTabsTrigger> 134 - </StatusMonitorTabsList> 135 - <StatusMonitorTabsContent value="global"> 136 - <StatusChartContent> 137 - <StatusChartHeader> 138 - <StatusChartTitle>Global Latency</StatusChartTitle> 139 - <StatusChartDescription> 140 - The aggregated latency from all active regions based on 141 - different <PopoverQuantile>quantiles</PopoverQuantile>. 142 - </StatusChartDescription> 143 - </StatusChartHeader> 144 - <ChartAreaPercentiles 145 - className="h-[250px]" 146 - legendClassName="justify-start pt-1 ps-1" 147 - legendVerticalAlign="top" 148 - xAxisHide={false} 149 - yAxisDomain={[0, "dataMax"]} 150 - /> 151 - </StatusChartContent> 152 - </StatusMonitorTabsContent> 153 - <StatusMonitorTabsContent value="region"> 154 - <StatusChartContent> 155 - <StatusChartHeader> 156 - <StatusChartTitle>Latency by Region</StatusChartTitle> 157 - <StatusChartDescription> 158 - {/* TODO: we could add an information to p95 that it takes the highest selected global latency percentile */} 159 - Region latency per{" "} 160 - <code className="font-medium text-foreground">p75</code>{" "} 161 - <PopoverQuantile>quantile</PopoverQuantile>, sorted by slowest 162 - region. Compare up to{" "} 163 - <code className="font-medium text-foreground">3</code>{" "} 164 - regions. 165 - </StatusChartDescription> 166 - </StatusChartHeader> 167 - <ChartLineRegions className="h-[250px]" /> 168 - </StatusChartContent> 169 - </StatusMonitorTabsContent> 170 - <StatusMonitorTabsContent value="uptime"> 171 - <StatusChartContent> 172 - <StatusChartHeader> 173 - <StatusChartTitle>Total Uptime</StatusChartTitle> 174 - <StatusChartDescription> 175 - Main values of uptime and availability, transparent. 176 - </StatusChartDescription> 177 - </StatusChartHeader> 178 - <MetricCardGroup className="sm:grid-cols-4 lg:grid-cols-4"> 179 - {metrics.map((metric) => { 180 - if (metric === null) 181 - return <div key={metric} className="hidden lg:block" />; 182 - return ( 183 - <MetricCard key={metric.label} variant={metric.variant}> 184 - <MetricCardHeader> 185 - <MetricCardTitle className="truncate"> 186 - {metric.label} 187 - </MetricCardTitle> 188 - </MetricCardHeader> 189 - <MetricCardValue>{metric.value}</MetricCardValue> 190 - </MetricCard> 191 - ); 192 - })} 193 - </MetricCardGroup> 194 - <StatusMonitor 195 - barType="absolute" 196 - cardType="requests" 197 - data={chartData} 198 - monitor={monitors[1]} 199 - /> 200 - </StatusChartContent> 201 - </StatusMonitorTabsContent> 202 - </StatusMonitorTabs> 203 - </StatusContent> 204 - </Status> 205 - ); 206 - } 207 - 208 - // Use Link instead of copy (same for reports and maintenance) 209 - function CopyButton({ 210 - className, 211 - ...props 212 - }: React.ComponentProps<typeof Button>) { 213 - const { copy, isCopied } = useCopyToClipboard(); 214 - 215 - return ( 216 - <Button 217 - variant="outline" 218 - size="icon" 219 - onClick={() => 220 - copy(window.location.href, { 221 - successMessage: "Link copied to clipboard", 222 - }) 223 - } 224 - className={cn("size-8", className)} 225 - {...props} 226 - > 227 - {isCopied ? <Check /> : <Copy />} 228 - <span className="sr-only">Copy Link</span> 229 - </Button> 230 - ); 231 - } 232 - 233 - const PERIOD_VALUES = [ 234 - { 235 - value: "1d", 236 - label: "Last day", 237 - }, 238 - { 239 - value: "7d", 240 - label: "Last 7 days", 241 - }, 242 - { 243 - value: "14d", 244 - label: "Last 14 days", 245 - }, 246 - ]; 247 - 248 - function DropdownPeriod() { 249 - const [period, setPeriod] = useState("1d"); 250 - return ( 251 - <DropdownMenu> 252 - <DropdownMenuTrigger asChild> 253 - <Button variant="outline" size="sm"> 254 - {PERIOD_VALUES.find(({ value }) => value === period)?.label} 255 - </Button> 256 - </DropdownMenuTrigger> 257 - <DropdownMenuContent align="start"> 258 - <DropdownMenuGroup> 259 - <DropdownMenuLabel className="font-medium text-muted-foreground text-xs"> 260 - Period 261 - </DropdownMenuLabel> 262 - {PERIOD_VALUES.map(({ value, label }) => ( 263 - <DropdownMenuItem key={value} onSelect={() => setPeriod(value)}> 264 - {label} 265 - {period === value ? <Check className="ml-auto shrink-0" /> : null} 266 - </DropdownMenuItem> 267 - ))} 268 - </DropdownMenuGroup> 269 - </DropdownMenuContent> 270 - </DropdownMenu> 271 - ); 272 - } 273 - 274 - function PopoverQuantile({ 275 - children, 276 - className, 277 - ...props 278 - }: React.ComponentProps<typeof PopoverTrigger>) { 279 - return ( 280 - <Popover> 281 - <PopoverTrigger 282 - className={cn( 283 - "shrink-0 rounded-md p-0 underline decoration-muted-foreground/70 decoration-dotted underline-offset-2 outline-none transition-all hover:decoration-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=open]:decoration-foreground dark:aria-invalid:ring-destructive/40", 284 - className, 285 - )} 286 - {...props} 287 - > 288 - {children} 289 - </PopoverTrigger> 290 - <PopoverContent side="top" className="p-0 text-sm"> 291 - <p className="px-3 py-2 font-medium"> 292 - A quantile represents a specific percentile in your dataset. 293 - </p> 294 - <Separator /> 295 - <p className="px-3 py-2 text-muted-foreground"> 296 - For example, p50 is the 50th percentile - the point below which 50% of 297 - data falls. Higher percentiles include more data and highlight the 298 - upper range. 299 - </p> 300 - </PopoverContent> 301 - </Popover> 302 - ); 303 - } 304 - 305 - function StatusMonitorTabs({ 306 - className, 307 - ...props 308 - }: React.ComponentProps<typeof Tabs>) { 309 - return <Tabs className={cn("gap-6", className)} {...props} />; 310 - } 311 - 312 - function StatusMonitorTabsList({ 313 - className, 314 - ...props 315 - }: React.ComponentProps<typeof TabsList>) { 316 - return ( 317 - <TabsList 318 - className={cn("flex h-auto min-h-fit w-full", className)} 319 - {...props} 320 - /> 321 - ); 322 - } 323 - 324 - function StatusMonitorTabsTrigger({ 325 - className, 326 - ...props 327 - }: React.ComponentProps<typeof TabsTrigger>) { 328 - return ( 329 - <TabsTrigger 330 - className={cn( 331 - "min-w-0 flex-1 flex-col items-start gap-0.5 text-foreground dark:text-foreground", 332 - className, 333 - )} 334 - {...props} 335 - /> 336 - ); 337 - } 338 - 339 - function StatusMonitorTabsTriggerLabel({ 340 - className, 341 - ...props 342 - }: React.ComponentProps<"div">) { 343 - return ( 344 - <div className={cn("w-full truncate text-left", className)} {...props} /> 345 - ); 346 - } 347 - 348 - function StatusMonitorTabsTriggerValue({ 349 - className, 350 - ...props 351 - }: React.ComponentProps<"div">) { 352 - return ( 353 - <div 354 - className={cn( 355 - "text-wrap text-left text-muted-foreground text-xs", 356 - className, 357 - )} 358 - {...props} 359 - /> 360 - ); 361 - } 362 - 363 - function StatusMonitorTabsContent({ 364 - className, 365 - ...props 366 - }: React.ComponentProps<typeof TabsContent>) { 367 - return ( 368 - <TabsContent className={cn("flex flex-col gap-2", className)} {...props} /> 369 - ); 370 - }
-92
apps/status-page/src/app/(status-page)/[domain]/page.tsx
··· 1 - "use client"; 2 - 3 - import { useStatusPage } from "@/components/status-page/floating-button"; 4 - import { 5 - Status, 6 - StatusBanner, 7 - StatusContent, 8 - StatusDescription, 9 - StatusEmptyState, 10 - StatusEmptyStateDescription, 11 - StatusEmptyStateTitle, 12 - StatusHeader, 13 - StatusTitle, 14 - } from "@/components/status-page/status"; 15 - import { StatusMonitor } from "@/components/status-page/status-monitor"; 16 - import { StatusTrackerGroup } from "@/components/status-page/status-tracker-group"; 17 - import { chartData } from "@/components/status-page/utils"; 18 - import { monitors } from "@/data/monitors"; 19 - import { Newspaper } from "lucide-react"; 20 - 21 - export default function Page() { 22 - const { variant, cardType, barType, showUptime } = useStatusPage(); 23 - 24 - return ( 25 - <div className="flex flex-col gap-6"> 26 - <Status variant={variant}> 27 - <StatusHeader> 28 - <StatusTitle>Craft</StatusTitle> 29 - <StatusDescription> 30 - Stay informed about the stability 31 - </StatusDescription> 32 - </StatusHeader> 33 - <StatusBanner /> 34 - <StatusContent> 35 - <StatusMonitor 36 - variant={variant} 37 - cardType={cardType} 38 - barType={barType} 39 - data={chartData} 40 - monitor={monitors[1]} 41 - showUptime={showUptime} 42 - /> 43 - <StatusTrackerGroup title="US Endpoints" variant={variant}> 44 - <StatusMonitor 45 - variant={variant} 46 - cardType={cardType} 47 - barType={barType} 48 - data={chartData} 49 - monitor={monitors[0]} 50 - showUptime={showUptime} 51 - /> 52 - <StatusMonitor 53 - variant={variant} 54 - cardType={cardType} 55 - barType={barType} 56 - data={chartData} 57 - monitor={monitors[1]} 58 - showUptime={showUptime} 59 - /> 60 - </StatusTrackerGroup> 61 - <StatusTrackerGroup title="EU Endpoints" variant={variant}> 62 - <StatusMonitor 63 - variant={variant} 64 - cardType={cardType} 65 - barType={barType} 66 - data={chartData} 67 - monitor={monitors[0]} 68 - showUptime={showUptime} 69 - /> 70 - <StatusMonitor 71 - variant={variant} 72 - cardType={cardType} 73 - barType={barType} 74 - data={chartData} 75 - monitor={monitors[1]} 76 - showUptime={showUptime} 77 - /> 78 - </StatusTrackerGroup> 79 - </StatusContent> 80 - <StatusContent> 81 - <StatusEmptyState> 82 - <Newspaper className="size-4 text-muted-foreground" /> 83 - <StatusEmptyStateTitle>No recent reports</StatusEmptyStateTitle> 84 - <StatusEmptyStateDescription> 85 - There have been no reports within the last 7 days. 86 - </StatusEmptyStateDescription> 87 - </StatusEmptyState> 88 - </StatusContent> 89 - </Status> 90 - </div> 91 - ); 92 - }
+27
apps/status-page/src/components/button/button-back.tsx
··· 1 + "use client"; 2 + 3 + import { Button } from "@/components/ui/button"; 4 + import { cn } from "@/lib/utils"; 5 + import { ArrowLeft } from "lucide-react"; 6 + import Link from "next/link"; 7 + 8 + export function ButtonBack({ 9 + className, 10 + href = "/", 11 + ...props 12 + }: React.ComponentProps<typeof Button> & { href?: string }) { 13 + return ( 14 + <Button 15 + variant="ghost" 16 + size="sm" 17 + className={cn("text-muted-foreground", className)} 18 + asChild 19 + {...props} 20 + > 21 + <Link href={href}> 22 + <ArrowLeft /> 23 + Back 24 + </Link> 25 + </Button> 26 + ); 27 + }
+30
apps/status-page/src/components/button/button-copy-link.tsx
··· 1 + "use client"; 2 + 3 + import { Button } from "@/components/ui/button"; 4 + import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 5 + import { cn } from "@/lib/utils"; 6 + import { Check, Copy } from "lucide-react"; 7 + 8 + export function ButtonCopyLink({ 9 + className, 10 + ...props 11 + }: React.ComponentProps<typeof Button>) { 12 + const { copy, isCopied } = useCopyToClipboard(); 13 + 14 + return ( 15 + <Button 16 + variant="outline" 17 + size="icon" 18 + onClick={() => 19 + copy(window.location.href, { 20 + successMessage: "Link copied to clipboard", 21 + }) 22 + } 23 + className={cn("size-8", className)} 24 + {...props} 25 + > 26 + {isCopied ? <Check /> : <Copy />} 27 + <span className="sr-only">Copy Link</span> 28 + </Button> 29 + ); 30 + }
+113 -64
apps/status-page/src/components/chart/chart-area-percentiles.tsx
··· 7 7 ChartTooltip, 8 8 ChartTooltipContent, 9 9 } from "@/components/ui/chart"; 10 - import { regionPercentile } from "@/data/region-percentile"; 10 + import { Skeleton } from "@/components/ui/skeleton"; 11 11 import { formatMilliseconds } from "@/lib/formatter"; 12 12 import { cn } from "@/lib/utils"; 13 13 import { useState } from "react"; ··· 17 17 import { ChartTooltipNumber } from "./chart-tooltip-number"; 18 18 19 19 const chartConfig = { 20 - p50: { 20 + p50Latency: { 21 21 label: "p50", 22 22 color: "var(--chart-1)", 23 23 }, 24 - p75: { 24 + p75Latency: { 25 25 label: "p75", 26 26 color: "var(--chart-2)", 27 27 }, 28 - p90: { 28 + p90Latency: { 29 29 label: "p90", 30 30 color: "var(--chart-4)", 31 31 }, 32 - p95: { 32 + p95Latency: { 33 33 label: "p95", 34 34 color: "var(--chart-3)", 35 35 }, 36 - p99: { 36 + p99Latency: { 37 37 label: "p99", 38 38 color: "var(--chart-5)", 39 39 }, 40 - error: { 41 - label: "error", 42 - color: "var(--destructive)", 43 - }, 44 40 } satisfies ChartConfig; 45 41 46 42 function avg(values: number[]) { ··· 49 45 ); 50 46 } 51 47 52 - const chartData = regionPercentile; 48 + function formatAnnotation(values: number[]) { 49 + if (values.length === 0) return "N/A"; 50 + return formatMilliseconds(avg(values)); 51 + } 53 52 54 53 export function ChartAreaPercentiles({ 55 54 className, ··· 57 56 xAxisHide = true, 58 57 legendVerticalAlign = "bottom", 59 58 legendClassName, 60 - withError = false, 61 59 yAxisDomain = ["dataMin", "dataMax"], 60 + data, 62 61 }: { 63 62 className?: string; 64 63 singleSeries?: boolean; 65 64 xAxisHide?: boolean; 66 65 legendVerticalAlign?: "top" | "bottom"; 67 66 legendClassName?: string; 68 - withError?: boolean; 69 67 yAxisDomain?: AxisDomain; 68 + data: { 69 + timestamp: string; 70 + p50Latency: number; 71 + p75Latency: number; 72 + p90Latency: number; 73 + p95Latency: number; 74 + p99Latency: number; 75 + }[]; 70 76 }) { 71 77 const [activeSeries, setActiveSeries] = useState< 72 78 Array<keyof typeof chartConfig> 73 - >(["p75"]); 79 + >(["p75Latency"]); 80 + 81 + const annotation = { 82 + p50Latency: formatAnnotation(data.map((item) => item.p50Latency)), 83 + p75Latency: formatAnnotation(data.map((item) => item.p75Latency)), 84 + p90Latency: formatAnnotation(data.map((item) => item.p90Latency)), 85 + p95Latency: formatAnnotation(data.map((item) => item.p95Latency)), 86 + p99Latency: formatAnnotation(data.map((item) => item.p99Latency)), 87 + }; 74 88 75 89 return ( 76 90 <ChartContainer ··· 79 93 > 80 94 <AreaChart 81 95 accessibilityLayer 82 - data={chartData} 96 + data={data} 83 97 margin={{ 84 98 left: 0, 85 99 right: 0, ··· 108 122 }); 109 123 }} 110 124 active={activeSeries} 111 - annotation={{ 112 - p50: formatMilliseconds(avg(chartData.map((item) => item.p50))), 113 - p75: formatMilliseconds(avg(chartData.map((item) => item.p75))), 114 - p90: formatMilliseconds(avg(chartData.map((item) => item.p90))), 115 - p95: formatMilliseconds(avg(chartData.map((item) => item.p95))), 116 - p99: formatMilliseconds(avg(chartData.map((item) => item.p99))), 117 - }} 125 + annotation={annotation} 118 126 className={cn("overflow-x-scroll", legendClassName)} 119 127 /> 120 128 } ··· 125 133 cursor={false} 126 134 content={ 127 135 <ChartTooltipContent 128 - className="w-[180px]" 136 + className="w-[200px]" 129 137 formatter={(value, name) => ( 130 138 <ChartTooltipNumber 131 139 chartConfig={chartConfig} ··· 138 146 /> 139 147 <defs> 140 148 <linearGradient id="fillP50" x1="0" y1="0" x2="0" y2="1"> 141 - <stop offset="5%" stopColor="var(--color-p50)" stopOpacity={0.8} /> 142 - <stop offset="95%" stopColor="var(--color-p50)" stopOpacity={0.1} /> 149 + <stop 150 + offset="5%" 151 + stopColor="var(--color-p50Latency)" 152 + stopOpacity={0.8} 153 + /> 154 + <stop 155 + offset="95%" 156 + stopColor="var(--color-p50Latency)" 157 + stopOpacity={0.1} 158 + /> 143 159 </linearGradient> 144 160 <linearGradient id="fillP75" x1="0" y1="0" x2="0" y2="1"> 145 - <stop offset="5%" stopColor="var(--color-p75)" stopOpacity={0.8} /> 146 - <stop offset="95%" stopColor="var(--color-p75)" stopOpacity={0.1} /> 161 + <stop 162 + offset="5%" 163 + stopColor="var(--color-p75Latency)" 164 + stopOpacity={0.8} 165 + /> 166 + <stop 167 + offset="95%" 168 + stopColor="var(--color-p75Latency)" 169 + stopOpacity={0.1} 170 + /> 147 171 </linearGradient> 148 172 <linearGradient id="fillP90" x1="0" y1="0" x2="0" y2="1"> 149 - <stop offset="5%" stopColor="var(--color-p90)" stopOpacity={0.8} /> 150 - <stop offset="95%" stopColor="var(--color-p90)" stopOpacity={0.1} /> 173 + <stop 174 + offset="5%" 175 + stopColor="var(--color-p90Latency)" 176 + stopOpacity={0.8} 177 + /> 178 + <stop 179 + offset="95%" 180 + stopColor="var(--color-p90Latency)" 181 + stopOpacity={0.1} 182 + /> 151 183 </linearGradient> 152 184 <linearGradient id="fillP95" x1="0" y1="0" x2="0" y2="1"> 153 - <stop offset="5%" stopColor="var(--color-p95)" stopOpacity={0.8} /> 154 - <stop offset="95%" stopColor="var(--color-p95)" stopOpacity={0.1} /> 185 + <stop 186 + offset="5%" 187 + stopColor="var(--color-p95Latency)" 188 + stopOpacity={0.8} 189 + /> 190 + <stop 191 + offset="95%" 192 + stopColor="var(--color-p95Latency)" 193 + stopOpacity={0.1} 194 + /> 155 195 </linearGradient> 156 196 <linearGradient id="fillP99" x1="0" y1="0" x2="0" y2="1"> 157 - <stop offset="5%" stopColor="var(--color-p99)" stopOpacity={0.8} /> 158 - <stop offset="95%" stopColor="var(--color-p99)" stopOpacity={0.1} /> 197 + <stop 198 + offset="5%" 199 + stopColor="var(--color-p99Latency)" 200 + stopOpacity={0.8} 201 + /> 202 + <stop 203 + offset="95%" 204 + stopColor="var(--color-p99Latency)" 205 + stopOpacity={0.1} 206 + /> 159 207 </linearGradient> 160 208 </defs> 161 - {withError ? ( 162 - <Area 163 - dataKey="error" 164 - type="monotone" 165 - stroke="var(--color-error)" 166 - strokeWidth={1} 167 - fill="var(--color-error)" 168 - fillOpacity={0.5} 169 - legendType="none" 170 - tooltipType="none" 171 - yAxisId="error" 172 - dot={false} 173 - activeDot={false} 174 - /> 175 - ) : null} 176 209 <Area 177 - hide={!activeSeries.includes("p50")} 178 - dataKey="p50" 210 + hide={!activeSeries.includes("p50Latency")} 211 + dataKey="p50Latency" 179 212 type="monotone" 180 - stroke="var(--color-p50)" 213 + stroke="var(--color-p50Latency)" 181 214 fill="url(#fillP50)" 182 215 fillOpacity={0.4} 183 216 dot={false} 184 217 yAxisId="percentile" 218 + connectNulls 185 219 /> 186 220 <Area 187 - hide={!activeSeries.includes("p75")} 188 - dataKey="p75" 221 + hide={!activeSeries.includes("p75Latency")} 222 + dataKey="p75Latency" 189 223 type="monotone" 190 - stroke="var(--color-p75)" 224 + stroke="var(--color-p75Latency)" 191 225 fill="url(#fillP75)" 192 226 fillOpacity={0.4} 193 227 dot={false} 194 228 yAxisId="percentile" 229 + connectNulls 195 230 /> 196 231 {/* <Area 197 - hide={!activeSeries.includes("p90")} 198 - dataKey="p90" 232 + hide={!activeSeries.includes("p90Latency")} 233 + dataKey="p90Latency" 199 234 type="monotone" 200 - stroke="var(--color-p90)" 235 + stroke="var(--color-p90Latency)" 201 236 fill="url(#fillP90)" 202 237 fillOpacity={0.4} 203 238 dot={false} 204 239 yAxisId="percentile" 240 + connectNulls 205 241 /> */} 206 242 <Area 207 - hide={!activeSeries.includes("p95")} 208 - dataKey="p95" 243 + hide={!activeSeries.includes("p95Latency")} 244 + dataKey="p95Latency" 209 245 type="monotone" 210 - stroke="var(--color-p95)" 246 + stroke="var(--color-p95Latency)" 211 247 fill="url(#fillP95)" 212 248 fillOpacity={0.4} 213 249 dot={false} 214 250 yAxisId="percentile" 251 + connectNulls 215 252 /> 216 253 <Area 217 - hide={!activeSeries.includes("p99")} 218 - dataKey="p99" 254 + hide={!activeSeries.includes("p99Latency")} 255 + dataKey="p99Latency" 219 256 type="monotone" 220 - stroke="var(--color-p99)" 257 + stroke="var(--color-p99Latency)" 221 258 fill="url(#fillP99)" 222 259 fillOpacity={0.4} 223 260 dot={false} 224 261 yAxisId="percentile" 262 + connectNulls 225 263 /> 226 264 <YAxis 227 265 domain={yAxisDomain} ··· 232 270 yAxisId="percentile" 233 271 tickFormatter={(value) => `${value}ms`} 234 272 /> 235 - <YAxis orientation="left" yAxisId="error" hide /> 236 273 </AreaChart> 237 274 </ChartContainer> 238 275 ); 239 276 } 277 + 278 + export function ChartAreaPercentilesSkeleton({ 279 + className, 280 + ...props 281 + }: React.ComponentProps<typeof Skeleton>) { 282 + return ( 283 + <Skeleton 284 + className={cn("h-[100px] w-full rounded-lg", className)} 285 + {...props} 286 + /> 287 + ); 288 + }
+78 -60
apps/status-page/src/components/chart/chart-bar-uptime.tsx
··· 6 6 type ChartConfig, 7 7 ChartContainer, 8 8 ChartLegend, 9 - ChartLegendContent, 10 9 ChartTooltip, 11 10 ChartTooltipContent, 12 11 } from "@/components/ui/chart"; 13 - import { type PERIODS, mapUptime } from "@/data/metrics.client"; 14 - import { useIsMobile } from "@/hooks/use-mobile"; 15 - import { useTRPC } from "@/lib/trpc/client"; 16 - import type { Region } from "@openstatus/db/src/schema/constants"; 17 - import { useQuery } from "@tanstack/react-query"; 18 - import { endOfDay, startOfDay, subDays } from "date-fns"; 12 + import { formatNumber } from "@/lib/formatter"; 13 + import { cn } from "@/lib/utils"; 14 + import { useState } from "react"; 15 + import { Skeleton } from "../ui/skeleton"; 16 + import { ChartLegendBadge } from "./chart-legend-badge"; 19 17 20 18 const chartConfig = { 21 - ok: { 22 - label: "Success", 23 - color: "var(--color-success)", 19 + success: { 20 + label: "success", 21 + // WTF: why is var(--color-success) not working 22 + color: "var(--success)", 24 23 }, 25 24 degraded: { 26 - label: "Degraded", 25 + label: "degraded", 27 26 color: "var(--color-warning)", 28 27 }, 29 28 error: { 30 - label: "Error", 29 + label: "failed", 31 30 color: "var(--color-destructive)", 32 31 }, 33 32 } satisfies ChartConfig; 34 - 35 - const periodToInterval = { 36 - "1d": 60, 37 - "7d": 240, 38 - "14d": 480, 39 - } satisfies Record<(typeof PERIODS)[number], number>; 40 - 41 - const periodToFromDate = { 42 - "1d": startOfDay(subDays(new Date(), 1)), 43 - "7d": startOfDay(subDays(new Date(), 7)), 44 - "14d": startOfDay(subDays(new Date(), 14)), 45 - } satisfies Record<(typeof PERIODS)[number], Date>; 46 33 47 34 export function ChartBarUptime({ 48 - monitorId, 49 - period, 50 - type, 51 - regions, 35 + className, 36 + data, 52 37 }: { 53 - monitorId: string; 54 - period: (typeof PERIODS)[number]; 55 - type: "http" | "tcp"; 56 - regions: Region[]; 38 + className?: string; 39 + data: { 40 + timestamp: string; 41 + success: number; 42 + error: number; 43 + degraded: number; 44 + }[]; 57 45 }) { 58 - const isMobile = useIsMobile(); 59 - const trpc = useTRPC(); 60 - const fromDate = periodToFromDate[period]; 61 - const toDate = endOfDay(new Date()); 62 - const interval = periodToInterval[period]; 63 - 64 - const { data: uptime } = useQuery( 65 - trpc.tinybird.uptime.queryOptions({ 66 - monitorId, 67 - fromDate: fromDate.toISOString(), 68 - toDate: toDate.toISOString(), 69 - regions, 70 - interval, 71 - type, 72 - }), 73 - ); 46 + const [activeSeries, setActiveSeries] = useState< 47 + Array<keyof typeof chartConfig> 48 + >(["success", "error", "degraded"]); 74 49 75 - const refinedUptime = uptime ? mapUptime(uptime) : []; 50 + const annotation = { 51 + success: formatNumber(data.reduce((acc, item) => acc + item.success, 0)), 52 + error: formatNumber(data.reduce((acc, item) => acc + item.error, 0)), 53 + degraded: formatNumber(data.reduce((acc, item) => acc + item.degraded, 0)), 54 + }; 76 55 77 56 return ( 78 - <ChartContainer config={chartConfig} className="h-[130px] w-full"> 79 - <BarChart 80 - accessibilityLayer 81 - data={refinedUptime} 82 - barCategoryGap={isMobile ? 0 : 2} 83 - > 57 + <ChartContainer 58 + config={chartConfig} 59 + className={cn("h-[130px] w-full", className)} 60 + > 61 + <BarChart accessibilityLayer data={data} barCategoryGap={2}> 84 62 <CartesianGrid vertical={false} /> 85 63 <ChartTooltip 86 64 cursor={false} 87 65 content={<ChartTooltipContent indicator="dot" />} 88 66 /> 89 - <Bar dataKey="ok" stackId="a" fill="var(--color-ok)" /> 90 - <Bar dataKey="error" stackId="a" fill="var(--color-error)" /> 91 - <Bar dataKey="degraded" stackId="a" fill="var(--color-degraded)" /> 67 + <Bar 68 + dataKey="success" 69 + fill="var(--color-success)" 70 + stackId="a" 71 + hide={!activeSeries.includes("success")} 72 + /> 73 + <Bar 74 + dataKey="degraded" 75 + fill="var(--color-degraded)" 76 + stackId="a" 77 + hide={!activeSeries.includes("degraded")} 78 + /> 79 + <Bar 80 + dataKey="error" 81 + fill="var(--color-error)" 82 + stackId="a" 83 + hide={!activeSeries.includes("error")} 84 + /> 92 85 <YAxis 93 86 domain={["dataMin", "dataMax"]} 94 87 tickLine={false} ··· 97 90 orientation="right" 98 91 /> 99 92 <XAxis 100 - dataKey="interval" 93 + dataKey="timestamp" 101 94 tickLine={false} 102 95 tickMargin={8} 103 96 minTickGap={10} 104 97 axisLine={false} 105 98 /> 106 - <ChartLegend content={<ChartLegendContent />} /> 99 + <ChartLegend 100 + verticalAlign="top" 101 + content={ 102 + <ChartLegendBadge 103 + active={activeSeries} 104 + handleActive={(item) => { 105 + setActiveSeries((prev) => { 106 + if (item.dataKey) { 107 + const key = item.dataKey as keyof typeof chartConfig; 108 + if (prev.includes(key)) { 109 + return prev.filter((item) => item !== key); 110 + } 111 + return [...prev, key]; 112 + } 113 + return prev; 114 + }); 115 + }} 116 + annotation={annotation} 117 + className="justify-start overflow-x-scroll ps-1 pt-1" 118 + /> 119 + } 120 + /> 107 121 </BarChart> 108 122 </ChartContainer> 109 123 ); 110 124 } 125 + 126 + export function ChartBarUptimeSkeleton({ className }: { className?: string }) { 127 + return <Skeleton className={cn("h-[130px] w-full", className)} />; 128 + }
+1 -1
apps/status-page/src/components/chart/chart-legend-badge.tsx
··· 87 87 /> 88 88 )} 89 89 {itemConfig?.label} 90 - {suffix ? ( 90 + {suffix !== undefined ? ( 91 91 <span className="font-mono text-[10px] text-muted-foreground"> 92 92 {suffix} 93 93 </span>
+80 -94
apps/status-page/src/components/chart/chart-line-regions.tsx
··· 16 16 ChartTooltip, 17 17 ChartTooltipContent, 18 18 } from "@/components/ui/chart"; 19 + import { Skeleton } from "@/components/ui/skeleton"; 19 20 import { regions } from "@/data/regions"; 20 21 import { formatMilliseconds } from "@/lib/formatter"; 21 22 import { cn } from "@/lib/utils"; ··· 23 24 import { ChartLegendBadge } from "./chart-legend-badge"; 24 25 import { ChartTooltipNumber } from "./chart-tooltip-number"; 25 26 26 - const r = regions.filter((r) => 27 - ["ams", "bog", "arn", "atl", "bom", "syd", "fra"].includes(r.code), 28 - ); 27 + function avg(values: (number | null | string)[]) { 28 + const n = values.filter((val): val is number => typeof val === "number"); 29 + return Math.round(n.reduce((acc, curr) => acc + curr, 0) / n.length); 30 + } 29 31 30 - const randomizer = Math.random() * 50; 32 + function formatAnnotation(values: (number | null | string)[]) { 33 + if (values.length === 0) return "N/A"; 34 + return formatMilliseconds(avg(values)); 35 + } 31 36 32 - const chartData = Array.from({ length: 30 }, (_, i) => ({ 33 - timestamp: new Date( 34 - new Date().setMinutes(new Date().getMinutes() - i), 35 - ).toLocaleString("default", { 36 - hour: "numeric", 37 - minute: "numeric", 38 - }), 39 - ams: Math.floor(Math.random() * randomizer) * 100 * 0.75, 40 - bog: Math.floor(Math.random() * randomizer) * 100 * 0.75, 41 - arn: Math.floor(Math.random() * randomizer) * 100 * 0.75, 42 - atl: Math.floor(Math.random() * randomizer) * 100 * 0.75, 43 - bom: Math.floor(Math.random() * randomizer) * 100 * 0.75, 44 - syd: Math.floor(Math.random() * randomizer) * 100 * 0.75, 45 - fra: Math.floor(Math.random() * randomizer) * 100 * 0.75, 46 - })); 37 + function getChartConfig( 38 + data: { 39 + timestamp: string; 40 + [key: string]: string | number | null; 41 + }[], 42 + ): ChartConfig { 43 + const regions = 44 + data.length > 0 45 + ? Object.keys(data[0]).filter((item) => item !== "timestamp") 46 + : []; 47 47 48 - const s = r.sort((a, b) => { 49 - const aAvg = avg( 50 - chartData.map((d) => { 51 - const value = d[a.code as keyof typeof d]; 52 - if (typeof value === "number") { 53 - return value; 54 - } 55 - return 0; 56 - }), 57 - ); 58 - const bAvg = avg( 59 - chartData.map((d) => { 60 - const value = d[b.code as keyof typeof d]; 61 - if (typeof value === "number") { 62 - return value; 63 - } 64 - return 0; 65 - }), 66 - ); 67 - return bAvg - aAvg; 68 - }); 48 + return regions 49 + .sort((a, b) => { 50 + return ( 51 + avg(data.map((item) => item[b])) - avg(data.map((item) => item[a])) 52 + ); 53 + }) 54 + .map((region, index) => ({ 55 + code: region, 56 + color: `var(--rainbow-${((index + 5) % 17) + 1})`, 57 + })) 58 + .reduce( 59 + (acc, item) => { 60 + acc[item.code] = { 61 + label: item.code, 62 + color: item.color, 63 + }; 64 + return acc; 65 + }, 66 + {} as Record<string, { label: string; color: string }>, 67 + ) satisfies ChartConfig; 68 + } 69 69 70 - const chartConfig = s 71 - .map((item, index) => ({ 72 - code: item.code, 73 - label: item.code, 74 - color: `var(--rainbow-${index + 1})`, 75 - })) 76 - .reduce( 77 - (acc, item) => { 78 - acc[item.code] = item; 70 + export function ChartLineRegions({ 71 + className, 72 + data, 73 + }: { 74 + className?: string; 75 + data: { 76 + timestamp: string; 77 + [key: string]: string | number | null; 78 + }[]; 79 + }) { 80 + const chartConfig = getChartConfig(data); 81 + const [activeSeries, setActiveSeries] = useState< 82 + Array<keyof typeof chartConfig> 83 + >(Object.keys(chartConfig).slice(0, 2)); 84 + 85 + const annotation = Object.keys(chartConfig).reduce( 86 + (acc, region) => { 87 + acc[region] = formatAnnotation(data.map((item) => item[region])); 79 88 return acc; 80 89 }, 81 - {} as Record<string, { label: string; color: string }>, 82 - ) satisfies ChartConfig; 83 - 84 - function avg(values: number[]) { 85 - return Math.round( 86 - values.reduce((acc, curr) => acc + curr, 0) / values.length, 90 + {} as Record<string, string>, 87 91 ); 88 - } 89 92 90 - const annotation = r.reduce( 91 - (acc, item) => { 92 - acc[item.code] = formatMilliseconds( 93 - avg( 94 - chartData.map((d) => { 95 - const value = d[item.code as keyof typeof d]; 96 - if (typeof value === "number") { 97 - return value; 98 - } 99 - return 0; 100 - }), 101 - ), 102 - ); 103 - return acc; 104 - }, 105 - {} as Record<string, string>, 106 - ); 93 + // TODO: tooltip 107 94 108 - const tooltip = r.reduce( 109 - (acc, item) => { 110 - acc[item.code] = item.location; 111 - return acc; 112 - }, 113 - {} as Record<string, string>, 114 - ); 115 - 116 - export function ChartLineRegions({ className }: { className?: string }) { 117 - const [activeSeries, setActiveSeries] = useState< 118 - Array<keyof typeof chartConfig> 119 - >([s[0].code, s[1].code]); 120 95 return ( 121 96 <ChartContainer 122 97 config={chartConfig} ··· 124 99 > 125 100 <LineChart 126 101 accessibilityLayer 127 - data={chartData} 102 + data={data} 128 103 margin={{ 129 104 left: 0, 130 105 right: 0, ··· 159 134 /> 160 135 } 161 136 /> 162 - {r.map((item) => ( 137 + {Object.keys(chartConfig).map((item) => ( 163 138 <Line 164 - key={item.code} 165 - dataKey={item.code} 139 + key={item} 140 + dataKey={item} 166 141 type="monotone" 167 - stroke={`var(--color-${item.code})`} 142 + stroke={`var(--color-${item})`} 168 143 dot={false} 169 - hide={!activeSeries.includes(item.code)} 144 + hide={!activeSeries.includes(item)} 145 + connectNulls 170 146 /> 171 147 ))} 172 - 173 148 <YAxis 174 149 domain={["dataMin", "dataMax"]} 175 150 tickLine={false} ··· 195 170 }); 196 171 }} 197 172 active={activeSeries} 198 - maxActive={3} 199 173 annotation={annotation} 200 - tooltip={tooltip} 174 + maxActive={6} 201 175 className="justify-start overflow-x-scroll ps-1 pt-1 font-mono" 202 176 /> 203 177 } ··· 206 180 </ChartContainer> 207 181 ); 208 182 } 183 + 184 + export function ChartLineRegionsSkeleton({ 185 + className, 186 + ...props 187 + }: React.ComponentProps<typeof Skeleton>) { 188 + return ( 189 + <Skeleton 190 + className={cn("h-[100px] w-full rounded-lg", className)} 191 + {...props} 192 + /> 193 + ); 194 + }
+80
apps/status-page/src/components/forms/form-password.tsx
··· 1 + "use client"; 2 + 3 + import { Form } from "@/components/ui/form"; 4 + import { 5 + FormControl, 6 + FormField, 7 + FormItem, 8 + FormLabel, 9 + } from "@/components/ui/form"; 10 + import { Input } from "@/components/ui/input"; 11 + import { zodResolver } from "@hookform/resolvers/zod"; 12 + import { isTRPCClientError } from "@trpc/client"; 13 + import { useTransition } from "react"; 14 + import { useForm } from "react-hook-form"; 15 + import { toast } from "sonner"; 16 + import { z } from "zod"; 17 + 18 + const schema = z.object({ 19 + password: z.string().min(1), 20 + }); 21 + 22 + type FormValues = z.infer<typeof schema>; 23 + 24 + export function FormPassword({ 25 + onSubmit, 26 + ...props 27 + }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 28 + onSubmit: (values: FormValues) => Promise<void>; 29 + }) { 30 + const form = useForm<FormValues>({ 31 + resolver: zodResolver(schema), 32 + defaultValues: { 33 + password: "", 34 + }, 35 + }); 36 + const [isPending, startTransition] = useTransition(); 37 + 38 + function submitAction(values: FormValues) { 39 + if (isPending) return; 40 + 41 + startTransition(async () => { 42 + try { 43 + const promise = onSubmit(values); 44 + toast.promise(promise, { 45 + loading: "Confirming...", 46 + success: "Confirmed", 47 + error: (error) => { 48 + if (isTRPCClientError(error)) { 49 + form.setError("password", { message: error.message }); 50 + return error.message; 51 + } 52 + return "Failed to confirm"; 53 + }, 54 + }); 55 + await promise; 56 + } catch (error) { 57 + console.error(error); 58 + } 59 + }); 60 + } 61 + 62 + return ( 63 + <Form {...form}> 64 + <form onSubmit={form.handleSubmit(submitAction)} {...props}> 65 + <FormField 66 + control={form.control} 67 + name="password" 68 + render={({ field }) => ( 69 + <FormItem> 70 + <FormLabel>Password</FormLabel> 71 + <FormControl> 72 + <Input type="password" {...field} /> 73 + </FormControl> 74 + </FormItem> 75 + )} 76 + /> 77 + </form> 78 + </Form> 79 + ); 80 + }
+80
apps/status-page/src/components/forms/form-subscribe-email.tsx
··· 1 + "use client"; 2 + 3 + import { Form } from "@/components/ui/form"; 4 + import { 5 + FormControl, 6 + FormField, 7 + FormItem, 8 + FormLabel, 9 + } from "@/components/ui/form"; 10 + import { Input } from "@/components/ui/input"; 11 + import { zodResolver } from "@hookform/resolvers/zod"; 12 + import { isTRPCClientError } from "@trpc/client"; 13 + import { useTransition } from "react"; 14 + import { useForm } from "react-hook-form"; 15 + import { toast } from "sonner"; 16 + import { z } from "zod"; 17 + 18 + const schema = z.object({ 19 + email: z.string().email(), 20 + }); 21 + 22 + type FormValues = z.infer<typeof schema>; 23 + 24 + export function FormSubscribeEmail({ 25 + onSubmit, 26 + ...props 27 + }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 28 + onSubmit: (values: FormValues) => Promise<void>; 29 + onSubmitCallback?: () => void; 30 + }) { 31 + const form = useForm<FormValues>({ 32 + resolver: zodResolver(schema), 33 + defaultValues: { 34 + email: "", 35 + }, 36 + }); 37 + const [isPending, startTransition] = useTransition(); 38 + 39 + function submitAction(values: FormValues) { 40 + if (isPending) return; 41 + 42 + startTransition(async () => { 43 + try { 44 + const promise = onSubmit(values); 45 + toast.promise(promise, { 46 + loading: "Subscribing...", 47 + success: "Subscribed", 48 + error: (error) => { 49 + if (isTRPCClientError(error)) { 50 + return error.message; 51 + } 52 + return "Failed to subscribe"; 53 + }, 54 + }); 55 + await promise; 56 + } catch (error) { 57 + console.error(error); 58 + } 59 + }); 60 + } 61 + 62 + return ( 63 + <Form {...form}> 64 + <form onSubmit={form.handleSubmit(submitAction)} {...props}> 65 + <FormField 66 + control={form.control} 67 + name="email" 68 + render={({ field }) => ( 69 + <FormItem> 70 + <FormLabel className="sr-only">Email</FormLabel> 71 + <FormControl> 72 + <Input placeholder="subscribe@me.com" {...field} /> 73 + </FormControl> 74 + </FormItem> 75 + )} 76 + /> 77 + </form> 78 + </Form> 79 + ); 80 + }
+30
apps/status-page/src/components/nav/footer.tsx
··· 1 + "use client"; 2 + 3 + import { Link } from "@/components/common/link"; 4 + import { ThemeToggle } from "@/components/theme-toggle"; 5 + import { useTRPC } from "@/lib/trpc/client"; 6 + import { useQuery } from "@tanstack/react-query"; 7 + import { useParams } from "next/navigation"; 8 + 9 + export function Footer(props: React.ComponentProps<"footer">) { 10 + const { domain } = useParams<{ domain: string }>(); 11 + const trpc = useTRPC(); 12 + const { data: page } = useQuery( 13 + trpc.statusPage.get.queryOptions({ slug: domain }), 14 + ); 15 + 16 + if (!page) return null; 17 + 18 + return ( 19 + <footer {...props}> 20 + <div className="mx-auto flex max-w-2xl items-center justify-between px-3 py-2"> 21 + {page.workspacePlan === "team" ? null : ( 22 + <p className="text-muted-foreground"> 23 + Powered by <Link href="#">OpenStatus</Link> 24 + </p> 25 + )} 26 + <ThemeToggle className="w-[140px]" /> 27 + </div> 28 + </footer> 29 + ); 30 + }
+171
apps/status-page/src/components/nav/header.tsx
··· 1 + "use client"; 2 + 3 + import { Link } from "@/components/common/link"; 4 + import { StatusUpdates } from "@/components/status-page/status-updates"; 5 + import { Button } from "@/components/ui/button"; 6 + import { 7 + Sheet, 8 + SheetContent, 9 + SheetHeader, 10 + SheetTitle, 11 + SheetTrigger, 12 + } from "@/components/ui/sheet"; 13 + import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 14 + import { useTRPC } from "@/lib/trpc/client"; 15 + import { cn } from "@/lib/utils"; 16 + import { useMutation, useQuery } from "@tanstack/react-query"; 17 + import { Menu } from "lucide-react"; 18 + import NextLink from "next/link"; 19 + import { useParams, usePathname } from "next/navigation"; 20 + import { useState } from "react"; 21 + 22 + function useNav() { 23 + const pathname = usePathname(); 24 + const prefix = usePathnamePrefix(); 25 + 26 + return [ 27 + { 28 + label: "Status", 29 + href: `/${prefix}`, 30 + isActive: pathname === `/${prefix}`, 31 + }, 32 + { 33 + label: "Events", 34 + href: `/${prefix}/events`, 35 + isActive: pathname.startsWith(`/${prefix}/events`), 36 + }, 37 + { 38 + label: "Monitors", 39 + href: `/${prefix}/monitors`, 40 + isActive: pathname.startsWith(`/${prefix}/monitors`), 41 + }, 42 + ]; 43 + } 44 + 45 + export function Header(props: React.ComponentProps<"header">) { 46 + const trpc = useTRPC(); 47 + const { domain } = useParams<{ domain: string }>(); 48 + const { data: page } = useQuery( 49 + trpc.statusPage.get.queryOptions({ slug: domain }), 50 + ); 51 + 52 + const sendPageSubscriptionMutation = useMutation( 53 + trpc.emailRouter.sendPageSubscription.mutationOptions({}), 54 + ); 55 + 56 + const subscribeMutation = useMutation( 57 + trpc.statusPage.subscribe.mutationOptions({ 58 + onSuccess: (id) => { 59 + if (!id) return; 60 + sendPageSubscriptionMutation.mutate({ id }); 61 + }, 62 + }), 63 + ); 64 + 65 + const types = ( 66 + page?.workspacePlan === "free" ? ["rss", "atom"] : ["email", "rss", "atom"] 67 + ) satisfies ("email" | "rss" | "atom")[]; 68 + 69 + return ( 70 + <header {...props}> 71 + <nav className="mx-auto flex max-w-2xl items-center justify-between gap-3 px-3 py-2"> 72 + {/* NOTE: same width as the `StatusUpdates` button */} 73 + <div className="w-[105px] shrink-0"> 74 + <Link href="/"> 75 + {page?.icon ? ( 76 + <img 77 + src={page.icon} 78 + alt={`${page.title} status page`} 79 + className="size-8 rounded-full border" 80 + /> 81 + ) : null} 82 + </Link> 83 + </div> 84 + <NavDesktop className="hidden md:flex" /> 85 + <StatusUpdates 86 + className="hidden md:block" 87 + types={types} 88 + onSubscribe={async (email) => { 89 + await subscribeMutation.mutateAsync({ slug: domain, email }); 90 + }} 91 + /> 92 + <div className="flex gap-3 md:hidden"> 93 + <NavMobile /> 94 + <StatusUpdates 95 + types={types} 96 + onSubscribe={async (email) => { 97 + await subscribeMutation.mutateAsync({ slug: domain, email }); 98 + }} 99 + /> 100 + </div> 101 + </nav> 102 + </header> 103 + ); 104 + } 105 + 106 + function NavDesktop({ className, ...props }: React.ComponentProps<"ul">) { 107 + const nav = useNav(); 108 + return ( 109 + <ul className={cn("flex flex-row gap-2", className)} {...props}> 110 + {nav.map((item) => { 111 + return ( 112 + <li key={item.label}> 113 + <Button 114 + variant={item.isActive ? "secondary" : "ghost"} 115 + size="sm" 116 + asChild 117 + > 118 + <NextLink href={item.href}>{item.label}</NextLink> 119 + </Button> 120 + </li> 121 + ); 122 + })} 123 + </ul> 124 + ); 125 + } 126 + 127 + function NavMobile({ 128 + className, 129 + ...props 130 + }: React.ComponentProps<typeof Button>) { 131 + const [open, setOpen] = useState(false); 132 + const nav = useNav(); 133 + return ( 134 + <Sheet open={open} onOpenChange={setOpen}> 135 + <SheetTrigger asChild> 136 + <Button 137 + variant="secondary" 138 + size="sm" 139 + className={cn("size-8", className)} 140 + {...props} 141 + > 142 + <Menu /> 143 + </Button> 144 + </SheetTrigger> 145 + <SheetContent side="top"> 146 + <SheetHeader className="border-b"> 147 + <SheetTitle>Menu</SheetTitle> 148 + </SheetHeader> 149 + <div className="px-1 pb-4"> 150 + <ul className="flex flex-col gap-1"> 151 + {nav.map((item) => { 152 + return ( 153 + <li key={item.label} className="w-full"> 154 + <Button 155 + variant={item.isActive ? "secondary" : "ghost"} 156 + onClick={() => setOpen(false)} 157 + className="w-full justify-start" 158 + size="sm" 159 + asChild 160 + > 161 + <NextLink href={item.href}>{item.label}</NextLink> 162 + </Button> 163 + </li> 164 + ); 165 + })} 166 + </ul> 167 + </div> 168 + </SheetContent> 169 + </Sheet> 170 + ); 171 + }
+38
apps/status-page/src/components/popover/popover-quantile.tsx
··· 1 + import { 2 + Popover, 3 + PopoverContent, 4 + PopoverTrigger, 5 + } from "@/components/ui/popover"; 6 + import { Separator } from "@/components/ui/separator"; 7 + import { cn } from "@/lib/utils"; 8 + 9 + export function PopoverQuantile({ 10 + children, 11 + className, 12 + ...props 13 + }: React.ComponentProps<typeof PopoverTrigger>) { 14 + return ( 15 + <Popover> 16 + <PopoverTrigger 17 + className={cn( 18 + "shrink-0 rounded-md p-0 underline decoration-muted-foreground/70 decoration-dotted underline-offset-2 outline-none transition-all hover:decoration-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=open]:decoration-foreground dark:aria-invalid:ring-destructive/40", 19 + className, 20 + )} 21 + {...props} 22 + > 23 + {children} 24 + </PopoverTrigger> 25 + <PopoverContent side="top" className="p-0 text-sm"> 26 + <p className="px-3 py-2 font-medium"> 27 + A quantile represents a specific percentile in your dataset. 28 + </p> 29 + <Separator /> 30 + <p className="px-3 py-2 text-muted-foreground"> 31 + For example, p50 is the 50th percentile - the point below which 50% of 32 + data falls. Higher percentiles include more data and highlight the 33 + upper range. 34 + </p> 35 + </PopoverContent> 36 + </Popover> 37 + ); 38 + }
+2 -2
apps/status-page/src/components/status-page/community-themes.ts
··· 84 84 85 85 "--success": "oklch(54.34% 0.1634 145.98)", 86 86 "--destructive": "oklch(47.1% 0.1909 25.95)", 87 - "--warning": "oklch(40.97% 0.2064 289.57)", 87 + "--warning": "oklch(81.84% 0.1328 85.87)", 88 88 "--info": "oklch(46.96% 0.2957 264.51)", 89 89 } as React.CSSProperties, 90 90 }; ··· 98 98 }, 99 99 github: { 100 100 name: "Github", 101 - author: { name: "@openstatus", url: "https://openstatus.dev" }, 101 + author: { name: "@github", url: "https://github.com" }, 102 102 ...githubTheme, 103 103 }, 104 104 supabase: {
+32 -13
apps/status-page/src/components/status-page/floating-button.tsx
··· 26 26 export const VARIANT = ["success", "degraded", "error", "info"] as const; 27 27 export type VariantType = (typeof VARIANT)[number]; 28 28 29 - export const CARD_TYPE = ["duration", "requests", "dominant"] as const; 29 + export const CARD_TYPE = [ 30 + "duration", 31 + "requests", 32 + "dominant", 33 + "manual", 34 + ] as const; 30 35 export type CardType = (typeof CARD_TYPE)[number]; 31 36 32 - export const BAR_TYPE = ["absolute", "dominant"] as const; 37 + export const BAR_TYPE = ["absolute", "dominant", "manual"] as const; 33 38 export type BarType = (typeof BAR_TYPE)[number]; 34 39 35 40 export const COMMUNITY_THEME = ["default", "github", "supabase"] as const; ··· 174 179 <SelectTrigger 175 180 id="status-variant" 176 181 className="w-full capitalize" 182 + disabled 177 183 > 178 184 <SelectValue /> 179 185 </SelectTrigger> ··· 205 211 </Select> 206 212 </div> 207 213 <div className="space-y-2"> 208 - <Label htmlFor="card-type">Card Type</Label> 214 + <Label htmlFor="bar-type">Bar Type</Label> 209 215 <Select 210 - value={cardType} 211 - onValueChange={(v) => setCardType(v as CardType)} 216 + value={barType} 217 + onValueChange={(v) => { 218 + setBarType(v as BarType); 219 + if (v !== "absolute") { 220 + setCardType(v as CardType); 221 + } else { 222 + setCardType("requests"); 223 + } 224 + }} 212 225 > 213 - <SelectTrigger id="card-type" className="w-full capitalize"> 226 + <SelectTrigger id="bar-type" className="w-full capitalize"> 214 227 <SelectValue /> 215 228 </SelectTrigger> 216 229 <SelectContent> 217 - {CARD_TYPE.map((v) => ( 230 + {BAR_TYPE.map((v) => ( 218 231 <SelectItem key={v} value={v} className="capitalize"> 219 232 {v} 220 233 </SelectItem> ··· 223 236 </Select> 224 237 </div> 225 238 <div className="space-y-2"> 226 - <Label htmlFor="bar-type">Bar Type</Label> 239 + <Label htmlFor="card-type">Card Type</Label> 227 240 <Select 228 - value={barType} 229 - onValueChange={(v) => setBarType(v as BarType)} 241 + value={cardType} 242 + onValueChange={(v) => setCardType(v as CardType)} 243 + disabled={barType !== "absolute"} 230 244 > 231 - <SelectTrigger id="bar-type" className="w-full capitalize"> 245 + <SelectTrigger id="card-type" className="w-full capitalize"> 232 246 <SelectValue /> 233 247 </SelectTrigger> 234 248 <SelectContent> 235 - {BAR_TYPE.map((v) => ( 236 - <SelectItem key={v} value={v} className="capitalize"> 249 + {CARD_TYPE.map((v) => ( 250 + <SelectItem 251 + key={v} 252 + value={v} 253 + className="capitalize" 254 + disabled={["dominant", "manual"].includes(v)} 255 + > 237 256 {v} 238 257 </SelectItem> 239 258 ))}
+10
apps/status-page/src/components/status-page/messages.ts
··· 4 4 degraded: "Degraded Performance", 5 5 error: "Downtime Performance", 6 6 info: "Maintenance", 7 + empty: "No Data", 7 8 }, 8 9 short: { 9 10 success: "Operational", 10 11 degraded: "Degraded", 11 12 error: "Downtime", 12 13 info: "Maintenance", 14 + empty: "No Data", 13 15 }, 14 16 }; 15 17 ··· 18 20 degraded: "Degraded", 19 21 error: "Error", 20 22 info: "Maintenance", 23 + empty: "No Data", 24 + }; 25 + 26 + export const status = { 27 + resolved: "Resolved", 28 + monitoring: "Monitoring", 29 + identified: "Identified", 30 + investigating: "Investigating", 21 31 };
+141
apps/status-page/src/components/status-page/status-banner.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + import { 3 + AlertCircleIcon, 4 + CheckIcon, 5 + TriangleAlertIcon, 6 + WrenchIcon, 7 + } from "lucide-react"; 8 + import { messages } from "./messages"; 9 + import { StatusTimestamp } from "./status"; 10 + 11 + export function StatusBanner({ 12 + className, 13 + status, 14 + }: React.ComponentProps<"div"> & { 15 + status?: "success" | "degraded" | "error" | "info"; 16 + }) { 17 + return ( 18 + <StatusBannerContainer 19 + status={status} 20 + className={cn( 21 + "flex items-center gap-3 px-3 py-2 sm:px-4 sm:py-3", 22 + "data-[status=success]:bg-success/20", 23 + "data-[status=degraded]:bg-warning/20", 24 + "data-[status=error]:bg-destructive/20", 25 + "data-[status=info]:bg-info/20", 26 + className, 27 + )} 28 + > 29 + <StatusBannerIcon className="flex-shrink-0" /> 30 + <div className="flex flex-1 flex-wrap items-center justify-between gap-2"> 31 + <StatusBannerMessage className="font-semibold text-xl" /> 32 + <StatusTimestamp date={new Date()} className="text-xs" /> 33 + </div> 34 + </StatusBannerContainer> 35 + ); 36 + } 37 + 38 + export function StatusBannerContainer({ 39 + className, 40 + children, 41 + status, 42 + }: React.ComponentProps<"div"> & { 43 + status?: "success" | "degraded" | "error" | "info"; 44 + }) { 45 + return ( 46 + <div 47 + data-slot="status-banner" 48 + data-status={status} 49 + className={cn( 50 + "group/status-banner overflow-hidden rounded-lg border", 51 + "data-[status=success]:border-success", 52 + "data-[status=degraded]:border-warning", 53 + "data-[status=error]:border-destructive", 54 + "data-[status=info]:border-info", 55 + className, 56 + )} 57 + > 58 + {children} 59 + </div> 60 + ); 61 + } 62 + 63 + export function StatusBannerMessage({ 64 + className, 65 + ...props 66 + }: React.ComponentProps<"div">) { 67 + return ( 68 + <div className={cn(className)} {...props}> 69 + <span className="hidden group-data-[status=success]/status-banner:block"> 70 + {messages.long.success} 71 + </span> 72 + <span className="hidden group-data-[status=degraded]/status-banner:block"> 73 + {messages.long.degraded} 74 + </span> 75 + <span className="hidden group-data-[status=error]/status-banner:block"> 76 + {messages.long.error} 77 + </span> 78 + <span className="hidden group-data-[status=info]/status-banner:block"> 79 + {messages.long.info} 80 + </span> 81 + </div> 82 + ); 83 + } 84 + 85 + export function StatusBannerTitle({ 86 + className, 87 + children, 88 + ...props 89 + }: React.ComponentProps<"div">) { 90 + return ( 91 + <div 92 + className={cn( 93 + "px-3 py-2 font-medium text-background sm:px-4 sm:py-3", 94 + "group-data-[status=success]/status-banner:bg-success", 95 + "group-data-[status=degraded]/status-banner:bg-warning", 96 + "group-data-[status=error]/status-banner:bg-destructive", 97 + "group-data-[status=info]/status-banner:bg-info", 98 + className, 99 + )} 100 + {...props} 101 + > 102 + {children} 103 + </div> 104 + ); 105 + } 106 + 107 + export function StatusBannerContent({ 108 + className, 109 + children, 110 + ...props 111 + }: React.ComponentProps<"div">) { 112 + return ( 113 + <div className={cn("px-3 py-2 sm:px-4 sm:py-3", className)} {...props}> 114 + {children} 115 + </div> 116 + ); 117 + } 118 + 119 + export function StatusBannerIcon({ 120 + className, 121 + ...props 122 + }: React.ComponentProps<"div">) { 123 + return ( 124 + <div 125 + className={cn( 126 + "flex size-7 items-center justify-center rounded-full bg-muted text-background [&>svg]:size-4", 127 + "group-data-[status=success]/status-banner:bg-success", 128 + "group-data-[status=degraded]/status-banner:bg-warning", 129 + "group-data-[status=error]/status-banner:bg-destructive", 130 + "group-data-[status=info]/status-banner:bg-info", 131 + className, 132 + )} 133 + {...props} 134 + > 135 + <CheckIcon className="hidden group-data-[status=success]/status-banner:block" /> 136 + <TriangleAlertIcon className="hidden group-data-[status=degraded]/status-banner:block" /> 137 + <AlertCircleIcon className="hidden group-data-[status=error]/status-banner:block" /> 138 + <WrenchIcon className="hidden group-data-[status=info]/status-banner:block" /> 139 + </div> 140 + ); 141 + }
+57 -124
apps/status-page/src/components/status-page/status-events.tsx
··· 1 - import { Badge } from "@/components/ui/badge"; 2 1 import { Separator } from "@/components/ui/separator"; 3 - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 4 - import { type Maintenance, maintenances } from "@/data/maintenances"; 5 - import { type StatusReport, statusReports } from "@/data/status-reports"; 6 2 import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 7 - import { formatDate, formatTime } from "@/lib/formatter"; 3 + import { formatDateRange, formatDateTime } from "@/lib/formatter"; 8 4 import { cn } from "@/lib/utils"; 9 5 import { UTCDate } from "@date-fns/utc"; 10 6 import { HoverCardPortal } from "@radix-ui/react-hover-card"; ··· 14 10 formatDistanceToNowStrict, 15 11 } from "date-fns"; 16 12 import { Check, Copy } from "lucide-react"; 17 - import Link from "next/link"; 18 13 import { 19 14 HoverCard, 20 15 HoverCardContent, 21 16 HoverCardTrigger, 22 17 } from "../ui/hover-card"; 23 - 24 - const STATUS_LABELS = { 25 - operational: "Resolved", 26 - monitoring: "Monitoring", 27 - identified: "Identified", 28 - investigating: "Investigating", 29 - }; 30 - 31 - // TODO: move to page level 32 - export function StatusEventsTabs() { 33 - return ( 34 - <Tabs defaultValue="reports" className="gap-4"> 35 - <TabsList> 36 - <TabsTrigger value="reports">Reports</TabsTrigger> 37 - <TabsTrigger value="maintenances">Maintenances</TabsTrigger> 38 - </TabsList> 39 - <TabsContent value="reports" className="flex flex-col gap-4"> 40 - {statusReports.map((report) => ( 41 - <StatusEvent key={report.id}> 42 - <StatusEventAside> 43 - <span className="font-medium text-foreground/80"> 44 - {formatDate(report.startedAt, { month: "short" })} 45 - </span> 46 - </StatusEventAside> 47 - <Link href="/status-page/events/report" className="rounded-lg"> 48 - <StatusEventContent> 49 - <StatusEventTitle>{report.name}</StatusEventTitle> 50 - <StatusEventAffected className="flex flex-wrap gap-1"> 51 - {report.affected.map((affected) => ( 52 - <Badge 53 - key={affected} 54 - variant="outline" 55 - className="text-[10px]" 56 - > 57 - {affected} 58 - </Badge> 59 - ))} 60 - </StatusEventAffected> 61 - <StatusEventTimelineReport updates={report.updates} /> 62 - </StatusEventContent> 63 - </Link> 64 - </StatusEvent> 65 - ))} 66 - </TabsContent> 67 - <TabsContent value="maintenances" className="flex flex-col gap-4"> 68 - {maintenances.map((maintenance) => { 69 - const isFuture = maintenance.startDate > new Date(); 70 - return ( 71 - <StatusEvent key={maintenance.id}> 72 - <StatusEventAside> 73 - <span className="font-medium text-foreground/80"> 74 - {formatDate(maintenance.startDate, { month: "short" })} 75 - </span> 76 - {isFuture ? ( 77 - <span className="text-info text-sm">Upcoming</span> 78 - ) : null} 79 - </StatusEventAside> 80 - <Link 81 - href="/status-page/events/maintenance" 82 - className="rounded-lg" 83 - > 84 - <StatusEventContent> 85 - <StatusEventTitle>{maintenance.title}</StatusEventTitle> 86 - <StatusEventAffected className="flex flex-wrap gap-1"> 87 - {maintenance.affected.map((affected) => ( 88 - <Badge 89 - key={affected} 90 - variant="outline" 91 - className="text-[10px]" 92 - > 93 - {affected} 94 - </Badge> 95 - ))} 96 - </StatusEventAffected> 97 - <StatusEventTimelineMaintenance maintenance={maintenance} /> 98 - </StatusEventContent> 99 - </Link> 100 - </StatusEvent> 101 - ); 102 - })} 103 - </TabsContent> 104 - </Tabs> 105 - ); 106 - } 18 + import { status } from "./messages"; 107 19 108 20 // TODO: rename file to status-event and move the `StatusEvents` component to the page level. 109 21 ··· 191 103 export function StatusEventTimelineReport({ 192 104 className, 193 105 updates, 106 + withDot = true, 194 107 ...props 195 108 }: React.ComponentProps<"div"> & { 196 - updates: StatusReport["updates"]; 109 + // TODO: remove unused props 110 + updates: { 111 + date: Date; 112 + message: string; 113 + status: "investigating" | "identified" | "monitoring" | "resolved"; 114 + }[]; 115 + withDot?: boolean; 197 116 }) { 198 117 const startedAt = new Date(updates[0].date); 199 118 const endedAt = new Date(updates[updates.length - 1].date); ··· 205 124 .sort((a, b) => b.date.getTime() - a.date.getTime()) 206 125 .map((update, index) => ( 207 126 <StatusEventTimelineReportUpdate 208 - key={update.id} 127 + key={index} 209 128 report={update} 210 129 duration={ 211 - index === 0 && update.status === "operational" 212 - ? duration 213 - : undefined 130 + index === 0 && update.status === "resolved" ? duration : undefined 214 131 } 215 132 withSeparator={index !== updates.length - 1} 133 + withDot={withDot} 216 134 /> 217 135 ))} 218 136 </div> ··· 223 141 report, 224 142 duration, 225 143 withSeparator = true, 144 + withDot = true, 226 145 }: { 227 - report: StatusReport["updates"][number]; 146 + report: { 147 + date: Date; 148 + message: string; 149 + status: "investigating" | "identified" | "monitoring" | "resolved"; 150 + }; 228 151 withSeparator?: boolean; 229 152 duration?: string; 153 + withDot?: boolean; 230 154 }) { 231 155 return ( 232 156 <div data-variant={report.status} className="group"> 233 157 <div className="flex flex-row items-center justify-between gap-2"> 234 158 <div className="flex flex-row gap-2"> 235 - <div className="flex flex-col"> 236 - <div className="flex h-5 flex-col items-center justify-center"> 237 - <StatusEventTimelineDot /> 159 + {withDot ? ( 160 + <div className="flex flex-col"> 161 + <div className="flex h-5 flex-col items-center justify-center"> 162 + <StatusEventTimelineDot /> 163 + </div> 164 + {withSeparator ? <StatusEventTimelineSeparator /> : null} 238 165 </div> 239 - {withSeparator ? <StatusEventTimelineSeparator /> : null} 240 - </div> 166 + ) : null} 241 167 <div className="mb-2"> 242 168 <StatusEventTimelineTitle> 243 - <span>{STATUS_LABELS[report.status]}</span>{" "} 244 - <span className="font-mono text-muted-foreground/70 text-xs underline decoration-dashed underline-offset-2"> 169 + <span>{status[report.status]}</span>{" "} 170 + {/* underline decoration-dashed underline-offset-2 decoration-muted-foreground/30 */} 171 + <span className="font-mono text-muted-foreground/70 text-xs"> 245 172 <StatusEventDateHoverCard date={new Date(report.date)}> 246 - {formatTime(report.date)} 173 + {formatDateTime(report.date)} 247 174 </StatusEventDateHoverCard> 248 175 </span>{" "} 249 176 {duration ? ( ··· 264 191 265 192 export function StatusEventTimelineMaintenance({ 266 193 maintenance, 194 + withDot = true, 267 195 }: { 268 - maintenance: Maintenance; 196 + maintenance: { 197 + title: string; 198 + message: string; 199 + from: Date; 200 + to: Date; 201 + }; 202 + withDot?: boolean; 269 203 }) { 270 - const start = new Date(maintenance.startDate); 271 - const end = new Date(maintenance.endDate); 272 - const duration = formatDistanceStrict(start, end); 204 + const duration = formatDistanceStrict(maintenance.from, maintenance.to); 205 + const range = formatDateRange(maintenance.from, maintenance.to); 206 + // NOTE: because formatDateRange is sure to return a range, we can split it into two dates 207 + const [from, to] = range.split(" - "); 273 208 return ( 274 209 <div data-variant="maintenance" className="group"> 275 210 <div className="flex flex-row items-center justify-between gap-2"> 276 211 <div className="flex flex-row gap-2"> 277 - <div className="flex flex-col"> 278 - <div className="flex h-5 flex-col items-center justify-center"> 279 - <StatusEventTimelineDot /> 212 + {withDot ? ( 213 + <div className="flex flex-col"> 214 + <div className="flex h-5 flex-col items-center justify-center"> 215 + <StatusEventTimelineDot /> 216 + </div> 280 217 </div> 281 - </div> 218 + ) : null} 282 219 <div className="mb-2"> 283 220 <StatusEventTimelineTitle> 284 221 <span>Maintenance</span>{" "} 285 222 <span className="font-mono text-muted-foreground/70 text-xs"> 286 - <span className="underline decoration-dashed underline-offset-2"> 287 - <StatusEventDateHoverCard date={new Date(start)}> 288 - {formatTime(start)} 289 - </StatusEventDateHoverCard> 290 - </span> 223 + <StatusEventDateHoverCard date={maintenance.from}> 224 + {from} 225 + </StatusEventDateHoverCard> 291 226 {" - "} 292 - <span className="underline decoration-dashed underline-offset-2"> 293 - <StatusEventDateHoverCard date={new Date(end)}> 294 - {formatTime(end)} 295 - </StatusEventDateHoverCard> 296 - </span> 227 + <StatusEventDateHoverCard date={maintenance.to}> 228 + {to} 229 + </StatusEventDateHoverCard> 297 230 </span>{" "} 298 231 {duration ? ( 299 232 <span className="font-mono text-muted-foreground/70 text-xs"> ··· 347 280 <div 348 281 className={cn( 349 282 "size-2.5 shrink-0 rounded-full bg-muted", 350 - "group-data-[variant=operational]:bg-success", 283 + "group-data-[variant=resolved]:bg-success", 351 284 "group-data-[variant=monitoring]:bg-info", 352 285 "group-data-[variant=identified]:bg-warning", 353 286 "group-data-[variant=investigating]:bg-destructive", ··· 368 301 orientation="vertical" 369 302 className={cn( 370 303 "mx-auto flex-1", 371 - "group-data-[variant=operational]:bg-success", 304 + "group-data-[variant=resolved]:bg-success", 372 305 "group-data-[variant=monitoring]:bg-info", 373 306 "group-data-[variant=identified]:bg-warning", 374 307 "group-data-[variant=investigating]:bg-destructive",
+180
apps/status-page/src/components/status-page/status-feed.tsx
··· 1 + "use client"; 2 + 3 + import { Badge } from "@/components/ui/badge"; 4 + import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 5 + import { formatDate } from "@/lib/formatter"; 6 + import { cn } from "@/lib/utils"; 7 + import { Newspaper } from "lucide-react"; 8 + import Link from "next/link"; 9 + import { 10 + StatusEmptyState, 11 + StatusEmptyStateDescription, 12 + StatusEmptyStateTitle, 13 + } from "./status"; 14 + import { 15 + StatusEvent, 16 + StatusEventAffected, 17 + StatusEventAside, 18 + StatusEventContent, 19 + StatusEventTimelineMaintenance, 20 + StatusEventTimelineReport, 21 + StatusEventTitle, 22 + } from "./status-events"; 23 + 24 + type StatusReport = { 25 + id: number; 26 + title: string; 27 + affected: string[]; 28 + updates: { 29 + date: Date; 30 + message: string; 31 + status: "investigating" | "identified" | "monitoring" | "resolved"; 32 + }[]; 33 + }; 34 + 35 + type Maintenance = { 36 + id: number; 37 + title: string; 38 + message: string; 39 + from: Date; 40 + to: Date; 41 + affected: string[]; 42 + }; 43 + 44 + type UnifiedEvent = { 45 + id: number; 46 + title: string; 47 + type: "report" | "maintenance"; 48 + startDate: Date; 49 + data: StatusReport | Maintenance; 50 + }; 51 + 52 + export function StatusFeed({ 53 + className, 54 + statusReports = [], 55 + maintenances = [], 56 + ...props 57 + }: React.ComponentProps<"div"> & { 58 + statusReports?: StatusReport[]; 59 + maintenances?: Maintenance[]; 60 + showLinks?: boolean; 61 + }) { 62 + const prefix = usePathnamePrefix(); 63 + const unifiedEvents: UnifiedEvent[] = [ 64 + ...statusReports.map((report) => ({ 65 + id: report.id, 66 + title: report.title, 67 + type: "report" as const, 68 + // FIXME: we have a flicker here when the report is updated 69 + startDate: report.updates[report.updates.length - 1]?.date || new Date(), 70 + data: report, 71 + })), 72 + ...maintenances.map((maintenance) => ({ 73 + id: maintenance.id, 74 + title: maintenance.title, 75 + type: "maintenance" as const, 76 + startDate: maintenance.from, 77 + data: maintenance, 78 + })), 79 + ].sort((a, b) => b.startDate.getTime() - a.startDate.getTime()); 80 + 81 + if (unifiedEvents.length === 0) { 82 + return ( 83 + <StatusEmptyState> 84 + <Newspaper className="size-4 text-muted-foreground" /> 85 + <StatusEmptyStateTitle>No recent reports</StatusEmptyStateTitle> 86 + <StatusEmptyStateDescription> 87 + There have been no reports within the last 7 days. 88 + </StatusEmptyStateDescription> 89 + </StatusEmptyState> 90 + ); 91 + } 92 + 93 + return ( 94 + <div className={cn("flex flex-col gap-4", className)} {...props}> 95 + {unifiedEvents.map((event) => { 96 + if (event.type === "report") { 97 + const report = event.data as StatusReport; 98 + return ( 99 + <StatusEvent key={`report-${event.id}`}> 100 + <StatusEventAside> 101 + <span className="font-medium text-foreground/80"> 102 + {formatDate(event.startDate, { month: "short" })} 103 + </span> 104 + </StatusEventAside> 105 + <Link 106 + href={`${prefix}/events/report/${report.id}`} 107 + className="rounded-lg" 108 + > 109 + <StatusEventContent> 110 + <StatusEventTitle>{report.title}</StatusEventTitle> 111 + {report.affected.length > 0 && ( 112 + <StatusEventAffected className="flex flex-wrap gap-1"> 113 + {report.affected.map((affected, index) => ( 114 + <Badge 115 + key={index} 116 + variant="outline" 117 + className="text-[10px]" 118 + > 119 + {affected} 120 + </Badge> 121 + ))} 122 + </StatusEventAffected> 123 + )} 124 + <StatusEventTimelineReport updates={report.updates} /> 125 + </StatusEventContent> 126 + </Link> 127 + </StatusEvent> 128 + ); 129 + } 130 + 131 + if (event.type === "maintenance") { 132 + const maintenance = event.data as Maintenance; 133 + const isFuture = maintenance.from > new Date(); 134 + return ( 135 + <StatusEvent key={`maintenance-${event.id}`}> 136 + <StatusEventAside> 137 + <span className="font-medium text-foreground/80"> 138 + {formatDate(event.startDate, { month: "short" })} 139 + </span> 140 + {isFuture ? ( 141 + <span className="text-info text-sm">Upcoming</span> 142 + ) : null} 143 + </StatusEventAside> 144 + <Link 145 + href={`${prefix}/events/maintenance/${maintenance.id}`} 146 + className="rounded-lg" 147 + > 148 + <StatusEventContent> 149 + <StatusEventTitle>{maintenance.title}</StatusEventTitle> 150 + {maintenance.affected.length > 0 && ( 151 + <StatusEventAffected className="flex flex-wrap gap-1"> 152 + {maintenance.affected.map((affected, index) => ( 153 + <Badge 154 + key={index} 155 + variant="outline" 156 + className="text-[10px]" 157 + > 158 + {affected} 159 + </Badge> 160 + ))} 161 + </StatusEventAffected> 162 + )} 163 + <StatusEventTimelineMaintenance 164 + maintenance={{ 165 + title: maintenance.title, 166 + message: maintenance.message, 167 + from: maintenance.from, 168 + to: maintenance.to, 169 + }} 170 + /> 171 + </StatusEventContent> 172 + </Link> 173 + </StatusEvent> 174 + ); 175 + } 176 + return null; 177 + })} 178 + </div> 179 + ); 180 + }
+83
apps/status-page/src/components/status-page/status-monitor-tabs.tsx
··· 1 + import { Skeleton } from "@/components/ui/skeleton"; 2 + import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 3 + import { cn } from "@/lib/utils"; 4 + 5 + export function StatusMonitorTabs({ 6 + className, 7 + ...props 8 + }: React.ComponentProps<typeof Tabs>) { 9 + return <Tabs className={cn("gap-6", className)} {...props} />; 10 + } 11 + 12 + export function StatusMonitorTabsList({ 13 + className, 14 + ...props 15 + }: React.ComponentProps<typeof TabsList>) { 16 + return ( 17 + <TabsList 18 + className={cn("flex h-auto min-h-fit w-full", className)} 19 + {...props} 20 + /> 21 + ); 22 + } 23 + 24 + export function StatusMonitorTabsTrigger({ 25 + className, 26 + ...props 27 + }: React.ComponentProps<typeof TabsTrigger>) { 28 + return ( 29 + <TabsTrigger 30 + className={cn( 31 + "min-w-0 flex-1 flex-col items-start gap-0.5 text-foreground dark:text-foreground", 32 + className, 33 + )} 34 + {...props} 35 + /> 36 + ); 37 + } 38 + 39 + export function StatusMonitorTabsTriggerLabel({ 40 + className, 41 + ...props 42 + }: React.ComponentProps<"div">) { 43 + return ( 44 + <div className={cn("w-full truncate text-left", className)} {...props} /> 45 + ); 46 + } 47 + 48 + export function StatusMonitorTabsTriggerValue({ 49 + className, 50 + ...props 51 + }: React.ComponentProps<"div">) { 52 + return ( 53 + <div 54 + className={cn( 55 + "flex flex-row flex-wrap items-center gap-1 text-left text-muted-foreground text-xs", 56 + className, 57 + )} 58 + {...props} 59 + /> 60 + ); 61 + } 62 + 63 + export function StatusMonitorTabsTriggerValueSkeleton({ 64 + className, 65 + ...props 66 + }: React.ComponentProps<typeof Skeleton>) { 67 + return <Skeleton className={cn("h-4 w-24", className)} {...props} />; 68 + } 69 + 70 + export function StatusMonitorTabsContent({ 71 + className, 72 + ...props 73 + }: React.ComponentProps<typeof TabsContent>) { 74 + return ( 75 + <TabsContent 76 + className={cn( 77 + "flex flex-col gap-2 rounded-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", 78 + className, 79 + )} 80 + {...props} 81 + /> 82 + ); 83 + }
+88 -33
apps/status-page/src/components/status-page/status-monitor.tsx
··· 1 1 "use client"; 2 2 3 + import { Skeleton } from "@/components/ui/skeleton"; 3 4 import { 4 5 Tooltip, 5 6 TooltipContent, 6 7 TooltipProvider, 7 8 TooltipTrigger, 8 9 } from "@/components/ui/tooltip"; 9 - import type { Monitor } from "@/data/monitors"; 10 10 import { useMediaQuery } from "@/hooks/use-media-query"; 11 11 import { cn } from "@/lib/utils"; 12 + import type { RouterOutputs } from "@openstatus/api"; 12 13 import { formatDistanceToNowStrict } from "date-fns"; 13 14 import { 14 15 AlertCircleIcon, ··· 18 19 WrenchIcon, 19 20 } from "lucide-react"; 20 21 import { useState } from "react"; 21 - import type { BarType, CardType, VariantType } from "./floating-button"; 22 - import { StatusTracker } from "./status-tracker"; 23 - import type { ChartData } from "./utils"; 22 + import type { VariantType } from "./floating-button"; 23 + import { StatusTracker, StatusTrackerSkeleton } from "./status-tracker"; 24 + 25 + // TODO: use status instead of variant 26 + 27 + type Data = NonNullable< 28 + RouterOutputs["statusPage"]["getUptime"] 29 + >[number]["data"]; 24 30 25 31 export function StatusMonitor({ 26 32 className, 27 - variant = "success", 28 - cardType = "duration", 29 - barType = "absolute", 33 + status = "success", 30 34 showUptime = true, 31 - data, 35 + data = [], 32 36 monitor, 37 + uptime, 38 + isLoading = false, 33 39 ...props 34 40 }: React.ComponentProps<"div"> & { 35 - variant?: VariantType; 36 - cardType?: CardType; 37 - barType?: BarType; 41 + status?: VariantType; 38 42 showUptime?: boolean; 39 - monitor: Monitor; 40 - data: ChartData[]; 43 + uptime?: string; 44 + monitor: { 45 + name: string; 46 + description: string; 47 + }; 48 + data?: Data; 49 + isLoading?: boolean; 41 50 }) { 42 51 return ( 43 52 <div 44 53 data-slot="status-monitor" 45 - data-variant={variant} 54 + data-variant={status} 46 55 className={cn("group/monitor flex flex-col gap-1", className)} 47 56 {...props} 48 57 > ··· 54 63 </StatusMonitorDescription> 55 64 </div> 56 65 <div className="flex flex-row items-center gap-2"> 57 - {showUptime ? <StatusMonitorUptime /> : null} 58 - <StatusMonitorIcon /> 66 + {/* TODO: check if we can improve that cuz its looking ugly */} 67 + {showUptime ? ( 68 + <> 69 + {isLoading ? ( 70 + <StatusMonitorUptimeSkeleton /> 71 + ) : ( 72 + <StatusMonitorUptime>{uptime}</StatusMonitorUptime> 73 + )} 74 + <StatusMonitorIcon /> 75 + </> 76 + ) : ( 77 + <StatusMonitorStatus className="text-sm" /> 78 + )} 59 79 </div> 60 80 </div> 61 - <StatusTracker cardType={cardType} barType={barType} data={data} /> 62 - <div 63 - className={cn( 64 - "flex flex-row items-center justify-between text-muted-foreground text-xs", 65 - className, 66 - )} 67 - {...props} 68 - > 69 - <div> 70 - {formatDistanceToNowStrict(new Date(data[0].timestamp), { 71 - unit: "day", 72 - })} 73 - </div> 74 - <div>today</div> 75 - </div> 81 + {isLoading ? <StatusTrackerSkeleton /> : <StatusTracker data={data} />} 82 + <StatusMonitorFooter data={data} isLoading={isLoading} /> 76 83 </div> 77 84 ); 78 85 } ··· 97 104 const isTouch = useMediaQuery("(hover: none)"); 98 105 const [open, setOpen] = useState(false); 99 106 107 + if (!children) return null; 108 + 100 109 return ( 101 110 <TooltipProvider delayDuration={0}> 102 111 <Tooltip open={open} onOpenChange={setOpen}> ··· 105 114 if (isTouch) setOpen((prev) => !prev); 106 115 onClick?.(e); 107 116 }} 117 + className="rounded-full" 108 118 {...props} 109 119 > 110 120 <InfoIcon className="size-4 text-muted-foreground" /> ··· 116 126 </TooltipProvider> 117 127 ); 118 128 } 129 + 119 130 export function StatusMonitorIcon({ 120 131 className, 121 132 ...props ··· 139 150 </div> 140 151 ); 141 152 } 153 + 154 + export function StatusMonitorFooter({ 155 + data, 156 + isLoading, 157 + }: { 158 + data: Data; 159 + isLoading?: boolean; 160 + }) { 161 + return ( 162 + <div className="flex flex-row items-center justify-between text-muted-foreground text-xs"> 163 + <div> 164 + {isLoading ? ( 165 + <Skeleton className="h-4 w-18" /> 166 + ) : data.length > 0 ? ( 167 + formatDistanceToNowStrict(new Date(data[0].day), { 168 + unit: "day", 169 + addSuffix: true, 170 + }) 171 + ) : ( 172 + "-" 173 + )} 174 + </div> 175 + <div>today</div> 176 + </div> 177 + ); 178 + } 179 + 142 180 export function StatusMonitorUptime({ 143 181 className, 182 + children, 144 183 ...props 145 184 }: React.ComponentProps<"div">) { 146 185 return ( ··· 148 187 {...props} 149 188 className={cn("font-mono text-muted-foreground text-sm", className)} 150 189 > 151 - 99.90% 190 + {children} 152 191 </div> 153 192 ); 154 193 } 155 194 195 + export function StatusMonitorUptimeSkeleton({ 196 + className, 197 + ...props 198 + }: React.ComponentProps<typeof Skeleton>) { 199 + return <Skeleton className={cn("h-4 w-16", className)} {...props} />; 200 + } 201 + 156 202 export function StatusMonitorStatus({ 157 203 className, 158 204 ...props 159 205 }: React.ComponentProps<"div">) { 160 206 return ( 161 - <div className={cn(className)} {...props}> 207 + <div 208 + className={cn( 209 + "group-data-[variant=success]/monitor:text-success", 210 + "group-data-[variant=degraded]/monitor:text-warning", 211 + "group-data-[variant=error]/monitor:text-destructive", 212 + "group-data-[variant=info]/monitor:text-info", 213 + className, 214 + )} 215 + {...props} 216 + > 162 217 <span className="hidden group-data-[variant=success]/monitor:block"> 163 218 Operational 164 219 </span>
+243 -182
apps/status-page/src/components/status-page/status-tracker.tsx
··· 7 7 HoverCardTrigger, 8 8 } from "@/components/ui/hover-card"; 9 9 import { Separator } from "@/components/ui/separator"; 10 - // TODO: make it a property of the component 11 - import { statusReports } from "@/data/status-reports"; 10 + import { Skeleton } from "@/components/ui/skeleton"; 12 11 import { useMediaQuery } from "@/hooks/use-media-query"; 12 + import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 13 13 import { formatDateRange } from "@/lib/formatter"; 14 - import { formatDistanceStrict, isSameDay } from "date-fns"; 14 + import { cn } from "@/lib/utils"; 15 + import type { RouterOutputs } from "@openstatus/api"; 16 + import { formatDistanceStrict } from "date-fns"; 15 17 import Link from "next/link"; 16 18 import { useEffect, useRef, useState } from "react"; 17 - import { type BarType, type CardType, VARIANT } from "./floating-button"; 18 - import { messages, requests } from "./messages"; 19 - import { type ChartData, chartConfig, getHighestPriorityStatus } from "./utils"; 19 + import { requests } from "./messages"; 20 + import { chartConfig } from "./utils"; 21 + 22 + type UptimeData = NonNullable< 23 + RouterOutputs["statusPage"]["getUptime"] 24 + >[number]["data"]; 20 25 21 26 // TODO: keyboard arrow navigation 22 27 // FIXME: on small screens, avoid pinned state ··· 26 31 // TODO: support status page logo + onClick to homepage 27 32 // TODO: widget type -> current status only | with status history 28 33 29 - const STATUS = VARIANT; 30 - 31 - export function StatusTracker({ 32 - cardType = "duration", 33 - barType = "absolute", 34 - data, 35 - }: { 36 - cardType?: CardType; 37 - barType?: BarType; 38 - data: ChartData[]; 39 - }) { 34 + export function StatusTracker({ data }: { data: UptimeData }) { 40 35 const [pinnedIndex, setPinnedIndex] = useState<number | null>(null); 36 + const [focusedIndex, setFocusedIndex] = useState<number | null>(null); 37 + const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); 41 38 const containerRef = useRef<HTMLDivElement>(null); 39 + const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); 42 40 const isTouch = useMediaQuery("(hover: none)"); 41 + const prefix = usePathnamePrefix(); 43 42 44 - // Window-level Escape key listener 45 - useEffect(() => { 46 - const handleEscape = (e: KeyboardEvent) => { 47 - if (e.key === "Escape" && pinnedIndex !== null) { 48 - setPinnedIndex(null); 49 - } 50 - }; 51 - 52 - window.addEventListener("keydown", handleEscape); 53 - return () => window.removeEventListener("keydown", handleEscape); 54 - }, [pinnedIndex]); 55 - 56 - // Document-level outside click listener 57 43 useEffect(() => { 58 44 const handleOutsideClick = (e: MouseEvent) => { 59 45 if ( ··· 72 58 } 73 59 }, [pinnedIndex]); 74 60 75 - // Handle keyboard events for accessibility (kept for fallback) 61 + useEffect(() => { 62 + if (focusedIndex !== null && containerRef.current) { 63 + const buttons = containerRef.current.querySelectorAll('[role="button"]'); 64 + const targetButton = buttons[focusedIndex] as HTMLElement; 65 + if (targetButton) { 66 + targetButton.focus(); 67 + } 68 + } 69 + }, [focusedIndex]); 70 + 71 + useEffect(() => { 72 + return () => { 73 + if (hoverTimeoutRef.current) { 74 + clearTimeout(hoverTimeoutRef.current); 75 + } 76 + }; 77 + }, []); 78 + 76 79 const handleKeyDown = (e: React.KeyboardEvent) => { 77 80 if (e.key === "Escape") { 78 81 setPinnedIndex(null); 82 + setFocusedIndex(null); 83 + setHoveredIndex(null); 84 + 85 + if (hoverTimeoutRef.current) { 86 + clearTimeout(hoverTimeoutRef.current); 87 + hoverTimeoutRef.current = null; 88 + } 89 + return; 90 + } 91 + 92 + if (focusedIndex !== null) { 93 + switch (e.key) { 94 + case "ArrowLeft": 95 + e.preventDefault(); 96 + setFocusedIndex((prev) => 97 + prev !== null && prev > 0 ? prev - 1 : data.length - 1, 98 + ); 99 + break; 100 + case "ArrowRight": 101 + e.preventDefault(); 102 + setFocusedIndex((prev) => 103 + prev !== null && prev < data.length - 1 ? prev + 1 : 0, 104 + ); 105 + break; 106 + case "Enter": 107 + case " ": 108 + e.preventDefault(); 109 + handleBarClick(focusedIndex); 110 + break; 111 + } 79 112 } 80 113 }; 81 114 82 115 const handleBarClick = (index: number) => { 83 - // Toggle pinned state: if clicking the same bar, unpin it; otherwise, pin the new bar 116 + // Clear any pending hover timeout 117 + if (hoverTimeoutRef.current) { 118 + clearTimeout(hoverTimeoutRef.current); 119 + hoverTimeoutRef.current = null; 120 + } 84 121 if (pinnedIndex === index) { 85 122 setPinnedIndex(null); 86 123 } else { ··· 88 125 } 89 126 }; 90 127 128 + const handleBarFocus = (index: number) => { 129 + setFocusedIndex(index); 130 + }; 131 + 132 + const handleBarBlur = (e: React.FocusEvent, _currentIndex: number) => { 133 + const relatedTarget = e.relatedTarget as HTMLElement; 134 + const isMovingToAnotherBar = 135 + relatedTarget && 136 + relatedTarget.closest('[role="toolbar"]') === containerRef.current && 137 + relatedTarget.getAttribute("role") === "button"; 138 + 139 + if (!isMovingToAnotherBar) { 140 + setFocusedIndex(null); 141 + } 142 + }; 143 + 144 + const handleBarMouseEnter = (index: number) => { 145 + if (hoverTimeoutRef.current) { 146 + clearTimeout(hoverTimeoutRef.current); 147 + hoverTimeoutRef.current = null; 148 + } 149 + setHoveredIndex(index); 150 + }; 151 + 152 + const handleBarMouseLeave = () => { 153 + hoverTimeoutRef.current = setTimeout(() => { 154 + setHoveredIndex(null); 155 + }, 100); 156 + }; 157 + 158 + const handleHoverCardMouseEnter = () => { 159 + if (hoverTimeoutRef.current) { 160 + clearTimeout(hoverTimeoutRef.current); 161 + hoverTimeoutRef.current = null; 162 + } 163 + }; 164 + 165 + const handleHoverCardMouseLeave = () => { 166 + setHoveredIndex(null); 167 + }; 168 + 91 169 return ( 92 170 <div 93 171 ref={containerRef} 94 172 className="flex h-[50px] w-full items-end" 95 173 onKeyDown={handleKeyDown} 96 - // tabIndex={0} 174 + role="toolbar" 175 + aria-label="Status tracker" 97 176 > 98 177 {data.map((item, index) => { 99 178 const isPinned = pinnedIndex === index; 100 - 101 - const reports = statusReports.filter((report) => { 102 - const reportDate = new Date(report.startedAt); 103 - const itemDate = new Date(item.timestamp); 104 - return isSameDay(reportDate, itemDate); 105 - }); 179 + const isFocused = focusedIndex === index; 180 + const isHovered = hoveredIndex === index; 106 181 107 182 return ( 108 183 <HoverCard 109 - key={item.timestamp} 184 + key={item.day} 110 185 openDelay={0} 111 186 closeDelay={0} 112 - open={isPinned ? true : undefined} 187 + open={isPinned || isFocused || isHovered} 113 188 > 114 189 <HoverCardTrigger asChild> 115 190 <div 116 - className="group relative flex h-full w-full cursor-pointer flex-col px-px transition-opacity hover:opacity-80" // sm:px-0.5 191 + className={cn( 192 + "group relative flex h-full w-full cursor-pointer flex-col px-px outline-none hover:opacity-80 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 data-[aria-pressed=true]:opacity-80", 193 + )} 117 194 onClick={() => handleBarClick(index)} 195 + onFocus={() => handleBarFocus(index)} 196 + onBlur={(e) => handleBarBlur(e, index)} 197 + onMouseEnter={() => handleBarMouseEnter(index)} 198 + onMouseLeave={handleBarMouseLeave} 199 + tabIndex={ 200 + index === 0 && focusedIndex === null ? 0 : isFocused ? 0 : -1 201 + } 202 + role="button" 203 + aria-label={`Day ${index + 1} status`} 204 + aria-pressed={isPinned} 118 205 > 119 - {(() => { 120 - switch (barType) { 121 - case "absolute": 122 - return <StatusTrackerTriggerAbsolute item={item} />; 123 - case "dominant": 124 - return <StatusTrackerTriggerDominant item={item} />; 125 - default: 126 - return null; 127 - } 128 - })()} 206 + {/* Render processed bar segments from backend */} 207 + {item.bar.map((segment, segmentIndex) => ( 208 + <div 209 + key={`${item.day}-${segment.status}-${segmentIndex}`} 210 + className="w-full transition-all" 211 + style={{ 212 + height: `${segment.height}%`, 213 + backgroundColor: chartConfig[segment.status].color, 214 + }} 215 + /> 216 + ))} 129 217 </div> 130 218 </HoverCardTrigger> 131 - <HoverCardContent side="top" align="center" className="w-auto p-0"> 219 + <HoverCardContent 220 + side="top" 221 + align="center" 222 + // NOTE: remove animation and transition to avoid flickering 223 + className="![animation-duration:0ms] ![transition-duration:0ms] w-auto min-w-40 p-0" 224 + onMouseEnter={handleHoverCardMouseEnter} 225 + onMouseLeave={handleHoverCardMouseLeave} 226 + > 132 227 <div> 133 228 <div className="p-2 text-xs"> 134 - {new Date(item.timestamp).toLocaleDateString("default", { 229 + {new Date(item.day).toLocaleDateString("default", { 135 230 day: "numeric", 136 231 month: "short", 232 + year: "numeric", 137 233 })} 138 234 </div> 139 235 <Separator /> 140 236 <div className="space-y-1 p-2 text-sm"> 141 - {(() => { 142 - switch (cardType) { 143 - case "duration": 144 - return <StatusTrackerContentDuration item={item} />; 145 - case "dominant": 146 - return <StatusTrackerContentDominant item={item} />; 147 - case "requests": 148 - return <StatusTrackerContentRequests item={item} />; 149 - default: 150 - return null; 151 - } 152 - })()} 237 + {/* Render processed card data from backend */} 238 + {item.card.map((cardItem, cardIndex) => ( 239 + <StatusTrackerContent 240 + key={`${item.day}-card-${cardIndex}`} 241 + status={cardItem.status} 242 + value={cardItem.value} 243 + /> 244 + ))} 153 245 </div> 154 - {reports.length > 0 ? ( 246 + {item.events.length > 0 && ( 155 247 <> 156 248 <Separator /> 157 249 <div className="p-2"> 158 - {reports.map((report) => { 159 - const updates = report.updates.sort( 160 - (a, b) => a.date.getTime() - b.date.getTime(), 161 - ); 162 - const startedAt = new Date(updates[0].date); 163 - const endedAt = new Date( 164 - updates[updates.length - 1].date, 165 - ); 166 - const duration = formatDistanceStrict( 167 - startedAt, 168 - endedAt, 169 - ); 170 - return ( 171 - <Link 172 - key={report.id} 173 - href="/status-page/events/report" 174 - > 175 - <div className="group relative text-sm"> 176 - {/* NOTE: this is to make the text truncate based on the with of the sibling element */} 177 - {/* REMINDER: height needs to be equal the text height */} 178 - <div className="h-4 w-full" /> 179 - <div className="absolute inset-0 text-muted-foreground hover:text-foreground"> 180 - <div className="truncate">{report.name}</div> 181 - </div> 182 - <div className="mt-1 text-muted-foreground text-xs"> 183 - {formatDateRange(startedAt, endedAt)}{" "} 184 - <span className="ml-1.5 font-mono text-muted-foreground/70"> 185 - {duration} 186 - </span> 187 - </div> 188 - </div> 189 - </Link> 250 + {item.events.map((event) => { 251 + const eventStatus = 252 + event.type === "incident" 253 + ? "error" 254 + : event.type === "report" 255 + ? "degraded" 256 + : "info"; 257 + 258 + const content = ( 259 + <StatusTrackerEvent 260 + key={event.id} 261 + status={eventStatus} 262 + name={event.name} 263 + from={event.from} 264 + to={event.to} 265 + /> 190 266 ); 267 + 268 + // Wrap reports and maintenances with links 269 + if ( 270 + event.type === "report" || 271 + event.type === "maintenance" 272 + ) { 273 + return ( 274 + <Link 275 + key={event.id} 276 + href={`/${prefix}/events/report/${event.id}`} 277 + > 278 + {content} 279 + </Link> 280 + ); 281 + } 282 + 283 + // Incidents don't have links 284 + return content; 191 285 })} 192 286 </div> 193 287 </> 194 - ) : null} 288 + )} 195 289 {isPinned && !isTouch && ( 196 290 <> 197 291 <Separator /> ··· 210 304 ); 211 305 } 212 306 213 - function StatusTrackerTriggerAbsolute({ item }: { item: ChartData }) { 214 - const total = item.success + item.degraded + item.info + item.error; 215 - 216 - return STATUS.map((status) => { 217 - const value = item[status as keyof typeof item] as number; 218 - if (value === 0) return null; 219 - const heightPercentage = (value / total) * 100; 220 - return ( 221 - <div 222 - key={`${item.timestamp}-${status}`} 223 - className="w-full transition-all" 224 - style={{ 225 - height: `${heightPercentage}%`, 226 - backgroundColor: chartConfig[status].color, 227 - // IDEA: only for status === "success", make the color less pop to emphasize the other statuses 228 - }} 229 - /> 230 - ); 231 - }); 232 - } 233 - 234 - function StatusTrackerTriggerDominant({ item }: { item: ChartData }) { 235 - const highestPriorityStatus = getHighestPriorityStatus(item); 236 - 307 + export function StatusTrackerSkeleton({ 308 + className, 309 + ...props 310 + }: React.ComponentProps<typeof Skeleton>) { 237 311 return ( 238 - <div 239 - key={`${item.timestamp}-${highestPriorityStatus}`} 240 - className="w-full transition-all" 241 - style={{ 242 - height: "100%", 243 - backgroundColor: chartConfig[highestPriorityStatus].color, 244 - }} 312 + <Skeleton 313 + className={cn("h-[50px] w-full rounded-none bg-muted", className)} 314 + {...props} 245 315 /> 246 316 ); 247 317 } 248 318 249 - function StatusTrackerContentDuration({ item }: { item: ChartData }) { 250 - return STATUS.map((status) => { 251 - const value = item[status]; 252 - if (value === 0) return null; 253 - 254 - // const percentage = ((value / total) * 100).toFixed(1); 255 - 256 - const now = new Date(); 257 - const duration = formatDistanceStrict( 258 - now, 259 - new Date(now.getTime() + value * 60 * 1000), 260 - ); 261 - 262 - return ( 263 - <div key={status} className="flex items-baseline gap-4"> 264 - <div className="flex items-center gap-2"> 265 - <div 266 - className="h-2.5 w-2.5 rounded-sm" 267 - style={{ 268 - backgroundColor: chartConfig[status].color, 269 - }} 270 - /> 271 - <div className="text-sm">{messages.short[status]}</div> 272 - </div> 273 - <div className="ml-auto font-mono text-muted-foreground text-xs tracking-tight"> 274 - {duration} 275 - </div> 276 - </div> 277 - ); 278 - }); 279 - } 280 - 281 - function StatusTrackerContentDominant({ item }: { item: ChartData }) { 282 - const highestPriorityStatus = getHighestPriorityStatus(item); 319 + function StatusTrackerContent({ 320 + status, 321 + value, 322 + }: { 323 + status: "success" | "degraded" | "error" | "info" | "empty"; 324 + value: string; 325 + }) { 283 326 return ( 284 - <div className="flex min-w-32 items-baseline gap-4"> 327 + <div className="flex items-baseline gap-4"> 285 328 <div className="flex items-center gap-2"> 286 329 <div 287 330 className="h-2.5 w-2.5 rounded-sm" 288 331 style={{ 289 - backgroundColor: chartConfig[highestPriorityStatus].color, 332 + backgroundColor: chartConfig[status].color, 290 333 }} 291 334 /> 292 - <div className="text-sm">{messages.short[highestPriorityStatus]}</div> 335 + <div className="text-sm">{requests[status]}</div> 336 + </div> 337 + <div className="ml-auto font-mono text-muted-foreground text-xs tracking-tight"> 338 + {value} 293 339 </div> 294 340 </div> 295 341 ); 296 342 } 297 343 298 - function StatusTrackerContentRequests({ item }: { item: ChartData }) { 299 - return STATUS.map((status) => { 300 - const value = item[status]; 301 - if (value === 0) return null; 302 - 303 - return ( 304 - <div key={status} className="flex items-baseline gap-4"> 344 + function StatusTrackerEvent({ 345 + name, 346 + from, 347 + to, 348 + status, 349 + }: { 350 + name: string; 351 + from?: Date | null; 352 + to?: Date | null; 353 + status: "success" | "degraded" | "error" | "info" | "empty"; 354 + }) { 355 + if (!from) return null; 356 + const duration = to ? formatDistanceStrict(from, to) : "ongoing"; 357 + return ( 358 + <div className="group relative text-sm"> 359 + {/* NOTE: this is to make the text truncate based on the with of the sibling element */} 360 + {/* REMINDER: height needs to be equal the text height */} 361 + <div className="h-4 w-full" /> 362 + <div className="absolute inset-0 text-muted-foreground hover:text-foreground"> 305 363 <div className="flex items-center gap-2"> 306 364 <div 307 - className="h-2.5 w-2.5 rounded-sm" 365 + className="h-2.5 w-2.5 shrink-0 rounded-sm" 308 366 style={{ 309 367 backgroundColor: chartConfig[status].color, 310 368 }} 311 369 /> 312 - <div className="text-sm">{requests[status]}</div> 313 - </div> 314 - <div className="ml-auto font-mono text-muted-foreground text-xs tracking-tight"> 315 - {value} req 370 + <div className="truncate">{name}</div> 316 371 </div> 317 372 </div> 318 - ); 319 - }); 373 + <div className="mt-1 text-muted-foreground text-xs"> 374 + {formatDateRange(from, to ?? undefined)}{" "} 375 + <span className="ml-1.5 font-mono text-muted-foreground/70"> 376 + {duration} 377 + </span> 378 + </div> 379 + </div> 380 + ); 320 381 }
+80 -23
apps/status-page/src/components/status-page/status-updates.tsx
··· 1 + "use client"; 2 + 3 + import { FormSubscribeEmail } from "@/components/forms/form-subscribe-email"; 1 4 import { Button } from "@/components/ui/button"; 5 + import { Input } from "@/components/ui/input"; 2 6 import { 3 7 Popover, 4 8 PopoverContent, ··· 6 10 } from "@/components/ui/popover"; 7 11 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 8 12 import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 13 + import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; 9 14 import { cn } from "@/lib/utils"; 10 - import { Input } from "../ui/input"; 15 + import { Inbox } from "lucide-react"; 16 + import { useState } from "react"; 17 + 18 + type StatusUpdateType = "email" | "rss" | "atom"; 19 + 20 + interface StatusUpdatesProps extends React.ComponentProps<typeof Button> { 21 + types?: StatusUpdateType[]; 22 + onSubscribe?: (value: string) => Promise<void> | void; 23 + } 11 24 12 25 export function StatusUpdates({ 13 26 className, 27 + types = ["rss", "atom"], 28 + onSubscribe, 14 29 ...props 15 - }: React.ComponentProps<typeof Button>) { 30 + }: StatusUpdatesProps) { 31 + const [success, setSuccess] = useState(false); 32 + const prefix = usePathnamePrefix(); 33 + 16 34 return ( 17 35 <Popover> 18 36 <PopoverTrigger asChild> ··· 28 46 <PopoverContent align="end" className="overflow-hidden p-0"> 29 47 <Tabs defaultValue="email"> 30 48 <TabsList className="w-full rounded-none border-b"> 31 - <TabsTrigger value="email">Email</TabsTrigger> 32 - <TabsTrigger value="rss">RSS</TabsTrigger> 33 - <TabsTrigger value="atom">Atom</TabsTrigger> 49 + {types.includes("email") ? ( 50 + <TabsTrigger value="email">Email</TabsTrigger> 51 + ) : null} 52 + {types.includes("rss") ? ( 53 + <TabsTrigger value="rss">RSS</TabsTrigger> 54 + ) : null} 55 + {types.includes("atom") ? ( 56 + <TabsTrigger value="atom">Atom</TabsTrigger> 57 + ) : null} 34 58 </TabsList> 35 59 <TabsContent value="email" className="flex flex-col gap-2"> 36 - <div className="flex flex-col gap-2 border-b px-2 pb-2"> 37 - <p className="text-foreground text-sm"> 38 - Get email notifications whenever a report has been created or 39 - resolved 40 - </p> 41 - <Input placeholder="notify@me.com" /> 42 - </div> 43 - <div className="px-2 pb-2"> 44 - <Button className="w-full">Subscribe</Button> 45 - </div> 60 + {success ? ( 61 + <SuccessMessage /> 62 + ) : ( 63 + <> 64 + <div className="flex flex-col gap-2 border-b px-2 pb-2"> 65 + <p className="text-foreground text-sm"> 66 + Get email notifications whenever a report has been created 67 + or resolved 68 + </p> 69 + <FormSubscribeEmail 70 + id="email-form" 71 + onSubmit={async (values) => { 72 + await onSubscribe?.(values.email); 73 + setSuccess(true); 74 + }} 75 + /> 76 + </div> 77 + <div className="px-2 pb-2"> 78 + <Button className="w-full" type="submit" form="email-form"> 79 + Subscribe 80 + </Button> 81 + </div>{" "} 82 + </> 83 + )} 46 84 </TabsContent> 47 85 <TabsContent value="rss" className="flex flex-col gap-2"> 48 86 <div className="border-b px-2 pb-2"> 49 87 <Input 50 - placeholder="https://status.openstatus.dev/feed/rss" 88 + placeholder={`https://${prefix}.openstatus.dev/feed/rss`} 51 89 className="disabled:opacity-90" 52 90 disabled 53 91 /> ··· 55 93 <div className="px-2 pb-2"> 56 94 <CopyButton 57 95 className="w-full" 58 - value="https://status.openstatus.dev/feed/rss" 96 + value={`https://${prefix}.openstatus.dev/feed/rss`} 59 97 /> 60 98 </div> 61 99 </TabsContent> 62 100 <TabsContent value="atom" className="flex flex-col gap-2"> 63 101 <div className="border-b px-2 pb-2"> 64 102 <Input 65 - placeholder="https://status.openstatus.dev/feed/atom" 103 + placeholder={`https://${prefix}.openstatus.dev/feed/atom`} 66 104 className="disabled:opacity-90" 67 105 disabled 68 106 /> ··· 70 108 <div className="px-2 pb-2"> 71 109 <CopyButton 72 110 className="w-full" 73 - value="https://status.openstatus.dev/feed/atom" 111 + value={`https://${prefix}.openstatus.dev/feed/atom`} 74 112 /> 75 113 </div> 76 114 </TabsContent> ··· 82 120 83 121 function CopyButton({ 84 122 value, 85 - className, 86 - }: { 123 + onClick, 124 + ...props 125 + }: React.ComponentProps<typeof Button> & { 87 126 value: string; 88 - className?: string; 89 127 }) { 90 128 const { copy, isCopied } = useCopyToClipboard(); 91 129 return ( 92 - <Button size="sm" className={className} onClick={() => copy(value, {})}> 130 + <Button 131 + size="sm" 132 + onClick={(e) => { 133 + copy(value, {}); 134 + onClick?.(e); 135 + }} 136 + {...props} 137 + > 93 138 {isCopied ? "Copied" : "Copy link"} 94 139 </Button> 95 140 ); 96 141 } 142 + 143 + function SuccessMessage() { 144 + return ( 145 + <div className="flex flex-col items-center justify-center gap-1 p-3"> 146 + <Inbox className="size-4 shrink-0" /> 147 + <p className="text-center font-medium">Check your inbox!</p> 148 + <p className="text-center text-muted-foreground text-sm"> 149 + Validate your email to receive updates and you are all set. 150 + </p> 151 + </div> 152 + ); 153 + }
+1 -46
apps/status-page/src/components/status-page/status.tsx
··· 13 13 TriangleAlertIcon, 14 14 WrenchIcon, 15 15 } from "lucide-react"; 16 - import { messages } from "./messages"; 17 16 18 17 export function Status({ 19 18 children, ··· 97 96 return <div className={cn("flex flex-col gap-3", className)}>{children}</div>; 98 97 } 99 98 100 - export function StatusBanner({ className }: React.ComponentProps<"div">) { 101 - return ( 102 - <div 103 - className={cn( 104 - "flex items-center gap-3 rounded-lg border px-3 py-2", 105 - "group-data-[variant=success]:border-success/20 group-data-[variant=success]:bg-success/10", 106 - "group-data-[variant=degraded]:border-warning/20 group-data-[variant=degraded]:bg-warning/10", 107 - "group-data-[variant=error]:border-destructive/20 group-data-[variant=error]:bg-destructive/10", 108 - "group-data-[variant=info]:border-info/20 group-data-[variant=info]:bg-info/10", 109 - className, 110 - )} 111 - > 112 - <StatusIcon className="flex-shrink-0" /> 113 - <div className="flex flex-1 flex-wrap items-center justify-between gap-2"> 114 - <StatusBannerMessage className="font-semibold text-xl" /> 115 - <StatusTimestamp date={new Date()} className="text-xs" /> 116 - </div> 117 - </div> 118 - ); 119 - } 120 - 121 - export function StatusBannerMessage({ 122 - className, 123 - ...props 124 - }: React.ComponentProps<"div">) { 125 - return ( 126 - <div className={cn(className)} {...props}> 127 - <span className="hidden group-data-[variant=success]:block"> 128 - {messages.long.success} 129 - </span> 130 - <span className="hidden group-data-[variant=degraded]:block"> 131 - {messages.long.degraded} 132 - </span> 133 - <span className="hidden group-data-[variant=error]:block"> 134 - {messages.long.error} 135 - </span> 136 - <span className="hidden group-data-[variant=info]:block"> 137 - {messages.long.info} 138 - </span> 139 - </div> 140 - ); 141 - } 142 - 143 99 export function StatusIcon({ 144 100 className, 145 101 ...props ··· 172 128 return ( 173 129 <TooltipProvider> 174 130 <Tooltip> 175 - {/* TODO: add outline focus */} 176 131 <TooltipTrigger 177 132 className={cn( 178 133 "font-mono text-muted-foreground underline decoration-muted-foreground/30 decoration-dashed underline-offset-4", ··· 198 153 return ( 199 154 <div 200 155 className={cn( 201 - "flex flex-col items-center justify-center gap-0.5 rounded-lg border border-dashed px-3 py-2 text-center", 156 + "flex flex-col items-center justify-center gap-0.5 rounded-lg border border-dashed bg-muted/30 px-3 py-2 text-center sm:px-8 sm:py-6", 202 157 className, 203 158 )} 204 159 {...props}
+64 -2
apps/status-page/src/components/status-page/utils.ts
··· 1 1 import type { ChartConfig } from "@/components/ui/chart"; 2 - import { VARIANT } from "./floating-button"; 2 + import { VARIANT, type VariantType } from "./floating-button"; 3 3 4 4 export const chartData = Array.from({ length: 45 }, (_, i) => { 5 5 const date = new Date(); ··· 66 66 label: "info", 67 67 color: "var(--info)", 68 68 }, 69 + empty: { 70 + label: "empty", 71 + color: "var(--muted)", 72 + }, 69 73 } satisfies ChartConfig; 70 74 71 75 export const PRIORITY = { ··· 76 80 } as const; // satisfies Record<XXX, number>; 77 81 78 82 export function getHighestPriorityStatus(item: ChartData) { 83 + const total = item.success + item.degraded + item.info + item.error; 84 + if (total === 0) return "empty"; 79 85 return ( 80 86 VARIANT.filter((status) => item[status] > 0).sort( 81 87 (a, b) => PRIORITY[b] - PRIORITY[a], 82 - )[0] || "success" 88 + )[0] || "empty" 83 89 ); 84 90 } 91 + 92 + export const PERCENTAGE_PRIORITY = { 93 + info: -1, 94 + error: 0, 95 + degraded: 0.75, 96 + success: 0.95, 97 + } as const; 98 + 99 + export function getPercentagePriorityStatus(item: ChartData) { 100 + const total = item.success + item.degraded + item.info + item.error; 101 + if (total === 0) return "empty"; 102 + 103 + const percentage = item.success / total; 104 + if (percentage >= PERCENTAGE_PRIORITY.success) return "success"; 105 + if (percentage >= PERCENTAGE_PRIORITY.degraded) return "degraded"; 106 + if (percentage >= PERCENTAGE_PRIORITY.error) return "error"; 107 + if (percentage >= PERCENTAGE_PRIORITY.info) return "info"; 108 + return "info"; 109 + } 110 + 111 + export function getHighestStatus(items: VariantType[]) { 112 + if (items.some((item) => item === "error")) return "error"; 113 + if (items.some((item) => item === "degraded")) return "degraded"; 114 + if (items.some((item) => item === "info")) return "info"; 115 + return "success"; 116 + } 117 + 118 + export function getTotalUptime(item: ChartData[]) { 119 + const { ok, total } = item.reduce( 120 + (acc, item) => ({ 121 + ok: acc.ok + item.success + item.degraded + item.info, 122 + total: acc.total + item.success + item.degraded + item.info + item.error, 123 + }), 124 + { 125 + ok: 0, 126 + total: 0, 127 + }, 128 + ); 129 + 130 + if (total === 0) return 100; 131 + return Math.round((ok / total) * 10000) / 100; 132 + } 133 + 134 + export function getManualUptime( 135 + items: { from: Date | null; to: Date | null }[], 136 + days: number, 137 + ) { 138 + const duration = items.reduce((acc, item) => { 139 + if (!item.from) return acc; 140 + return acc + ((item.to || new Date()).getTime() - item.from.getTime()); 141 + }, 0); 142 + 143 + const total = days * 24 * 60 * 60 * 1000; 144 + 145 + return Math.round(((total - duration) / total) * 10000) / 100; 146 + }
+25
apps/status-page/src/hooks/use-pathname-prefix.ts
··· 1 + "use client"; 2 + 3 + import { useEffect, useState } from "react"; 4 + 5 + export function usePathnamePrefix() { 6 + const [prefix, setPrefix] = useState(""); 7 + 8 + useEffect(() => { 9 + if (typeof window !== "undefined") { 10 + const hostnames = window.location.hostname.split("."); 11 + const pathnames = window.location.pathname.split("/"); 12 + if ( 13 + hostnames.length > 2 && 14 + hostnames[0] !== "www" && 15 + !window.location.hostname.endsWith(".vercel.app") 16 + ) { 17 + setPrefix(hostnames[0]); 18 + } else { 19 + setPrefix(pathnames[1]); 20 + } 21 + } 22 + }, []); 23 + 24 + return prefix; 25 + }
+8
apps/status-page/src/lib/formatter.ts
··· 15 15 }).format(ms)}`; 16 16 } 17 17 18 + export function formatMillisecondsRange(min: number, max: number) { 19 + if ((min > 1000 && max > 1000) || (min < 1000 && max < 1000)) { 20 + return `${formatNumber(min / 1000)} - ${formatMilliseconds(max)}`; 21 + } 22 + 23 + return `${formatMilliseconds(min)} - ${formatMilliseconds(max)}`; 24 + } 25 + 18 26 export function formatPercentage(value: number) { 19 27 if (Number.isNaN(value)) return "100%"; 20 28 return `${Intl.NumberFormat("en-US", {
+3
apps/status-page/src/lib/protected.ts
··· 1 + export function createProtectedCookieKey(value: string) { 2 + return `secured-${value}`; 3 + }
+2 -2
apps/status-page/src/lib/trpc/shared.ts
··· 7 7 const getBaseUrl = () => { 8 8 if (typeof window !== "undefined") return ""; 9 9 const vc = process.env.VERCEL_URL; 10 - // if (vc) return `https://${vc}`; 11 - if (vc) return "https://app.openstatus.dev"; 10 + if (vc) return `https://${vc}`; 11 + // if (vc) return "https://app.openstatus.dev"; 12 12 return "http://localhost:3000"; 13 13 }; 14 14
+69
apps/status-page/src/middleware.ts
··· 1 + import { type NextRequest, NextResponse } from "next/server"; 2 + 3 + import { db, eq } from "@openstatus/db"; 4 + import { page } from "@openstatus/db/src/schema"; 5 + import { createProtectedCookieKey } from "./lib/protected"; 6 + 7 + export default async function middleware(req: NextRequest) { 8 + const url = req.nextUrl.clone(); 9 + const response = NextResponse.next(); 10 + const cookies = req.cookies; 11 + 12 + let prefix = ""; 13 + let type: "hostname" | "pathname"; 14 + 15 + const hostnames = url.host.split("."); 16 + const pathnames = url.pathname.split("/"); 17 + if ( 18 + hostnames.length > 2 && 19 + hostnames[0] !== "www" && 20 + !url.host.endsWith(".vercel.app") 21 + ) { 22 + prefix = hostnames[0].toLowerCase(); 23 + type = "hostname"; 24 + } else { 25 + prefix = pathnames[1].toLowerCase(); 26 + type = "pathname"; 27 + } 28 + 29 + if (url.pathname === "/") { 30 + return response; 31 + } 32 + 33 + const _page = await db.select().from(page).where(eq(page.slug, prefix)).get(); 34 + 35 + if (!_page) { 36 + return NextResponse.redirect(new URL("https://openstatus.dev")); 37 + } 38 + 39 + if (_page?.passwordProtected) { 40 + const protectedCookie = cookies.get(createProtectedCookieKey(prefix)); 41 + const password = protectedCookie ? protectedCookie.value : undefined; 42 + if (password !== _page.password && !url.pathname.endsWith("/protected")) { 43 + const url = new URL( 44 + `${req.nextUrl.origin}${ 45 + type === "pathname" ? `/${prefix}` : "" 46 + }/protected?redirect=${encodeURIComponent(req.url)}`, 47 + ); 48 + return NextResponse.redirect(url); 49 + } 50 + if (password === _page.password && url.pathname.endsWith("/protected")) { 51 + const redirect = url.searchParams.get("redirect"); 52 + return NextResponse.redirect( 53 + new URL( 54 + `${req.nextUrl.origin}${ 55 + redirect ?? type === "pathname" ? `/${prefix}` : "/" 56 + }`, 57 + ), 58 + ); 59 + } 60 + } 61 + 62 + return response; 63 + } 64 + 65 + export const config = { 66 + matcher: [ 67 + "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", 68 + ], 69 + };
+1
apps/web/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 + /// <reference types="next/navigation-types/compat/navigation" /> 3 4 4 5 // NOTE: This file should not be edited 5 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
apps/web/src/app/api/og/monitor/route.tsx
··· 32 32 // TODO: we need to pass the monitor type here 33 33 34 34 const res = (monitorId && 35 - (await tb.httpStatus45d({ 35 + (await tb.legacy_httpStatus45d({ 36 36 monitorId, 37 37 }))) || { data: [] }; 38 38
+1 -1
apps/web/src/components/data-table/status-page/columns.tsx
··· 26 26 export const columns: ColumnDef< 27 27 Page & { 28 28 monitorsToPages: { monitor: { name: string } }[]; 29 - maintenancesToPages: Maintenance[]; // we get only the active maintenances! 29 + maintenances: Maintenance[]; // we get only the active maintenances! 30 30 statusReports: (StatusReport & { 31 31 statusReportUpdates: StatusReportUpdate[]; 32 32 })[];
+2 -2
apps/web/src/lib/tb.ts
··· 167 167 } 168 168 case "45d": { 169 169 const getData = { 170 - http: tb.httpStatus45d, 171 - tcp: tb.tcpStatus45d, 170 + http: tb.legacy_httpStatus45d, 171 + tcp: tb.legacy_tcpStatus45d, 172 172 } as const; 173 173 return { getData: getData[type] }; 174 174 }
+2
packages/api/src/edge.ts
··· 13 13 import { notificationRouter } from "./router/notification"; 14 14 import { pageRouter } from "./router/page"; 15 15 import { pageSubscriberRouter } from "./router/pageSubscriber"; 16 + import { statusPageRouter } from "./router/statusPage"; 16 17 import { statusReportRouter } from "./router/statusReport"; 17 18 import { tinybirdRouter } from "./router/tinybird"; 18 19 import { userRouter } from "./router/user"; ··· 40 41 checker: checkerRouter, 41 42 blob: blobRouter, 42 43 feedback: feedbackRouter, 44 + statusPage: statusPageRouter, 43 45 });
+26
packages/api/src/router/email/index.ts
··· 92 92 }); 93 93 } 94 94 }), 95 + 96 + sendPageSubscription: protectedProcedure 97 + .input(z.object({ id: z.number() })) 98 + .mutation(async (opts) => { 99 + const limits = opts.ctx.workspace.limits; 100 + 101 + if (limits["status-subscribers"]) { 102 + const _pageSubscriber = 103 + await opts.ctx.db.query.pageSubscriber.findFirst({ 104 + where: eq(pageSubscriber.id, opts.input.id), 105 + with: { 106 + page: true, 107 + }, 108 + }); 109 + 110 + if (!_pageSubscriber || !_pageSubscriber.token) return; 111 + 112 + await emailClient.sendPageSubscription({ 113 + to: _pageSubscriber.email, 114 + token: _pageSubscriber.token, 115 + page: _pageSubscriber.page.title, 116 + // TODO: or use custom domain 117 + domain: _pageSubscriber.page.slug, 118 + }); 119 + } 120 + }), 95 121 });
+1
packages/api/src/router/page.test.ts
··· 23 23 statusReports: expect.any(Array), 24 24 monitors: expect.any(Array), 25 25 incidents: expect.any(Array), 26 + maintenances: expect.any(Array), 26 27 published: expect.any(Boolean), 27 28 slug: expect.any(String), 28 29 title: expect.any(String),
+8 -11
packages/api/src/router/page.ts
··· 15 15 import { 16 16 incidentTable, 17 17 insertPageSchema, 18 + legacy_selectPublicPageSchemaWithRelation, 18 19 maintenance, 19 20 monitor, 20 21 monitorsToPages, ··· 23 24 selectMonitorSchema, 24 25 selectPageSchema, 25 26 selectPageSchemaWithMonitorsRelation, 26 - selectPublicPageSchemaWithRelation, 27 27 statusReport, 28 28 subdomainSafeList, 29 29 workspace, ··· 262 262 where: and(eq(page.workspaceId, opts.ctx.workspace.id)), 263 263 with: { 264 264 monitorsToPages: { with: { monitor: true } }, 265 - maintenancesToPages: { 265 + maintenances: { 266 266 where: and( 267 267 lte(maintenance.from, new Date()), 268 268 gte(maintenance.to, new Date()), ··· 278 278 }, 279 279 }, 280 280 }); 281 - console.log(allPages.map((page) => page.statusReports)); 282 281 return z.array(selectPageSchemaWithMonitorsRelation).parse(allPages); 283 282 }), 284 283 285 284 // public if we use trpc hooks to get the page from the url 286 285 getPageBySlug: publicProcedure 287 286 .input(z.object({ slug: z.string().toLowerCase() })) 288 - .output(selectPublicPageSchemaWithRelation.optional()) 287 + .output(legacy_selectPublicPageSchemaWithRelation.nullish()) 289 288 .query(async (opts) => { 290 289 if (!opts.input.slug) return; 291 290 ··· 297 296 ) 298 297 .get(); 299 298 300 - if (!result) { 301 - return; 302 - } 299 + if (!result) return; 303 300 304 301 const [workspaceResult, monitorsToPagesResult] = await Promise.all([ 305 302 opts.ctx.db ··· 354 351 355 352 const maintenancesQuery = opts.ctx.db.query.maintenance.findMany({ 356 353 where: eq(maintenance.pageId, result.id), 357 - with: { maintenancesToMonitors: true }, 354 + with: { maintenancesToMonitors: { with: { monitor: true } } }, 358 355 orderBy: (maintenances, { desc }) => desc(maintenances.from), 359 356 }); 360 357 ··· 373 370 incidentsQuery, 374 371 ]); 375 372 376 - return selectPublicPageSchemaWithRelation.parse({ 373 + return legacy_selectPublicPageSchemaWithRelation.parse({ 377 374 ...result, 378 375 // TODO: improve performance and move into SQLite query 379 376 monitors: monitors.sort((a, b) => { ··· 482 479 where: and(...whereConditions), 483 480 with: { 484 481 monitorsToPages: { with: { monitor: true } }, 485 - maintenancesToPages: true, 482 + maintenances: true, 486 483 }, 487 484 }); 488 485 ··· 499 496 ...m.monitor, 500 497 order: m.order, 501 498 })), 502 - maintenances: data?.maintenancesToPages, 499 + maintenances: data?.maintenances, 503 500 }); 504 501 }), 505 502
+637
packages/api/src/router/statusPage.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { and, eq, inArray, sql } from "@openstatus/db"; 4 + import { 5 + maintenance, 6 + monitorsToPages, 7 + page, 8 + pageSubscriber, 9 + selectPublicMonitorSchema, 10 + selectPublicPageSchemaWithRelation, 11 + statusReport, 12 + } from "@openstatus/db/src/schema"; 13 + 14 + import { TRPCError } from "@trpc/server"; 15 + import { createTRPCRouter, publicProcedure } from "../trpc"; 16 + import { 17 + fillStatusDataFor45Days, 18 + fillStatusDataFor45DaysNoop, 19 + getEvents, 20 + getUptime, 21 + setDataByType, 22 + } from "./statusPage.utils"; 23 + import { 24 + getMetricsLatencyMultiProcedure, 25 + getMetricsLatencyProcedure, 26 + getMetricsRegionsProcedure, 27 + getStatusProcedure, 28 + getUptimeProcedure, 29 + } from "./tinybird"; 30 + 31 + // NOTE: publicProcedure is used to get the status page 32 + // TODO: improve performance of SQL query (make a single query with joins) 33 + 34 + // IMPORTANT: we cannot use the tinybird procedure because it has protectedProcedure 35 + // instead, we should add TB logic in here!!!! 36 + 37 + // NOTE: this router is used on status pages only - do not confuse with the page router which is used in the dashboard for the config 38 + 39 + export const statusPageRouter = createTRPCRouter({ 40 + get: publicProcedure 41 + .input(z.object({ slug: z.string().toLowerCase() })) 42 + .output(selectPublicPageSchemaWithRelation.nullish()) 43 + .query(async (opts) => { 44 + if (!opts.input.slug) return null; 45 + 46 + const _page = await opts.ctx.db.query.page.findFirst({ 47 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 48 + with: { 49 + workspace: true, 50 + statusReports: { 51 + orderBy: (reports, { desc }) => desc(reports.createdAt), 52 + with: { 53 + statusReportUpdates: { 54 + orderBy: (reports, { desc }) => desc(reports.date), 55 + }, 56 + monitorsToStatusReports: { with: { monitor: true } }, 57 + }, 58 + }, 59 + maintenances: { 60 + with: { 61 + maintenancesToMonitors: { with: { monitor: true } }, 62 + }, 63 + orderBy: (maintenances, { desc }) => desc(maintenances.from), 64 + }, 65 + monitorsToPages: { 66 + with: { 67 + monitor: { 68 + with: { 69 + incidents: true, 70 + }, 71 + }, 72 + }, 73 + orderBy: (monitorsToPages, { asc }) => asc(monitorsToPages.order), 74 + }, 75 + }, 76 + }); 77 + 78 + if (!_page) return null; 79 + 80 + const monitors = _page.monitorsToPages 81 + // NOTE: we cannot nested `where` in drizzle to filter active monitors 82 + .filter((m) => m.monitor.active && !m.monitor.deletedAt) 83 + .map((m) => { 84 + const events = getEvents({ 85 + maintenances: _page.maintenances, 86 + incidents: m.monitor.incidents, 87 + reports: _page.statusReports, 88 + monitorId: m.monitor.id, 89 + }); 90 + const status = events.some((e) => e.type === "incident" && !e.to) 91 + ? "error" 92 + : events.some((e) => e.type === "report" && !e.to) 93 + ? "degraded" 94 + : events.some( 95 + (e) => 96 + e.type === "maintenance" && 97 + e.to && 98 + e.from.getTime() <= new Date().getTime() && 99 + e.to.getTime() >= new Date().getTime(), 100 + ) 101 + ? "info" 102 + : "success"; 103 + return { ...m.monitor, status, events }; 104 + }); 105 + 106 + const status = monitors.some((m) => m.status === "error") 107 + ? "error" 108 + : monitors.some((m) => m.status === "degraded") 109 + ? "degraded" 110 + : monitors.some((m) => m.status === "info") 111 + ? "info" 112 + : "success"; 113 + 114 + // Get page-wide events (not tied to specific monitors) 115 + const pageEvents = getEvents({ 116 + maintenances: _page.maintenances, 117 + incidents: 118 + _page.monitorsToPages.flatMap((m) => m.monitor.incidents) ?? [], 119 + reports: _page.statusReports, 120 + // No monitorId provided, so we get all events for the page 121 + }); 122 + 123 + const threshold = new Date().getTime() - 7 * 24 * 60 * 60 * 1000; 124 + const lastEvents = pageEvents 125 + .filter((e) => { 126 + if (e.type !== "incident") return false; 127 + if (!e.to || e.to.getTime() >= threshold) return true; 128 + return false; 129 + }) 130 + .sort((a, b) => a.from.getTime() - b.from.getTime()); 131 + 132 + const openEvents = pageEvents.filter((event) => { 133 + console.log(event.type, event.from, event.to); 134 + if (event.type === "incident" || event.type === "report") { 135 + if (!event.to) return true; 136 + if (event.to < new Date()) return false; 137 + return false; 138 + } 139 + if (event.type === "maintenance") { 140 + if (!event.to) return false; // NOTE: this never happens 141 + if (event.from <= new Date() && event.to >= new Date()) return true; 142 + return false; 143 + } 144 + return false; 145 + }); 146 + 147 + return selectPublicPageSchemaWithRelation.parse({ 148 + ..._page, 149 + monitors, 150 + incidents: monitors.flatMap((m) => m.incidents) ?? [], 151 + statusReports: _page.statusReports ?? [], 152 + maintenances: _page.maintenances ?? [], 153 + workspacePlan: _page.workspace.plan, 154 + status, 155 + lastEvents, 156 + openEvents, 157 + }); 158 + }), 159 + 160 + getMaintenance: publicProcedure 161 + .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 162 + .query(async (opts) => { 163 + if (!opts.input.slug) return null; 164 + 165 + const _page = await opts.ctx.db 166 + .select() 167 + .from(page) 168 + .where( 169 + sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 170 + ) 171 + .get(); 172 + 173 + if (!_page) return null; 174 + 175 + const _maintenance = await opts.ctx.db.query.maintenance.findFirst({ 176 + where: and( 177 + eq(maintenance.id, opts.input.id), 178 + eq(maintenance.pageId, _page.id), 179 + ), 180 + with: { maintenancesToMonitors: { with: { monitor: true } } }, 181 + }); 182 + 183 + if (!_maintenance) return null; 184 + 185 + return _maintenance; 186 + }), 187 + 188 + getUptime: publicProcedure 189 + .input( 190 + z.object({ 191 + slug: z.string().toLowerCase(), 192 + monitorIds: z.string().array(), 193 + cardType: z 194 + .enum(["requests", "duration", "dominant", "manual"]) 195 + .default("requests"), 196 + barType: z.enum(["absolute", "dominant", "manual"]).default("dominant"), 197 + }), 198 + ) 199 + .query(async (opts) => { 200 + if (!opts.input.slug) return null; 201 + 202 + const _page = await opts.ctx.db.query.page.findFirst({ 203 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 204 + with: { 205 + maintenances: { 206 + with: { 207 + maintenancesToMonitors: true, 208 + }, 209 + }, 210 + statusReports: { 211 + with: { 212 + monitorsToStatusReports: true, 213 + statusReportUpdates: true, 214 + }, 215 + }, 216 + monitorsToPages: { 217 + where: inArray( 218 + monitorsToPages.monitorId, 219 + opts.input.monitorIds.map(Number), 220 + ), 221 + with: { 222 + monitor: { 223 + with: { 224 + incidents: true, 225 + }, 226 + }, 227 + }, 228 + }, 229 + }, 230 + }); 231 + 232 + if (!_page) return null; 233 + 234 + const monitors = _page.monitorsToPages.filter( 235 + (m) => m.monitor.active && !m.monitor.deletedAt, 236 + ); 237 + 238 + if (monitors.length !== opts.input.monitorIds.length) return null; 239 + 240 + const monitorsByType = { 241 + http: monitors.filter((m) => m.monitor.jobType === "http"), 242 + tcp: monitors.filter((m) => m.monitor.jobType === "tcp"), 243 + }; 244 + 245 + const proceduresByType = { 246 + http: getStatusProcedure("45d", "http"), 247 + tcp: getStatusProcedure("45d", "tcp"), 248 + }; 249 + 250 + const [statusHttp, statusTcp] = await Promise.all( 251 + Object.entries(proceduresByType).map(([type, procedure]) => { 252 + const monitorIds = monitorsByType[ 253 + type as keyof typeof proceduresByType 254 + ].map((m) => m.monitor.id.toString()); 255 + if (monitorIds.length === 0) return null; 256 + // NOTE: if manual mode, don't fetch data from tinybird 257 + return opts.input.barType === "manual" 258 + ? null 259 + : procedure({ monitorIds }); 260 + }), 261 + ); 262 + 263 + const statusDataByMonitorId = new Map< 264 + string, 265 + | Awaited<ReturnType<(typeof proceduresByType)["http"]>>["data"] 266 + | Awaited<ReturnType<(typeof proceduresByType)["tcp"]>>["data"] 267 + >(); 268 + 269 + if (statusHttp?.data) { 270 + statusHttp.data.forEach((status) => { 271 + const monitorId = status.monitorId; 272 + if (!statusDataByMonitorId.has(monitorId)) { 273 + statusDataByMonitorId.set(monitorId, []); 274 + } 275 + statusDataByMonitorId.get(monitorId)?.push(status); 276 + }); 277 + } 278 + 279 + if (statusTcp?.data) { 280 + statusTcp.data.forEach((status) => { 281 + const monitorId = status.monitorId; 282 + if (!statusDataByMonitorId.has(monitorId)) { 283 + statusDataByMonitorId.set(monitorId, []); 284 + } 285 + statusDataByMonitorId.get(monitorId)?.push(status); 286 + }); 287 + } 288 + 289 + return monitors.map((m) => { 290 + const monitorId = m.monitor.id.toString(); 291 + const events = getEvents({ 292 + maintenances: _page.maintenances, 293 + incidents: m.monitor.incidents, 294 + reports: _page.statusReports, 295 + monitorId: m.monitor.id, 296 + }); 297 + const rawData = statusDataByMonitorId.get(monitorId) || []; 298 + const filledData = fillStatusDataFor45Days(rawData, monitorId); 299 + const processedData = setDataByType({ 300 + events, 301 + data: filledData, 302 + cardType: opts.input.cardType, 303 + barType: opts.input.barType, 304 + }); 305 + const uptime = getUptime({ 306 + data: filledData, 307 + events, 308 + barType: opts.input.barType, 309 + }); 310 + 311 + return { 312 + ...selectPublicMonitorSchema.parse(m.monitor), 313 + data: processedData, 314 + uptime, 315 + }; 316 + }); 317 + }), 318 + 319 + // NOTE: used for the theme store 320 + getNoopUptime: publicProcedure.query(async () => { 321 + const data = fillStatusDataFor45DaysNoop(); 322 + const processedData = setDataByType({ 323 + events: [ 324 + { 325 + type: "maintenance", 326 + from: new Date(new Date().setDate(new Date().getDate() - 10)), 327 + to: new Date(new Date().setDate(new Date().getDate() - 10)), 328 + name: "", 329 + id: 1, 330 + status: "info", 331 + }, 332 + ], 333 + data, 334 + cardType: "requests", 335 + barType: "dominant", 336 + }); 337 + return { 338 + data: processedData, 339 + uptime: "100%", 340 + }; 341 + }), 342 + 343 + getReport: publicProcedure 344 + .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 345 + .query(async (opts) => { 346 + if (!opts.input.slug) return null; 347 + 348 + const _page = await opts.ctx.db 349 + .select() 350 + .from(page) 351 + .where( 352 + sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 353 + ) 354 + .get(); 355 + 356 + if (!_page) return null; 357 + 358 + const _report = await opts.ctx.db.query.statusReport.findFirst({ 359 + where: and( 360 + eq(statusReport.id, opts.input.id), 361 + eq(statusReport.pageId, _page.id), 362 + ), 363 + with: { 364 + monitorsToStatusReports: { with: { monitor: true } }, 365 + statusReportUpdates: { 366 + orderBy: (reports, { desc }) => desc(reports.date), 367 + }, 368 + }, 369 + }); 370 + 371 + if (!_report) return null; 372 + 373 + return _report; 374 + }), 375 + 376 + getMonitors: publicProcedure 377 + .input(z.object({ slug: z.string().toLowerCase() })) 378 + .query(async (opts) => { 379 + if (!opts.input.slug) return null; 380 + 381 + // NOTE: revalidate the public monitors first 382 + const data = await opts.ctx.db.query.page.findFirst({ 383 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 384 + with: { 385 + monitorsToPages: { 386 + with: { 387 + monitor: true, 388 + }, 389 + }, 390 + }, 391 + }); 392 + 393 + if (!data) return null; 394 + 395 + const publicMonitors = data.monitorsToPages.filter( 396 + (m) => m.monitor.public, 397 + ); 398 + 399 + const monitorsByType = { 400 + http: publicMonitors.filter((m) => m.monitor.jobType === "http"), 401 + tcp: publicMonitors.filter((m) => m.monitor.jobType === "tcp"), 402 + }; 403 + 404 + const proceduresByType = { 405 + http: getMetricsLatencyMultiProcedure("1d", "http"), 406 + tcp: getMetricsLatencyMultiProcedure("1d", "tcp"), 407 + }; 408 + 409 + const [metricsLatencyMultiHttp, metricsLatencyMultiTcp] = 410 + await Promise.all( 411 + Object.entries(proceduresByType).map(([type, procedure]) => { 412 + const monitorIds = monitorsByType[ 413 + type as keyof typeof proceduresByType 414 + ].map((m) => m.monitor.id.toString()); 415 + if (monitorIds.length === 0) return null; 416 + return procedure({ monitorIds }); 417 + }), 418 + ); 419 + 420 + const metricsDataByMonitorId = new Map< 421 + string, 422 + | Awaited<ReturnType<(typeof proceduresByType)["http"]>>["data"] 423 + | Awaited<ReturnType<(typeof proceduresByType)["tcp"]>>["data"] 424 + >(); 425 + 426 + if (metricsLatencyMultiHttp?.data) { 427 + metricsLatencyMultiHttp.data.forEach((metric) => { 428 + const monitorId = metric.monitorId; 429 + if (!metricsDataByMonitorId.has(monitorId)) { 430 + metricsDataByMonitorId.set(monitorId, []); 431 + } 432 + metricsDataByMonitorId.get(monitorId)?.push(metric); 433 + }); 434 + } 435 + 436 + if (metricsLatencyMultiTcp?.data) { 437 + metricsLatencyMultiTcp.data.forEach((metric) => { 438 + const monitorId = metric.monitorId; 439 + if (!metricsDataByMonitorId.has(monitorId)) { 440 + metricsDataByMonitorId.set(monitorId, []); 441 + } 442 + metricsDataByMonitorId.get(monitorId)?.push(metric); 443 + }); 444 + } 445 + 446 + return publicMonitors.map((m) => { 447 + const monitorId = m.monitor.id.toString(); 448 + const data = metricsDataByMonitorId.get(monitorId) || []; 449 + 450 + return { 451 + ...selectPublicMonitorSchema.parse(m.monitor), 452 + data, 453 + }; 454 + }); 455 + }), 456 + 457 + getMonitor: publicProcedure 458 + .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) 459 + .query(async (opts) => { 460 + if (!opts.input.slug) return null; 461 + 462 + const _page = await opts.ctx.db.query.page.findFirst({ 463 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 464 + with: { 465 + monitorsToPages: { 466 + where: eq(monitorsToPages.monitorId, opts.input.id), 467 + with: { 468 + monitor: true, 469 + }, 470 + }, 471 + }, 472 + }); 473 + 474 + if (!_page) return null; 475 + 476 + const _monitor = _page.monitorsToPages.find( 477 + (m) => m.monitorId === opts.input.id, 478 + )?.monitor; 479 + 480 + if (!_monitor) return null; 481 + if (!_monitor.public) return null; 482 + if (_monitor.deletedAt) return null; 483 + 484 + const type = _monitor.jobType as "http" | "tcp"; 485 + 486 + const proceduresByType = { 487 + http: { 488 + latency: getMetricsLatencyProcedure("7d", "http"), 489 + regions: getMetricsRegionsProcedure("7d", "http"), 490 + uptime: getUptimeProcedure("7d", "http"), 491 + }, 492 + tcp: { 493 + latency: getMetricsLatencyProcedure("7d", "tcp"), 494 + regions: getMetricsRegionsProcedure("7d", "tcp"), 495 + uptime: getUptimeProcedure("7d", "tcp"), 496 + }, 497 + }; 498 + 499 + const [latency, regions, uptime] = await Promise.all([ 500 + await proceduresByType[type].latency({ 501 + monitorId: _monitor.id.toString(), 502 + }), 503 + await proceduresByType[type].regions({ 504 + monitorId: _monitor.id.toString(), 505 + }), 506 + await proceduresByType[type].uptime({ 507 + monitorId: _monitor.id.toString(), 508 + interval: 240, 509 + }), 510 + ]); 511 + 512 + return { 513 + ...selectPublicMonitorSchema.parse(_monitor), 514 + data: { 515 + latency, 516 + regions, 517 + uptime, 518 + }, 519 + }; 520 + }), 521 + 522 + subscribe: publicProcedure 523 + .input( 524 + z.object({ slug: z.string().toLowerCase(), email: z.string().email() }), 525 + ) 526 + .mutation(async (opts) => { 527 + if (!opts.input.slug) return null; 528 + 529 + const _page = await opts.ctx.db.query.page.findFirst({ 530 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 531 + with: { 532 + workspace: true, 533 + }, 534 + }); 535 + 536 + if (!_page) return null; 537 + 538 + if (_page.workspace.plan === "free") return null; 539 + 540 + const _alreadySubscribed = 541 + await opts.ctx.db.query.pageSubscriber.findFirst({ 542 + where: and( 543 + eq(pageSubscriber.pageId, _page.id), 544 + eq(pageSubscriber.email, opts.input.email), 545 + ), 546 + }); 547 + 548 + if (_alreadySubscribed) { 549 + throw new TRPCError({ 550 + code: "BAD_REQUEST", 551 + message: "Email already subscribed", 552 + }); 553 + } 554 + 555 + const _pageSubscriber = await opts.ctx.db 556 + .insert(pageSubscriber) 557 + .values({ 558 + pageId: _page.id, 559 + email: opts.input.email, 560 + token: crypto.randomUUID(), 561 + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), 562 + }) 563 + .returning() 564 + .get(); 565 + 566 + return _pageSubscriber.id; 567 + }), 568 + 569 + verifyEmail: publicProcedure 570 + .input(z.object({ slug: z.string().toLowerCase(), token: z.string() })) 571 + .mutation(async (opts) => { 572 + if (!opts.input.slug) return null; 573 + 574 + const _page = await opts.ctx.db.query.page.findFirst({ 575 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 576 + }); 577 + 578 + if (!_page) return null; 579 + 580 + const _pageSubscriber = await opts.ctx.db.query.pageSubscriber.findFirst({ 581 + where: and( 582 + eq(pageSubscriber.token, opts.input.token), 583 + eq(pageSubscriber.pageId, _page.id), 584 + ), 585 + }); 586 + 587 + if (_pageSubscriber?.acceptedAt) { 588 + throw new TRPCError({ 589 + code: "BAD_REQUEST", 590 + message: "Email already verified", 591 + }); 592 + } 593 + 594 + if (!_pageSubscriber) { 595 + throw new TRPCError({ 596 + code: "NOT_FOUND", 597 + message: "Subscription not found", 598 + }); 599 + } 600 + 601 + await opts.ctx.db 602 + .update(pageSubscriber) 603 + .set({ 604 + acceptedAt: new Date(), 605 + }) 606 + .where(eq(pageSubscriber.id, _pageSubscriber.id)) 607 + .execute(); 608 + 609 + return _pageSubscriber; 610 + }), 611 + 612 + verifyPassword: publicProcedure 613 + .input(z.object({ slug: z.string().toLowerCase(), password: z.string() })) 614 + .mutation(async (opts) => { 615 + if (!opts.input.slug) return null; 616 + 617 + const _page = await opts.ctx.db.query.page.findFirst({ 618 + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, 619 + }); 620 + 621 + if (!_page) { 622 + throw new TRPCError({ 623 + code: "NOT_FOUND", 624 + message: "Page not found", 625 + }); 626 + } 627 + 628 + if (_page.password !== opts.input.password) { 629 + throw new TRPCError({ 630 + code: "BAD_REQUEST", 631 + message: "Invalid password", 632 + }); 633 + } 634 + 635 + return true; 636 + }), 637 + });
+581
packages/api/src/router/statusPage.utils.ts
··· 1 + import type { 2 + Incident, 3 + Maintenance, 4 + StatusReport, 5 + StatusReportUpdate, 6 + } from "@openstatus/db/src/schema"; 7 + 8 + type StatusData = { 9 + day: string; 10 + count: number; 11 + ok: number; 12 + degraded: number; 13 + error: number; 14 + monitorId: string; 15 + }; 16 + 17 + export function fillStatusDataFor45Days( 18 + data: Array<StatusData>, 19 + monitorId: string, 20 + ): Array<StatusData> { 21 + const result = []; 22 + const dataByDay = new Map(); 23 + 24 + // Index existing data by day 25 + data.forEach((item) => { 26 + const dayKey = new Date(item.day).toISOString().split("T")[0]; // YYYY-MM-DD format 27 + dataByDay.set(dayKey, item); 28 + }); 29 + 30 + // Generate all 45 days from today backwards 31 + const now = new Date(); 32 + for (let i = 0; i < 45; i++) { 33 + const date = new Date(now); 34 + date.setUTCDate(date.getUTCDate() - i); 35 + date.setUTCHours(0, 0, 0, 0); // Set to start of day in UTC 36 + 37 + const dayKey = date.toISOString().split("T")[0]; // YYYY-MM-DD format 38 + const isoString = date.toISOString(); 39 + 40 + if (dataByDay.has(dayKey)) { 41 + // Use existing data but ensure the day is properly formatted 42 + const existingData = dataByDay.get(dayKey); 43 + result.push({ 44 + ...existingData, 45 + day: isoString, 46 + }); 47 + } else { 48 + // Fill missing day with default values 49 + result.push({ 50 + day: isoString, 51 + count: 0, 52 + ok: 0, 53 + degraded: 0, 54 + error: 0, 55 + monitorId, 56 + }); 57 + } 58 + } 59 + 60 + // Sort by day (oldest first) 61 + return result.sort( 62 + (a, b) => new Date(a.day).getTime() - new Date(b.day).getTime(), 63 + ); 64 + } 65 + 66 + export function fillStatusDataFor45DaysNoop(): Array<StatusData> { 67 + const data: StatusData[] = Array.from({ length: 45 }, (_, i) => ({ 68 + day: new Date(new Date().setDate(new Date().getDate() - i)).toISOString(), 69 + count: 1, 70 + ok: [4, 40].includes(i) ? 0 : 1, 71 + degraded: i === 40 ? 1 : 0, 72 + error: i === 4 ? 1 : 0, 73 + monitorId: "1", 74 + })); 75 + return fillStatusDataFor45Days(data, "1"); 76 + } 77 + 78 + type Event = { 79 + id: number; 80 + name: string; 81 + from: Date; 82 + to: Date | null; 83 + type: "maintenance" | "incident" | "report"; 84 + status: "success" | "degraded" | "error" | "info"; 85 + }; 86 + 87 + export function getEvents({ 88 + maintenances, 89 + incidents, 90 + reports, 91 + monitorId, 92 + pastDays = 45, 93 + }: { 94 + maintenances: (Maintenance & { 95 + maintenancesToMonitors: { monitorId: number }[]; 96 + })[]; 97 + incidents: Incident[]; 98 + reports: (StatusReport & { 99 + monitorsToStatusReports: { monitorId: number }[]; 100 + statusReportUpdates: StatusReportUpdate[]; 101 + })[]; 102 + monitorId?: number; 103 + pastDays?: number; 104 + }): Event[] { 105 + const events: Event[] = []; 106 + const pastThreshod = new Date(); 107 + pastThreshod.setDate(pastThreshod.getDate() - pastDays); 108 + 109 + // Filter maintenances - if monitorId is provided, filter by monitor, otherwise include all 110 + maintenances 111 + .filter((maintenance) => 112 + monitorId 113 + ? maintenance.maintenancesToMonitors.some( 114 + (m) => m.monitorId === monitorId, 115 + ) 116 + : true, 117 + ) 118 + .forEach((maintenance) => { 119 + if (maintenance.from < pastThreshod) return; 120 + events.push({ 121 + id: maintenance.id, 122 + name: maintenance.title, 123 + from: maintenance.from, 124 + to: maintenance.to, 125 + type: "maintenance", 126 + status: "info" as const, 127 + }); 128 + }); 129 + 130 + // Filter incidents - if monitorId is provided, filter by monitor, otherwise include all 131 + incidents 132 + .filter((incident) => (monitorId ? incident.monitorId === monitorId : true)) 133 + .forEach((incident) => { 134 + if (!incident.createdAt || incident.createdAt < pastThreshod) return; 135 + events.push({ 136 + id: incident.id, 137 + name: incident.title, 138 + from: incident.createdAt, 139 + to: incident.resolvedAt, 140 + type: "incident", 141 + status: "error" as const, 142 + }); 143 + }); 144 + 145 + // Filter reports - if monitorId is provided, filter by monitor, otherwise include all 146 + reports 147 + .filter((report) => 148 + monitorId 149 + ? report.monitorsToStatusReports.some((m) => m.monitorId === monitorId) 150 + : true, 151 + ) 152 + .map((report) => { 153 + const updates = report.statusReportUpdates.sort( 154 + (a, b) => a.date.getTime() - b.date.getTime(), 155 + ); 156 + const firstUpdate = updates[0]; 157 + const lastUpdate = updates[updates.length - 1]; 158 + if (!firstUpdate?.date || firstUpdate.date < pastThreshod) return; 159 + events.push({ 160 + id: report.id, 161 + name: report.title, 162 + from: firstUpdate?.date, 163 + to: 164 + lastUpdate?.status === "resolved" || 165 + lastUpdate?.status === "monitoring" 166 + ? lastUpdate?.date 167 + : null, 168 + type: "report", 169 + status: "degraded" as const, 170 + }); 171 + }); 172 + 173 + return events; 174 + } 175 + 176 + // Keep the old function name for backward compatibility 177 + export const getEventsByMonitorId = getEvents; 178 + 179 + type UptimeData = { 180 + day: string; 181 + events: Event[]; 182 + bar: { 183 + status: "success" | "degraded" | "error" | "info" | "empty"; 184 + height: number; // percentage 185 + }[]; 186 + card: { 187 + status: "success" | "degraded" | "error" | "info" | "empty"; 188 + value: string; 189 + }[]; 190 + }; 191 + 192 + // Priority mapping for status types (higher number = higher priority) 193 + const STATUS_PRIORITY = { 194 + error: 3, 195 + degraded: 2, 196 + info: 1, 197 + success: 0, 198 + empty: -1, 199 + } as const; 200 + 201 + // Helper to get highest priority status from data 202 + function getHighestPriorityStatus( 203 + item: StatusData, 204 + ): keyof typeof STATUS_PRIORITY { 205 + if (item.error > 0) return "error"; 206 + if (item.degraded > 0) return "degraded"; 207 + if (item.ok > 0) return "success"; 208 + 209 + return "empty"; 210 + } 211 + 212 + // Helper to format numbers 213 + function formatNumber(num: number): string { 214 + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; 215 + if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; 216 + return num.toString(); 217 + } 218 + 219 + // Helper to check if date is today 220 + function isToday(date: Date): boolean { 221 + const today = new Date(); 222 + return ( 223 + date.getDate() === today.getDate() && 224 + date.getMonth() === today.getMonth() && 225 + date.getFullYear() === today.getFullYear() 226 + ); 227 + } 228 + 229 + // Helper to format duration from minutes 230 + function formatDuration(minutes: number): string { 231 + if (minutes < 60) return `${minutes}m`; 232 + const hours = Math.floor(minutes / 60); 233 + const remainingMinutes = minutes % 60; 234 + if (remainingMinutes === 0) return `${hours}h`; 235 + return `${hours}h ${remainingMinutes}m`; 236 + } 237 + 238 + // Helper to check if date is within event range 239 + function isDateWithinEvent(date: Date, event: Event): boolean { 240 + const startOfDay = new Date(date); 241 + startOfDay.setUTCHours(0, 0, 0, 0); 242 + 243 + const endOfDay = new Date(date); 244 + endOfDay.setUTCHours(23, 59, 59, 999); 245 + 246 + const eventStart = new Date(event.from); 247 + const eventEnd = event.to ? new Date(event.to) : new Date(); 248 + 249 + return ( 250 + eventStart.getTime() <= endOfDay.getTime() && 251 + eventEnd.getTime() >= startOfDay.getTime() 252 + ); 253 + } 254 + 255 + function getTotalEventsDurationMs(events: Event[], date: Date): number { 256 + if (events.length === 0) return 0; 257 + 258 + const startOfDay = new Date(date); 259 + startOfDay.setUTCHours(0, 0, 0, 0); 260 + 261 + const endOfDay = new Date(date); 262 + endOfDay.setUTCHours(23, 59, 59, 999); 263 + 264 + const total = events.reduce((acc, curr) => { 265 + if (!curr.from) return acc; 266 + 267 + const eventStart = new Date(curr.from); 268 + const eventEnd = curr.to ? new Date(curr.to) : new Date(); 269 + 270 + // Only count events that overlap with this date 271 + if ( 272 + eventEnd.getTime() < startOfDay.getTime() || 273 + eventStart.getTime() > endOfDay.getTime() 274 + ) { 275 + return acc; 276 + } 277 + 278 + // Calculate the overlapping duration within the date boundaries 279 + const overlapStart = Math.max(eventStart.getTime(), startOfDay.getTime()); 280 + const overlapEnd = Math.min(eventEnd.getTime(), endOfDay.getTime()); 281 + 282 + const duration = overlapEnd - overlapStart; 283 + return acc + Math.max(0, duration); 284 + }, 0); 285 + 286 + // Cap at 24 hours (86400000 milliseconds) per day 287 + return Math.min(total, 24 * 60 * 60 * 1000); 288 + } 289 + 290 + export function setDataByType({ 291 + events, 292 + data, 293 + cardType, 294 + barType, 295 + }: { 296 + events: Event[]; 297 + data: StatusData[]; 298 + cardType: "requests" | "duration" | "dominant" | "manual"; 299 + barType: "absolute" | "dominant" | "manual"; 300 + }): UptimeData[] { 301 + return data.map((dayData) => { 302 + const date = new Date(dayData.day); 303 + 304 + // Find events for this day 305 + const dayEvents = events.filter((event) => isDateWithinEvent(date, event)); 306 + 307 + // Determine status override based on events 308 + const incidents = dayEvents.filter((e) => e.type === "incident"); 309 + const reports = dayEvents.filter((e) => e.type === "report"); 310 + const maintenances = dayEvents.filter((e) => e.type === "maintenance"); 311 + 312 + const hasIncidents = incidents.length > 0; 313 + const hasReports = reports.length > 0; 314 + const hasMaintenances = maintenances.length > 0; 315 + 316 + const eventStatus = hasIncidents 317 + ? "error" 318 + : hasReports 319 + ? "degraded" 320 + : hasMaintenances 321 + ? "info" 322 + : undefined; 323 + 324 + // Calculate bar data based on barType 325 + // TODO: transform into a new Map<type, number>(); 326 + let barData: UptimeData["bar"]; 327 + 328 + const total = dayData.ok + dayData.degraded + dayData.error; 329 + const dataStatus = getHighestPriorityStatus(dayData); 330 + 331 + switch (barType) { 332 + case "absolute": 333 + if (eventStatus) { 334 + // If there's an event override, show single status 335 + barData = [ 336 + { 337 + status: eventStatus, 338 + height: 100, 339 + }, 340 + ]; 341 + } else if (total === 0) { 342 + // Empty day 343 + barData = [ 344 + { 345 + status: "empty", 346 + height: 100, 347 + }, 348 + ]; 349 + } else { 350 + // Multiple segments for absolute view 351 + const segments = [ 352 + { status: "success" as const, count: dayData.ok }, 353 + { status: "degraded" as const, count: dayData.degraded }, 354 + { status: "error" as const, count: dayData.error }, 355 + ] 356 + .filter((segment) => segment.count > 0) 357 + .map((segment) => ({ 358 + status: segment.status, 359 + height: (segment.count / total) * 100, 360 + })); 361 + 362 + barData = segments; 363 + } 364 + break; 365 + case "dominant": 366 + barData = [ 367 + { 368 + status: eventStatus ?? dataStatus, 369 + height: 100, 370 + }, 371 + ]; 372 + break; 373 + case "manual": 374 + const manualEventStatus = hasReports 375 + ? "degraded" 376 + : hasMaintenances 377 + ? "info" 378 + : undefined; 379 + barData = [ 380 + { 381 + status: manualEventStatus || "success", 382 + height: 100, 383 + }, 384 + ]; 385 + break; 386 + default: 387 + // Default to dominant behavior 388 + barData = [ 389 + { 390 + status: eventStatus ?? dataStatus, 391 + height: 100, 392 + }, 393 + ]; 394 + break; 395 + } 396 + 397 + // Calculate card data based on cardType 398 + // TODO: transform into a new Map<type, number>(); 399 + let cardData: UptimeData["card"] = []; 400 + 401 + switch (cardType) { 402 + case "requests": 403 + if (total === 0) { 404 + cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; 405 + } else { 406 + const entries = [ 407 + { status: "success" as const, count: dayData.ok }, 408 + { status: "degraded" as const, count: dayData.degraded }, 409 + { status: "error" as const, count: dayData.error }, 410 + { status: "info" as const, count: 0 }, 411 + ]; 412 + 413 + cardData = entries 414 + .filter((entry) => entry.count > 0) 415 + .map((entry) => ({ 416 + status: entry.status, 417 + value: `${formatNumber(entry.count)} reqs`, 418 + })); 419 + } 420 + break; 421 + 422 + case "duration": 423 + if (total === 0) { 424 + cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; 425 + } else { 426 + const entries = [ 427 + { status: "error" as const, count: dayData.error }, 428 + { status: "degraded" as const, count: dayData.degraded }, 429 + { status: "success" as const, count: dayData.ok }, 430 + { status: "info" as const, count: 0 }, 431 + ]; 432 + 433 + const map = new Map< 434 + "error" | "degraded" | "success" | "info", 435 + number 436 + >(); 437 + 438 + cardData = entries 439 + .map((entry) => { 440 + if (entry.status === "error") { 441 + const totalDuration = getTotalEventsDurationMs(incidents, date); 442 + const minutes = Math.round(totalDuration / (1000 * 60)); 443 + map.set("error", minutes); 444 + if (minutes === 0) return null; 445 + return { 446 + status: entry.status, 447 + value: formatDuration(minutes), 448 + }; 449 + } 450 + 451 + if (entry.status === "degraded") { 452 + const totalDuration = getTotalEventsDurationMs(reports, date); 453 + const minutes = Math.round(totalDuration / (1000 * 60)); 454 + map.set("degraded", minutes); 455 + if (minutes === 0) return null; 456 + return { 457 + status: entry.status, 458 + value: formatDuration(minutes), 459 + }; 460 + } 461 + 462 + if (entry.status === "info") { 463 + const totalDuration = getTotalEventsDurationMs( 464 + maintenances, 465 + date, 466 + ); 467 + const minutes = Math.round(totalDuration / (1000 * 60)); 468 + map.set("info", minutes); 469 + if (minutes === 0) return null; 470 + return { 471 + status: entry.status, 472 + value: formatDuration(minutes), 473 + }; 474 + } 475 + 476 + if (entry.status === "success") { 477 + let total = 0; 478 + // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> 479 + map.forEach((d) => (total += d)); 480 + const day = 24 * 60; 481 + const minutes = Math.max(day - total, 0); 482 + if (minutes === 0) return null; 483 + return { 484 + status: entry.status, 485 + value: formatDuration(minutes), 486 + }; 487 + } 488 + }) 489 + .filter((item): item is NonNullable<typeof item> => item !== null); 490 + } 491 + break; 492 + 493 + case "dominant": 494 + cardData = [ 495 + { 496 + status: eventStatus ?? dataStatus, 497 + value: "", 498 + }, 499 + ]; 500 + break; 501 + 502 + case "manual": 503 + const manualEventStatus = hasReports 504 + ? "degraded" 505 + : hasMaintenances 506 + ? "info" 507 + : undefined; 508 + cardData = [ 509 + { 510 + status: manualEventStatus || "success", 511 + value: "", 512 + }, 513 + ]; 514 + break; 515 + default: 516 + // Default to requests behavior 517 + if (total === 0) { 518 + cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; 519 + } else { 520 + const entries = [ 521 + { status: "error" as const, count: dayData.error }, 522 + { status: "degraded" as const, count: dayData.degraded }, 523 + { status: "success" as const, count: dayData.ok }, 524 + ]; 525 + 526 + cardData = entries 527 + .filter((entry) => entry.count > 0) 528 + .map((entry) => ({ 529 + status: entry.status, 530 + value: `${formatNumber(entry.count)} reqs`, 531 + })); 532 + } 533 + break; 534 + } 535 + 536 + return { 537 + day: dayData.day, 538 + events: [...reports, ...maintenances], 539 + bar: barData, 540 + card: cardData, 541 + }; 542 + }); 543 + } 544 + 545 + export function getUptime({ 546 + data, 547 + events, 548 + barType, 549 + }: { 550 + data: StatusData[]; 551 + events: Event[]; 552 + barType: "absolute" | "dominant" | "manual"; 553 + }): string { 554 + if (barType === "manual") { 555 + const duration = events 556 + // NOTE: we want only user events 557 + .filter((e) => e.type === "report") 558 + .reduce((acc, item) => { 559 + if (!item.from) return acc; 560 + return acc + ((item.to || new Date()).getTime() - item.from.getTime()); 561 + }, 0); 562 + 563 + const total = data.length * 24 * 60 * 60 * 1000; 564 + 565 + return `${Math.round(((total - duration) / total) * 10000) / 100}%`; 566 + } 567 + 568 + const { ok, total } = data.reduce( 569 + (acc, item) => ({ 570 + ok: acc.ok + item.ok + item.degraded, 571 + total: acc.total + item.ok + item.degraded + item.error, 572 + }), 573 + { 574 + ok: 0, 575 + total: 0, 576 + }, 577 + ); 578 + 579 + if (total === 0) return "100%"; 580 + return `${Math.round((ok / total) * 10000) / 100}%`; 581 + }
+49 -27
packages/api/src/router/tinybird/index.ts
··· 18 18 type Type = (typeof types)[number]; 19 19 20 20 // NEW: workspace-level counters helper 21 - function getWorkspace30dProcedure(type: Type) { 21 + export function getWorkspace30dProcedure(type: Type) { 22 22 return type === "http" ? tb.httpWorkspace30d : tb.tcpWorkspace30d; 23 23 } 24 24 // Helper functions to get the right procedure based on period and type 25 - function getListProcedure(period: Period, type: Type) { 25 + export function getListProcedure(period: Period, type: Type) { 26 26 switch (period) { 27 27 case "1d": 28 28 return type === "http" ? tb.httpListDaily : tb.tcpListDaily; ··· 35 35 } 36 36 } 37 37 38 - function getMetricsProcedure(period: Period, type: Type) { 38 + export function getMetricsProcedure(period: Period, type: Type) { 39 39 switch (period) { 40 40 case "1d": 41 41 return type === "http" ? tb.httpMetricsDaily : tb.tcpMetricsDaily; ··· 48 48 } 49 49 } 50 50 51 - function getMetricsByRegionProcedure(period: Period, type: Type) { 51 + export function getMetricsByRegionProcedure(period: Period, type: Type) { 52 52 switch (period) { 53 53 case "1d": 54 54 return type === "http" ··· 69 69 } 70 70 } 71 71 72 - function getMetricsByIntervalProcedure(period: Period, type: Type) { 72 + export function getMetricsByIntervalProcedure(period: Period, type: Type) { 73 73 switch (period) { 74 74 case "1d": 75 75 return type === "http" ··· 91 91 } 92 92 93 93 // FIXME: tb pipes are deprecated, we need new ones 94 - function getMetricsRegionsProcedure(period: Period, type: Type) { 94 + export function getMetricsRegionsProcedure(period: Period, type: Type) { 95 95 switch (period) { 96 96 case "1d": 97 97 return type === "http" ··· 112 112 } 113 113 } 114 114 115 - function getStatusProcedure(period: "7d" | "45d", type: Type) { 116 - switch (period) { 117 - case "7d": 118 - return type === "http" ? tb.httpStatusWeekly : tb.tcpStatusWeekly; 119 - case "45d": 120 - return type === "http" ? tb.httpStatus45d : tb.tcpStatus45d; 121 - default: 122 - return type === "http" ? tb.httpStatusWeekly : tb.tcpStatusWeekly; 123 - } 115 + export function getStatusProcedure(_period: "45d", type: Type) { 116 + return type === "http" ? tb.httpStatus45d : tb.tcpStatus45d; 124 117 } 125 118 126 - function getGetProcedure(period: "14d", type: Type) { 119 + export function getGetProcedure(period: "14d", type: Type) { 127 120 switch (period) { 128 121 case "14d": 129 122 return type === "http" ? tb.httpGetBiweekly : tb.tcpGetBiweekly; ··· 132 125 } 133 126 } 134 127 135 - function getGlobalMetricsProcedure(type: Type) { 128 + export function getGlobalMetricsProcedure(type: Type) { 136 129 return type === "http" ? tb.httpGlobalMetricsDaily : tb.tcpGlobalMetricsDaily; 137 130 } 138 131 139 - function getUptimeProcedure(period: "7d" | "30d", type: Type) { 132 + export function getUptimeProcedure(period: "7d" | "30d", type: Type) { 140 133 switch (period) { 141 134 case "7d": 142 135 return type === "http" ? tb.httpUptimeWeekly : tb.tcpUptimeWeekly; ··· 148 141 } 149 142 150 143 // TODO: missing pipes for other periods 151 - function getMetricsLatencyProcedure(_period: Period, type: Type) { 152 - return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d; 144 + export function getMetricsLatencyProcedure(_period: Period, type: Type) { 145 + switch (_period) { 146 + case "1d": 147 + return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d; 148 + case "7d": 149 + return type === "http" ? tb.httpMetricsLatency7d : tb.tcpMetricsLatency7d; 150 + default: 151 + return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d; 152 + } 153 153 } 154 154 155 - function getTimingPhasesProcedure(type: Type) { 155 + export function getMetricsLatencyMultiProcedure(_period: Period, type: Type) { 156 + return type === "http" 157 + ? tb.httpMetricsLatency1dMulti 158 + : tb.tcpMetricsLatency1dMulti; 159 + } 160 + 161 + export function getTimingPhasesProcedure(type: Type) { 156 162 return type === "http" ? tb.httpTimingPhases14d : null; 157 163 } 158 164 ··· 420 426 status: protectedProcedure 421 427 .input( 422 428 z.object({ 423 - monitorId: z.string(), 424 - period: z.enum(["7d", "45d"]), 429 + monitorIds: z.string().array(), 430 + period: z.enum(["45d"]), 425 431 type: z.enum(types).default("http"), 426 432 region: z.enum(flyRegions).optional(), 427 433 cronTimestamp: z.number().int().optional(), ··· 429 435 ) 430 436 .query(async (opts) => { 431 437 const whereConditions: SQL[] = [ 432 - eq(monitor.id, Number.parseInt(opts.input.monitorId)), 438 + inArray(monitor.id, opts.input.monitorIds.map(Number)), 433 439 eq(monitor.workspaceId, opts.ctx.workspace.id), 434 440 ]; 435 441 436 - const _monitor = await db.query.monitor.findFirst({ 442 + const _monitors = await db.query.monitor.findMany({ 437 443 where: and(...whereConditions), 438 444 }); 439 445 440 - if (!_monitor) { 446 + if (_monitors.length !== opts.input.monitorIds.length) { 441 447 throw new TRPCError({ 442 448 code: "NOT_FOUND", 443 - message: "Monitor not found", 449 + message: "Some monitors not found", 444 450 }); 445 451 } 446 452 ··· 553 559 }); 554 560 } 555 561 562 + return await procedure(opts.input); 563 + }), 564 + 565 + metricsLatencyMulti: protectedProcedure 566 + .input( 567 + z.object({ 568 + monitorIds: z.string().array(), 569 + period: z.enum(["1d"]).default("1d"), 570 + type: z.enum(types).default("http"), 571 + }), 572 + ) 573 + .query(async (opts) => { 574 + const procedure = getMetricsLatencyMultiProcedure( 575 + opts.input.period, 576 + opts.input.type, 577 + ); 556 578 return await procedure(opts.input); 557 579 }), 558 580
+1 -1
packages/db/src/schema/pages/page.ts
··· 53 53 54 54 export const pageRelations = relations(page, ({ many, one }) => ({ 55 55 monitorsToPages: many(monitorsToPages), 56 - maintenancesToPages: many(maintenance), 56 + maintenances: many(maintenance), 57 57 statusReports: many(statusReport), 58 58 workspace: one(workspace, { 59 59 fields: [page.workspaceId],
+55 -3
packages/db/src/schema/shared.ts
··· 39 39 z.object({ 40 40 monitorId: z.number(), 41 41 maintenanceId: z.number(), 42 + monitor: selectPublicMonitorSchema, 42 43 }), 43 44 ) 44 45 .default([]), ··· 60 61 monitor: selectMonitorSchema, 61 62 }), 62 63 ), 63 - maintenancesToPages: selectMaintenanceSchema.array().default([]), 64 + maintenances: selectMaintenanceSchema.array().default([]), 64 65 statusReports: selectStatusReportSchema 65 66 .extend({ statusReportUpdates: selectStatusReportUpdateSchema.array() }) 66 67 .array() 67 68 .default([]), 68 69 }); 69 70 71 + export const legacy_selectPublicPageSchemaWithRelation = selectPageSchema 72 + .extend({ 73 + monitors: z.array(selectPublicMonitorSchema).default([]), 74 + statusReports: z.array(selectStatusReportPageSchema).default([]), 75 + incidents: z.array(selectIncidentSchema).default([]), 76 + maintenances: z.array(selectMaintenancePageSchema).default([]), 77 + workspacePlan: workspacePlanSchema 78 + .nullable() 79 + .default("free") 80 + .transform((val) => val ?? "free"), 81 + }) 82 + .omit({ 83 + // workspaceId: true, 84 + id: true, 85 + }); 86 + 70 87 export const selectPublicPageSchemaWithRelation = selectPageSchema 71 88 .extend({ 72 - monitors: z.array(selectPublicMonitorSchema), 89 + // TODO: include status of the monitor 90 + monitors: selectPublicMonitorSchema 91 + .extend({ 92 + status: z 93 + .enum(["success", "degraded", "error", "info"]) 94 + .default("success"), 95 + }) 96 + .array(), 97 + lastEvents: z.array( 98 + z.object({ 99 + id: z.number(), 100 + name: z.string(), 101 + from: z.date(), 102 + to: z.date().nullable(), 103 + status: z 104 + .enum(["success", "degraded", "error", "info"]) 105 + .default("success"), 106 + type: z.enum(["maintenance", "incident", "report"]), 107 + }), 108 + ), 109 + openEvents: z.array( 110 + z.object({ 111 + id: z.number(), 112 + name: z.string(), 113 + from: z.date(), 114 + to: z.date().nullable(), 115 + status: z 116 + .enum(["success", "degraded", "error", "info"]) 117 + .default("success"), 118 + type: z.enum(["maintenance", "incident", "report"]), 119 + }), 120 + ), 73 121 statusReports: z.array(selectStatusReportPageSchema), 74 122 incidents: z.array(selectIncidentSchema), 75 123 maintenances: z.array(selectMaintenancePageSchema), 124 + status: z.enum(["success", "degraded", "error", "info"]).default("success"), 76 125 workspacePlan: workspacePlanSchema 77 126 .nullable() 78 127 .default("free") ··· 81 130 .omit({ 82 131 // workspaceId: true, 83 132 id: true, 133 + password: true, 84 134 }); 85 135 86 136 export const selectPublicStatusReportSchemaWithRelation = ··· 101 151 typeof selectStatusReportPageSchema 102 152 >; 103 153 export type PublicMonitor = z.infer<typeof selectPublicMonitorSchema>; 104 - export type PublicPage = z.infer<typeof selectPublicPageSchemaWithRelation>; 154 + export type PublicPage = z.infer< 155 + typeof legacy_selectPublicPageSchemaWithRelation 156 + >;
+14
packages/tinybird/datasources/mv__http_status_45d__v1.datasource
··· 1 + # Data Source created from Pipe 'aggregate__http_status_45d__v1' 2 + 3 + SCHEMA > 4 + `time` DateTime('UTC'), 5 + `monitorId` String, 6 + `count` AggregateFunction(count), 7 + `success` AggregateFunction(count, Nullable(UInt8)), 8 + `error` AggregateFunction(count, Nullable(UInt8)), 9 + `degraded` AggregateFunction(count, Nullable(UInt8)) 10 + 11 + ENGINE "AggregatingMergeTree" 12 + ENGINE_PARTITION_KEY "toYYYYMM(time)" 13 + ENGINE_SORTING_KEY "monitorId, time" 14 + ENGINE_TTL "time + toIntervalDay(46)"
+14
packages/tinybird/datasources/mv__tcp_status_45d__v1.datasource
··· 1 + # Data Source created from Pipe 'aggregate__tcp_status_45d__v1' 2 + 3 + SCHEMA > 4 + `time` DateTime('UTC'), 5 + `monitorId` Int32, 6 + `count` AggregateFunction(count), 7 + `success` AggregateFunction(count, Nullable(UInt8)), 8 + `error` AggregateFunction(count, Nullable(UInt8)), 9 + `degraded` AggregateFunction(count, Nullable(UInt8)) 10 + 11 + ENGINE "AggregatingMergeTree" 12 + ENGINE_PARTITION_KEY "toYYYYMM(time)" 13 + ENGINE_SORTING_KEY "monitorId, time" 14 + ENGINE_TTL "time + toIntervalDay(46)"
+21
packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe
··· 1 + TAGS "http, statuspage" 2 + 3 + NODE aggregate 4 + SQL > 5 + 6 + SELECT 7 + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, 8 + monitorId, 9 + countState() AS count, 10 + countState(if(requestStatus = 'success', 1, NULL)) AS success, 11 + countState(if(requestStatus = 'error', 1, NULL)) AS error, 12 + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded 13 + FROM ping_response__v8 14 + GROUP BY 15 + time, 16 + monitorId 17 + 18 + TYPE materialized 19 + DATASOURCE mv__http_status_45d__v1 20 + 21 +
+21
packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe
··· 1 + TAGS "tcp, statuspage" 2 + 3 + NODE aggregate 4 + SQL > 5 + 6 + SELECT 7 + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, 8 + monitorId, 9 + countState() AS count, 10 + countState(if(requestStatus = 'success', 1, NULL)) AS success, 11 + countState(if(requestStatus = 'error', 1, NULL)) AS error, 12 + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded 13 + FROM tcp_response__v0 14 + GROUP BY 15 + time, 16 + monitorId 17 + 18 + TYPE materialized 19 + DATASOURCE mv__tcp_status_45d__v1 20 + 21 +
+24
packages/tinybird/pipes/endpoint__http_metrics_latency_1d_multi__v1.pipe
··· 1 + TAGS "http" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + toStartOfInterval( 9 + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE 10 + ) as h, 11 + monitorId, 12 + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, 13 + round(quantile(0.50)(latency)) as p50Latency, 14 + round(quantile(0.75)(latency)) as p75Latency, 15 + round(quantile(0.90)(latency)) as p90Latency, 16 + round(quantile(0.95)(latency)) as p95Latency, 17 + round(quantile(0.99)(latency)) as p99Latency 18 + FROM mv__http_1d__v0 19 + WHERE 20 + monitorId IN {{ Array(monitorIds, 'String', '1,666') }} 21 + GROUP BY h, monitorId 22 + ORDER BY h DESC 23 + 24 +
+23
packages/tinybird/pipes/endpoint__http_metrics_latency_7d__v1.pipe
··· 1 + TAGS "tcp" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + toStartOfInterval( 9 + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE 10 + ) as h, 11 + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, 12 + round(quantile(0.50)(latency)) as p50Latency, 13 + round(quantile(0.75)(latency)) as p75Latency, 14 + round(quantile(0.90)(latency)) as p90Latency, 15 + round(quantile(0.95)(latency)) as p95Latency, 16 + round(quantile(0.99)(latency)) as p99Latency 17 + FROM mv__http_7d__v0 18 + WHERE 19 + monitorId = {{ String(monitorId, '1', required=True) }} 20 + GROUP BY h 21 + ORDER BY h DESC 22 + 23 +
+19
packages/tinybird/pipes/endpoint__http_status_45d__v1.pipe
··· 1 + TAGS "http" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + time as day, 9 + monitorId, 10 + countMerge(count) as count, 11 + countMerge(success) as ok, 12 + countMerge(error) as error, 13 + countMerge(degraded) as degraded 14 + FROM mv__http_status_45d__v1 15 + WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} 16 + GROUP BY day, monitorId 17 + ORDER BY day DESC 18 + 19 +
+24
packages/tinybird/pipes/endpoint__tcp_metrics_latency_1d_multi__v1.pipe
··· 1 + TAGS "tcp" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + toStartOfInterval( 9 + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE 10 + ) as h, 11 + monitorId, 12 + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, 13 + round(quantile(0.50)(latency)) as p50Latency, 14 + round(quantile(0.75)(latency)) as p75Latency, 15 + round(quantile(0.90)(latency)) as p90Latency, 16 + round(quantile(0.95)(latency)) as p95Latency, 17 + round(quantile(0.99)(latency)) as p99Latency 18 + FROM mv__tcp_1d__v0 19 + WHERE 20 + monitorId IN {{ Array(monitorIds, 'String', '4433') }} 21 + GROUP BY h, monitorId 22 + ORDER BY h DESC 23 + 24 +
+23
packages/tinybird/pipes/endpoint__tcp_metrics_latency_7d__v1.pipe
··· 1 + TAGS "tcp" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + toStartOfInterval( 9 + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE 10 + ) as h, 11 + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, 12 + round(quantile(0.50)(latency)) as p50Latency, 13 + round(quantile(0.75)(latency)) as p75Latency, 14 + round(quantile(0.90)(latency)) as p90Latency, 15 + round(quantile(0.95)(latency)) as p95Latency, 16 + round(quantile(0.99)(latency)) as p99Latency 17 + FROM mv__tcp_7d__v1 18 + WHERE 19 + monitorId = {{ String(monitorId, '4433', required=True) }} 20 + GROUP BY h 21 + ORDER BY h DESC 22 + 23 +
+19
packages/tinybird/pipes/endpoint__tcp_status_45d__v1.pipe
··· 1 + TAGS "tcp" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + time as day, 9 + monitorId, 10 + countMerge(count) as count, 11 + countMerge(success) as ok, 12 + countMerge(error) as error, 13 + countMerge(degraded) as degraded 14 + FROM mv__tcp_status_45d__v1 15 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} 16 + GROUP BY day, monitorId 17 + ORDER BY day DESC 18 + 19 +
+122 -3
packages/tinybird/src/client.ts
··· 16 16 private readonly tb: Client; 17 17 18 18 constructor(token: string) { 19 - // this.tb = new Client({ token }); 20 19 if (process.env.NODE_ENV === "development") { 21 20 this.tb = new NoopTinybird(); 22 21 } else { 23 22 this.tb = new Client({ token }); 24 23 } 24 + // this.tb = new Client({ token }); 25 25 } 26 26 27 27 public get homeStats() { ··· 459 459 }); 460 460 } 461 461 462 - public get httpStatus45d() { 462 + public get legacy_httpStatus45d() { 463 463 return this.tb.buildPipe({ 464 464 pipe: "endpoint__http_status_45d__v0", 465 465 parameters: z.object({ ··· 482 482 }); 483 483 } 484 484 485 + public get httpStatus45d() { 486 + return this.tb.buildPipe({ 487 + pipe: "endpoint__http_status_45d__v1", 488 + parameters: z.object({ 489 + monitorIds: z.string().array(), 490 + }), 491 + data: z.object({ 492 + day: z.string().transform((val) => { 493 + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) 494 + return new Date(`${val} GMT`).toISOString(); 495 + }), 496 + count: z.number().default(0), 497 + ok: z.number().default(0), 498 + degraded: z.number().default(0), 499 + error: z.number().default(0), 500 + monitorId: z.string(), 501 + }), 502 + opts: { next: { revalidate: REVALIDATE } }, 503 + }); 504 + } 505 + 485 506 public get httpGetBiweekly() { 486 507 return this.tb.buildPipe({ 487 508 pipe: "endpoint__http_get_14d__v0", ··· 987 1008 }); 988 1009 } 989 1010 990 - public get tcpStatus45d() { 1011 + public get legacy_tcpStatus45d() { 991 1012 return this.tb.buildPipe({ 992 1013 pipe: "endpoint__tcp_status_45d__v0", 993 1014 parameters: z.object({ ··· 1010 1031 }); 1011 1032 } 1012 1033 1034 + public get tcpStatus45d() { 1035 + return this.tb.buildPipe({ 1036 + pipe: "endpoint__tcp_status_45d__v1", 1037 + parameters: z.object({ 1038 + monitorIds: z.string().array(), 1039 + days: z.number().int().max(45).optional(), 1040 + }), 1041 + data: z.object({ 1042 + day: z.string().transform((val) => { 1043 + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) 1044 + return new Date(`${val} GMT`).toISOString(); 1045 + }), 1046 + count: z.number().default(0), 1047 + ok: z.number().default(0), 1048 + degraded: z.number().default(0), 1049 + error: z.number().default(0), 1050 + monitorId: z.coerce.string(), 1051 + }), 1052 + opts: { 1053 + next: { 1054 + revalidate: PUBLIC_CACHE, 1055 + }, 1056 + }, 1057 + }); 1058 + } 1059 + 1013 1060 public get httpWorkspace30d() { 1014 1061 return this.tb.buildPipe({ 1015 1062 pipe: "endpoint__http_workspace_30d__v0", ··· 1360 1407 }); 1361 1408 } 1362 1409 1410 + public get httpMetricsLatency7d() { 1411 + return this.tb.buildPipe({ 1412 + pipe: "endpoint__http_metrics_latency_7d__v1", 1413 + parameters: z.object({ 1414 + monitorId: z.string(), 1415 + }), 1416 + data: z.object({ 1417 + timestamp: z.number().int(), 1418 + p50Latency: z.number().int(), 1419 + p75Latency: z.number().int(), 1420 + p90Latency: z.number().int(), 1421 + p95Latency: z.number().int(), 1422 + p99Latency: z.number().int(), 1423 + }), 1424 + }); 1425 + } 1426 + 1427 + public get httpMetricsLatency1dMulti() { 1428 + return this.tb.buildPipe({ 1429 + pipe: "endpoint__http_metrics_latency_1d_multi__v1", 1430 + parameters: z.object({ 1431 + monitorIds: z.string().array().min(1), 1432 + }), 1433 + data: z.object({ 1434 + timestamp: z.number().int(), 1435 + monitorId: z.string(), 1436 + p50Latency: z.number().int(), 1437 + p75Latency: z.number().int(), 1438 + p90Latency: z.number().int(), 1439 + p95Latency: z.number().int(), 1440 + p99Latency: z.number().int(), 1441 + }), 1442 + opts: { next: { revalidate: REVALIDATE } }, 1443 + }); 1444 + } 1445 + 1363 1446 public get tcpMetricsLatency1d() { 1364 1447 return this.tb.buildPipe({ 1365 1448 pipe: "endpoint__tcp_metrics_latency_1d__v1", ··· 1375 1458 p95Latency: z.number().int(), 1376 1459 p99Latency: z.number().int(), 1377 1460 }), 1461 + }); 1462 + } 1463 + 1464 + public get tcpMetricsLatency7d() { 1465 + return this.tb.buildPipe({ 1466 + pipe: "endpoint__tcp_metrics_latency_7d__v1", 1467 + parameters: z.object({ 1468 + monitorId: z.string(), 1469 + }), 1470 + data: z.object({ 1471 + timestamp: z.number().int(), 1472 + p50Latency: z.number().int(), 1473 + p75Latency: z.number().int(), 1474 + p90Latency: z.number().int(), 1475 + p95Latency: z.number().int(), 1476 + p99Latency: z.number().int(), 1477 + }), 1478 + }); 1479 + } 1480 + 1481 + public get tcpMetricsLatency1dMulti() { 1482 + return this.tb.buildPipe({ 1483 + pipe: "endpoint__tcp_metrics_latency_1d_multi__v1", 1484 + parameters: z.object({ 1485 + monitorIds: z.string().array().min(1), 1486 + }), 1487 + data: z.object({ 1488 + timestamp: z.number().int(), 1489 + monitorId: z.coerce.string(), 1490 + p50Latency: z.number().int(), 1491 + p75Latency: z.number().int(), 1492 + p90Latency: z.number().int(), 1493 + p95Latency: z.number().int(), 1494 + p99Latency: z.number().int(), 1495 + }), 1496 + opts: { next: { revalidate: REVALIDATE } }, 1378 1497 }); 1379 1498 } 1380 1499 }