Openstatus www.openstatus.dev

fix: more stpg stuff (#1400)

authored by

Maximilian Kaske and committed by
GitHub
237481ec 50ed5fce

+154 -38
+2 -8
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx
··· 15 15 StatusEventTimelineMaintenance, 16 16 StatusEventTimelineReport, 17 17 StatusEventTitle, 18 + StatusEventTitleCheck, 18 19 } from "@/components/status-page/status-events"; 19 20 import { Badge } from "@/components/ui/badge"; 20 21 import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 21 22 import { formatDate } from "@/lib/formatter"; 22 23 import { useTRPC } from "@/lib/trpc/client"; 23 24 import { useQuery } from "@tanstack/react-query"; 24 - import { Check } from "lucide-react"; 25 25 import Link from "next/link"; 26 26 import { useParams } from "next/navigation"; 27 27 ··· 71 71 <StatusEventContent> 72 72 <StatusEventTitle className="inline-flex gap-1"> 73 73 {report.title} 74 - {isReportResolvedOnly ? ( 75 - <div className="mt-1 ml-1.5"> 76 - <div className="rounded-full border border-success/20 bg-success/10 p-0.5 text-success"> 77 - <Check className="size-3 shrink-0" /> 78 - </div> 79 - </div> 80 - ) : null} 74 + {isReportResolvedOnly ? <StatusEventTitleCheck /> : null} 81 75 </StatusEventTitle> 82 76 {report.monitorsToStatusReports.length > 0 ? ( 83 77 <StatusEventAffected className="flex flex-wrap gap-1">
+13 -3
apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx
··· 11 11 StatusEventContent, 12 12 StatusEventTimelineReport, 13 13 StatusEventTitle, 14 + StatusEventTitleCheck, 14 15 } from "@/components/status-page/status-events"; 15 16 import { Badge } from "@/components/ui/badge"; 16 17 import { useTRPC } from "@/lib/trpc/client"; ··· 26 27 27 28 if (!report) return null; 28 29 29 - const startedAt = report.statusReportUpdates[0].date; 30 + const firstUpdate = report.statusReportUpdates[0]; 31 + const lastUpdate = 32 + report.statusReportUpdates[report.statusReportUpdates.length - 1]; 33 + 34 + // HACKY: LEGACY: only resolved via report and not via report update 35 + const isReportResolvedOnly = 36 + report.status === "resolved" && lastUpdate.status !== "resolved"; 30 37 31 38 return ( 32 39 <div className="flex flex-col gap-4"> ··· 37 44 <StatusEvent> 38 45 <StatusEventAside> 39 46 <span className="font-medium text-foreground/80"> 40 - {formatDate(startedAt, { month: "short" })} 47 + {formatDate(firstUpdate.date, { month: "short" })} 41 48 </span> 42 49 </StatusEventAside> 43 50 <StatusEventContent hoverable={false}> 44 - <StatusEventTitle>{report.title}</StatusEventTitle> 51 + <StatusEventTitle className="inline-flex gap-1"> 52 + {report.title} 53 + {isReportResolvedOnly ? <StatusEventTitleCheck /> : null} 54 + </StatusEventTitle> 45 55 {report.monitorsToStatusReports.length > 0 ? ( 46 56 <StatusEventAffected className="flex flex-wrap gap-1"> 47 57 {report.monitorsToStatusReports.map((affected) => (
+26 -19
apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx
··· 129 129 <StatusBanner status={page.status} /> 130 130 )} 131 131 {/* NOTE: check what gap feels right */} 132 - <StatusContent className="gap-5"> 133 - {page.monitors.map((monitor) => { 134 - const { data, uptime } = 135 - uptimeData?.find((m) => m.id === monitor.id) ?? {}; 136 - return ( 137 - <StatusMonitor 138 - key={monitor.id} 139 - status={monitor.status} 140 - data={data} 141 - monitor={monitor} 142 - uptime={uptime} 143 - showUptime={showUptime} 144 - isLoading={isLoading} 145 - /> 146 - ); 147 - })} 148 - </StatusContent> 132 + {page.monitors.length > 0 ? ( 133 + <StatusContent className="gap-5"> 134 + {page.monitors.map((monitor) => { 135 + const { data, uptime } = 136 + uptimeData?.find((m) => m.id === monitor.id) ?? {}; 137 + return ( 138 + <StatusMonitor 139 + key={monitor.id} 140 + status={monitor.status} 141 + data={data} 142 + monitor={monitor} 143 + uptime={uptime} 144 + showUptime={showUptime} 145 + isLoading={isLoading} 146 + /> 147 + ); 148 + })} 149 + </StatusContent> 150 + ) : null} 149 151 <Separator /> 150 152 <StatusContent> 151 153 <StatusFeed 152 154 statusReports={page.statusReports 153 155 .filter((report) => 154 - page.lastEvents.some((event) => event.id === report.id), 156 + page.lastEvents.some( 157 + (event) => event.id === report.id && event.type === "report", 158 + ), 155 159 ) 156 160 .map((report) => ({ 157 161 ...report, ··· 162 166 }))} 163 167 maintenances={page.maintenances 164 168 .filter((maintenance) => 165 - page.lastEvents.some((event) => event.id === maintenance.id), 169 + page.lastEvents.some( 170 + (event) => 171 + event.id === maintenance.id && event.type === "maintenance", 172 + ), 166 173 ) 167 174 .map((maintenance) => ({ 168 175 ...maintenance,
+23 -2
apps/status-page/src/components/status-page/status-blank.tsx
··· 1 + import { Button } from "@/components/ui/button"; 1 2 import { cn } from "@/lib/utils"; 3 + import Link from "next/link"; 2 4 3 5 export function StatusBlankContainer({ 4 6 children, ··· 8 10 return ( 9 11 <div 10 12 className={cn( 11 - "flex flex-col items-center justify-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-center sm:px-8 sm:py-6", 13 + "flex flex-col items-center justify-center gap-2.5 rounded-lg border bg-muted/30 px-3 py-2 text-center sm:px-8 sm:py-6", 12 14 className, 13 15 )} 14 16 {...props} ··· 45 47 ); 46 48 } 47 49 50 + export function StatusBlankLink({ 51 + children, 52 + className, 53 + href, 54 + ...props 55 + }: React.ComponentProps<typeof Button> & { href: string }) { 56 + return ( 57 + <Button 58 + variant="outline" 59 + size="sm" 60 + className={cn("text-foreground", className)} 61 + asChild 62 + {...props} 63 + > 64 + <Link href={href}>{children}</Link> 65 + </Button> 66 + ); 67 + } 68 + 48 69 export function StatusBlankContent({ 49 70 children, 50 71 className, 51 72 ...props 52 73 }: React.ComponentProps<"div">) { 53 74 return ( 54 - <div className={cn("", className)} {...props}> 75 + <div className={cn("space-y-1", className)} {...props}> 55 76 {children} 56 77 </div> 57 78 );
+40 -2
apps/status-page/src/components/status-page/status-events.tsx
··· 1 1 import { ProcessMessage } from "@/components/content/process-message"; 2 2 import { TimestampHoverCard } from "@/components/content/timestamp-hover-card"; 3 3 import { Separator } from "@/components/ui/separator"; 4 + import { 5 + Tooltip, 6 + TooltipContent, 7 + TooltipProvider, 8 + TooltipTrigger, 9 + } from "@/components/ui/tooltip"; 4 10 import { formatDateRange, formatDateTime } from "@/lib/formatter"; 5 11 import { cn } from "@/lib/utils"; 6 12 import { formatDistanceStrict } from "date-fns"; 13 + import { Check } from "lucide-react"; 7 14 import { status } from "./messages"; 8 15 9 16 export function StatusEvent({ ··· 54 61 ); 55 62 } 56 63 64 + export function StatusEventTitleCheck({ 65 + className, 66 + children, 67 + ...props 68 + }: React.ComponentProps<"div">) { 69 + return ( 70 + <div className={cn("mt-1 ml-1.5", className)} {...props}> 71 + <TooltipProvider> 72 + <Tooltip> 73 + <TooltipTrigger> 74 + <div className="rounded-full border border-success/20 bg-success/10 p-0.5 text-success"> 75 + <Check className="size-3 shrink-0" /> 76 + </div> 77 + </TooltipTrigger> 78 + <TooltipContent> 79 + <p>Report resolved</p> 80 + </TooltipContent> 81 + </Tooltip> 82 + </TooltipProvider> 83 + </div> 84 + ); 85 + } 86 + 57 87 // TODO: affected monitors 58 88 export function StatusEventAffected({ 59 89 className, ··· 186 216 ) : null} 187 217 </StatusEventTimelineTitle> 188 218 <StatusEventTimelineMessage> 189 - <ProcessMessage value={report.message} /> 219 + {report.message.trim() === "" ? ( 220 + <span className="text-muted-foreground/70">-</span> 221 + ) : ( 222 + <ProcessMessage value={report.message} /> 223 + )} 190 224 </StatusEventTimelineMessage> 191 225 </div> 192 226 </div> ··· 242 276 ) : null} 243 277 </StatusEventTimelineTitle> 244 278 <StatusEventTimelineMessage> 245 - <ProcessMessage value={maintenance.message} /> 279 + {maintenance.message.trim() === "" ? ( 280 + <span className="text-muted-foreground/70">-</span> 281 + ) : ( 282 + <ProcessMessage value={maintenance.message} /> 283 + )} 246 284 </StatusEventTimelineMessage> 247 285 </div> 248 286 </div>
+10
apps/status-page/src/components/status-page/status-feed.tsx
··· 9 9 StatusBlankContainer, 10 10 StatusBlankContent, 11 11 StatusBlankDescription, 12 + StatusBlankLink, 12 13 StatusBlankReport, 13 14 StatusBlankTitle, 14 15 } from "./status-blank"; ··· 92 93 <StatusBlankDescription> 93 94 There have been no reports within the last 7 days. 94 95 </StatusBlankDescription> 96 + <StatusBlankLink href={`${prefix ? `/${prefix}` : ""}/events`}> 97 + View all reports 98 + </StatusBlankLink> 95 99 </StatusBlankContent> 96 100 </StatusBlankContainer> 97 101 ); ··· 186 190 } 187 191 return null; 188 192 })} 193 + <StatusBlankLink 194 + className="mx-auto" 195 + href={`${prefix ? `/${prefix}` : ""}/events`} 196 + > 197 + View all events 198 + </StatusBlankLink> 189 199 </div> 190 200 ); 191 201 }
+16 -2
apps/status-page/src/components/status-page/status-tracker.tsx
··· 401 401 status: "success" | "degraded" | "error" | "info" | "empty"; 402 402 }) { 403 403 if (!from) return null; 404 - const duration = to ? formatDistanceStrict(from, to) : "ongoing"; 404 + 405 405 return ( 406 406 <div className="group relative text-sm"> 407 407 {/* NOTE: this is to make the text truncate based on the with of the sibling element */} ··· 421 421 <div className="mt-1 text-muted-foreground text-xs"> 422 422 {formatDateRange(from, to ?? undefined)}{" "} 423 423 <span className="ml-1.5 font-mono text-muted-foreground/70"> 424 - {duration === "0 seconds" ? null : duration} 424 + {formatDuration({ from, to, name, status })} 425 425 </span> 426 426 </div> 427 427 </div> 428 428 ); 429 429 } 430 + 431 + const formatDuration = ({ 432 + from, 433 + to, 434 + name, 435 + }: React.ComponentProps<typeof StatusTrackerEvent>) => { 436 + if (!from) return null; 437 + if (!to) return "ongoing"; 438 + const duration = formatDistanceStrict(from, to); 439 + const isMultipleIncidents = name.includes("Downtime ("); 440 + if (isMultipleIncidents) return `across ${duration}`; 441 + if (duration === "0 seconds") return null; 442 + return duration; 443 + };
+1
packages/api/src/router/statusPage.ts
··· 125 125 .filter((e) => { 126 126 if (e.type === "incident") return false; 127 127 if (!e.from || e.from.getTime() >= threshold) return true; 128 + if (e.type === "report" && e.status !== "success") return true; 128 129 return false; 129 130 }) 130 131 .sort((a, b) => a.from.getTime() - b.from.getTime());
+23 -2
packages/api/src/router/statusPage.utils.ts
··· 134 134 if (!incident.createdAt || incident.createdAt < pastThreshod) return; 135 135 events.push({ 136 136 id: incident.id, 137 - name: incident.title || "Downtime", 137 + name: "Downtime", 138 138 from: incident.createdAt, 139 139 to: incident.resolvedAt, 140 140 type: "incident", ··· 703 703 break; 704 704 } 705 705 706 + // Bundle incidents that occur on the same day if there are more than 4 707 + const bundledIncidents = 708 + incidents.length > 4 709 + ? [ 710 + { 711 + id: -1, // Use -1 to indicate bundled incidents 712 + name: `Downtime (${incidents.length} incidents)`, 713 + from: new Date( 714 + Math.min(...incidents.map((i) => i.from.getTime())), 715 + ), 716 + to: new Date( 717 + Math.max( 718 + ...incidents.map((i) => (i.to || new Date()).getTime()), 719 + ), 720 + ), 721 + type: "incident" as const, 722 + status: "error" as const, 723 + }, 724 + ] 725 + : incidents; 726 + 706 727 return { 707 728 day: dayData.day, 708 729 events: [ 709 730 ...reports, 710 731 ...maintenances, 711 - ...(barType === "absolute" ? incidents : []), 732 + ...(barType === "absolute" ? bundledIncidents : []), 712 733 ], 713 734 bar: barData, 714 735 card: cardData,