Openstatus www.openstatus.dev

feat: alternative pages (#1229)

* feat: alternative pages

* chore: metadata and callout

* chore: footer and description

* fix: tw class order

* fix: typo

* fix: key

* fix: typo

authored by

Maximilian Kaske and committed by
GitHub
88ef0684 89735cc2

+833 -16
apps/web/public/assets/alternatives/betterstack.png

This is a binary file and will not be displayed.

apps/web/public/assets/alternatives/checkly.png

This is a binary file and will not be displayed.

apps/web/public/assets/alternatives/uptime-kuma.png

This is a binary file and will not be displayed.

apps/web/public/assets/alternatives/uptime-robot.png

This is a binary file and will not be displayed.

+22 -2
apps/web/src/app/(content)/blog/[slug]/page.tsx
··· 9 9 } from "@/app/shared-metadata"; 10 10 import { Article } from "@/components/content/article"; 11 11 import { Shell } from "@/components/dashboard/shell"; 12 - import { BackButton } from "@/components/layout/back-button"; 12 + import { 13 + Breadcrumb, 14 + BreadcrumbItem, 15 + BreadcrumbLink, 16 + BreadcrumbList, 17 + BreadcrumbPage, 18 + BreadcrumbSeparator, 19 + } from "@openstatus/ui"; 20 + import Link from "next/link"; 13 21 14 22 // export const dynamic = "force-static"; 15 23 ··· 71 79 72 80 return ( 73 81 <> 74 - <BackButton href="/blog" /> 82 + <Breadcrumb className="mb-4 px-3 md:px-6"> 83 + <BreadcrumbList> 84 + <BreadcrumbItem> 85 + <BreadcrumbLink asChild> 86 + <Link href="/blog">Blog</Link> 87 + </BreadcrumbLink> 88 + </BreadcrumbItem> 89 + <BreadcrumbSeparator /> 90 + <BreadcrumbItem> 91 + <BreadcrumbPage>{post.title}</BreadcrumbPage> 92 + </BreadcrumbItem> 93 + </BreadcrumbList> 94 + </Breadcrumb> 75 95 <Shell className="sm:py-8 md:py-12"> 76 96 <Article post={post} /> 77 97 </Shell>
+23 -3
apps/web/src/app/(content)/changelog/[slug]/page.tsx
··· 2 2 import type { Metadata } from "next"; 3 3 import { notFound } from "next/navigation"; 4 4 5 - import { Separator } from "@openstatus/ui"; 5 + import { 6 + Breadcrumb, 7 + BreadcrumbItem, 8 + BreadcrumbLink, 9 + BreadcrumbList, 10 + BreadcrumbPage, 11 + BreadcrumbSeparator, 12 + Separator, 13 + } from "@openstatus/ui"; 6 14 7 15 import { 8 16 defaultMetadata, ··· 11 19 } from "@/app/shared-metadata"; 12 20 import { ChangelogCard } from "@/components/content/changelog"; 13 21 import { Shell } from "@/components/dashboard/shell"; 14 - import { BackButton } from "@/components/layout/back-button"; 22 + import Link from "next/link"; 15 23 import { Pagination } from "../../_components/pagination"; 16 24 17 25 // export const dynamic = "force-static"; ··· 93 101 94 102 return ( 95 103 <> 96 - <BackButton href="/changelog" /> 104 + <Breadcrumb className="mb-4 px-3 md:px-6"> 105 + <BreadcrumbList> 106 + <BreadcrumbItem> 107 + <BreadcrumbLink asChild> 108 + <Link href="/changelog">Changelog</Link> 109 + </BreadcrumbLink> 110 + </BreadcrumbItem> 111 + <BreadcrumbSeparator /> 112 + <BreadcrumbItem> 113 + <BreadcrumbPage>{post.title}</BreadcrumbPage> 114 + </BreadcrumbItem> 115 + </BreadcrumbList> 116 + </Breadcrumb> 97 117 <Shell className="flex flex-col gap-8 sm:py-8 md:gap-12 md:py-12"> 98 118 <ChangelogCard post={post} /> 99 119 <Separator className="mx-auto max-w-prose" />
+159
apps/web/src/app/(content)/compare/[slug]/page.tsx
··· 1 + import { 2 + defaultMetadata, 3 + ogMetadata, 4 + twitterMetadata, 5 + } from "@/app/shared-metadata"; 6 + import { Shell } from "@/components/dashboard/shell"; 7 + import { FAQs } from "@/components/marketing/faqs"; 8 + import { PricingSlider } from "@/components/marketing/pricing/pricing-slider"; 9 + import { alternativesConfig as config } from "@/config/alternatives"; 10 + import { 11 + Breadcrumb, 12 + BreadcrumbItem, 13 + BreadcrumbLink, 14 + BreadcrumbList, 15 + BreadcrumbPage, 16 + BreadcrumbSeparator, 17 + } from "@openstatus/ui/src/components/breadcrumb"; 18 + import { Button } from "@openstatus/ui/src/components/button"; 19 + import type { Metadata } from "next"; 20 + import Image from "next/image"; 21 + import Link from "next/link"; 22 + import { notFound } from "next/navigation"; 23 + import { ComparisonTable } from "../_components/comparison-table"; 24 + 25 + export async function generateStaticParams() { 26 + return Object.keys(config).map((slug) => ({ slug })); 27 + } 28 + export async function generateMetadata(props: { 29 + params: Promise<{ slug: string }>; 30 + }): Promise<Metadata | undefined> { 31 + const { slug } = await props.params; 32 + const alternative = config[slug as keyof typeof config]; 33 + 34 + if (!alternative) return; 35 + 36 + const { name } = alternative; 37 + const title = `${name} vs. OpenStatus`; 38 + const description = `Looking for a ${name} alternative? OpenStatus is an open-source alternative to ${name}. Try it out for free.`; 39 + 40 + const encodedTitle = encodeURIComponent(title); 41 + // TODO: check if there is a better wording 42 + const encodedDescription = encodeURIComponent( 43 + "Compare both and pick what fits best to you.", 44 + ); 45 + 46 + return { 47 + ...defaultMetadata, 48 + title, 49 + description, 50 + openGraph: { 51 + ...ogMetadata, 52 + title, 53 + description, 54 + url: `https://www.openstatus.dev/compare/${slug}`, 55 + images: [ 56 + { 57 + url: `https://openstatus.dev/api/og?title=${encodedTitle}&description=${encodedDescription}`, 58 + }, 59 + ], 60 + }, 61 + twitter: { 62 + ...twitterMetadata, 63 + title, 64 + description, 65 + images: [ 66 + { 67 + url: `https://openstatus.dev/api/og?title=${encodedTitle}&description=${encodedDescription}`, 68 + }, 69 + ], 70 + }, 71 + }; 72 + } 73 + // add to sitemap 74 + 75 + export default async function Page({ 76 + params, 77 + }: { 78 + params: Promise<{ slug: string }>; 79 + }) { 80 + const { slug } = await params; 81 + 82 + const alternative = config[slug as keyof typeof config]; 83 + 84 + if (!alternative) { 85 + notFound(); 86 + } 87 + 88 + return ( 89 + <div className="flex w-full flex-col gap-4"> 90 + {/* TODO: use the breadcrump component for the changelog and blog and play pages */} 91 + <Breadcrumb className="px-3 md:px-6"> 92 + <BreadcrumbList> 93 + <BreadcrumbItem> 94 + <BreadcrumbLink asChild> 95 + <Link href="/compare">Compare</Link> 96 + </BreadcrumbLink> 97 + </BreadcrumbItem> 98 + <BreadcrumbSeparator /> 99 + <BreadcrumbItem> 100 + <BreadcrumbPage>{alternative.name}</BreadcrumbPage> 101 + </BreadcrumbItem> 102 + </BreadcrumbList> 103 + </Breadcrumb> 104 + <div className="grid gap-12"> 105 + <Shell className="space-y-12"> 106 + <div className="flex flex-col items-center gap-4"> 107 + <Image 108 + src={alternative.logo} 109 + alt={alternative.name} 110 + width={60} 111 + height={60} 112 + className="overflow-hidden rounded-full border border-border bg-muted" 113 + /> 114 + <h1 className="text-center font-cal text-4xl"> 115 + {alternative.name} Alternative 116 + </h1> 117 + <div className="mx-auto max-w-lg text-center text-lg text-muted-foreground"> 118 + <p>{alternative.description}</p> 119 + </div> 120 + </div> 121 + <ComparisonTable slug={slug} /> 122 + </Shell> 123 + <Shell className="flex flex-col gap-6 bg-muted md:flex-row md:items-center md:justify-between"> 124 + <div> 125 + <p className="font-semibold text-2xl"> 126 + Don't talk to Sales. Talk to Founders. 127 + </p> 128 + <p className="text-muted-foreground text-sm"> 129 + We are here to help you with any questions or concerns you may 130 + have. 131 + </p> 132 + </div> 133 + <div className="flex gap-2"> 134 + <Button className="rounded-full" variant="outline" asChild> 135 + <Link href="/app/login" className="text-nowrap"> 136 + Start for free 137 + </Link> 138 + </Button> 139 + <Button className="rounded-full" asChild> 140 + <a 141 + target="_blank" 142 + rel="noreferrer" 143 + href="https://cal.com/team/openstatus/30min" 144 + className="text-nowrap" 145 + > 146 + Talk to us 147 + </a> 148 + </Button> 149 + </div> 150 + </Shell> 151 + {/* FIXME: responsive design */} 152 + <Shell className="w-full max-w-4xl"> 153 + <PricingSlider /> 154 + </Shell> 155 + <FAQs /> 156 + </div> 157 + </div> 158 + ); 159 + }
+105
apps/web/src/app/(content)/compare/_components/comparison-table.tsx
··· 1 + "use client"; 2 + 3 + import { alternativesConfig as config } from "@/config/alternatives"; 4 + import { 5 + Table, 6 + TableBody, 7 + TableCaption, 8 + TableCell, 9 + TableHead, 10 + TableHeader, 11 + TableRow, 12 + } from "@openstatus/ui/src/components/table"; 13 + import { ArrowUpRight, CircleCheck, CircleHelp, Minus } from "lucide-react"; 14 + import type React from "react"; 15 + 16 + export function ComparisonTable({ slug }: { slug: string }) { 17 + const alternative = config[slug as keyof typeof config]; 18 + return ( 19 + <Table className="relative"> 20 + <TableCaption> 21 + Plan comparison between OpenStatus and {alternative.name}. 22 + </TableCaption> 23 + <TableHeader> 24 + <TableRow className="bg-muted/50"> 25 + <TableHead className="px-3 py-3 align-bottom">Feature</TableHead> 26 + <TableHead className="h-auto px-3 py-3 text-center align-middle text-foreground"> 27 + OpenStatus 28 + </TableHead> 29 + <TableHead className="h-auto px-3 py-3 text-center align-middle text-foreground"> 30 + {alternative.name} 31 + </TableHead> 32 + </TableRow> 33 + </TableHeader> 34 + <TableBody> 35 + {alternative.features.map( 36 + ({ label, description, openstatus, alternative, url }, _i) => { 37 + return ( 38 + <TableRow key={label} className="group/row"> 39 + <TableCell className="p-3"> 40 + <div className="flex flex-col gap-0.5"> 41 + <span className="font-medium">{label}</span> 42 + {url ? ( 43 + <a 44 + href={url} 45 + target="_blank" 46 + rel="noreferrer" 47 + className="group hidden items-center gap-1 font-normal text-muted-foreground text-sm sm:flex" 48 + > 49 + {description} 50 + {/* FIXME: arrow is not `text-foreground` when hovered */} 51 + <ArrowUpRight className="h-4 w-4 flex-shrink-0 text-transparent group-hover/row:text-muted-foreground group-hover:text-foreground" /> 52 + </a> 53 + ) : ( 54 + <span className="hidden font-normal text-muted-foreground text-sm sm:block"> 55 + {description} 56 + </span> 57 + )} 58 + </div> 59 + </TableCell> 60 + <TableCell className="text-center"> 61 + <Cell value={openstatus} /> 62 + </TableCell> 63 + <TableCell className="text-center"> 64 + <Cell value={alternative} /> 65 + </TableCell> 66 + </TableRow> 67 + ); 68 + }, 69 + )} 70 + </TableBody> 71 + </Table> 72 + ); 73 + } 74 + 75 + function Cell({ 76 + value, 77 + }: { 78 + value: boolean | string | number | React.ReactNode; 79 + }) { 80 + if (typeof value === "boolean") { 81 + return value ? ( 82 + <CircleCheck className="mx-auto size-5 text-green-500" /> 83 + ) : ( 84 + <Minus className="mx-auto size-5 text-muted-foreground" /> 85 + ); 86 + } 87 + 88 + if ( 89 + typeof value === "number" || 90 + (typeof value === "string" && value === "Unlimited") 91 + ) { 92 + return <span className="font-medium font-mono">{value}</span>; 93 + } 94 + 95 + if (typeof value === "string") { 96 + return <span className="font-medium">{value}</span>; 97 + } 98 + 99 + if (typeof value === "undefined") { 100 + // TODO check for rounded question 101 + return <CircleHelp className="mx-auto size-5 text-muted-foreground" />; 102 + } 103 + 104 + return value; 105 + }
+71
apps/web/src/app/(content)/compare/page.tsx
··· 1 + import { 2 + defaultMetadata, 3 + ogMetadata, 4 + twitterMetadata, 5 + } from "@/app/shared-metadata"; 6 + import { alternativesConfig as config } from "@/config/alternatives"; 7 + import { 8 + Card, 9 + CardDescription, 10 + CardHeader, 11 + CardTitle, 12 + } from "@openstatus/ui/src/components/card"; 13 + import { ChevronRight } from "lucide-react"; 14 + import type { Metadata } from "next"; 15 + import Link from "next/link"; 16 + 17 + export const metadata: Metadata = { 18 + ...defaultMetadata, 19 + title: "Compare Alternatives", 20 + description: 21 + "Discover how OpenStatus compares to other services and start monitoring your website or api for free.", 22 + openGraph: { 23 + ...ogMetadata, 24 + title: "Compare Alternatives", 25 + description: 26 + "Discover how OpenStatus compares to other services and start monitoring your website or api for free.", 27 + }, 28 + twitter: { 29 + ...twitterMetadata, 30 + title: "Compare Alternatives", 31 + description: 32 + "Discover how OpenStatus compares to other services and start monitoring your website or api for free.", 33 + }, 34 + }; 35 + 36 + export default function Page() { 37 + return ( 38 + <> 39 + <div className="mb-5 space-y-3"> 40 + <h1 className="font-cal text-4xl text-foreground"> 41 + Compare OpenStatus 42 + </h1> 43 + <p className="max-w-lg text-lg text-muted-foreground"> 44 + Discover how OpenStatus compares to other Uptime and Synthetic 45 + Monitoring solutions. 46 + </p> 47 + </div> 48 + <div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2"> 49 + {Object.entries(config).map(([slug, alternative]) => ( 50 + <Link 51 + key={slug} 52 + href={`/compare/${slug}`} 53 + className="group flex w-full flex-1" 54 + > 55 + <Card className="flex w-full flex-col"> 56 + <CardHeader className="flex-1"> 57 + <CardTitle>{alternative.name} Alternative</CardTitle> 58 + <div className="flex flex-1 justify-between gap-3"> 59 + <CardDescription className="mr-3 truncate"> 60 + {alternative.description} 61 + </CardDescription> 62 + <ChevronRight className="h-5 w-5 shrink-0 self-end text-muted-foreground group-hover:text-foreground" /> 63 + </div> 64 + </CardHeader> 65 + </Card> 66 + </Link> 67 + ))} 68 + </div> 69 + </> 70 + ); 71 + }
+31
apps/web/src/app/play/checker/layout.tsx
··· 1 + import { 2 + Breadcrumb, 3 + BreadcrumbItem, 4 + BreadcrumbLink, 5 + BreadcrumbList, 6 + BreadcrumbPage, 7 + BreadcrumbSeparator, 8 + } from "@openstatus/ui"; 9 + import Link from "next/link"; 10 + import type React from "react"; 11 + 12 + export default function Layout({ children }: { children: React.ReactNode }) { 13 + return ( 14 + <> 15 + <Breadcrumb className="px-3 md:px-6 mb-4"> 16 + <BreadcrumbList> 17 + <BreadcrumbItem> 18 + <BreadcrumbLink asChild> 19 + <Link href="/play">Play</Link> 20 + </BreadcrumbLink> 21 + </BreadcrumbItem> 22 + <BreadcrumbSeparator /> 23 + <BreadcrumbItem> 24 + <BreadcrumbPage>Checker</BreadcrumbPage> 25 + </BreadcrumbItem> 26 + </BreadcrumbList> 27 + </Breadcrumb> 28 + {children} 29 + </> 30 + ); 31 + }
+31
apps/web/src/app/play/curl/layout.tsx
··· 1 + import { 2 + Breadcrumb, 3 + BreadcrumbItem, 4 + BreadcrumbLink, 5 + BreadcrumbList, 6 + BreadcrumbPage, 7 + BreadcrumbSeparator, 8 + } from "@openstatus/ui"; 9 + import Link from "next/link"; 10 + import type React from "react"; 11 + 12 + export default function Layout({ children }: { children: React.ReactNode }) { 13 + return ( 14 + <> 15 + <Breadcrumb className="px-3 md:px-6 mb-4"> 16 + <BreadcrumbList> 17 + <BreadcrumbItem> 18 + <BreadcrumbLink asChild> 19 + <Link href="/play">Play</Link> 20 + </BreadcrumbLink> 21 + </BreadcrumbItem> 22 + <BreadcrumbSeparator /> 23 + <BreadcrumbItem> 24 + <BreadcrumbPage>cURL</BreadcrumbPage> 25 + </BreadcrumbItem> 26 + </BreadcrumbList> 27 + </Breadcrumb> 28 + {children} 29 + </> 30 + ); 31 + }
+31
apps/web/src/app/play/status/layout.tsx
··· 1 + import { 2 + Breadcrumb, 3 + BreadcrumbItem, 4 + BreadcrumbLink, 5 + BreadcrumbList, 6 + BreadcrumbPage, 7 + BreadcrumbSeparator, 8 + } from "@openstatus/ui"; 9 + import Link from "next/link"; 10 + import type React from "react"; 11 + 12 + export default function Layout({ children }: { children: React.ReactNode }) { 13 + return ( 14 + <> 15 + <Breadcrumb className="px-3 md:px-6 mb-4"> 16 + <BreadcrumbList> 17 + <BreadcrumbItem> 18 + <BreadcrumbLink asChild> 19 + <Link href="/play">Play</Link> 20 + </BreadcrumbLink> 21 + </BreadcrumbItem> 22 + <BreadcrumbSeparator /> 23 + <BreadcrumbItem> 24 + <BreadcrumbPage>Status Page</BreadcrumbPage> 25 + </BreadcrumbItem> 26 + </BreadcrumbList> 27 + </Breadcrumb> 28 + {children} 29 + </> 30 + ); 31 + }
+7 -1
apps/web/src/app/sitemap.ts
··· 1 + import { alternativesConfig } from "@/config/alternatives"; 1 2 import { allChangelogs, allPosts } from "content-collections"; 2 3 import type { MetadataRoute } from "next"; 3 4 ··· 14 15 lastModified: post.publishedAt, // date format should be YYYY-MM 15 16 })); 16 17 18 + const comparisons = Object.keys(alternativesConfig).map((slug) => ({ 19 + url: `https://www.openstatus.dev/compare/${slug}`, 20 + lastModified: new Date(), 21 + })); 22 + 17 23 const routes = [ 18 24 "/", 19 25 "/about", ··· 32 38 lastModified: new Date(), 33 39 })); 34 40 35 - return [...routes, ...blogs, ...changelogs]; 41 + return [...routes, ...blogs, ...changelogs, ...comparisons]; 36 42 }
+2
apps/web/src/components/icons.tsx
··· 1 1 import { 2 2 Activity, 3 3 AlertTriangle, 4 + ArrowLeftRight, 4 5 Bell, 5 6 Book, 6 7 BookOpenCheck, ··· 92 93 twitter: TwitterIcon, 93 94 terminal: Terminal, 94 95 globe: Globe, 96 + compare: ArrowLeftRight, 95 97 plug: Plug, 96 98 copy: Copy, 97 99 check: Check,
+20 -5
apps/web/src/components/layout/marketing-footer.tsx
··· 2 2 import Link from "next/link"; 3 3 4 4 import { ThemeToggle } from "@/components/theme/theme-toggle"; 5 + import { alternativesConfig } from "@/config/alternatives"; 5 6 import { socialsConfig } from "@/config/socials"; 6 7 import { cn } from "@/lib/utils"; 7 8 import { Shell } from "../dashboard/shell"; ··· 17 18 return ( 18 19 <footer className={cn("w-full", className)}> 19 20 <Shell className="grid gap-6"> 20 - <div className="grid grid-cols-2 gap-6 md:grid-cols-5"> 21 - <div className="col-span-2 flex flex-col gap-3"> 21 + <div className="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-11"> 22 + <div className="col-span-2 flex flex-col gap-3 md:col-span-3"> 22 23 <div> 23 24 <BrandName /> 24 25 <p className="mt-2 max-w-md font-light text-muted-foreground text-sm"> ··· 32 33 </div> 33 34 <StatusWidgetContainer slug="status" /> 34 35 </div> 35 - <div className="order-2 flex flex-col gap-3 text-sm"> 36 + <div className="col-span-2 flex flex-col gap-3 text-sm sm:col-span-1 md:col-span-2"> 36 37 <p className="font-semibold text-foreground">Resources</p> 37 38 <FooterLink href="/blog" label="Blog" /> 38 39 <FooterLink href="/pricing" label="Pricing" /> ··· 40 41 <FooterLink href="/oss-friends" label="OSS Friends" /> 41 42 <FooterLink href="/status" label="External Providers Monitoring" /> 42 43 </div> 43 - <div className="order-3 flex flex-col gap-3 text-sm"> 44 + <div className="col-span-2 flex flex-col gap-3 text-sm sm:col-span-1 md:col-span-2"> 45 + <p className="font-semibold text-foreground">Compare</p> 46 + {Object.keys(alternativesConfig).map((slug) => ( 47 + <FooterLink 48 + key={slug} 49 + href={`/compare/${slug}`} 50 + label={ 51 + alternativesConfig[slug as keyof typeof alternativesConfig] 52 + .name 53 + } 54 + /> 55 + ))} 56 + </div> 57 + <div className="col-span-2 flex flex-col gap-3 text-sm sm:col-span-1 md:col-span-2"> 44 58 <p className="font-semibold text-foreground">Company</p> 45 59 <FooterLink href="/about" label="About" /> 46 60 <FooterLink href="/changelog" label="Changelog" /> 47 61 <FooterLink href="/legal/terms" label="Terms" /> 48 62 <FooterLink href="/legal/privacy" label="Privacy" /> 49 63 </div> 50 - <div className="order-3 flex flex-col gap-3 text-sm"> 64 + <div className="col-span-2 flex flex-col gap-3 text-sm sm:col-span-1 md:col-span-2"> 51 65 <p className="font-semibold text-foreground">Tools</p> 66 + <FooterLink href="/play" label="Playground" /> 52 67 <FooterLink href="/play/checker" label="Speed Checker" /> 53 68 <FooterLink href="/play/curl" label="cURL Builder" /> 54 69 <FooterLink href="https://openstat.us" label="All Status Codes" />
+210
apps/web/src/config/alternatives.ts
··· 1 + type Config = Record< 2 + string, 3 + { 4 + name: string; 5 + logo: string; 6 + description: string; 7 + features: Feature[]; 8 + uptime?: Feature[]; 9 + monitoring?: Feature[]; 10 + } 11 + >; 12 + 13 + type Feature = { 14 + label: string; 15 + description: string; 16 + openstatus: string | boolean | number | undefined; 17 + alternative: string | boolean | number | undefined; 18 + url?: string; 19 + }; 20 + 21 + export const alternativesConfig = { 22 + betterstack: { 23 + name: "BetterStack", 24 + logo: "/assets/alternatives/betterstack.png", 25 + description: 26 + "Open-source uptime monitoring. Learn how OpenStatus compares to BetterStack.", 27 + features: [ 28 + opensource(), 29 + bootstrap(), 30 + global(), 31 + incident(), 32 + otelexport(), 33 + githubaction(), 34 + cli(), 35 + privatepage("additional $42/month"), 36 + pagesubscribers("additional $40/month per 1000 subscribers"), 37 + ], 38 + }, 39 + "uptime-robot": { 40 + name: "UptimeRobot", 41 + description: 42 + "Monitor your endpoints globally. Learn how OpenStatus compares to UptimeRobot.", 43 + logo: "/assets/alternatives/uptime-robot.png", 44 + features: [ 45 + opensource(), 46 + bootstrap(), 47 + global(), 48 + otelexport(), 49 + githubaction(), 50 + teammembers("additional 19$/seat"), 51 + ], 52 + }, 53 + "uptime-kuma": { 54 + name: "Uptime Kuma", 55 + description: 56 + "Monitor your endpoints globally. Learn how OpenStatus compares to Uptime Kuma.", 57 + logo: "/assets/alternatives/uptime-kuma.png", 58 + features: [ 59 + opensource(), 60 + global(), 61 + selfhost(), 62 + managed(), 63 + otelexport(), 64 + githubaction(), 65 + ], 66 + }, 67 + checkly: { 68 + name: "Checkly", 69 + description: 70 + "Open-source multi-region monitoring. Learn how OpenStatus compares to Checkly.", 71 + logo: "/assets/alternatives/checkly.png", 72 + features: [ 73 + multiregion(19), 74 + statuspage(undefined), 75 + opensource(), 76 + bootstrap(), 77 + ], 78 + }, 79 + } satisfies Config; 80 + 81 + /** HELPER FUNCTIONS */ 82 + 83 + function opensource(alternative = false): Feature { 84 + return { 85 + label: "Open Source", 86 + description: "Self-hosted or cloud-based.", 87 + openstatus: true, 88 + alternative, 89 + url: "https://github.com/openstatusHQ/openstatus", 90 + }; 91 + } 92 + 93 + function bootstrap(alternative = false): Feature { 94 + return { 95 + label: "Bootstrap", 96 + description: "Talk directly to the founder.", 97 + openstatus: true, 98 + alternative, 99 + url: "https://cal.com/team/openstatus/30min", 100 + }; 101 + } 102 + 103 + function global(alternative = false): Feature { 104 + return { 105 + label: "Global (35 regions)", 106 + description: "Monitor your endpoints globally.", 107 + openstatus: true, 108 + alternative, 109 + }; 110 + } 111 + 112 + function multiregion(alternative = 1): Feature { 113 + return { 114 + label: "Multi-region", 115 + description: "Monitor your endpoints globally", 116 + openstatus: 35, 117 + alternative, 118 + }; 119 + } 120 + 121 + function incident(alternative = true): Feature { 122 + return { 123 + label: "Incident Escalation", 124 + description: "Escalate incidents within your team.", 125 + openstatus: false, 126 + alternative, 127 + }; 128 + } 129 + 130 + function otelexport(alternative = false): Feature { 131 + return { 132 + label: "OTel Export", 133 + description: "Export synthetic checks to OTel", 134 + openstatus: true, 135 + alternative, 136 + }; 137 + } 138 + 139 + function githubaction(alternative = false): Feature { 140 + return { 141 + label: "GitHub Action", 142 + description: "Trigger your monitor via CI/CD", 143 + openstatus: true, 144 + alternative, 145 + url: "https://github.com/marketplace/actions/openstatus-synthetics-ci", 146 + }; 147 + } 148 + 149 + function cli(alternative = false): Feature { 150 + return { 151 + label: "CLI to trigger checks", 152 + description: "Never leave your terminal.", 153 + openstatus: true, 154 + alternative, 155 + }; 156 + } 157 + 158 + function statuspage(alternative: boolean | undefined): Feature { 159 + return { 160 + label: "Status Page", 161 + description: "Share your uptime with your customers.", 162 + openstatus: true, 163 + alternative, 164 + }; 165 + } 166 + 167 + function privatepage(alternative: string): Feature { 168 + return { 169 + label: "Private status page", 170 + description: "Share your status page with your team.", 171 + openstatus: "included in team plan", 172 + alternative, 173 + }; 174 + } 175 + 176 + function pagesubscribers(alternative: string): Feature { 177 + return { 178 + label: "Status page subscribers", 179 + description: "Keep your customers in the loop.", 180 + openstatus: "Unlimited", 181 + alternative, 182 + }; 183 + } 184 + 185 + function teammembers(alternative: string): Feature { 186 + return { 187 + label: "Team members", 188 + description: "Invite your team to the dashboard.", 189 + openstatus: "Unlimited", 190 + alternative, 191 + }; 192 + } 193 + 194 + function selfhost(alternative = "easy"): Feature { 195 + return { 196 + label: "Self hostable", 197 + description: "Make it yours and deploy it.", 198 + openstatus: "hard", 199 + alternative, 200 + }; 201 + } 202 + 203 + function managed(alternative = false): Feature { 204 + return { 205 + label: "Managed", 206 + description: "Don't worry about managing your instance.", 207 + openstatus: true, 208 + alternative, 209 + }; 210 + }
+5 -5
apps/web/src/config/pages.ts
··· 246 246 icon: "gauge", 247 247 }, 248 248 { 249 - href: "/play", 250 - title: "Playground", 251 - description: "All the latest tools build by OpenStatus.", 252 - segment: "play", 253 - icon: "toy-brick", 249 + href: "/compare", 250 + title: "Alternatives", 251 + description: "Compare OpenStatus with other services.", 252 + segment: "alternative", 253 + icon: "compare", 254 254 }, 255 255 ] as const satisfies Page[]; 256 256
+115
packages/ui/src/components/breadcrumb.tsx
··· 1 + import * as React from "react"; 2 + import { Slot } from "@radix-ui/react-slot"; 3 + import { ChevronRight, MoreHorizontal } from "lucide-react"; 4 + 5 + import { cn } from "../lib/utils"; 6 + 7 + const Breadcrumb = React.forwardRef< 8 + HTMLElement, 9 + React.ComponentPropsWithoutRef<"nav"> & { 10 + separator?: React.ReactNode; 11 + } 12 + >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />); 13 + Breadcrumb.displayName = "Breadcrumb"; 14 + 15 + const BreadcrumbList = React.forwardRef< 16 + HTMLOListElement, 17 + React.ComponentPropsWithoutRef<"ol"> 18 + >(({ className, ...props }, ref) => ( 19 + <ol 20 + ref={ref} 21 + className={cn( 22 + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", 23 + className 24 + )} 25 + {...props} 26 + /> 27 + )); 28 + BreadcrumbList.displayName = "BreadcrumbList"; 29 + 30 + const BreadcrumbItem = React.forwardRef< 31 + HTMLLIElement, 32 + React.ComponentPropsWithoutRef<"li"> 33 + >(({ className, ...props }, ref) => ( 34 + <li 35 + ref={ref} 36 + className={cn("inline-flex items-center gap-1.5", className)} 37 + {...props} 38 + /> 39 + )); 40 + BreadcrumbItem.displayName = "BreadcrumbItem"; 41 + 42 + const BreadcrumbLink = React.forwardRef< 43 + HTMLAnchorElement, 44 + React.ComponentPropsWithoutRef<"a"> & { 45 + asChild?: boolean; 46 + } 47 + >(({ asChild, className, ...props }, ref) => { 48 + const Comp = asChild ? Slot : "a"; 49 + 50 + return ( 51 + <Comp 52 + ref={ref} 53 + className={cn("transition-colors hover:text-foreground", className)} 54 + {...props} 55 + /> 56 + ); 57 + }); 58 + BreadcrumbLink.displayName = "BreadcrumbLink"; 59 + 60 + const BreadcrumbPage = React.forwardRef< 61 + HTMLSpanElement, 62 + React.ComponentPropsWithoutRef<"span"> 63 + >(({ className, ...props }, ref) => ( 64 + <span 65 + ref={ref} 66 + role="link" 67 + aria-disabled="true" 68 + aria-current="page" 69 + className={cn("font-normal text-foreground", className)} 70 + {...props} 71 + /> 72 + )); 73 + BreadcrumbPage.displayName = "BreadcrumbPage"; 74 + 75 + const BreadcrumbSeparator = ({ 76 + children, 77 + className, 78 + ...props 79 + }: React.ComponentProps<"li">) => ( 80 + <li 81 + role="presentation" 82 + aria-hidden="true" 83 + className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} 84 + {...props} 85 + > 86 + {children ?? <ChevronRight />} 87 + </li> 88 + ); 89 + BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; 90 + 91 + const BreadcrumbEllipsis = ({ 92 + className, 93 + ...props 94 + }: React.ComponentProps<"span">) => ( 95 + <span 96 + role="presentation" 97 + aria-hidden="true" 98 + className={cn("flex h-9 w-9 items-center justify-center", className)} 99 + {...props} 100 + > 101 + <MoreHorizontal className="h-4 w-4" /> 102 + <span className="sr-only">More</span> 103 + </span> 104 + ); 105 + BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; 106 + 107 + export { 108 + Breadcrumb, 109 + BreadcrumbList, 110 + BreadcrumbItem, 111 + BreadcrumbLink, 112 + BreadcrumbPage, 113 + BreadcrumbSeparator, 114 + BreadcrumbEllipsis, 115 + };
+1
packages/ui/src/index.tsx
··· 41 41 export * from "./components/navigation-menu"; 42 42 export * from "./components/slider"; 43 43 export * from "./components/chart"; 44 + export * from "./components/breadcrumb";