Openstatus www.openstatus.dev

chore: stpg improvements (#1370)

* fix: small stuff

* fix: tcp tb cache

* fix: resolved report without resolved update

authored by

Maximilian Kaske and committed by
GitHub
d3fb165a ff97f68d

+111 -44
+17 -2
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx
··· 21 21 import { Badge } from "@/components/ui/badge"; 22 22 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 23 23 import { formatDate } from "@/lib/formatter"; 24 + import { CircleCheck } from "lucide-react"; 24 25 import Link from "next/link"; 25 26 26 27 // TODO: include ?filter=maintenance/reports ··· 45 46 <TabsContent value="reports" className="flex flex-col gap-4"> 46 47 {statusReports.length > 0 ? ( 47 48 statusReports.map((report) => { 48 - const startedAt = report.statusReportUpdates[0].date; 49 + const updates = report.statusReportUpdates.sort( 50 + (a, b) => b.date.getTime() - a.date.getTime(), 51 + ); 52 + const firstUpdate = updates[updates.length - 1]; 53 + const lastUpdate = updates[0]; 54 + // NOTE: updates are sorted descending by date 55 + const startedAt = firstUpdate.date; 56 + // HACKY: LEGACY: only resolved via report and not via report update 57 + const isReportResolvedOnly = 58 + report.status === "resolved" && lastUpdate.status !== "resolved"; 49 59 return ( 50 60 <StatusEvent key={report.id}> 51 61 <StatusEventAside> ··· 58 68 className="rounded-lg" 59 69 > 60 70 <StatusEventContent> 61 - <StatusEventTitle>{report.title}</StatusEventTitle> 71 + <StatusEventTitle className="inline-flex gap-1"> 72 + {report.title} 73 + {isReportResolvedOnly ? ( 74 + <CircleCheck className="size-4 text-success shrink-0 mt-1 ml-1.5" /> 75 + ) : null} 76 + </StatusEventTitle> 62 77 {report.monitorsToStatusReports.length > 0 ? ( 63 78 <StatusEventAffected className="flex flex-wrap gap-1"> 64 79 {report.monitorsToStatusReports.map((affected) => (
+31 -18
apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx
··· 23 23 import { Separator } from "@/components/ui/separator"; 24 24 import { useTRPC } from "@/lib/trpc/client"; 25 25 import { useQuery } from "@tanstack/react-query"; 26 + import Link from "next/link"; 26 27 import { useParams } from "next/navigation"; 27 28 28 29 export default function Page() { ··· 61 62 ); 62 63 if (!maintenance) return null; 63 64 return ( 64 - <StatusBannerContainer key={e.id} status={e.status}> 65 - <StatusBannerTitle>{e.name}</StatusBannerTitle> 66 - <StatusBannerContent> 67 - <StatusEventTimelineMaintenance 68 - maintenance={maintenance} 69 - withDot={false} 70 - /> 71 - </StatusBannerContent> 72 - </StatusBannerContainer> 65 + <Link 66 + href={`./events/maintenance/${e.id}`} 67 + key={e.id} 68 + className="rounded-lg" 69 + > 70 + <StatusBannerContainer status={e.status}> 71 + <StatusBannerTitle>{e.name}</StatusBannerTitle> 72 + <StatusBannerContent> 73 + <StatusEventTimelineMaintenance 74 + maintenance={maintenance} 75 + withDot={false} 76 + /> 77 + </StatusBannerContent> 78 + </StatusBannerContainer> 79 + </Link> 73 80 ); 74 81 } 75 82 if (e.type === "report") { ··· 78 85 ); 79 86 if (!report) return null; 80 87 return ( 81 - <StatusBannerContainer key={e.id} status={e.status}> 82 - <StatusBannerTitle>{e.name}</StatusBannerTitle> 83 - <StatusBannerContent> 84 - <StatusEventTimelineReport 85 - updates={report.statusReportUpdates} 86 - withDot={false} 87 - /> 88 - </StatusBannerContent> 89 - </StatusBannerContainer> 88 + <Link 89 + href={`./events/report/${e.id}`} 90 + key={e.id} 91 + className="rounded-lg" 92 + > 93 + <StatusBannerContainer status={e.status}> 94 + <StatusBannerTitle>{e.name}</StatusBannerTitle> 95 + <StatusBannerContent> 96 + <StatusEventTimelineReport 97 + updates={report.statusReportUpdates} 98 + withDot={false} 99 + /> 100 + </StatusBannerContent> 101 + </StatusBannerContainer> 102 + </Link> 90 103 ); 91 104 } 92 105 return null;
+13 -1
apps/status-page/src/components/content/timestamp-hover-card.tsx
··· 4 4 HoverCardTrigger, 5 5 } from "@/components/ui/hover-card"; 6 6 import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 7 + import { useMediaQuery } from "@/hooks/use-media-query"; 7 8 import { UTCDate } from "@date-fns/utc"; 8 9 import { 9 10 type HoverCardContentProps, ··· 21 22 alignOffset = -4, 22 23 sideOffset, 23 24 children, 25 + onClick, 24 26 ...props 25 27 }: React.ComponentProps<typeof HoverCardTrigger> & { 26 28 date: Date; ··· 30 32 sideOffset?: HoverCardContentProps["sideOffset"]; 31 33 }) { 32 34 const [open, setOpen] = useState(false); 35 + const isTouch = useMediaQuery("(hover: none)"); 33 36 const [_, setRerender] = useState(0); 34 37 35 38 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; ··· 51 54 return ( 52 55 <HoverCard openDelay={0} closeDelay={0} open={open} onOpenChange={setOpen}> 53 56 {/* NOTE: the trigger is an `a` tag per default */} 54 - <HoverCardTrigger {...props}>{children}</HoverCardTrigger> 57 + <HoverCardTrigger 58 + onClick={(e) => { 59 + // NOTE: support touch devices 60 + if (isTouch) setOpen((prev) => !prev); 61 + onClick?.(e); 62 + }} 63 + {...props} 64 + > 65 + {children} 66 + </HoverCardTrigger> 55 67 <HoverCardPortal> 56 68 <HoverCardContent 57 69 className="z-10 w-auto p-2"
+1 -7
apps/status-page/src/components/nav/footer.tsx
··· 4 4 import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; 5 5 import { ThemeToggle } from "@/components/theme-toggle"; 6 6 import { useTRPC } from "@/lib/trpc/client"; 7 - import { cn } from "@/lib/utils"; 8 7 import { useQuery } from "@tanstack/react-query"; 9 8 import { format } from "date-fns"; 10 9 import { useParams } from "next/navigation"; ··· 22 21 <footer {...props}> 23 22 <div className="mx-auto flex max-w-2xl items-center justify-between gap-4 px-3 py-2"> 24 23 <div className="leading-[0.9]"> 25 - <p 26 - className={cn( 27 - "text-muted-foreground text-sm", 28 - page.workspacePlan === "team" && "sr-only", 29 - )} 30 - > 24 + <p className="text-muted-foreground text-sm"> 31 25 Powered by <Link href="#">OpenStatus</Link> 32 26 </p> 33 27 <TimestampHoverCard date={new Date(dataUpdatedAt)} side="top">
+6 -3
apps/status-page/src/components/nav/header.tsx
··· 69 69 ); 70 70 71 71 const types = ( 72 - page?.workspacePlan === "free" ? ["rss", "atom"] : ["email", "rss", "atom"] 73 - ) satisfies ("email" | "rss" | "atom")[]; 72 + page?.workspacePlan === "free" 73 + ? ["rss", "atom", "ssh"] 74 + : ["email", "rss", "atom", "ssh"] 75 + ) satisfies ("email" | "rss" | "atom" | "ssh")[]; 74 76 75 77 return ( 76 78 <header {...props}> ··· 78 80 {/* NOTE: same width as the `StatusUpdates` button */} 79 81 <div className="w-[150px] shrink-0"> 80 82 <Link 81 - href={page?.homepageUrl ?? "/"} 83 + href={page?.homepageUrl || "/"} 82 84 target={page?.homepageUrl ? "_blank" : undefined} 83 85 rel={page?.homepageUrl ? "noreferrer" : undefined} 84 86 className="rounded-full" ··· 212 214 size="icon" 213 215 type="button" 214 216 className={cn("size-8", className)} 217 + asChild 215 218 {...props} 216 219 > 217 220 <a href={link} target="_blank" rel="noreferrer">
+5 -1
apps/status-page/src/components/status-page/status-events.tsx
··· 116 116 key={index} 117 117 report={update} 118 118 duration={ 119 - index === 0 && update.status === "resolved" ? duration : undefined 119 + index === 0 && 120 + update.status === "resolved" && 121 + duration !== "0 seconds" 122 + ? duration 123 + : undefined 120 124 } 121 125 withSeparator={index !== updates.length - 1} 122 126 withDot={withDot}
+1 -1
apps/status-page/src/components/status-page/status-tracker.tsx
··· 375 375 <div className="mt-1 text-muted-foreground text-xs"> 376 376 {formatDateRange(from, to ?? undefined)}{" "} 377 377 <span className="ml-1.5 font-mono text-muted-foreground/70"> 378 - {duration} 378 + {duration === "0 seconds" ? null : duration} 379 379 </span> 380 380 </div> 381 381 </div>
+18 -6
apps/status-page/src/components/status-page/status-updates.tsx
··· 14 14 import { Inbox } from "lucide-react"; 15 15 import { useState } from "react"; 16 16 17 - type StatusUpdateType = "email" | "rss" | "atom"; 17 + type StatusUpdateType = "email" | "rss" | "atom" | "ssh"; 18 18 19 19 interface StatusUpdatesProps extends React.ComponentProps<typeof Button> { 20 20 types?: StatusUpdateType[]; ··· 55 55 {types.includes("atom") ? ( 56 56 <TabsTrigger value="atom">Atom</TabsTrigger> 57 57 ) : null} 58 + {types.includes("ssh") ? ( 59 + <TabsTrigger value="ssh">SSH</TabsTrigger> 60 + ) : null} 58 61 </TabsList> 59 62 <TabsContent value="email" className="flex flex-col gap-2"> 60 63 {success ? ( ··· 86 89 <div className="border-b px-2 pb-2"> 87 90 <Input 88 91 placeholder={`https://${slug}.openstatus.dev/feed/rss`} 89 - className="disabled:opacity-90" 90 - disabled 92 + readOnly 91 93 /> 92 94 </div> 93 95 <div className="px-2 pb-2"> ··· 101 103 <div className="border-b px-2 pb-2"> 102 104 <Input 103 105 placeholder={`https://${slug}.openstatus.dev/feed/atom`} 104 - className="disabled:opacity-90" 105 - disabled 106 + readOnly 106 107 /> 107 108 </div> 108 109 <div className="px-2 pb-2"> ··· 112 113 /> 113 114 </div> 114 115 </TabsContent> 116 + <TabsContent value="ssh" className="flex flex-col gap-2"> 117 + <div className="border-b px-2 pb-2"> 118 + <Input placeholder={`ssh ${slug}@ssh.openstatus.dev`} readOnly /> 119 + </div> 120 + <div className="px-2 pb-2"> 121 + <CopyButton 122 + className="w-full" 123 + value={`ssh ${slug}@ssh.openstatus.dev`} 124 + /> 125 + </div> 126 + </TabsContent> 115 127 </Tabs> 116 128 </PopoverContent> 117 129 </Popover> ··· 135 147 }} 136 148 {...props} 137 149 > 138 - {isCopied ? "Copied" : "Copy link"} 150 + {isCopied ? "Copied" : "Copy"} 139 151 </Button> 140 152 ); 141 153 }
+3
apps/status-page/src/lib/formatter.ts
··· 72 72 const isToEndDay = to && endOfDay(to).getTime() === to.getTime(); 73 73 74 74 if (sameDay) { 75 + if (from.getTime() === to.getTime()) { 76 + return formatDateTime(from); 77 + } 75 78 if (from && to) { 76 79 return `${formatDateTime(from)} - ${formatTime(to)}`; 77 80 }
+15
packages/api/src/router/statusPage.utils.ts
··· 156 156 const firstUpdate = updates[0]; 157 157 const lastUpdate = updates[updates.length - 1]; 158 158 if (!firstUpdate?.date || firstUpdate.date < pastThreshod) return; 159 + 160 + // HACKY: LEGACY: we shouldn't have report.status anymore and instead use the update status for that. 161 + // Ideally, we could replace the status with "downtime", "degraded", "operational" to indicate the gravity of the issue 162 + if (report.status === "resolved") { 163 + events.push({ 164 + id: report.id, 165 + name: report.title, 166 + from: firstUpdate?.date, 167 + to: lastUpdate?.date, 168 + type: "report", 169 + status: "success" as const, 170 + }); 171 + return; 172 + } 173 + 159 174 events.push({ 160 175 id: report.id, 161 176 name: report.title,
+1 -5
packages/tinybird/src/client.ts
··· 1049 1049 error: z.number().default(0), 1050 1050 monitorId: z.coerce.string(), 1051 1051 }), 1052 - opts: { 1053 - next: { 1054 - revalidate: PUBLIC_CACHE, 1055 - }, 1056 - }, 1052 + opts: { next: { revalidate: REVALIDATE } }, 1057 1053 }); 1058 1054 } 1059 1055