Openstatus www.openstatus.dev

feat: status pages (#942)

* chore: empty state

* wip:

* wip:

* fix: status report without monitor

* chore: redirect to reports

* feat: maintenance

* feat: feed

* wip: feed

* wip: colors

* chore: events and layout

* wip:

* fix: ts error

* chore: add theme switch to user dropdown

* fix: status iff reports

* chore: switch status and incidents order

* wip

* wip

* chore: delete files

* chore: status check improvement

* chore: radix colors

* chore: add changelog

* chore: clean up

* fix: header link props

* ci: apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Maximilian Kaske
autofix-ci[bot]
and committed by
GitHub
0d5829a1 cbaeea22

+764 -524
apps/web/public/assets/changelog/status-page-colors-and-more.png

This is a binary file and will not be displayed.

+14 -11
apps/web/src/app/(content)/features/mock.ts
··· 1014 1014 }; 1015 1015 1016 1016 export const maintenanceData = { 1017 - id: 0, 1018 - from: new Date("2024-07-09T21:02:43.000Z"), 1019 - to: new Date("2024-07-09T21:05:43.000Z"), 1020 - title: "Maintenance", 1021 - message: 1022 - "We are performing maintenance on our environment. Services and projects may be unavailable for a few minutes.", 1023 - createdAt: null, 1024 - updatedAt: null, 1025 - workspaceId: null, 1026 - pageId: null, 1027 - monitors: undefined, 1017 + maintenance: { 1018 + id: 0, 1019 + from: new Date("2024-07-09T21:02:43.000Z"), 1020 + to: new Date("2024-07-09T21:05:43.000Z"), 1021 + title: "Maintenance", 1022 + message: 1023 + "We are performing maintenance on our environment. Services and projects may be unavailable for a few minutes.", 1024 + createdAt: null, 1025 + updatedAt: null, 1026 + workspaceId: null, 1027 + pageId: null, 1028 + monitors: undefined, 1029 + }, 1030 + monitors: [], 1028 1031 }; 1029 1032 1030 1033 export const statusReportData = {
+8 -9
apps/web/src/app/(content)/features/status-page/page.tsx
··· 5 5 } from "@/app/shared-metadata"; 6 6 import { PasswordFormSuspense } from "@/app/status-page/[domain]/_components/password-form"; 7 7 import { SubscribeButton } from "@/app/status-page/[domain]/_components/subscribe-button"; 8 - import { MaintenanceBanner } from "@/components/status-page/maintenance-banner"; 8 + import { MaintenanceContainer } from "@/components/status-page/maintenance"; 9 9 import { StatusCheck } from "@/components/status-page/status-check"; 10 10 import { StatusReport } from "@/components/status-page/status-report"; 11 11 import { Tracker } from "@/components/tracker/tracker"; ··· 93 93 subTitle="Down't let your users in the dark and show what's wrong." 94 94 component={ 95 95 <div className="-translate-y-6 m-auto scale-[0.80]"> 96 - <StatusReport {...statusReportData} /> 96 + <StatusReport isDemo {...statusReportData} /> 97 97 </div> 98 98 } 99 99 col={1} ··· 118 118 iconText="Keep it simple" 119 119 title="Build trust." 120 120 subTitle="Showcase your reliability to your users, and reduce the number of customer service tickets." 121 - component={ 122 - <div className="m-auto max-w-lg"> 123 - <StatusCheck /> 124 - </div> 125 - } 121 + component={<StatusCheck />} 126 122 action={ 127 123 <div className="mt-2"> 128 124 <Button variant="outline" className="rounded-full" asChild> ··· 139 135 title="Maintenance." 140 136 subTitle="Mute your monitors for a specific period and inform the users about upcoming maintenance." 141 137 component={ 142 - <div className="m-auto"> 143 - <MaintenanceBanner {...maintenanceData} /> 138 + <div className="m-auto scale-[0.80]"> 139 + <MaintenanceContainer 140 + className="rounded-lg border-status-monitoring/10 bg-status-monitoring/5" 141 + {...maintenanceData} 142 + /> 144 143 </div> 145 144 } 146 145 col={2}
+22 -2
apps/web/src/app/api/og/_components/status-check.tsx
··· 4 4 5 5 export function StatusCheck({ tracker }: { tracker: Tracker }) { 6 6 const details = tracker.currentDetails; 7 - const className = tracker.currentClassName; 8 7 9 8 // FIXME: move icons into @openstatus/tracker lib 10 9 function getVariant() { ··· 22 21 } 23 22 } 24 23 24 + // REMINDER: we cannot use custom tailwind utility colors like `bg-status-operational/90` here 25 + function getClassName() { 26 + switch (details.variant) { 27 + case "maintenance": 28 + return "bg-blue-500/90 border-blue-500"; 29 + case "down": 30 + return "bg-rose-500/90 border-rose-500"; 31 + case "degraded": 32 + return "bg-amber-500/90 border-amber-500"; 33 + case "incident": 34 + return "bg-rose-500/90 border-rose-500"; 35 + default: 36 + return "bg-green-500/90 border-green-500"; 37 + } 38 + } 39 + 25 40 const Icon = getVariant(); 26 41 27 42 return ( 28 43 <div tw="flex flex-col justify-center items-center w-full"> 29 - <div tw={cn("flex text-white rounded-full p-3 border-2 mb-2", className)}> 44 + <div 45 + tw={cn( 46 + "flex text-white rounded-full p-3 border-2 mb-2", 47 + getClassName(), 48 + )} 49 + > 30 50 <Icon /> 31 51 </div> 32 52 <p style={{ fontFamily: "Cal" }} tw="text-4xl">
+5 -2
apps/web/src/app/api/og/_components/tracker.tsx
··· 23 23 const isBlackListed = Boolean(item.blacklist); 24 24 if (isBlackListed) { 25 25 return ( 26 - // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 27 - <div key={i} tw="h-16 w-3 rounded-full mr-1 bg-green-400" /> 26 + <div 27 + // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 28 + key={i} 29 + tw="h-16 w-3 rounded-full mr-1 bg-status-operational/90" 30 + /> 28 31 ); 29 32 } 30 33 return (
+10 -5
apps/web/src/app/status-page/[domain]/_components/footer.tsx
··· 5 5 6 6 interface Props { 7 7 plan: WorkspacePlan; 8 + timeZone?: string | null; 8 9 } 9 10 10 - export function Footer({ plan }: Props) { 11 + export function Footer({ plan, timeZone }: Props) { 11 12 const isWhiteLabel = allPlans[plan].limits["white-label"]; 12 13 return ( 13 - <footer className="z-10 mx-auto flex w-full items-center justify-between"> 14 - <div /> 14 + <footer className="z-10 mx-auto grid w-full grid-cols-5 items-center justify-between gap-4"> 15 + <p className="truncate font-light text-muted-foreground text-xs"> 16 + {timeZone} 17 + </p> 15 18 {!isWhiteLabel ? ( 16 - <p className="text-center text-muted-foreground text-sm"> 19 + <p className="col-span-3 text-center text-muted-foreground text-sm"> 17 20 powered by{" "} 18 21 <a 19 22 href="https://www.openstatus.dev" ··· 25 28 </a> 26 29 </p> 27 30 ) : null} 28 - <ThemeToggle /> 31 + <div className="text-right"> 32 + <ThemeToggle /> 33 + </div> 29 34 </footer> 30 35 ); 31 36 }
+25 -15
apps/web/src/app/status-page/[domain]/_components/header.tsx
··· 9 9 10 10 import { Shell } from "@/components/dashboard/shell"; 11 11 import { TabsContainer, TabsLink } from "@/components/dashboard/tabs-link"; 12 + import { cn } from "@/lib/utils"; 13 + import Link from "next/link"; 12 14 import { Menu } from "./menu"; 13 15 import { SubscribeButton } from "./subscribe-button"; 14 16 ··· 25 27 26 28 export function Header({ navigation, plan, page }: Props) { 27 29 const selectedSegment = useSelectedLayoutSegment(); 28 - const isSubscribers = allPlans[plan].limits["status-subscribers"]; 30 + const isSubscribers = allPlans[plan].limits["status-subscribers"]; // FIXME: use the workspace.limits 29 31 30 32 return ( 31 - <header className="w-full"> 32 - <Shell className="flex items-center justify-between gap-4 px-3 py-3 md:px-6 md:py-3"> 33 - <div className="relative sm:w-[100px]"> 33 + <header className="sticky top-3 z-10 w-full"> 34 + <div className="flex w-full items-center justify-between gap-8 rounded-full border border-border px-2.5 py-1.5 backdrop-blur-lg md:top-6"> 35 + <div className="relative sm:w-[120px]"> 34 36 {page?.icon ? ( 35 - <div className="flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border bg-muted"> 37 + <div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-border bg-muted"> 36 38 <Image 37 39 height={36} 38 40 width={36} ··· 43 45 </div> 44 46 ) : null} 45 47 </div> 46 - <TabsContainer className="-mb-[14px] hidden sm:block"> 48 + <ul className="hidden items-center space-x-1 sm:flex"> 47 49 {navigation.map(({ label, href, disabled, segment }) => { 48 50 const active = segment === selectedSegment; 49 51 return ( 50 - <TabsLink key={segment} {...{ active, href, label, disabled }}> 51 - {label} 52 - </TabsLink> 52 + <li key={segment}> 53 + <Link 54 + className={cn( 55 + "h-9 rounded-full bg-transparent px-4 py-2 font-medium text-muted-foreground hover:bg-accent/50", 56 + { "text-foreground": active }, 57 + )} 58 + {...{ href, disabled }} 59 + > 60 + {label} 61 + </Link> 62 + </li> 53 63 ); 54 64 })} 55 - </TabsContainer> 56 - <div className="flex items-center gap-4"> 57 - <div className="text-end sm:w-[100px]"> 58 - {isSubscribers ? <SubscribeButton slug={page.slug} /> : null} 59 - </div> 65 + </ul> 66 + <div className="flex items-center gap-3"> 60 67 <div className="block sm:hidden"> 61 68 <Menu navigation={navigation} /> 62 69 </div> 70 + <div className="text-end sm:w-[120px]"> 71 + {isSubscribers ? <SubscribeButton slug={page.slug} /> : null} 72 + </div> 63 73 </div> 64 - </Shell> 74 + </div> 65 75 </header> 66 76 ); 67 77 }
+2 -2
apps/web/src/app/status-page/[domain]/_components/subscribe-button.tsx
··· 26 26 return ( 27 27 <Popover> 28 28 <PopoverTrigger asChild> 29 - <Button size="sm" variant="outline"> 29 + <Button variant="outline" className="rounded-full"> 30 30 Get updates 31 31 </Button> 32 32 </PopoverTrigger> 33 - <PopoverContent> 33 + <PopoverContent align="end"> 34 34 <div className="grid gap-4"> 35 35 <div className="space-y-2"> 36 36 <h4 className="flex items-center font-medium leading-none">
+55
apps/web/src/app/status-page/[domain]/events/page.tsx
··· 1 + import { Header } from "@/components/dashboard/header"; 2 + import { SearchParamsPreset } from "@/components/monitor-dashboard/search-params-preset"; // TOO: move to shared components 3 + import { Feed } from "@/components/status-page/feed"; 4 + import { api } from "@/trpc/server"; 5 + import { notFound } from "next/navigation"; 6 + import { z } from "zod"; 7 + import { formatter } from "./utils"; 8 + 9 + const searchParamsSchema = z.object({ 10 + filter: z.enum(["all", "maintenances", "reports"]).optional().default("all"), 11 + }); 12 + 13 + type Props = { 14 + params: { domain: string }; 15 + searchParams: { [key: string]: string | string[] | undefined }; 16 + }; 17 + 18 + export const revalidate = 120; 19 + 20 + export default async function Page({ params, searchParams }: Props) { 21 + const page = await api.page.getPageBySlug.query({ slug: params.domain }); 22 + const search = searchParamsSchema.safeParse(searchParams); 23 + 24 + if (!page) return notFound(); 25 + 26 + const filter = search.success ? search.data.filter : "all"; 27 + 28 + return ( 29 + <div className="grid gap-8"> 30 + <Header 31 + title={page.title} 32 + description={page.description} 33 + actions={ 34 + <SearchParamsPreset 35 + searchParam="filter" 36 + defaultValue={filter} 37 + values={["all", "maintenances", "reports"]} 38 + className="w-auto sm:w-[150px]" 39 + formatter={formatter} 40 + /> 41 + } 42 + className="text-left" 43 + /> 44 + <Feed 45 + monitors={page.monitors} 46 + maintenances={ 47 + ["all", "maintenances"].includes(filter) ? page.maintenances : [] 48 + } 49 + statusReports={ 50 + ["all", "reports"].includes(filter) ? page.statusReports : [] 51 + } 52 + /> 53 + </div> 54 + ); 55 + }
+58
apps/web/src/app/status-page/[domain]/events/report/[id]/page.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + 3 + import { Header } from "@/components/dashboard/header"; 4 + import { DateTimeTooltip } from "@/components/status-page/datetime-tooltip"; 5 + import { StatusReportUpdates } from "@/components/status-page/status-report"; 6 + import { api } from "@/trpc/server"; 7 + import { Badge } from "@openstatus/ui"; 8 + import { CopyLinkButton } from "./_components/copy-link-button"; 9 + 10 + export default async function IncidentPage({ 11 + params, 12 + }: { 13 + params: { domain: string; id: string }; 14 + }) { 15 + const report = await api.statusReport.getPublicStatusReportById.query({ 16 + slug: params.domain, 17 + id: Number(params.id), 18 + }); 19 + 20 + if (!report) return notFound(); 21 + 22 + const affectedMonitors = report.monitorsToStatusReports.map( 23 + ({ monitor }) => monitor, 24 + ); 25 + 26 + const firstUpdate = 27 + report.statusReportUpdates[report.statusReportUpdates.length - 1] || 28 + undefined; 29 + 30 + return ( 31 + <div className="grid gap-8 text-left"> 32 + <Header 33 + title={report.title} 34 + description={ 35 + <div className="mt-2 flex flex-wrap items-center gap-2"> 36 + <p className="text-muted-foreground text-sm tracking-wide"> 37 + Started at <DateTimeTooltip date={firstUpdate?.date} /> 38 + </p> 39 + {affectedMonitors.length > 0 ? ( 40 + <> 41 + <span className="text-muted-foreground/50 text-xs">•</span> 42 + <div className="flex flex-wrap gap-2"> 43 + {affectedMonitors.map((monitor) => ( 44 + <Badge key={monitor.id} variant="secondary"> 45 + {monitor.name} 46 + </Badge> 47 + ))} 48 + </div> 49 + </> 50 + ) : null} 51 + </div> 52 + } 53 + actions={<CopyLinkButton />} 54 + /> 55 + <StatusReportUpdates updates={report.statusReportUpdates} /> 56 + </div> 57 + ); 58 + }
+5
apps/web/src/app/status-page/[domain]/events/utils.tsx
··· 1 + "use client"; 2 + 3 + export function formatter(value: string) { 4 + return <span className="capitalize">{value}</span>; 5 + }
+2 -36
apps/web/src/app/status-page/[domain]/incidents/[id]/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import { 5 - StatusReportDescription, 6 - StatusReportUpdates, 7 - } from "@/components/status-page/status-report"; 8 - import { api } from "@/trpc/server"; 9 - import { CopyLinkButton } from "./_components/copy-link-button"; 1 + import { redirect } from "next/navigation"; 10 2 11 3 export default async function IncidentPage({ 12 4 params, 13 5 }: { 14 6 params: { domain: string; id: string }; 15 7 }) { 16 - const report = await api.statusReport.getPublicStatusReportById.query({ 17 - slug: params.domain, 18 - id: Number(params.id), 19 - }); 20 - 21 - if (!report) return notFound(); 22 - 23 - const affectedMonitors = report.monitorsToStatusReports.map( 24 - ({ monitor }) => monitor, 25 - ); 26 - 27 - return ( 28 - <div className="grid gap-8 text-left"> 29 - <Header 30 - title={report.title} 31 - description={ 32 - <StatusReportDescription 33 - report={report} 34 - monitors={affectedMonitors} 35 - className="mt-2" 36 - /> 37 - } 38 - actions={<CopyLinkButton />} 39 - /> 40 - <StatusReportUpdates report={report} /> 41 - </div> 42 - ); 8 + redirect(`../events/report/${params.id}`); 43 9 }
+3 -29
apps/web/src/app/status-page/[domain]/incidents/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import { StatusReportList } from "@/components/status-page/status-report-list"; 5 - import { api } from "@/trpc/server"; 6 - 7 - type Props = { 8 - params: { domain: string }; 9 - searchParams: { [key: string]: string | string[] | undefined }; 10 - }; 11 - 12 - export default async function Page({ params }: Props) { 13 - if (!params.domain) return notFound(); 14 - 15 - const page = await api.page.getPageBySlug.query({ slug: params.domain }); 16 - if (!page) return notFound(); 1 + import { redirect } from "next/navigation"; 17 2 18 - return ( 19 - <div className="grid gap-8"> 20 - <Header 21 - title={page.title} 22 - description={page.description} 23 - className="text-left" 24 - /> 25 - <StatusReportList 26 - statusReports={page.statusReports} 27 - monitors={page.monitors} 28 - /> 29 - </div> 30 - ); 3 + export default async function Page() { 4 + redirect("./events?filter=reports"); 31 5 }
+10 -14
apps/web/src/app/status-page/[domain]/layout.tsx
··· 8 8 twitterMetadata, 9 9 } from "@/app/shared-metadata"; 10 10 import { Shell } from "@/components/dashboard/shell"; 11 + import { getRequestHeaderTimezone } from "@/lib/timezone"; 11 12 import { api } from "@/trpc/server"; 12 13 import { Footer } from "./_components/footer"; 13 14 import { Header } from "./_components/header"; ··· 21 22 22 23 export default async function StatusPageLayout({ children, params }: Props) { 23 24 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 25 + const timeZone = getRequestHeaderTimezone(); 24 26 25 27 if (!page) return notFound(); 26 28 ··· 33 35 href: setPrefixUrl("/", params), 34 36 }, 35 37 { 36 - label: "Maintenances", 37 - segment: "maintenances", 38 - href: setPrefixUrl("/maintenances", params), 39 - disabled: page.maintenances.length === 0, 40 - }, 41 - { 42 - label: "Incidents", 43 - segment: "incidents", 44 - href: setPrefixUrl("/incidents", params), 38 + label: "Events", 39 + segment: "events", 40 + href: setPrefixUrl("/events", params), 45 41 }, 46 42 { 47 43 label: "Monitors", 48 44 segment: "monitors", 49 45 href: setPrefixUrl("/monitors", params), 50 - disabled: 51 - page.monitors.filter((monitor) => Boolean(monitor.public)).length === 0, 52 46 }, 53 47 ]; 54 48 ··· 64 58 } 65 59 66 60 return ( 67 - <div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col space-y-6 p-4 md:p-8"> 61 + <div className="relative mx-auto flex min-h-screen w-full max-w-4xl flex-col space-y-6 p-4 md:p-8"> 68 62 <Header navigation={navigation} plan={plan} page={page} /> 69 63 <main className="flex h-full w-full flex-1 flex-col"> 70 - <Shell className="mx-auto h-full flex-1 px-4 py-4">{children}</Shell> 64 + <Shell className="mx-auto h-full flex-1 rounded-2xl px-4 py-4 md:p-8"> 65 + {children} 66 + </Shell> 71 67 </main> 72 - <Footer plan={plan} /> 68 + <Footer plan={plan} timeZone={timeZone} /> 73 69 </div> 74 70 ); 75 71 }
+3 -29
apps/web/src/app/status-page/[domain]/maintenances/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import { MaintenanceList } from "@/components/status-page/maintenance-list"; 5 - import { api } from "@/trpc/server"; 6 - 7 - type Props = { 8 - params: { domain: string }; 9 - searchParams: { [key: string]: string | string[] | undefined }; 10 - }; 11 - 12 - export default async function Page({ params }: Props) { 13 - if (!params.domain) return notFound(); 14 - 15 - const page = await api.page.getPageBySlug.query({ slug: params.domain }); 16 - if (!page) return notFound(); 1 + import { redirect } from "next/navigation"; 17 2 18 - return ( 19 - <div className="grid gap-8"> 20 - <Header 21 - title={page.title} 22 - description={page.description} 23 - className="text-left" 24 - /> 25 - <MaintenanceList 26 - maintenances={page.maintenances} 27 - monitors={page.monitors} 28 - /> 29 - </div> 30 - ); 3 + export default async function Page() { 4 + redirect("./events?filter=maintenances"); 31 5 }
+6 -3
apps/web/src/app/status-page/[domain]/monitors/page.tsx
··· 6 6 import { OSTinybird } from "@openstatus/tinybird"; 7 7 import { Button } from "@openstatus/ui"; 8 8 9 + import { EmptyState } from "@/components/dashboard/empty-state"; 9 10 import { Header } from "@/components/dashboard/header"; 10 11 import { SimpleChart } from "@/components/monitor-charts/simple-chart"; 11 12 import { groupDataByTimestamp } from "@/components/monitor-charts/utils"; ··· 111 112 </ul> 112 113 </div> 113 114 ) : ( 114 - <p className="text-center font-light text-muted-foreground text-sm"> 115 - No public monitor. 116 - </p> 115 + <EmptyState 116 + icon="activity" 117 + title="No public monitors" 118 + description="No public monitors have been added to this page." 119 + /> 117 120 )} 118 121 </div> 119 122 );
+47 -35
apps/web/src/app/status-page/[domain]/page.tsx
··· 1 1 import { subDays } from "date-fns"; 2 2 import { notFound } from "next/navigation"; 3 3 4 - import { Separator } from "@openstatus/ui"; 5 - 4 + import { EmptyState } from "@/components/dashboard/empty-state"; 6 5 import { Header } from "@/components/dashboard/header"; 7 - import { MaintenanceBanner } from "@/components/status-page/maintenance-banner"; 6 + import { Feed } from "@/components/status-page/feed"; 8 7 import { MonitorList } from "@/components/status-page/monitor-list"; 9 8 import { StatusCheck } from "@/components/status-page/status-check"; 10 - import { StatusReportList } from "@/components/status-page/status-report-list"; 11 9 import { api } from "@/trpc/server"; 10 + import { Separator } from "@openstatus/ui"; 12 11 13 12 type Props = { 14 13 params: { domain: string }; ··· 21 20 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 22 21 if (!page) return notFound(); 23 22 24 - const currentMaintenances = page.maintenances.filter( 25 - (maintenance) => 26 - maintenance.to.getTime() > Date.now() && 27 - maintenance.from.getTime() < Date.now(), 28 - ); 23 + const lastMaintenances = page.maintenances.filter((maintenance) => { 24 + return maintenance.from.getTime() > subDays(new Date(), 7).getTime(); 25 + }); 26 + 27 + const lastStatusReports = page.statusReports.filter((report) => { 28 + return report.statusReportUpdates.some( 29 + (update) => update.date.getTime() > subDays(new Date(), 7).getTime(), 30 + ); 31 + }); 29 32 30 33 return ( 31 - <div className="mx-auto flex w-full flex-col gap-8"> 34 + <div className="mx-auto flex w-full flex-col gap-12"> 32 35 <Header 33 36 title={page.title} 34 37 description={page.description} ··· 39 42 incidents={page.incidents} 40 43 maintenances={page.maintenances} 41 44 /> 42 - {currentMaintenances.length ? ( 43 - <div className="grid w-full gap-3"> 44 - {currentMaintenances.map((maintenance) => ( 45 - <MaintenanceBanner key={maintenance.id} {...maintenance} /> 46 - ))} 47 - </div> 48 - ) : null} 49 - <MonitorList 50 - monitors={page.monitors} 51 - statusReports={page.statusReports} 52 - incidents={page.incidents} 53 - maintenances={page.maintenances} 54 - /> 55 - <Separator /> 56 - <div className="grid gap-6"> 57 - <div> 58 - <h2 className="font-semibold text-xl">Last updates</h2> 59 - <p className="text-muted-foreground text-sm"> 60 - Reports of the last 7 days or incidents that have not been resolved 61 - yet. 62 - </p> 63 - </div> 64 - <StatusReportList 45 + {page.monitors.length ? ( 46 + <MonitorList 47 + monitors={page.monitors} 65 48 statusReports={page.statusReports} 49 + incidents={page.incidents} 50 + maintenances={page.maintenances} 51 + /> 52 + ) : ( 53 + <EmptyState 54 + icon="activity" 55 + title="No monitors" 56 + description="The status page has no connected monitors." 57 + /> 58 + )} 59 + <Separator /> 60 + {lastStatusReports.length || lastMaintenances.length ? ( 61 + <Feed 66 62 monitors={page.monitors} 67 - filter={{ date: subDays(Date.now(), 7), open: true }} 63 + maintenances={lastMaintenances.filter((maintenance) => { 64 + return ( 65 + maintenance.from.getTime() > subDays(new Date(), 7).getTime() 66 + ); 67 + })} 68 + statusReports={lastStatusReports.filter((report) => { 69 + return report.statusReportUpdates.some( 70 + (update) => 71 + update.date.getTime() > subDays(new Date(), 7).getTime(), 72 + ); 73 + })} 68 74 /> 69 - </div> 75 + ) : ( 76 + <EmptyState 77 + icon="newspaper" 78 + title="No recent notices" 79 + description="There have been no reports within the last 7 days." 80 + /> 81 + )} 70 82 </div> 71 83 ); 72 84 }
+7 -7
apps/web/src/app/status/utils.ts
··· 34 34 export function getClassname(status: ExternalStatus) { 35 35 switch (status.status_description) { 36 36 case "All Systems Operational": 37 - return "text-green-500"; 37 + return "text-status-operational"; 38 38 case "Major System Outage": 39 - return "text-rose-500"; 39 + return "text-status-down"; 40 40 case "Partial System Outage": 41 - return "text-orange-500"; 41 + return "text-status-degraded"; 42 42 case "Minor Service Outage": 43 - return "text-amber-500"; 43 + return "text-status-degraded"; 44 44 case "Degraded System Service": 45 - return "text-amber-500"; 45 + return "text-status-degraded"; 46 46 case "Partially Degraded Service": 47 - return "text-amber-500"; 47 + return "text-status-degraded"; 48 48 case "Service Under Maintenance": 49 - return "text-blue-500"; 49 + return "text-status-monitoring"; 50 50 default: 51 51 return "text-gray-500"; 52 52 }
+1 -8
apps/web/src/components/data-table/data-table-status-badge.tsx
··· 9 9 statusCode: Ping["statusCode"]; 10 10 }) { 11 11 if (!statusCode) { 12 - return ( 13 - <Badge 14 - variant="outline" 15 - className="border-rose-500/20 bg-rose-500/10 text-rose-800 dark:text-rose-300" 16 - > 17 - Error 18 - </Badge> 19 - ); 12 + return <Badge variant="destructive">Error</Badge>; 20 13 } 21 14 return <StatusCodeBadge statusCode={statusCode} />; 22 15 }
+1 -1
apps/web/src/components/data-table/status-page/columns.tsx
··· 29 29 cell: ({ row }) => { 30 30 return ( 31 31 <Link 32 - href={`./status-pages/${row.original.id}/edit`} 32 + href={`./status-pages/${row.original.id}/reports`} 33 33 className="group flex items-center gap-2" 34 34 > 35 35 <span className="max-w-[125px] truncate group-hover:underline">
+3 -2
apps/web/src/components/forms/monitor/section-limits.tsx
··· 60 60 </Select> 61 61 <FormDescription> 62 62 When the response time exceeds this limit, the monitor is will be 63 - considered as <span className="text-amber-500">degraded</span>. 63 + considered as{" "} 64 + <span className="text-status-degraded">degraded</span>. 64 65 </FormDescription> 65 66 <FormMessage /> 66 67 </FormItem> ··· 91 92 </Select> 92 93 <FormDescription> 93 94 When the response time exceeds this limit, the monitor is will be 94 - considered as <span className="text-rose-500">failed</span>. 95 + considered as <span className="text-status-down">failed</span>. 95 96 </FormDescription> 96 97 <FormMessage /> 97 98 </FormItem>
+29 -1
apps/web/src/components/layout/header/user-nav.tsx
··· 1 1 import { signOut, useSession } from "next-auth/react"; 2 + import { useTheme } from "next-themes"; 2 3 import Link from "next/link"; 3 4 import { useParams } from "next/navigation"; 4 5 ··· 8 9 AvatarImage, 9 10 Button, 10 11 DropdownMenu, 12 + DropdownMenuCheckboxItem, 11 13 DropdownMenuContent, 12 14 DropdownMenuGroup, 13 15 DropdownMenuItem, 14 16 DropdownMenuLabel, 17 + DropdownMenuPortal, 15 18 DropdownMenuSeparator, 19 + DropdownMenuSub, 20 + DropdownMenuSubContent, 21 + DropdownMenuSubTrigger, 16 22 DropdownMenuTrigger, 17 23 Skeleton, 18 24 } from "@openstatus/ui"; ··· 20 26 export function UserNav() { 21 27 const session = useSession(); 22 28 const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); 29 + const { setTheme, theme } = useTheme(); 23 30 24 31 if (session.status !== "authenticated") { 25 32 return <Skeleton className="h-8 w-8 rounded-full" />; ··· 41 48 </Avatar> 42 49 </Button> 43 50 </DropdownMenuTrigger> 44 - <DropdownMenuContent className="w-56" align="end" forceMount> 51 + <DropdownMenuContent className="w-52" align="end" forceMount> 45 52 <DropdownMenuLabel className="font-normal"> 46 53 <div className="flex flex-col space-y-1"> 47 54 <p className="truncate font-medium text-sm leading-none"> ··· 61 68 <DropdownMenuItem asChild> 62 69 <Link href={`/app/${workspaceSlug}/settings/user`}>Profile</Link> 63 70 </DropdownMenuItem> 71 + </DropdownMenuGroup>{" "} 72 + <DropdownMenuSeparator /> 73 + <DropdownMenuGroup> 74 + <DropdownMenuSub> 75 + <DropdownMenuSubTrigger>Switch theme</DropdownMenuSubTrigger> 76 + <DropdownMenuPortal> 77 + <DropdownMenuSubContent> 78 + <DropdownMenuLabel>Appearance</DropdownMenuLabel> 79 + {["light", "dark", "system"].map((option) => ( 80 + <DropdownMenuCheckboxItem 81 + key={option} 82 + checked={theme === option} 83 + onClick={() => setTheme(option)} 84 + className="capitalize" 85 + > 86 + {option} 87 + </DropdownMenuCheckboxItem> 88 + ))} 89 + </DropdownMenuSubContent> 90 + </DropdownMenuPortal> 91 + </DropdownMenuSub> 64 92 </DropdownMenuGroup> 65 93 <DropdownMenuSeparator /> 66 94 <DropdownMenuItem onClick={() => signOut()}>Log out</DropdownMenuItem>
+5 -2
apps/web/src/components/marketing/monitor/card.tsx
··· 29 29 // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> 30 30 <CardFeature key={i} {...feature} /> 31 31 ))} 32 - <div className="order-first text-center md:order-none"> 33 - <Button asChild variant="outline" className="rounded-full"> 32 + <div className="order-first flex items-center justify-center gap-2 text-center md:order-none"> 33 + <Button variant="outline" className="rounded-full" asChild> 34 34 <Link href="/play/checker">Playground</Link> 35 + </Button> 36 + <Button className="rounded-full" asChild> 37 + <Link href="/features/monitoring">Learn more</Link> 35 38 </Button> 36 39 </div> 37 40 </CardFeatureContainer>
+2 -2
apps/web/src/components/marketing/pricing/enterprice-plan.tsx
··· 6 6 <div className="flex-1"> 7 7 <div className="flex items-end justify-between gap-4"> 8 8 <div> 9 - <p className="mb-2 font-cal text-xl">Custom</p> 9 + <p className="mb-2 font-cal text-2xl">Custom</p> 10 10 <p className="text-muted-foreground"> 11 11 Want more regions? Want to host it on your own server? Want 12 12 something else? We can help you with that. ··· 15 15 </div> 16 16 </div> 17 17 <div> 18 - <Button asChild> 18 + <Button className="rounded-full" asChild> 19 19 <a 20 20 href="https://cal.com/team/openstatus/30min" 21 21 target="_blank"
+2 -2
apps/web/src/components/marketing/status-page/tracker-example.tsx
··· 17 17 <ExampleTracker /> 18 18 </Suspense> 19 19 </div> 20 - <Button asChild variant="outline" className="rounded-full"> 21 - <Link href="/play/status">Playground</Link> 20 + <Button className="rounded-full" asChild> 21 + <Link href="/features/status-page">Learn more</Link> 22 22 </Button> 23 23 </div> 24 24 );
+6 -1
apps/web/src/components/monitor-dashboard/search-params-preset.tsx
··· 13 13 import { Icons } from "@/components/icons"; 14 14 import type { ValidIcon } from "@/components/icons"; 15 15 import useUpdateSearchParams from "@/hooks/use-update-search-params"; 16 + import { cn } from "@/lib/utils"; 16 17 17 18 export function SearchParamsPreset<T extends string>({ 18 19 disabled, ··· 22 23 icon, 23 24 placeholder, 24 25 formatter, 26 + className, 25 27 }: { 26 28 disabled?: boolean; 27 29 defaultValue?: T; ··· 30 32 icon?: ValidIcon; 31 33 placeholder?: string; 32 34 formatter?(value: T): ReactNode; 35 + className?: string; 33 36 }) { 34 37 const router = useRouter(); 35 38 const pathname = usePathname(); ··· 48 51 onValueChange={onSelect} 49 52 disabled={disabled} 50 53 > 51 - <SelectTrigger className="w-[150px] bg-background text-left"> 54 + <SelectTrigger 55 + className={cn("w-[150px] bg-background text-left", className)} 56 + > 52 57 <span className="flex items-center gap-2"> 53 58 {Icon ? <Icon className="h-4 w-4" /> : null} 54 59 <SelectValue placeholder={placeholder} />
+9 -9
apps/web/src/components/monitor/status-dot.tsx
··· 10 10 if (!active) { 11 11 return ( 12 12 <span className="relative flex h-2 w-2"> 13 - <span className="absolute inline-flex h-2 w-2 rounded-full bg-orange-500" /> 13 + <span className="absolute inline-flex h-2 w-2 rounded-full bg-muted-foreground/80" /> 14 14 </span> 15 15 ); 16 16 } 17 17 if (maintenance) { 18 18 return ( 19 19 <span className="relative flex h-2 w-2"> 20 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500/80 opacity-75 duration-[2000ms]" /> 21 - <span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" /> 20 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-monitoring/80 opacity-75 duration-[2000ms]" /> 21 + <span className="relative inline-flex h-2 w-2 rounded-full bg-status-monitoring" /> 22 22 </span> 23 23 ); 24 24 } 25 25 if (status === "error") { 26 26 return ( 27 27 <span className="relative flex h-2 w-2"> 28 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500/80 opacity-75 duration-[2000ms]" /> 29 - <span className="absolute inline-flex h-2 w-2 rounded-full bg-red-500" /> 28 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-down/80 opacity-75 duration-[2000ms]" /> 29 + <span className="absolute inline-flex h-2 w-2 rounded-full bg-status-down" /> 30 30 </span> 31 31 ); 32 32 } 33 33 if (status === "degraded") { 34 34 return ( 35 35 <span className="relative flex h-2 w-2"> 36 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-500/80 opacity-75 duration-[2000ms]" /> 37 - <span className="relative inline-flex h-2 w-2 rounded-full bg-yellow-500" /> 36 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-degraded/80 opacity-75 duration-[2000ms]" /> 37 + <span className="relative inline-flex h-2 w-2 rounded-full bg-status-degraded" /> 38 38 </span> 39 39 ); 40 40 } 41 41 42 42 return ( 43 43 <span className="relative flex h-2 w-2"> 44 - <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500/80 opacity-75 duration-[2000ms]" /> 45 - <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" /> 44 + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-status-operational/80 opacity-75 duration-[2000ms]" /> 45 + <span className="relative inline-flex h-2 w-2 rounded-full bg-status-operational" /> 46 46 </span> 47 47 ); 48 48 }
+4 -4
apps/web/src/components/status-page/datetime-tooltip.tsx
··· 17 17 }: { 18 18 date?: Date; 19 19 className?: string; 20 + // formatter?: (date: Date) => string; 20 21 }) { 21 22 const [open, setOpen] = useState(false); 22 23 return ( ··· 25 26 <TooltipTrigger 26 27 onClick={() => setOpen(false)} 27 28 className={cn( 28 - "text-muted-foreground underline decoration-muted-foreground/30 decoration-dashed underline-offset-4", 29 + "font-mono text-muted-foreground underline decoration-muted-foreground/30 decoration-dashed underline-offset-4", 29 30 className, 30 31 )} 31 - asChild 32 32 > 33 - <span>{formatInTimeZone(date, "UTC", "LLL dd, y HH:mm (z)")}</span> 33 + {formatInTimeZone(date, "UTC", "LLL dd, y HH:mm (z)")} 34 34 </TooltipTrigger> 35 35 <TooltipContent> 36 - <p className="text-muted-foreground text-xs"> 36 + <p className="font-mono text-muted-foreground text-xs"> 37 37 {format(date, "LLL dd, y HH:mm (z)")} 38 38 </p> 39 39 </TooltipContent>
+17
apps/web/src/components/status-page/day-header.tsx
··· 1 + import { Badge, Separator } from "@openstatus/ui"; 2 + import { format } from "date-fns"; 3 + 4 + export function DayHeader({ date }: { date: Date }) { 5 + const isInFuture = date.getTime() > new Date().getTime(); 6 + return ( 7 + <div className="grid gap-2"> 8 + <div className="flex items-center justify-between"> 9 + <p className="font-mono text-muted-foreground text-sm"> 10 + {format(date, "LLL dd, y")} 11 + </p> 12 + {isInFuture ? <Badge>Coming up</Badge> : null} 13 + </div> 14 + <Separator /> 15 + </div> 16 + ); 17 + }
+149
apps/web/src/components/status-page/feed.tsx
··· 1 + import { notEmpty } from "@/lib/utils"; 2 + import type { 3 + Maintenance, 4 + PublicMonitor, 5 + StatusReportWithUpdates, 6 + } from "@openstatus/db/src/schema"; 7 + import { EmptyState } from "../dashboard/empty-state"; 8 + import { DayHeader } from "./day-header"; 9 + import { MaintenanceContainer } from "./maintenance"; 10 + import { StatusReport } from "./status-report"; 11 + 12 + function isMaintenanceType(value: unknown): value is Maintenance { 13 + return (value as Maintenance).from !== undefined; 14 + } 15 + 16 + function isStatusReportType(value: unknown): value is StatusReportWithUpdates { 17 + return (value as StatusReportWithUpdates).statusReportUpdates !== undefined; 18 + } 19 + 20 + export function Feed({ 21 + maintenances, 22 + monitors, 23 + statusReports, 24 + }: { 25 + maintenances: Maintenance[]; 26 + statusReports: StatusReportWithUpdates[]; 27 + monitors: PublicMonitor[]; 28 + }) { 29 + if ([...maintenances, ...statusReports].length === 0) { 30 + return ( 31 + <EmptyState icon="newspaper" title="No entries found." description="" /> 32 + ); 33 + } 34 + 35 + function groupByDay( 36 + maintenances: Maintenance[], 37 + statusReports: StatusReportWithUpdates[], 38 + ) { 39 + const grouped = [...maintenances, ...statusReports].reduce( 40 + (acc, element) => { 41 + const isMaintenance = isMaintenanceType(element); 42 + const isStatusReport = isStatusReportType(element); 43 + 44 + if (isMaintenance) { 45 + const date = new Date(element.from.toDateString()).getTime(); 46 + 47 + const exists = acc.find((item) => item.timestamp === date); 48 + 49 + if (exists) { 50 + exists.list.push({ type: "maintenance", value: element }); 51 + } else { 52 + acc.push({ 53 + timestamp: date, 54 + list: [{ type: "maintenance", value: element }], 55 + }); 56 + } 57 + } 58 + 59 + if (isStatusReport) { 60 + const firstUpdate = element.statusReportUpdates[0]; // make sure we get the correct order from backend query! 61 + const date = ( 62 + firstUpdate 63 + ? new Date(firstUpdate?.date.toDateString()) 64 + : new Date(new Date().toDateString()) 65 + ).getTime(); 66 + 67 + const exists = acc.find((item) => item.timestamp === date); 68 + 69 + if (exists) { 70 + exists.list.push({ type: "report", value: element }); 71 + } else { 72 + acc.push({ 73 + timestamp: date, 74 + list: [{ type: "report", value: element }], 75 + }); 76 + } 77 + } 78 + 79 + return acc; 80 + }, 81 + [] as { 82 + timestamp: number; 83 + list: ( 84 + | { 85 + type: "maintenance"; 86 + value: Maintenance; 87 + } 88 + | { 89 + type: "report"; 90 + value: StatusReportWithUpdates; 91 + } 92 + )[]; 93 + }[], 94 + ); 95 + 96 + grouped.sort((a, b) => b.timestamp - a.timestamp); 97 + 98 + return grouped; 99 + } 100 + 101 + return ( 102 + <div className="grid gap-8"> 103 + {groupByDay(maintenances, statusReports).map((group) => { 104 + return ( 105 + <div key={group.timestamp} className="grid gap-4"> 106 + <DayHeader date={new Date(group.timestamp)} /> 107 + {group.list.map((item, _i) => { 108 + if (item.type === "maintenance") { 109 + const affectedMonitors = item.value.monitors 110 + ?.map((monitorId) => { 111 + const monitor = monitors.find(({ id }) => monitorId === id); 112 + return monitor || undefined; 113 + }) 114 + .filter(notEmpty); 115 + 116 + return ( 117 + <MaintenanceContainer 118 + key={item.value.id} 119 + maintenance={item.value} 120 + monitors={affectedMonitors || []} 121 + className="rounded-lg border-status-monitoring/10 bg-status-monitoring/5" 122 + /> 123 + ); 124 + } 125 + if (item.type === "report") { 126 + const affectedMonitors = item.value.monitorsToStatusReports 127 + .map(({ monitorId }) => { 128 + const monitor = monitors.find(({ id }) => monitorId === id); 129 + return monitor || undefined; 130 + }) 131 + .filter(notEmpty); 132 + 133 + return ( 134 + <div key={item.value.id} className="grid gap-6"> 135 + <StatusReport 136 + monitors={affectedMonitors} 137 + report={item.value} 138 + /> 139 + </div> 140 + ); 141 + } 142 + return null; 143 + })} 144 + </div> 145 + ); 146 + })} 147 + </div> 148 + ); 149 + }
-21
apps/web/src/components/status-page/maintenance-banner.tsx
··· 1 - import { format, isSameDay } from "date-fns"; 2 - import { Hammer } from "lucide-react"; 3 - 4 - import type { Maintenance } from "@openstatus/db/src/schema"; 5 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 6 - 7 - export function MaintenanceBanner(props: Maintenance) { 8 - return ( 9 - <Alert className="border-blue-500/20 bg-blue-500/10"> 10 - <Hammer className="h-4 w-4" /> 11 - <AlertTitle>{props.title}</AlertTitle> 12 - <AlertDescription>{props.message}</AlertDescription> 13 - <AlertDescription className="mt-2 font-mono"> 14 - {format(props.from, "LLL dd, y HH:mm")} -{" "} 15 - {isSameDay(props.from, props.to) 16 - ? format(props.to, "HH:mm") 17 - : format(props.to, "LLL dd, y HH:mm")} 18 - </AlertDescription> 19 - </Alert> 20 - ); 21 - }
-62
apps/web/src/components/status-page/maintenance-list.tsx
··· 1 - import { notEmpty } from "@/lib/utils"; 2 - import type { Maintenance, PublicMonitor } from "@openstatus/db/src/schema"; 3 - import { Badge } from "@openstatus/ui"; 4 - import { format, isSameDay } from "date-fns"; 5 - 6 - export function MaintenanceList({ 7 - maintenances, 8 - monitors, 9 - }: { 10 - maintenances: Maintenance[]; 11 - monitors: PublicMonitor[]; 12 - }) { 13 - if (!maintenances.length) { 14 - return <EmptyState />; 15 - } 16 - 17 - return ( 18 - <ul className="grid gap-6"> 19 - {maintenances.map((maintenance) => { 20 - const maintenanceMonitors = maintenance.monitors 21 - ?.map((id) => { 22 - return monitors.find((monitor) => monitor.id === id); 23 - }) 24 - .filter(notEmpty); 25 - return ( 26 - <li key={maintenance.id} className="grid gap-3"> 27 - <div> 28 - <h3 className="font-semibold text-xl">{maintenance.title}</h3> 29 - <div className="flex flex-wrap items-center gap-2"> 30 - <span className="font-mono text-muted-foreground text-sm"> 31 - {format(maintenance.from, "LLL dd, y HH:mm")} -{" "} 32 - {isSameDay(maintenance.from, maintenance.to) 33 - ? format(maintenance.to, "HH:mm") 34 - : format(maintenance.to, "LLL dd, y HH:mm")} 35 - </span> 36 - {maintenanceMonitors?.length ? ( 37 - <> 38 - <span className="text-muted-foreground/50 text-xs">•</span> 39 - {maintenanceMonitors.map((monitor) => ( 40 - <Badge key={monitor.id} variant="secondary"> 41 - {monitor.name} 42 - </Badge> 43 - ))} 44 - </> 45 - ) : null} 46 - </div> 47 - </div> 48 - <p>{maintenance.message}</p> 49 - </li> 50 - ); 51 - })} 52 - </ul> 53 - ); 54 - } 55 - 56 - function EmptyState() { 57 - return ( 58 - <p className="text-center font-light text-muted-foreground text-sm"> 59 - No maintenances reported. 60 - </p> 61 - ); 62 - }
+38
apps/web/src/components/status-page/maintenance.tsx
··· 1 + import { cn } from "@/lib/utils"; 2 + import type { Maintenance, PublicMonitor } from "@openstatus/db/src/schema"; 3 + import { DateTimeTooltip } from "./datetime-tooltip"; 4 + import { StatusReportHeader, StatusReportUpdates } from "./status-report"; 5 + 6 + export function MaintenanceContainer({ 7 + maintenance, 8 + monitors, 9 + className, 10 + }: { 11 + maintenance: Maintenance; 12 + monitors: PublicMonitor[]; 13 + className?: string; 14 + }) { 15 + return ( 16 + <div className={cn("grid gap-4 border border-transparent p-3", className)}> 17 + <StatusReportHeader 18 + title={maintenance.title} 19 + monitors={monitors || []} 20 + actions={ 21 + <p className="font-mono text-muted-foreground text-xs"> 22 + <DateTimeTooltip date={maintenance.from} /> -{" "} 23 + <DateTimeTooltip date={maintenance.to} /> 24 + </p> 25 + } 26 + /> 27 + <StatusReportUpdates 28 + updates={[ 29 + { 30 + message: maintenance.message, 31 + id: maintenance.id, 32 + status: "maintenance", 33 + }, 34 + ]} 35 + /> 36 + </div> 37 + ); 38 + }
+20 -9
apps/web/src/components/status-page/status-check.tsx
··· 24 24 const details = tracker.currentDetails; 25 25 26 26 return ( 27 - <div className="flex flex-col items-center gap-3"> 28 - <div className="flex items-center gap-3"> 27 + <div 28 + className={cn( 29 + "flex items-center gap-3 rounded-lg border p-3", 30 + containerClassName(details.variant), 31 + )} 32 + > 33 + <span className={cn("rounded-full border p-1.5", className)}> 34 + <StatusIcon variant={details.variant} /> 35 + </span> 36 + <div className="flex w-full flex-wrap items-center justify-between gap-1"> 29 37 <h2 className="font-semibold text-xl">{details.long}</h2> 30 - <span className={cn("rounded-full border p-1.5", className)}> 31 - <StatusIcon variant={details.variant} /> 32 - </span> 33 - </div> 34 - <div className="flex flex-wrap gap-2"> 35 - <p className="text-muted-foreground text-xs">Status Check</p> 36 - <span className="text-muted-foreground/50 text-xs">•</span>{" "} 37 38 <p className="text-xs"> 38 39 <DateTimeTooltip date={new Date()} /> 39 40 </p> 40 41 </div> 41 42 </div> 42 43 ); 44 + } 45 + 46 + function containerClassName(variant: StatusVariant) { 47 + if (variant === "incident") return "bg-status-down/10 border-status-down/20"; 48 + if (variant === "maintenance") 49 + return "bg-status-monitoring/10 border-status-monitoring/20"; 50 + if (variant === "degraded") 51 + return "bg-status-degraded/10 border-status-degraded/20"; 52 + if (variant === "down") return "bg-status-down/10 border-status-down/20"; 53 + return "bg-status-operational/10 border-status-operational/20"; 43 54 } 44 55 45 56 export function StatusIcon({ variant }: { variant: StatusVariant }) {
-69
apps/web/src/components/status-page/status-report-list.tsx
··· 1 - "use client"; 2 - 3 - import type { 4 - PublicMonitor, 5 - StatusReportWithUpdates, 6 - } from "@openstatus/db/src/schema"; 7 - import { Separator } from "@openstatus/ui"; 8 - 9 - import { notEmpty } from "@/lib/utils"; 10 - import { StatusReport } from "./status-report"; 11 - 12 - export const StatusReportList = ({ 13 - statusReports, 14 - monitors, 15 - filter, 16 - }: { 17 - statusReports: StatusReportWithUpdates[]; 18 - monitors: PublicMonitor[]; 19 - filter?: { date: Date; open?: boolean }; 20 - }) => { 21 - function getFilteredReports() { 22 - if (!filter?.date) return statusReports; 23 - return statusReports.filter((report) => { 24 - if (filter.open && report.status !== "resolved") return true; 25 - return report.statusReportUpdates.some( 26 - (update) => update.date.getTime() > filter?.date?.getTime(), 27 - ); 28 - }); 29 - } 30 - 31 - const reports = getFilteredReports().sort((a, b) => { 32 - if (a.updatedAt === undefined || a.updatedAt === null) return 1; 33 - if (b?.updatedAt === undefined || b.updatedAt === null) return -1; 34 - return b.updatedAt.getTime() - a.updatedAt.getTime(); 35 - }); 36 - 37 - if (!reports.length) { 38 - return <EmptyState />; 39 - } 40 - 41 - return ( 42 - <div className="grid gap-8"> 43 - {reports.map((report, i) => { 44 - const affectedMonitors = report.monitorsToStatusReports 45 - .map(({ monitorId }) => { 46 - const monitor = monitors.find(({ id }) => monitorId === id); 47 - return monitor || undefined; 48 - }) 49 - .filter(notEmpty); 50 - const isLast = reports.length - 1 === i; 51 - 52 - return ( 53 - <div key={report.id} className="grid gap-6"> 54 - <StatusReport monitors={affectedMonitors} report={report} /> 55 - {!isLast ? <Separator /> : null} 56 - </div> 57 - ); 58 - })} 59 - </div> 60 - ); 61 - }; 62 - 63 - function EmptyState() { 64 - return ( 65 - <p className="text-center font-light text-muted-foreground text-sm"> 66 - No incident reported. 67 - </p> 68 - ); 69 - }
+96 -71
apps/web/src/components/status-page/status-report.tsx
··· 3 3 import { ChevronRight } from "lucide-react"; 4 4 import Link from "next/link"; 5 5 import { useParams } from "next/navigation"; 6 - import { Fragment } from "react"; 7 6 8 7 import type { 9 8 PublicMonitor, 10 9 StatusReportWithUpdates, 11 10 } from "@openstatus/db/src/schema"; 12 - import { Badge, Button } from "@openstatus/ui"; 11 + import { Badge } from "@openstatus/ui"; 13 12 14 13 import { setPrefixUrl } from "@/app/status-page/[domain]/utils"; 14 + import { statusDict } from "@/data/incidents-dictionary"; 15 15 import { cn } from "@/lib/utils"; 16 - import { StatusBadge } from "../status-update/status-badge"; 16 + import { Icons } from "../icons"; 17 17 import { DateTimeTooltip } from "./datetime-tooltip"; 18 18 import { ProcessMessage } from "./process-message"; 19 19 20 20 function StatusReport({ 21 21 report, 22 22 monitors, 23 + actions, 24 + isDemo, 23 25 }: { 24 26 report: StatusReportWithUpdates; 25 27 monitors: PublicMonitor[]; 28 + actions?: React.ReactNode; 29 + isDemo?: boolean; 26 30 }) { 27 - return ( 28 - <div className="group grid gap-4"> 29 - <div className="grid gap-1"> 30 - <StatusReportHeader {...{ report }} /> 31 - <StatusReportDescription {...{ report, monitors }} /> 31 + const params = useParams<{ domain: string }>(); 32 + 33 + if (isDemo) { 34 + return ( 35 + <div className="group grid gap-4 rounded-lg border border-transparent p-3 hover:border-border hover:bg-muted/20"> 36 + <StatusReportHeader title={report.title} {...{ monitors, actions }} /> 37 + <StatusReportUpdates updates={report.statusReportUpdates} /> 32 38 </div> 33 - <StatusReportUpdates {...{ report }} /> 34 - </div> 35 - ); 36 - } 39 + ); 40 + } 37 41 38 - function StatusReportHeader({ report }: { report: StatusReportWithUpdates }) { 39 - const params = useParams<{ domain: string }>(); 40 42 return ( 41 - <div className="flex items-center gap-2"> 42 - <h3 className="font-semibold text-xl">{report.title}</h3> 43 - {report.id ? ( 44 - <Button 45 - variant="ghost" 46 - size="icon" 47 - className="text-muted-foreground/50 group-hover:text-foreground" 48 - asChild 49 - > 50 - <Link href={setPrefixUrl(`/incidents/${report.id}`, params)}> 51 - <ChevronRight className="h-4 w-4" /> 52 - </Link> 53 - </Button> 54 - ) : null} 55 - </div> 43 + <Link href={setPrefixUrl(`/events/report/${report.id}`, params)}> 44 + <div className="group grid gap-4 rounded-lg border border-transparent p-3 hover:border-border hover:bg-muted/20"> 45 + <StatusReportHeader title={report.title} {...{ monitors, actions }} /> 46 + <StatusReportUpdates updates={report.statusReportUpdates} /> 47 + </div> 48 + </Link> 56 49 ); 57 50 } 58 51 59 - function StatusReportDescription({ 60 - report, 52 + // REMINDER: we had the report?.id in the link href to features page to be clickable 53 + function StatusReportHeader({ 54 + title, 61 55 monitors, 62 - className, 56 + actions, 63 57 }: { 64 - report: StatusReportWithUpdates; 58 + title: StatusReportWithUpdates["title"]; 65 59 monitors: PublicMonitor[]; 66 - className?: string; 60 + actions?: React.ReactNode; 67 61 }) { 68 - const firstReport = 69 - report.statusReportUpdates[report.statusReportUpdates.length - 1]; 70 - 71 62 return ( 72 - <div className={cn("flex flex-wrap items-center gap-2", className)}> 73 - <p className="text-muted-foreground text-sm"> 74 - Started at <DateTimeTooltip date={firstReport.date} /> 75 - </p> 76 - <span className="text-muted-foreground/50 text-xs">•</span> 77 - <StatusBadge status={report.status} /> 78 - {monitors.length > 0 ? ( 79 - <> 80 - <span className="text-muted-foreground/50 text-xs">•</span> 63 + <div className="flex flex-wrap items-center justify-between gap-2"> 64 + <div className="flex flex-wrap items-center gap-2"> 65 + <h3 className="font-semibold text-xl">{title}</h3> 66 + <ul className="flex flex-wrap gap-2"> 81 67 {monitors.map((monitor) => ( 82 - <Badge key={monitor.id} variant="secondary"> 83 - {monitor.name} 84 - </Badge> 68 + <li key={monitor.id}> 69 + <Badge variant="secondary">{monitor.name}</Badge> 70 + </li> 85 71 ))} 86 - </> 87 - ) : null} 72 + </ul> 73 + </div> 74 + <div> 75 + {/* NOT IDEAL BUT IT WORKS */} 76 + {actions ? ( 77 + actions 78 + ) : ( 79 + <ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground" /> 80 + )} 81 + </div> 88 82 </div> 89 83 ); 90 84 } 91 85 92 - // reports are already `orderBy: desc(report.date)` within the query itself 93 - function StatusReportUpdates({ report }: { report: StatusReportWithUpdates }) { 86 + interface StatusReportUpdatesProps { 87 + updates: { 88 + date?: Date; 89 + id: number; 90 + status: 91 + | "investigating" 92 + | "identified" 93 + | "monitoring" 94 + | "resolved" 95 + | "maintenance"; 96 + message: string; 97 + }[]; 98 + } 99 + 100 + function StatusReportUpdates({ updates }: StatusReportUpdatesProps) { 94 101 return ( 95 - <div className="grid gap-2 md:grid-cols-10 md:gap-4"> 96 - {report.statusReportUpdates.map((update) => { 102 + <div className="grid gap-4"> 103 + {updates.map((update, i) => { 104 + const { icon, label, color } = statusDict[update.status]; 105 + const StatusIcon = Icons[icon]; 97 106 return ( 98 - <Fragment key={update.id}> 99 - <div className="flex items-center gap-2 md:col-span-3 md:flex-col md:items-start md:gap-1"> 100 - <p className="font-medium capitalize">{update.status}</p> 101 - <p className="font-mono text-muted-foreground text-sm md:text-xs"> 102 - <DateTimeTooltip date={update.date} /> 103 - </p> 107 + <div 108 + key={update.id} 109 + className={cn( 110 + "group -m-2 relative flex gap-4 border border-transparent p-2", 111 + )} 112 + > 113 + <div className="relative"> 114 + <div 115 + className={cn( 116 + "rounded-full border border-border bg-background p-2", 117 + i === 0 ? color : null, 118 + )} 119 + > 120 + <StatusIcon className="h-4 w-4" /> 121 + </div> 122 + {i !== updates.length - 1 ? ( 123 + <div className="absolute inset-x-0 mx-auto h-full w-[2px] bg-muted" /> 124 + ) : null} 104 125 </div> 105 - <div className="prose dark:prose-invert md:col-span-7"> 106 - <ProcessMessage value={update.message} /> 126 + <div className="mt-2 grid flex-1 gap-1"> 127 + <div className="flex items-center justify-between gap-2"> 128 + <p className="font-medium text-sm">{label}</p> 129 + {update.date ? ( 130 + <p className="mt-px text-muted-foreground text-xs"> 131 + <DateTimeTooltip date={new Date(update.date)} /> 132 + </p> 133 + ) : null} 134 + </div> 135 + <div className="prose dark:prose-invert"> 136 + <ProcessMessage value={update.message} /> 137 + </div> 107 138 </div> 108 - </Fragment> 139 + </div> 109 140 ); 110 141 })} 111 142 </div> ··· 113 144 } 114 145 115 146 StatusReport.Header = StatusReportHeader; 116 - StatusReport.Description = StatusReportDescription; 117 147 StatusReport.Updates = StatusReportUpdates; 118 148 119 - export { 120 - StatusReport, 121 - StatusReportDescription, 122 - StatusReportHeader, 123 - StatusReportUpdates, 124 - }; 149 + export { StatusReport, StatusReportHeader, StatusReportUpdates };
+2 -12
apps/web/src/components/status-update/status-badge.tsx
··· 5 5 import { cn } from "@/lib/utils"; 6 6 import { Icons } from "../icons"; 7 7 8 - const variant = { 9 - investigating: "border-rose-500/20 bg-rose-500/10 text-rose-500", 10 - identified: "border-amber-500/20 bg-amber-500/10 text-amber-500", 11 - monitoring: "border-blue-500/20 bg-blue-500/10 text-blue-500", 12 - resolved: "border-green-500/20 bg-green-500/10 text-green-500", 13 - } satisfies Record<StatusReport["status"], string>; 14 - 15 8 export function StatusBadge({ 16 9 status, 17 10 className, ··· 19 12 status: StatusReport["status"]; 20 13 className?: string; 21 14 }) { 22 - const { label, icon } = statusDict[status]; 15 + const { label, icon, color } = statusDict[status]; 23 16 const Icon = Icons[icon]; 24 17 return ( 25 - <Badge 26 - variant="outline" 27 - className={cn("font-normal", variant[status], className)} 28 - > 18 + <Badge variant="outline" className={cn("font-normal", color, className)}> 29 19 <Icon className="mr-1 h-3 w-3" /> 30 20 {label} 31 21 </Badge>
+13 -11
apps/web/src/components/tracker/tracker.tsx
··· 35 35 const tracker = cva("h-10 rounded-full flex-1", { 36 36 variants: { 37 37 variant: { 38 - blacklist: "bg-green-500/80 data-[state=open]:bg-green-500", 38 + blacklist: 39 + "bg-status-operational/80 data-[state=open]:bg-status-operational", 39 40 ...classNames, 40 41 }, 41 42 report: { 42 - 0: "", 43 - // IDEA: data-[state=open]:from-40% data-[state=open]:to-40% 44 - 30: "bg-gradient-to-t from-blue-500/90 hover:from-blue-500 from-30% to-transparent to-30%", 43 + false: "", 44 + true: classNames.degraded, 45 45 }, 46 46 }, 47 47 defaultVariants: { 48 48 variant: "empty", 49 - report: 0, 49 + report: false, 50 50 }, 51 51 }); 52 52 ··· 132 132 const [open, setOpen] = React.useState(false); 133 133 134 134 const rootClassName = tracker({ 135 - report: statusReports && statusReports.length > 0 ? 30 : undefined, 135 + report: statusReports.length > 0, 136 136 variant: blacklist ? "blacklist" : variant, 137 137 }); 138 138 ··· 167 167 </div> 168 168 <div className="flex justify-between gap-8 font-light text-muted-foreground text-xs"> 169 169 <p> 170 - <code className="text-green-500">{count}</code> requests 170 + <code className="text-status-operational">{count}</code>{" "} 171 + requests 171 172 </p> 172 173 <p> 173 - <code className="text-red-500">{count - ok}</code> failed 174 + <code className="text-status-down">{count - ok}</code>{" "} 175 + failed 174 176 </p> 175 177 </div> 176 178 </div> ··· 201 203 <li key={report.id} className="text-muted-foreground text-sm"> 202 204 <Link 203 205 // TODO: include setPrefixUrl for local development 204 - href={`./incidents/${report.id}`} 206 + href={`./events/report/${report.id}`} 205 207 className="group flex items-center justify-between gap-2 hover:text-foreground" 206 208 > 207 209 <span className="truncate">{report.title}</span> 208 - <ChevronRight className="h-4 w-4" /> 210 + <ChevronRight className="h-4 w-4 shrink-0" /> 209 211 </Link> 210 212 </li> 211 213 ))} ··· 247 249 248 250 return ( 249 251 <p className="text-muted-foreground text-xs"> 250 - Down for{" "} 252 + Downtime for{" "} 251 253 {formatDuration( 252 254 { minutes, hours, days }, 253 255 { format: ["days", "hours", "minutes", "seconds"], zero: false },
+12
apps/web/src/content/changelog/status-page-colors-and-more.mdx
··· 1 + --- 2 + title: Status Page rework 3 + description: New status specific colors, improved navigation and more. 4 + publishedAt: 2024-07-21 5 + image: /assets/changelog/status-page-colors-and-more.png 6 + --- 7 + 8 + We have reworked our status page! 9 + 10 + - **less flashy colors** for _operational_, _degraded_, _downtime_ and _maintenance_ 11 + - **new sticky navigation** bar, merging _maintenances_ and _incidents_ into an _events_ page (your old links will still work) 12 + - redesign of **status reports**
+16
apps/web/src/data/incidents-dictionary.ts
··· 3 3 value: "investigating", 4 4 label: "Investigating", 5 5 icon: "search", 6 + color: "border-status-down/20 bg-status-down/10 text-status-down", 6 7 order: 1, 7 8 }, 8 9 identified: { 9 10 value: "identified", 10 11 label: "Identified", 11 12 icon: "fingerprint", 13 + color: 14 + "border-status-degraded/20 bg-status-degraded/10 text-status-degraded", 12 15 order: 2, 13 16 }, 14 17 monitoring: { 15 18 value: "monitoring", 16 19 label: "Monitoring", 17 20 icon: "activity", 21 + color: 22 + "border-status-monitoring/20 bg-status-monitoring/10 text-status-monitoring", 18 23 order: 3, 19 24 }, 20 25 resolved: { 21 26 value: "resolved", 22 27 label: "Resolved", 23 28 icon: "search-check", 29 + color: 30 + "border-status-operational/20 bg-status-operational/10 text-status-operational", 24 31 order: 4, 32 + }, 33 + // FIXME: check source of thruth 34 + maintenance: { 35 + value: "maintenance", 36 + label: "Maintenance", 37 + icon: "hammer", 38 + color: 39 + "border-status-monitoring/20 bg-status-monitoring/10 text-status-monitoring", 40 + order: 0, 25 41 }, 26 42 } as const;
+12
apps/web/src/styles/globals.css
··· 34 34 --ring: 215 20.2% 65.1%; 35 35 36 36 --radius: 0.5rem; 37 + 38 + /* Status Tracker Colors - Radix Color */ 39 + --status-degraded: 26 100% 56%; /* Orange 10 */ 40 + --status-operational: 131 39% 51%; /* Grass 10 */ 41 + --status-down: 11 82% 59%; /* Tomato 10 */ 42 + --status-monitoring: 210 100% 62%; /* Blue 10 */ 37 43 } 38 44 39 45 .dark { ··· 65 71 --destructive-foreground: 0 85.7% 97.3%; 66 72 67 73 --ring: 217.2 32.6% 17.5%; 74 + 75 + /* Status Tracker Colors - Radix Color */ 76 + --status-degraded: 26 100% 56%; /* Orange 10 */ 77 + --status-operational: 131 39% 51%; /* Grass 10 */ 78 + --status-down: 11 82% 59%; /* Tomato 10 */ 79 + --status-monitoring: 210 100% 62%; /* Blue 10 */ 68 80 } 69 81 } 70 82
+15
apps/web/tailwind.config.ts
··· 69 69 DEFAULT: "hsl(var(--card))", 70 70 foreground: "hsl(var(--card-foreground))", 71 71 }, 72 + /* Status Tracker */ 73 + status: { 74 + operational: { 75 + DEFAULT: "hsl(var(--status-operational))", 76 + }, 77 + degraded: { 78 + DEFAULT: "hsl(var(--status-degraded))", 79 + }, 80 + down: { 81 + DEFAULT: "hsl(var(--status-down))", 82 + }, 83 + monitoring: { 84 + DEFAULT: "hsl(var(--status-monitoring))", 85 + }, 86 + }, 72 87 /* Tremor */ 73 88 // light mode 74 89 tremor: {
+9 -27
packages/api/src/router/page.ts
··· 269 269 ({ monitors_to_pages }) => monitors_to_pages.monitorId, 270 270 ); 271 271 272 - const monitorsToStatusReportResult = 273 - monitorsId.length > 0 274 - ? await opts.ctx.db 275 - .select() 276 - .from(monitorsToStatusReport) 277 - .where(inArray(monitorsToStatusReport.monitorId, monitorsId)) 278 - .all() 279 - : []; 280 - 281 - const monitorStatusReportIds = monitorsToStatusReportResult.map( 282 - ({ statusReportId }) => statusReportId, 283 - ); 284 - 285 - const statusReportIds = Array.from(new Set([...monitorStatusReportIds])); 286 - 287 - const statusReports = 288 - statusReportIds.length > 0 289 - ? await opts.ctx.db.query.statusReport.findMany({ 290 - where: eq(statusReport.pageId, result.id), 291 - with: { 292 - statusReportUpdates: { 293 - orderBy: (reports, { desc }) => desc(reports.date), 294 - }, 295 - monitorsToStatusReports: { with: { monitor: true } }, 296 - }, 297 - }) 298 - : []; 272 + const statusReports = await opts.ctx.db.query.statusReport.findMany({ 273 + where: eq(statusReport.pageId, result.id), 274 + with: { 275 + statusReportUpdates: { 276 + orderBy: (reports, { desc }) => desc(reports.date), 277 + }, 278 + monitorsToStatusReports: { with: { monitor: true } }, 279 + }, 280 + }); 299 281 300 282 // TODO: monitorsToPagesResult has the result already, no need to query again 301 283 const monitors =
+8 -5
packages/tracker/src/config.ts
··· 41 41 42 42 // REMINDER: add `@openstatus/tracker/src/**/*.ts into tailwindcss content prop */ 43 43 export const classNames: Record<StatusVariant, string> = { 44 - up: "bg-green-500/90 data-[state=open]:bg-green-500 border-green-500", 45 - degraded: "bg-amber-500/90 data-[state=open]:bg-amber-500 border-amber-500", 46 - down: "bg-red-500/90 data-[state=open]:bg-red-500 border-red-500", 44 + up: "bg-status-operational/90 data-[state=open]:bg-status-operational border-status-operational", 45 + degraded: 46 + "bg-status-degraded/90 data-[state=open]:bg-status-degraded border-status-degraded", 47 + down: "bg-status-down/90 data-[state=open]:bg-status-down border-status-down", 47 48 empty: "bg-muted-foreground/20 data-[state=open]:bg-muted-foreground/30", 48 - incident: "bg-red-500/90 data-[state=open]:bg-red-500 border-red-500", 49 - maintenance: "bg-blue-500/90 data-[state=open]:bg-blue-500 border-blue-500", 49 + incident: 50 + "bg-status-down/90 data-[state=open]:bg-status-down border-status-down", 51 + maintenance: 52 + "bg-status-monitoring/90 data-[state=open]:bg-status-monitoring border-status-monitoring", 50 53 };
+13 -6
packages/tracker/src/tracker.ts
··· 187 187 188 188 const isMissingData = props.count === 0; 189 189 190 - // FIXME: 190 + /** 191 + * 1. Maintenance 192 + * 2. Status Reports (Degraded Performance) 193 + * 3. Incidents 194 + * 4. Uptime Status (Operational, Degraded Performance, Partial Outage, Major Outage) 195 + */ 191 196 const status = maintenances.length 192 197 ? Status.UnderMaintenance 193 - : incidents.length 194 - ? Status.Incident 195 - : isMissingData 196 - ? Status.Unknown 197 - : this.calculateUptimeStatus([props]); 198 + : statusReports.length 199 + ? Status.DegradedPerformance 200 + : incidents.length 201 + ? Status.Incident 202 + : isMissingData 203 + ? Status.Unknown 204 + : this.calculateUptimeStatus([props]); 198 205 199 206 const variant = statusDetails[status].variant; 200 207 const label = statusDetails[status].short;