Openstatus www.openstatus.dev
at 40ee67dc9bbbb4d39796e1b7e8b2ae17c61dd77e 312 lines 8.5 kB view raw
1"use client"; 2 3import { cva } from "class-variance-authority"; 4import { format, formatDuration } from "date-fns"; 5import { ChevronRight, Info } from "lucide-react"; 6import Link from "next/link"; 7import * as React from "react"; 8 9import type { 10 Incident, 11 Maintenance, 12 StatusReport, 13 StatusReportUpdate, 14} from "@openstatus/db/src/schema"; 15import { 16 Tracker as OSTracker, 17 classNames, 18 endOfDay, 19 startOfDay, 20} from "@openstatus/tracker"; 21import { 22 Tooltip, 23 TooltipContent, 24 TooltipProvider, 25 TooltipTrigger, 26} from "@openstatus/ui/src/components/tooltip"; 27 28import type { ResponseStatusTracker } from "@/lib/tb"; 29import { cn } from "@/lib/utils"; 30import { 31 HoverCard, 32 HoverCardContent, 33 HoverCardTrigger, 34} from "@openstatus/ui/src/components/hover-card"; 35import { Separator } from "@openstatus/ui/src/components/separator"; 36 37const tracker = cva("h-10 rounded-full flex-1", { 38 variants: { 39 variant: { 40 blacklist: 41 "bg-status-operational/80 data-[state=open]:bg-status-operational", 42 ...classNames, 43 }, 44 report: { 45 false: "", 46 true: classNames.degraded, 47 }, 48 incident: { 49 // only used to highlight incident that are 'light' (less than 10 minutes) 50 light: classNames.degraded, 51 }, 52 }, 53 defaultVariants: { 54 variant: "empty", 55 report: false, 56 }, 57}); 58 59interface TrackerProps { 60 data: ResponseStatusTracker[]; 61 name: string; 62 description?: string; 63 reports?: (StatusReport & { statusReportUpdates: StatusReportUpdate[] })[]; 64 incidents?: Incident[]; 65 maintenances?: Maintenance[]; 66 showValues?: boolean; 67} 68 69export function Tracker({ 70 data, 71 name, 72 description, 73 reports, 74 incidents, 75 maintenances, 76 showValues, 77}: TrackerProps) { 78 const tracker = new OSTracker({ 79 data, 80 statusReports: reports, 81 incidents, 82 maintenances, 83 }); 84 const uptime = tracker.totalUptime; 85 const isMissing = tracker.isDataMissing; 86 87 return ( 88 <div className="flex w-full flex-col gap-1.5"> 89 <div className="flex justify-between text-sm"> 90 <div className="flex items-center gap-2"> 91 <p className="line-clamp-1 font-semibold text-foreground">{name}</p> 92 {description ? ( 93 <TooltipProvider> 94 <Tooltip> 95 <TooltipTrigger asChild> 96 <Info className="h-4 w-4" /> 97 </TooltipTrigger> 98 <TooltipContent> 99 <p className="text-muted-foreground">{description}</p> 100 </TooltipContent> 101 </Tooltip> 102 </TooltipProvider> 103 ) : null} 104 </div> 105 {!isMissing && showValues ? ( 106 <p className="shrink-0 font-light text-muted-foreground">{uptime}%</p> 107 ) : null} 108 </div> 109 <div className="relative h-full w-full"> 110 <div className="flex flex-row-reverse gap-px sm:gap-0.5"> 111 {tracker.days.map((props, i) => { 112 return <Bar key={i} showValues={showValues} {...props} />; 113 })} 114 </div> 115 </div> 116 <div className="flex items-center justify-between font-light text-muted-foreground text-xs"> 117 <p>{tracker.days.length} days ago</p> 118 <p>Today</p> 119 </div> 120 </div> 121 ); 122} 123 124type BarProps = OSTracker["days"][number] & { 125 className?: string; 126 showValues?: boolean; 127}; 128 129export const Bar = ({ 130 count, 131 ok, 132 day, 133 variant, 134 label, 135 blacklist, 136 statusReports, 137 incidents, 138 showValues, 139 className, 140}: BarProps) => { 141 const [open, setOpen] = React.useState(false); 142 143 // total incident time in ms 144 const incidentLength = incidents.reduce((prev, curr) => { 145 return ( 146 prev + 147 Math.abs( 148 (curr.resolvedAt?.getTime() || new Date().getTime()) - 149 curr.startedAt?.getTime(), 150 ) 151 ); 152 }, 0); 153 154 const isLightIncident = incidentLength > 0 && incidentLength < 600_000; // 10 minutes in ms 155 156 const rootClassName = tracker({ 157 report: 158 statusReports.length > 0 && 159 // NOTE: avoid setting true for a report with a single update (e.g. post-mortem) + maintenance at the same time 160 statusReports.some( 161 (report) => 162 report.statusReportUpdates && report.statusReportUpdates?.length > 1, 163 ), 164 variant: blacklist ? "blacklist" : variant, 165 incident: isLightIncident ? "light" : undefined, 166 }); 167 168 return ( 169 <HoverCard 170 openDelay={100} 171 closeDelay={100} 172 open={open} 173 onOpenChange={setOpen} 174 > 175 <HoverCardTrigger onClick={() => setOpen(true)} asChild> 176 <div className={cn(rootClassName, className)} /> 177 </HoverCardTrigger> 178 <HoverCardContent side="top" className="w-auto p-2"> 179 {blacklist ? ( 180 <p className="text-muted-foreground text-sm">{blacklist}</p> 181 ) : ( 182 <div> 183 <BarDescription 184 label={label} 185 day={day} 186 count={count} 187 ok={ok} 188 barClassName={rootClassName} 189 showValues={showValues} 190 /> 191 {statusReports && statusReports.length > 0 ? ( 192 <> 193 <Separator className="my-1.5" /> 194 <StatusReportList reports={statusReports} /> 195 </> 196 ) : null} 197 {incidents && incidents.length > 0 ? ( 198 <> 199 <Separator className="my-1.5" /> 200 <DowntimeText incidents={incidents} day={day} /> 201 </> 202 ) : null} 203 </div> 204 )} 205 </HoverCardContent> 206 </HoverCard> 207 ); 208}; 209 210export function BarDescription({ 211 label, 212 day, 213 count, 214 ok, 215 showValues, 216 barClassName, 217 className, 218}: { 219 label: string; 220 day: string; 221 count: number; 222 ok: number; 223 showValues?: boolean; 224 barClassName?: string; 225 className?: string; 226}) { 227 return ( 228 <div className={cn("flex gap-2", className)}> 229 <div className={cn(barClassName, "h-auto w-1 flex-none")} /> 230 <div className="grid flex-1 gap-1"> 231 <div className="flex justify-between gap-8 text-sm"> 232 <p className="font-semibold">{label}</p> 233 <p className="shrink-0 text-muted-foreground"> 234 {format(new Date(day), "MMM d")} 235 </p> 236 </div> 237 {showValues ? ( 238 <div className="flex justify-between gap-8 font-light text-muted-foreground text-xs"> 239 <p> 240 <code className="text-status-operational">{count}</code> requests 241 </p> 242 <p> 243 <code className="text-status-down">{count - ok}</code> failed 244 </p> 245 </div> 246 ) : null} 247 </div> 248 </div> 249 ); 250} 251 252export function StatusReportList({ reports }: { reports: StatusReport[] }) { 253 return ( 254 <ul> 255 {reports?.map((report) => ( 256 <li key={report.id} className="text-muted-foreground text-sm"> 257 <Link 258 // TODO: include setPrefixUrl for local development 259 href={`./events/report/${report.id}`} 260 className="group flex items-center justify-between gap-2 hover:text-foreground" 261 > 262 <span className="truncate">{report.title}</span> 263 <ChevronRight className="h-4 w-4 shrink-0" /> 264 </Link> 265 </li> 266 ))} 267 </ul> 268 ); 269} 270 271export function DowntimeText({ 272 incidents, 273 day, 274}: { 275 incidents: Incident[]; 276 day: string; // TODO: use Date 277}) { 278 // TODO: MOVE INTO TRACKER CLASS? 279 const startOfDayDate = startOfDay(new Date(day)); 280 const endOfDayDate = endOfDay(new Date(day)); 281 282 const incidentLength = incidents 283 ?.map((incident) => { 284 const { startedAt, resolvedAt } = incident; 285 if (!startedAt) return 0; 286 if (!resolvedAt) 287 return ( 288 Math.min(endOfDayDate.getTime(), new Date().getTime()) - 289 Math.max(startOfDayDate.getTime(), startedAt.getTime()) 290 ); 291 return ( 292 Math.min(resolvedAt.getTime(), endOfDayDate.getTime()) - 293 Math.max(startOfDayDate.getTime(), startedAt.getTime()) 294 ); 295 }) 296 // add 1 second because end of day is 23:59:59 297 .reduce((acc, curr) => acc + 1 + curr, 0); 298 299 const days = Math.floor(incidentLength / (1000 * 60 * 60 * 24)); 300 const minutes = Math.floor((incidentLength / (1000 * 60)) % 60); 301 const hours = Math.floor((incidentLength / (1000 * 60 * 60)) % 24); 302 303 return ( 304 <p className="text-muted-foreground text-xs"> 305 Downtime for{" "} 306 {formatDuration( 307 { minutes, hours, days }, 308 { format: ["days", "hours", "minutes", "seconds"], zero: false }, 309 )} 310 </p> 311 ); 312}