Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: incidents improvements (#280)

* feat: status page style update

* fix: action buttons

* fix: udatedAt and sorting

* feat: add /status-page to public routes

authored by

Maximilian Kaske and committed by
GitHub
74012885 8eb62bd0

+234 -77
+41
apps/web/src/app/status-page/[domain]/_components/navigation-link.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import { usePathname, useSelectedLayoutSegment } from "next/navigation"; 5 + 6 + import { Button } from "@/components/ui/button"; 7 + 8 + export default function NavigationLink({ 9 + slug, 10 + children, 11 + }: { 12 + slug: string | null; 13 + children: React.ReactNode; 14 + }) { 15 + const pathname = usePathname(); 16 + const segment = useSelectedLayoutSegment(); 17 + const isActive = slug === segment; 18 + 19 + // REMINDER: `/status-page/${params.domain}/${slug}` won't work for subdomain 20 + let href = pathname; 21 + 22 + if (!isActive) { 23 + if (segment && slug) { 24 + href = `${pathname.replace(segment, slug)}`; 25 + } else if (segment) { 26 + href = `${pathname.replace(segment, "")}`; 27 + } else { 28 + href = `${pathname}${pathname.endsWith("/") ? "" : "/"}${slug}`; 29 + } 30 + } 31 + 32 + return ( 33 + <Button 34 + asChild 35 + variant={isActive ? "secondary" : "ghost"} 36 + className={isActive ? "font-bold" : ""} 37 + > 38 + <Link href={href}>{children}</Link> 39 + </Button> 40 + ); 41 + }
+66
apps/web/src/app/status-page/[domain]/incidents/page.tsx
··· 1 + import type { Metadata } from "next"; 2 + import { notFound } from "next/navigation"; 3 + 4 + import { 5 + defaultMetadata, 6 + ogMetadata, 7 + twitterMetadata, 8 + } from "@/app/shared-metadata"; 9 + import { Header } from "@/components/dashboard/header"; 10 + import { IncidentList } from "@/components/status-page/incident-list"; 11 + import { api } from "@/trpc/server"; 12 + 13 + type Props = { 14 + params: { domain: string }; 15 + searchParams: { [key: string]: string | string[] | undefined }; 16 + }; 17 + 18 + export default async function Page({ params }: Props) { 19 + if (!params.domain) return notFound(); 20 + 21 + const page = await api.page.getPageBySlug.query({ slug: params.domain }); 22 + if (!page) return notFound(); 23 + 24 + return ( 25 + <div className="grid gap-6"> 26 + <Header 27 + title={page.title} 28 + description={page.description} 29 + className="text-left" 30 + /> 31 + <IncidentList incidents={page.incidents} monitors={page.monitors} /> 32 + </div> 33 + ); 34 + } 35 + 36 + export async function generateMetadata({ params }: Props): Promise<Metadata> { 37 + const page = await api.page.getPageBySlug.query({ slug: params.domain }); 38 + const firstMonitor = page?.monitors?.[0]; // temporary solution 39 + 40 + return { 41 + ...defaultMetadata, 42 + title: page?.title, 43 + description: page?.description, 44 + icons: page?.icon, 45 + twitter: { 46 + ...twitterMetadata, 47 + images: [ 48 + `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 49 + page?.description || `The ${page?.title} status page}` 50 + }`, 51 + ], 52 + title: page?.title, 53 + description: page?.description, 54 + }, 55 + openGraph: { 56 + ...ogMetadata, 57 + images: [ 58 + `/api/og?monitorId=${firstMonitor?.id}&title=${page?.title}&description=${ 59 + page?.description || `The ${page?.title} status page}` 60 + }`, 61 + ], 62 + title: page?.title, 63 + description: page?.description, 64 + }, 65 + }; 66 + }
+13 -2
apps/web/src/app/status-page/[domain]/layout.tsx
··· 1 + import { Shell } from "@/components/dashboard/shell"; 2 + import NavigationLink from "./_components/navigation-link"; 3 + 1 4 export default function StatusPageLayout({ 2 5 children, 3 6 }: { ··· 5 8 }) { 6 9 return ( 7 10 <div className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8"> 8 - <main className="flex flex-1 flex-col items-center justify-center gap-8"> 9 - <div className="mx-auto w-full max-w-xl text-center">{children}</div> 11 + <header className="mx-auto w-full max-w-xl"> 12 + <Shell className="mx-auto flex items-center justify-center gap-2 p-2 px-2 md:p-3"> 13 + <NavigationLink slug={null}>Status</NavigationLink> 14 + <NavigationLink slug="incidents">Incidents</NavigationLink> 15 + </Shell> 16 + </header> 17 + <main className="flex h-full w-full flex-1 flex-col"> 18 + <Shell className="mx-auto h-full max-w-xl flex-1 px-4 py-4"> 19 + {children} 20 + </Shell> 10 21 </main> 11 22 <footer className="z-10"> 12 23 <p className="text-muted-foreground text-center text-sm">
+9 -12
apps/web/src/app/status-page/[domain]/loading.tsx
··· 1 1 import { Container } from "@/components/dashboard/container"; 2 2 import { Header } from "@/components/dashboard/header"; 3 - import { Shell } from "@/components/dashboard/shell"; 4 3 5 4 export default function StatusPageLoading() { 6 5 return ( 7 - <Shell> 8 - <div className="grid gap-6"> 9 - <Header.Skeleton /> 10 - <div className="grid gap-4"> 11 - <Container.Skeleton /> 12 - <Container.Skeleton /> 13 - </div> 14 - <div className="grid gap-4"> 15 - <Container.Skeleton /> 16 - </div> 6 + <div className="grid gap-6"> 7 + <Header.Skeleton /> 8 + <div className="grid gap-4"> 9 + <Container.Skeleton /> 10 + <Container.Skeleton /> 11 + </div> 12 + <div className="grid gap-4"> 13 + <Container.Skeleton /> 17 14 </div> 18 - </Shell> 15 + </div> 19 16 ); 20 17 }
+14 -12
apps/web/src/app/status-page/[domain]/page.tsx
··· 7 7 twitterMetadata, 8 8 } from "@/app/shared-metadata"; 9 9 import { Header } from "@/components/dashboard/header"; 10 - import { Shell } from "@/components/dashboard/shell"; 11 10 import { IncidentList } from "@/components/status-page/incident-list"; 12 11 import { MonitorList } from "@/components/status-page/monitor-list"; 13 12 import { api } from "@/trpc/server"; ··· 26 25 return notFound(); 27 26 } 28 27 return ( 29 - <Shell> 30 - <div className="grid gap-6"> 31 - <Header 32 - title={page.title} 33 - description={page.description} 34 - className="mx-auto max-w-lg lg:mx-auto" 28 + <div className="grid gap-6"> 29 + <Header 30 + title={page.title} 31 + description={page.description} 32 + className="text-left" 33 + /> 34 + <MonitorList monitors={page.monitors} /> 35 + {page.monitors?.length > 0 ? ( 36 + <IncidentList 37 + incidents={page.incidents} 38 + monitors={page.monitors} 39 + context="latest" 35 40 /> 36 - {/* Think of having a tab to switch between monitors and incidents? */} 37 - <MonitorList monitors={page.monitors} /> 38 - <IncidentList incidents={page.incidents} monitors={page.monitors} /> 39 - </div> 40 - </Shell> 41 + ) : null} 42 + </div> 41 43 ); 42 44 } 43 45
+4 -4
apps/web/src/components/incidents/affected-monitors.tsx
··· 2 2 3 3 import type { selectPublicMonitorSchema } from "@openstatus/db/src/schema"; 4 4 5 + import { Badge } from "../ui/badge"; 6 + 5 7 export function AffectedMonitors({ 6 8 monitors, 7 9 }: { 8 10 monitors: z.infer<typeof selectPublicMonitorSchema>[]; 9 11 }) { 10 12 return ( 11 - <ul role="list"> 13 + <ul role="list" className="flex gap-2"> 12 14 {monitors.length > 0 ? ( 13 15 monitors.map(({ name, url }, i) => ( 14 16 <li key={i} className="text-xs"> 15 - <span className="text-sm font-medium">{name}</span> 16 - <span className="text-muted-foreground/70 mx-1">&bull;</span> 17 - <span className="text-muted-foreground font-mono">{url}</span> 17 + <Badge variant="secondary">{name}</Badge> 18 18 </li> 19 19 )) 20 20 ) : (
+53 -32
apps/web/src/components/incidents/events.tsx
··· 2 2 3 3 import * as React from "react"; 4 4 import { useRouter } from "next/navigation"; 5 - import { format } from "date-fns"; 5 + import { format, formatDistance } from "date-fns"; 6 6 import type * as z from "zod"; 7 7 8 8 import type { selectIncidentUpdateSchema } from "@openstatus/db/src/schema"; 9 9 10 10 import { Icons } from "@/components/icons"; 11 - import { Badge } from "@/components/ui/badge"; 12 11 import { Button } from "@/components/ui/button"; 12 + import { 13 + Tooltip, 14 + TooltipContent, 15 + TooltipProvider, 16 + TooltipTrigger, 17 + } from "@/components/ui/tooltip"; 13 18 import { statusDict } from "@/data/incidents-dictionary"; 14 19 import { useProcessor } from "@/hooks/use-preprocessor"; 15 20 import { cn } from "@/lib/utils"; ··· 42 47 43 48 return ( 44 49 <div className="grid gap-3"> 45 - {slicedArray?.map((update) => { 50 + {slicedArray?.map((update, i) => { 46 51 const { icon, label } = statusDict[update.status]; 47 52 const StatusIcon = Icons[icon]; 48 53 return ( 49 54 <div 50 55 key={update.id} 51 56 className={cn( 52 - "group relative -m-2 grid gap-2 border border-transparent p-2", 53 - editable && 54 - "hover:bg-accent/40 hover:border-border hover:rounded-lg", 57 + "group relative -m-2 flex gap-4 border border-transparent p-2", 58 + editable && "hover:bg-accent/40 hover:rounded-lg", 55 59 )} 56 60 > 57 - {editable ? ( 58 - <div className="absolute right-2 top-2 hidden gap-2 group-hover:flex group-active:flex"> 59 - <Button 60 - size="icon" 61 - variant="outline" 62 - className="h-7 w-7 p-0" 63 - onClick={() => { 64 - router.push( 65 - `./incidents/update/edit?incidentId=${update.incidentId}&id=${update.id}`, 66 - ); 67 - }} 68 - > 69 - <Icons.pencil className="h-4 w-4" /> 70 - </Button> 71 - <DeleteIncidentUpdateButtonIcon id={update.id} /> 61 + <div className="relative"> 62 + <div className="bg-background border-border rounded-full border p-2"> 63 + <StatusIcon className="h-4 w-4" /> 64 + </div> 65 + {i !== sortedArray.length - 1 ? ( 66 + <div className="bg-muted absolute inset-x-0 mx-auto h-full w-[2px]" /> 67 + ) : null} 68 + </div> 69 + <div className="mt-1 grid flex-1 gap-3"> 70 + {editable ? ( 71 + <div className="absolute bottom-2 right-2 hidden gap-2 group-hover:flex group-active:flex"> 72 + <Button 73 + size="icon" 74 + variant="outline" 75 + className="h-7 w-7 p-0" 76 + onClick={() => { 77 + router.push( 78 + `./incidents/update/edit?incidentId=${update.incidentId}&id=${update.id}`, 79 + ); 80 + }} 81 + > 82 + <Icons.pencil className="h-4 w-4" /> 83 + </Button> 84 + <DeleteIncidentUpdateButtonIcon id={update.id} /> 85 + </div> 86 + ) : undefined} 87 + <div className="flex items-center justify-between gap-4"> 88 + <p className="text-sm font-semibold">{label}</p> 89 + <TooltipProvider> 90 + <Tooltip> 91 + <TooltipTrigger className="text-muted-foreground text-xs font-light"> 92 + {formatDistance(new Date(update.date), new Date(), { 93 + addSuffix: true, 94 + })} 95 + </TooltipTrigger> 96 + <TooltipContent> 97 + <p>{format(new Date(update.date), "LLL dd, y HH:mm")}</p> 98 + </TooltipContent> 99 + </Tooltip> 100 + </TooltipProvider> 72 101 </div> 73 - ) : undefined} 74 - <div className="text-muted-foreground flex items-center text-xs font-light"> 75 - {format(new Date(update.date), "LLL dd, y HH:mm")} 76 - <span className="text-muted-foreground/70 mx-1">&bull;</span> 77 - <Badge variant="secondary"> 78 - <StatusIcon className="mr-1 h-3 w-3" /> 79 - {label} 80 - </Badge> 102 + {/* <p className="max-w-3xl text-sm">{update.message}</p> */} 103 + <EventMessage message={update.message} /> 81 104 </div> 82 - {/* <p className="max-w-3xl text-sm">{update.message}</p> */} 83 - <EventMessage message={update.message} /> 84 105 </div> 85 106 ); 86 107 })} ··· 88 109 {incidentUpdates.length > 1 ? ( 89 110 <div className="text-center"> 90 111 <Button variant="ghost" onClick={toggle}> 91 - {open ? "Close all" : "All updates"} 112 + {open ? "Close" : "More"} 92 113 </Button> 93 114 </div> 94 115 ) : null}
+27 -12
apps/web/src/components/status-page/incident-list.tsx
··· 15 15 export const IncidentList = ({ 16 16 incidents, 17 17 monitors, 18 + context = "all", 18 19 }: { 19 20 incidents: z.infer<typeof selectIncidentsPageSchema>; 20 21 monitors: z.infer<typeof selectPublicMonitorSchema>[]; 22 + context?: "all" | "latest"; // latest 7 days 21 23 }) => { 22 - // TBD 23 - // const currentIncidents = incidents.filter( 24 - // ({ status }) => status !== "resolved", 25 - // ); 24 + const lastWeek = Date.now() - 1000 * 60 * 60 * 24 * 7; 25 + 26 + function getLastWeeksIncidents() { 27 + return incidents.filter((incident) => { 28 + return incident.incidentUpdates.some( 29 + (update) => update.date.getTime() > lastWeek, 30 + ); 31 + }); 32 + } 33 + 34 + const _incidents = context === "all" ? incidents : getLastWeeksIncidents(); 26 35 27 36 return ( 28 37 <> 29 - {incidents?.length > 0 ? ( 38 + {_incidents.sort((a, b) => { 39 + if (a.updatedAt == undefined) return 1; 40 + if (b.updatedAt == undefined) return -1; 41 + return b.updatedAt.getTime() - a.updatedAt.getTime(); 42 + })?.length > 0 ? ( 30 43 <div className="grid gap-4"> 31 44 <h2 className="text-muted-foreground text-lg font-light"> 32 - All Incidents 45 + {context === "all" ? "All incidents" : "Latest incidents"} 33 46 </h2> 34 - {incidents.map((incident) => { 47 + {_incidents.map((incident) => { 35 48 return ( 36 49 <div key={incident.id} className="grid gap-4 text-left"> 37 50 <div className="max-w-3xl font-semibold"> ··· 39 52 <StatusBadge status={incident.status} /> 40 53 </div> 41 54 <div className="overflow-hidden text-ellipsis"> 42 - <p className="text-muted-foreground mb-1 text-xs"> 55 + <p className="text-muted-foreground mb-2 text-xs"> 43 56 Affected Monitors 44 57 </p> 45 58 <AffectedMonitors ··· 54 67 /> 55 68 </div> 56 69 <div> 57 - <p className="text-muted-foreground mb-1 text-xs"> 70 + <p className="text-muted-foreground mb-2 text-xs"> 58 71 Latest Updates 59 72 </p> 60 73 <Events incidentUpdates={incident.incidentUpdates} /> ··· 64 77 })} 65 78 </div> 66 79 ) : ( 67 - <h2 className="text-muted-foreground text-lg font-light"> 68 - No Incidents 69 - </h2> 80 + <p className="text-muted-foreground text-center text-sm font-light"> 81 + {context === "all" 82 + ? "No incidents." 83 + : "No incidents in the last week."} 84 + </p> 70 85 )} 71 86 </> 72 87 );
+5 -1
apps/web/src/middleware.ts
··· 52 52 return subdomain; 53 53 }; 54 54 55 + // used when trying subdomain slug via status.localhost:3000/incidents 56 + const publicRoutesDev = ["/incidents"]; 57 + 55 58 export default authMiddleware({ 56 59 publicRoutes: [ 57 60 "/", ··· 68 71 "/discord", 69 72 "/github", 70 73 "/oss-friends", 71 - ], 74 + "/status-page/(.*)", 75 + ].concat(process.env.NODE_ENV === "development" ? publicRoutesDev : []), 72 76 ignoredRoutes: ["/api/og", "/discord", "github"], // FIXME: we should check the `publicRoutes` 73 77 beforeAuth: before, 74 78 async afterAuth(auth, req) {
+2 -2
packages/api/src/router/incident.ts
··· 81 81 // update parent incident with latest status 82 82 await opts.ctx.db 83 83 .update(incident) 84 - .set({ status: opts.input.status }) 84 + .set({ status: opts.input.status, updatedAt: new Date() }) 85 85 .where(eq(incident.id, opts.input.incidentId)) 86 86 .returning() 87 87 .get(); ··· 333 333 ], 334 334 }, 335 335 }, 336 - orderBy: (incident, { desc }) => [desc(incident.createdAt)], 336 + orderBy: (incident, { desc }) => [desc(incident.updatedAt)], 337 337 }); 338 338 339 339 return z.array(selectIncidentSchemaWithRelation).parse(result);