Openstatus www.openstatus.dev

chore: stpg improvements (#1387)

* feat: session storage to display floating button

* fix: key and title

* chore: improve metadata

* chore: error and not found

* chore: improve gap

* chore: status event durations

authored by

Maximilian Kaske and committed by
GitHub
7fbfedae 2c099701

+355 -180
+3 -3
apps/dashboard/src/app/metadata.ts
··· 1 1 import type { Metadata } from "next"; 2 2 3 - export const TITLE = "OpenStatus"; 3 + export const TITLE = "openstatus"; 4 4 export const DESCRIPTION = 5 - "OpenStatus is an open-source platform to monitor your services and keep your users informed."; 5 + "Open-source platform to monitor your services and keep your users informed."; 6 6 7 - const OG_TITLE = "OpenStatus"; 7 + const OG_TITLE = "openstatus"; 8 8 const OG_DESCRIPTION = "Monitor your services and keep your users informed."; 9 9 const FOOTER = "app.openstatus.dev"; 10 10 const IMAGE = "assets/og/dashboard-v2.png";
+36 -4
apps/status-page/src/app/global-error.tsx
··· 1 1 "use client"; 2 2 3 + import { Link } from "@/components/common/link"; 4 + import { Button } from "@/components/ui/button"; 3 5 import * as Sentry from "@sentry/nextjs"; 4 - import NextError from "next/error"; 5 6 import { useEffect } from "react"; 6 7 7 8 export default function GlobalError({ 8 9 error, 10 + reset, 9 11 }: { 10 12 error: Error & { digest?: string }; 13 + reset: () => void; 11 14 }) { 12 15 useEffect(() => { 13 16 Sentry.captureException(error); ··· 16 19 return ( 17 20 <html lang="en"> 18 21 <body> 19 - {/* This is the default Next.js error component but it doesn't allow omitting the statusCode property yet. */} 20 - {/* biome-ignore lint/suspicious/noExplicitAny: <explanation> */} 21 - <NextError statusCode={undefined as any} /> 22 + <main className="flex min-h-screen w-full flex-col space-y-6 bg-background p-4 md:p-8"> 23 + <div className="flex flex-1 flex-col items-center justify-center gap-8"> 24 + <div className="mx-auto max-w-xl border bg-card text-center"> 25 + <div className="flex flex-col gap-4 p-6 sm:p-12"> 26 + <div className="flex flex-col gap-1"> 27 + <h2 className="font-cal text-2xl text-foreground"> 28 + Application Error 29 + </h2> 30 + <p className="text-muted-foreground text-sm sm:text-base"> 31 + An unexpected error occurred. This has been reported and 32 + we&apos;re working on it.{" "} 33 + <Link href="mailto:ping@openstatus.dev">Contact us</Link> if 34 + it persists. 35 + </p> 36 + </div> 37 + <div className="flex flex-col items-center justify-center gap-4 sm:flex-row"> 38 + <Button 39 + variant="outline" 40 + size="lg" 41 + onClick={reset} 42 + className="cursor-pointer" 43 + > 44 + Try Again 45 + </Button> 46 + <Button size="lg" asChild> 47 + <Link href="/">Go Home</Link> 48 + </Button> 49 + </div> 50 + </div> 51 + </div> 52 + </div> 53 + </main> 22 54 </body> 23 55 </html> 24 56 );
+7 -6
apps/status-page/src/app/metadata.ts
··· 1 1 import type { Metadata } from "next"; 2 2 3 - export const TITLE = "OpenStatus"; 3 + export const TITLE = "openstatus"; 4 4 export const DESCRIPTION = 5 - "OpenStatus is an open-source platform to monitor your services and keep your users informed."; 5 + "Use community themes for your status page or contribute your own."; 6 6 7 - const OG_TITLE = "OpenStatus"; 8 - const OG_DESCRIPTION = "Monitor your services and keep your users informed."; 9 - const FOOTER = "app.openstatus.dev"; 10 - const IMAGE = "assets/og/dashboard-v2.png"; 7 + const OG_TITLE = "Theme Store"; 8 + const OG_DESCRIPTION = 9 + "Use community themes for your status page or contribute your own."; 10 + const FOOTER = "themes.openstatus.dev"; 11 + const IMAGE = "assets/og/status-page.png"; 11 12 12 13 export const defaultMetadata: Metadata = { 13 14 title: {
+4 -4
apps/status-page/src/app/not-found.tsx
··· 9 9 const router = useRouter(); 10 10 11 11 return ( 12 - <main className="flex min-h-screen w-full flex-col space-y-6 p-4 md:p-8 bg-background"> 12 + <main className="flex min-h-screen w-full flex-col space-y-6 bg-background p-4 md:p-8"> 13 13 <div className="flex flex-1 flex-col items-center justify-center gap-8"> 14 - <div className="mx-auto max-w-xl text-center border bg-card"> 14 + <div className="mx-auto max-w-xl border bg-card text-center"> 15 15 <div className="flex flex-col gap-4 p-6 sm:p-12"> 16 - <div className="flex flex-col gap-2"> 16 + <div className="flex flex-col gap-1"> 17 17 <p className="font-mono text-foreground">404 Page not found</p> 18 18 <h2 className="font-cal text-2xl text-foreground"> 19 19 Oops, something went wrong. ··· 26 26 <Button 27 27 variant="outline" 28 28 size="lg" 29 - onClick={() => void router.back()} 29 + onClick={router.back} 30 30 className="cursor-pointer" 31 31 > 32 32 Go Back
+19 -5
apps/status-page/src/components/status-page/floating-button.tsx
··· 149 149 ); 150 150 } 151 151 152 - const DISPLAY_FLOATING_BUTTON = 153 - process.env.NODE_ENV === "development" || 154 - process.env.NEXT_PUBLIC_ENABLE_FLOATING_BUTTON === "true"; 155 - 156 152 export function FloatingButton({ className }: { className?: string }) { 157 153 const { 158 154 cardType, ··· 166 162 radius, 167 163 setRadius, 168 164 } = useStatusPage(); 165 + const [display, setDisplay] = useState(false); 169 166 170 - if (!DISPLAY_FLOATING_BUTTON) return null; 167 + useEffect(() => { 168 + const enabled = 169 + sessionStorage.getItem("status-page-configuration") === "true"; 170 + const host = window.location.host; 171 + if ( 172 + (host.includes("localhost") || 173 + host.includes("stpg.dev") || 174 + host.includes("openstatus.dev") || 175 + host.includes("vercel.app")) && 176 + enabled 177 + ) { 178 + setDisplay(true); 179 + } else if (process.env.NODE_ENV === "development") { 180 + setDisplay(true); 181 + } 182 + }, []); 183 + 184 + if (!display) return null; 171 185 172 186 return ( 173 187 <div className={cn("fixed right-4 bottom-4 z-50", className)}>
+1
apps/status-page/src/components/status-page/floating-theme.tsx
··· 36 36 if ( 37 37 (host.includes("localhost") || 38 38 host.includes("stpg.dev") || 39 + host.includes("openstatus.dev") || 39 40 host.includes("vercel.app")) && 40 41 enabled 41 42 ) {
+37 -25
apps/status-page/src/components/status-page/status-events.tsx
··· 103 103 }[]; 104 104 withDot?: boolean; 105 105 }) { 106 - const startedAt = new Date(updates[0].date); 107 - const endedAt = new Date(updates[updates.length - 1].date); 108 - const duration = formatDistanceStrict(startedAt, endedAt); 109 106 return ( 110 107 <div className={cn("text-muted-foreground text-sm", className)} {...props}> 111 - {/* TODO: make sure they are sorted by date */} 108 + {/* NOTE: make sure they are sorted by date */} 112 109 {updates 113 110 .sort((a, b) => b.date.getTime() - a.date.getTime()) 114 - .map((update, index) => ( 115 - <StatusEventTimelineReportUpdate 116 - key={index} 117 - report={update} 118 - duration={ 119 - index === 0 && 120 - update.status === "resolved" && 121 - duration !== "0 seconds" 122 - ? duration 123 - : undefined 111 + .map((update, index) => { 112 + const updateDate = new Date(update.date); 113 + let durationText: string | undefined; 114 + 115 + if (index === 0) { 116 + const startedAt = new Date(updates[updates.length - 1].date); 117 + const duration = formatDistanceStrict(startedAt, updateDate); 118 + 119 + if (duration !== "0 seconds" && update.status === "resolved") { 120 + durationText = `(in ${duration})`; 124 121 } 125 - withSeparator={index !== updates.length - 1} 126 - withDot={withDot} 127 - isLast={index === updates.length - 1} 128 - /> 129 - ))} 122 + } else { 123 + const lastUpdateDate = new Date(updates[index - 1].date); 124 + const timeFromLast = formatDistanceStrict( 125 + updateDate, 126 + lastUpdateDate, 127 + ); 128 + durationText = `(${timeFromLast} earlier)`; 129 + } 130 + 131 + return ( 132 + <StatusEventTimelineReportUpdate 133 + key={index} 134 + report={update} 135 + duration={durationText} 136 + withSeparator={index !== updates.length - 1} 137 + withDot={withDot} 138 + isLast={index === updates.length - 1} 139 + /> 140 + ); 141 + })} 130 142 </div> 131 143 ); 132 144 } ··· 151 163 return ( 152 164 <div data-variant={report.status} className="group"> 153 165 <div className="flex flex-row items-center justify-between gap-2"> 154 - <div className="flex flex-row gap-2"> 166 + <div className="flex flex-row gap-4"> 155 167 {withDot ? ( 156 168 <div className="flex flex-col"> 157 169 <div className="flex h-5 flex-col items-center justify-center"> ··· 164 176 <StatusEventTimelineTitle> 165 177 <span>{status[report.status]}</span>{" "} 166 178 {/* underline decoration-dashed underline-offset-2 decoration-muted-foreground/30 */} 167 - <span className="font-mono text-muted-foreground/70 text-xs"> 179 + <span className="font-mono text-muted-foreground text-xs"> 168 180 <TimestampHoverCard date={new Date(report.date)} asChild> 169 181 <span>{formatDateTime(report.date)}</span> 170 182 </TimestampHoverCard> 171 183 </span>{" "} 172 184 {duration ? ( 173 185 <span className="font-mono text-muted-foreground/70 text-xs"> 174 - (in {duration}) 186 + {duration} 175 187 </span> 176 188 ) : null} 177 189 </StatusEventTimelineTitle> ··· 204 216 return ( 205 217 <div data-variant="maintenance" className="group"> 206 218 <div className="flex flex-row items-center justify-between gap-2"> 207 - <div className="flex flex-row gap-2"> 219 + <div className="flex flex-row gap-4"> 208 220 {withDot ? ( 209 221 <div className="flex flex-col"> 210 222 <div className="flex h-5 flex-col items-center justify-center"> ··· 216 228 <div> 217 229 <StatusEventTimelineTitle> 218 230 <span>Maintenance</span>{" "} 219 - <span className="font-mono text-muted-foreground/70 text-xs"> 231 + <span className="font-mono text-muted-foreground text-xs"> 220 232 <TimestampHoverCard date={maintenance.from} asChild> 221 233 <span>{from}</span> 222 234 </TimestampHoverCard> ··· 264 276 }: React.ComponentProps<"div">) { 265 277 return ( 266 278 <div 267 - className={cn("font-mono text-muted-foreground text-sm", className)} 279 + className={cn("py-1.5 font-mono text-foreground/90 text-sm", className)} 268 280 {...props} 269 281 > 270 282 {children}
+1 -1
apps/status-page/src/components/status-page/status-tracker.tsx
··· 303 303 304 304 const content = ( 305 305 <StatusTrackerEvent 306 - key={event.id} 306 + key={`${event.id}-${event.type}`} 307 307 status={eventStatus} 308 308 name={event.name} 309 309 from={event.from}
apps/web/public/assets/og/status-page.png

This is a binary file and will not be displayed.

+247 -132
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, 137 + name: incident.title || "Downtime", 138 138 from: incident.createdAt, 139 139 to: incident.resolvedAt, 140 140 type: "incident", ··· 212 212 success: 0, 213 213 empty: -1, 214 214 } as const; 215 + 216 + // Constants for time calculations 217 + const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; 218 + const MILLISECONDS_PER_MINUTE = 1000 * 60; 215 219 216 220 // Helper to get highest priority status from data 217 221 function getHighestPriorityStatus( ··· 298 302 return acc + Math.max(0, duration); 299 303 }, 0); 300 304 301 - // Cap at 24 hours (86400000 milliseconds) per day 302 - return Math.min(total, 24 * 60 * 60 * 1000); 305 + // Cap at 24 hours per day 306 + return Math.min(total, MILLISECONDS_PER_DAY); 303 307 } 304 308 305 309 export function setDataByType({ ··· 313 317 cardType: "requests" | "duration" | "dominant" | "manual"; 314 318 barType: "absolute" | "dominant" | "manual"; 315 319 }): UptimeData[] { 320 + // Helper functions moved inside to share inputs and avoid parameter passing 321 + function createEventSegments( 322 + incidents: Event[], 323 + reports: Event[], 324 + maintenances: Event[], 325 + date: Date, 326 + ): Array<{ status: "info" | "degraded" | "error"; count: number }> { 327 + const eventTypes = [ 328 + { status: "info" as const, events: maintenances }, 329 + { status: "degraded" as const, events: reports }, 330 + { status: "error" as const, events: incidents }, 331 + ]; 332 + 333 + return eventTypes 334 + .filter(({ events }) => events.length > 0) 335 + .map(({ status, events }) => ({ 336 + status, 337 + count: getTotalEventsDurationMs(events, date), 338 + })); 339 + } 340 + 341 + function createErrorOnlyBarData( 342 + errorSegmentCount: number, 343 + ): UptimeData["bar"] { 344 + return [ 345 + { 346 + status: "success" as const, 347 + height: 348 + ((MILLISECONDS_PER_DAY - errorSegmentCount) / MILLISECONDS_PER_DAY) * 349 + 100, 350 + }, 351 + { 352 + status: "error" as const, 353 + height: (errorSegmentCount / MILLISECONDS_PER_DAY) * 100, 354 + }, 355 + ]; 356 + } 357 + 358 + function createProportionalBarData( 359 + segments: Array<{ status: "info" | "degraded" | "error"; count: number }>, 360 + ): UptimeData["bar"] { 361 + const totalDuration = segments.reduce( 362 + (sum, segment) => sum + segment.count, 363 + 0, 364 + ); 365 + 366 + return segments.map((segment) => ({ 367 + status: segment.status, 368 + height: (segment.count / totalDuration) * 100, 369 + })); 370 + } 371 + 372 + function createStatusSegments( 373 + dayData: StatusData, 374 + ): Array<{ status: "success" | "degraded" | "error"; count: number }> { 375 + return [ 376 + { status: "success" as const, count: dayData.ok }, 377 + { status: "degraded" as const, count: dayData.degraded }, 378 + { status: "error" as const, count: dayData.error }, 379 + ]; 380 + } 381 + 382 + function segmentsToBarData( 383 + segments: Array<{ 384 + status: "success" | "degraded" | "error"; 385 + count: number; 386 + }>, 387 + total: number, 388 + ): UptimeData["bar"] { 389 + return segments 390 + .filter((segment) => segment.count > 0) 391 + .map((segment) => ({ 392 + status: segment.status, 393 + height: (segment.count / total) * 100, 394 + })); 395 + } 396 + 397 + function createEmptyBarData(): UptimeData["bar"] { 398 + return [ 399 + { 400 + status: "empty", 401 + height: 100, 402 + }, 403 + ]; 404 + } 405 + 406 + function createEmptyCardData( 407 + eventStatus?: "error" | "degraded" | "info" | "success" | "empty", 408 + ): UptimeData["card"] { 409 + return [{ status: eventStatus ?? "empty", value: "" }]; 410 + } 411 + 412 + function createRequestEntries(dayData: StatusData): Array<{ 413 + status: "success" | "degraded" | "error" | "info"; 414 + count: number; 415 + }> { 416 + return [ 417 + { status: "success" as const, count: dayData.ok }, 418 + { status: "degraded" as const, count: dayData.degraded }, 419 + { status: "error" as const, count: dayData.error }, 420 + { status: "info" as const, count: 0 }, 421 + ]; 422 + } 423 + 424 + function createDurationEntries(dayData: StatusData): Array<{ 425 + status: "success" | "degraded" | "error" | "info"; 426 + count: number; 427 + }> { 428 + return [ 429 + { status: "error" as const, count: dayData.error }, 430 + { status: "degraded" as const, count: dayData.degraded }, 431 + { status: "success" as const, count: dayData.ok }, 432 + { status: "info" as const, count: 0 }, 433 + ]; 434 + } 435 + 436 + function entriesToRequestCardData( 437 + entries: Array<{ 438 + status: "success" | "degraded" | "error" | "info"; 439 + count: number; 440 + }>, 441 + ): UptimeData["card"] { 442 + return entries 443 + .filter((entry) => entry.count > 0) 444 + .map((entry) => ({ 445 + status: entry.status, 446 + value: `${formatNumber(entry.count)} reqs`, 447 + })); 448 + } 449 + 450 + // Helper to calculate duration in minutes for a specific event type 451 + function calculateEventDurationMinutes(events: Event[], date: Date): number { 452 + const totalDuration = getTotalEventsDurationMs(events, date); 453 + return Math.round(totalDuration / MILLISECONDS_PER_MINUTE); 454 + } 455 + 456 + // Helper to calculate total minutes in a day (handles today vs past days) 457 + function getTotalMinutesInDay(date: Date): number { 458 + const now = new Date(); 459 + const startOfDay = new Date(date); 460 + startOfDay.setUTCHours(0, 0, 0, 0); 461 + 462 + if (isToday(date)) { 463 + const minutesElapsed = Math.floor( 464 + (now.getTime() - startOfDay.getTime()) / MILLISECONDS_PER_MINUTE, 465 + ); 466 + return minutesElapsed; 467 + } 468 + return 24 * 60; 469 + } 470 + 471 + // Helper to create duration card data for a specific status 472 + function createDurationCardEntry( 473 + status: "error" | "degraded" | "info" | "success", 474 + events: Event[], 475 + date: Date, 476 + durationMap: Map<string, number>, 477 + ): { 478 + status: "error" | "degraded" | "info" | "success"; 479 + value: string; 480 + } | null { 481 + if (status === "success") { 482 + // Calculate success duration as remaining time 483 + let totalEventMinutes = 0; 484 + // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> 485 + durationMap.forEach((minutes) => (totalEventMinutes += minutes)); 486 + 487 + const totalMinutesInDay = getTotalMinutesInDay(date); 488 + const successMinutes = Math.max(totalMinutesInDay - totalEventMinutes, 0); 489 + 490 + if (successMinutes === 0) return null; 491 + return { 492 + status, 493 + value: formatDuration(successMinutes), 494 + }; 495 + } 496 + 497 + // For error, degraded, info - calculate from events 498 + const minutes = calculateEventDurationMinutes(events, date); 499 + durationMap.set(status, minutes); 500 + 501 + if (minutes === 0) return null; 502 + return { 503 + status, 504 + value: formatDuration(minutes), 505 + }; 506 + } 507 + 316 508 return data.map((dayData) => { 317 509 const date = new Date(dayData.day); 318 510 ··· 346 538 switch (barType) { 347 539 case "absolute": 348 540 if (eventStatus) { 349 - // If there's an event override, show single status 350 - barData = [ 351 - { 352 - status: eventStatus, 353 - height: 100, 354 - }, 355 - ]; 541 + // Create segments based on event durations for the day 542 + const eventSegments = createEventSegments( 543 + incidents, 544 + reports, 545 + maintenances, 546 + date, 547 + ); 548 + 549 + // Special case: if only errors exist, show uptime vs downtime 550 + if ( 551 + eventSegments.length === 1 && 552 + eventSegments[0].status === "error" 553 + ) { 554 + barData = createErrorOnlyBarData(eventSegments[0].count); 555 + } else { 556 + // Multiple segments: show proportional distribution 557 + barData = createProportionalBarData(eventSegments); 558 + } 356 559 } else if (total === 0) { 357 - // Empty day 358 - barData = [ 359 - { 360 - status: "empty", 361 - height: 100, 362 - }, 363 - ]; 560 + // Empty day - no data available 561 + barData = createEmptyBarData(); 364 562 } else { 365 - // Multiple segments for absolute view 366 - const segments = [ 367 - { status: "success" as const, count: dayData.ok }, 368 - { status: "degraded" as const, count: dayData.degraded }, 369 - { status: "error" as const, count: dayData.error }, 370 - ] 371 - .filter((segment) => segment.count > 0) 372 - .map((segment) => ({ 373 - status: segment.status, 374 - height: (segment.count / total) * 100, 375 - })); 376 - 377 - barData = segments; 563 + // Multiple segments for absolute view - show proportional distribution of status data 564 + const statusSegments = createStatusSegments(dayData); 565 + barData = segmentsToBarData(statusSegments, total); 378 566 } 379 567 break; 380 568 case "dominant": ··· 416 604 switch (cardType) { 417 605 case "requests": 418 606 if (total === 0) { 419 - cardData = [{ status: eventStatus ?? "empty", value: "" }]; 607 + cardData = createEmptyCardData(eventStatus); 420 608 } else { 421 - const entries = [ 422 - { status: "success" as const, count: dayData.ok }, 423 - { status: "degraded" as const, count: dayData.degraded }, 424 - { status: "error" as const, count: dayData.error }, 425 - { status: "info" as const, count: 0 }, 426 - ]; 427 - 428 - cardData = entries 429 - .filter((entry) => entry.count > 0) 430 - .map((entry) => ({ 431 - status: entry.status, 432 - value: `${formatNumber(entry.count)} reqs`, 433 - })); 609 + const requestEntries = createRequestEntries(dayData); 610 + cardData = entriesToRequestCardData(requestEntries); 434 611 } 435 612 break; 436 613 437 614 case "duration": 438 615 if (total === 0) { 439 - cardData = [{ status: eventStatus ?? "empty", value: "" }]; 616 + cardData = createEmptyCardData(eventStatus); 440 617 } else { 441 - const entries = [ 442 - { status: "error" as const, count: dayData.error }, 443 - { status: "degraded" as const, count: dayData.degraded }, 444 - { status: "success" as const, count: dayData.ok }, 445 - { status: "info" as const, count: 0 }, 446 - ]; 447 - 448 - const map = new Map< 449 - "error" | "degraded" | "success" | "info", 450 - number 451 - >(); 618 + const entries = createDurationEntries(dayData); 619 + const durationMap = new Map<string, number>(); 452 620 453 621 cardData = entries 454 622 .map((entry) => { 455 - if (entry.status === "error") { 456 - const totalDuration = getTotalEventsDurationMs(incidents, date); 457 - const minutes = Math.round(totalDuration / (1000 * 60)); 458 - map.set("error", minutes); 459 - if (minutes === 0) return null; 460 - return { 461 - status: entry.status, 462 - value: formatDuration(minutes), 463 - }; 464 - } 465 - 466 - if (entry.status === "degraded") { 467 - const totalDuration = getTotalEventsDurationMs(reports, date); 468 - const minutes = Math.round(totalDuration / (1000 * 60)); 469 - map.set("degraded", minutes); 470 - if (minutes === 0) return null; 471 - return { 472 - status: entry.status, 473 - value: formatDuration(minutes), 474 - }; 475 - } 476 - 477 - if (entry.status === "info") { 478 - const totalDuration = getTotalEventsDurationMs( 479 - maintenances, 480 - date, 481 - ); 482 - const minutes = Math.round(totalDuration / (1000 * 60)); 483 - map.set("info", minutes); 484 - if (minutes === 0) return null; 485 - return { 486 - status: entry.status, 487 - value: formatDuration(minutes), 488 - }; 489 - } 490 - 491 - if (entry.status === "success") { 492 - let total = 0; 493 - // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> 494 - map.forEach((d) => (total += d)); 495 - 496 - const now = new Date(); 497 - const startOfDay = new Date(date); 498 - startOfDay.setUTCHours(0, 0, 0, 0); 499 - 500 - let totalMinutesInDay: number; 501 - if (isToday(date)) { 502 - const minutesElapsed = Math.floor( 503 - (now.getTime() - startOfDay.getTime()) / (1000 * 60), 504 - ); 505 - totalMinutesInDay = minutesElapsed; 506 - } else { 507 - totalMinutesInDay = 24 * 60; 508 - } 623 + // Map each entry status to its corresponding events 624 + const eventMap = { 625 + error: incidents, 626 + degraded: reports, 627 + info: maintenances, 628 + success: [], // Success is calculated differently 629 + }; 509 630 510 - const minutes = Math.max(totalMinutesInDay - total, 0); 511 - if (minutes === 0) return null; 512 - return { 513 - status: entry.status, 514 - value: formatDuration(minutes), 515 - }; 516 - } 631 + const events = eventMap[entry.status as keyof typeof eventMap]; 632 + return createDurationCardEntry( 633 + entry.status, 634 + events, 635 + date, 636 + durationMap, 637 + ); 517 638 }) 518 639 .filter((item): item is NonNullable<typeof item> => item !== null); 519 640 } ··· 544 665 default: 545 666 // Default to requests behavior 546 667 if (total === 0) { 547 - cardData = [{ status: eventStatus ?? "empty", value: "" }]; 668 + cardData = createEmptyCardData(eventStatus); 548 669 } else { 549 - const entries = [ 550 - { status: "error" as const, count: dayData.error }, 551 - { status: "degraded" as const, count: dayData.degraded }, 552 - { status: "success" as const, count: dayData.ok }, 553 - ]; 554 - 555 - cardData = entries 556 - .filter((entry) => entry.count > 0) 557 - .map((entry) => ({ 558 - status: entry.status, 559 - value: `${formatNumber(entry.count)} reqs`, 560 - })); 670 + const defaultEntries = createRequestEntries(dayData); 671 + cardData = entriesToRequestCardData(defaultEntries); 561 672 } 562 673 break; 563 674 } 564 675 565 676 return { 566 677 day: dayData.day, 567 - events: [...reports, ...maintenances], 678 + events: [ 679 + ...reports, 680 + ...maintenances, 681 + ...(barType === "absolute" ? incidents : []), 682 + ], 568 683 bar: barData, 569 684 card: cardData, 570 685 }; ··· 589 704 return acc + ((item.to || new Date()).getTime() - item.from.getTime()); 590 705 }, 0); 591 706 592 - const total = data.length * 24 * 60 * 60 * 1000; 707 + const total = data.length * MILLISECONDS_PER_DAY; 593 708 594 709 return `${Math.round(((total - duration) / total) * 10000) / 100}%`; 595 710 }