Openstatus
www.openstatus.dev
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}