Openstatus www.openstatus.dev
at main 988 lines 28 kB view raw
1import type { 2 Incident, 3 Maintenance, 4 PageComponent, 5 PageComponentType, 6 PageComponentWithMonitorRelation, 7 StatusReport, 8 StatusReportUpdate, 9} from "@openstatus/db/src/schema"; 10 11/** 12 * Type for a monitor component with a non-null monitor relation 13 */ 14export type MonitorComponentWithNonNullMonitor = 15 PageComponentWithMonitorRelation & { 16 type: "monitor"; 17 monitorId: number; 18 monitor: NonNullable<PageComponentWithMonitorRelation["monitor"]>; 19 }; 20 21/** 22 * Type guard to check if a pageComponent is a monitor type with a monitor relation 23 * Works with any object that has the shape of a pageComponent with a valid monitor relation 24 */ 25export function isMonitorComponent( 26 component: PageComponentWithMonitorRelation, 27): component is MonitorComponentWithNonNullMonitor { 28 return ( 29 component.type === "monitor" && 30 component.monitor !== null && 31 component.monitor !== undefined && 32 component.monitor.active === true && 33 component.monitor.deletedAt === null 34 ); 35} 36 37/** 38 * Transforms pageComponents to legacy monitorsToStatusReports format 39 */ 40export function transformToMonitorsToStatusReports( 41 statusReportId: number, 42 pageComponents: PageComponentWithMonitorRelation[], 43) { 44 const monitors = pageComponents.filter(isMonitorComponent); 45 return monitors.map((m) => ({ 46 statusReportId, 47 monitorId: m.monitor.id, 48 monitor: m.monitor, 49 })); 50} 51 52/** 53 * Transforms pageComponents to legacy maintenancesToMonitors format 54 */ 55export function transformToMaintenancesToMonitors( 56 maintenanceId: number, 57 pageComponents: PageComponentWithMonitorRelation[], 58) { 59 const monitors = pageComponents.filter(isMonitorComponent); 60 return monitors.map((m) => ({ 61 maintenanceId, 62 monitorId: m.monitor.id, 63 monitor: m.monitor, 64 })); 65} 66 67/** 68 * Transforms statusReportsToPageComponents relations using a monitorByIdMap for performance 69 */ 70export function transformStatusReportWithPageComponents< 71 T extends { 72 id: number; 73 statusReportsToPageComponents: Array<{ 74 pageComponent: PageComponent | null; 75 }>; 76 }, 77>( 78 report: T, 79 monitorByIdMap: Map< 80 number, 81 NonNullable<PageComponentWithMonitorRelation["monitor"]> 82 >, 83) { 84 return { 85 ...report, 86 monitorsToStatusReports: report.statusReportsToPageComponents.flatMap( 87 (r) => { 88 const pc = r.pageComponent; 89 if (!pc?.monitorId) return []; 90 const monitor = monitorByIdMap.get(pc.monitorId); 91 if (!monitor) return []; 92 return [ 93 { statusReportId: report.id, monitorId: pc.monitorId, monitor }, 94 ]; 95 }, 96 ), 97 }; 98} 99 100/** 101 * Transforms maintenancesToPageComponents relations using a monitorByIdMap for performance 102 */ 103export function transformMaintenanceWithPageComponents< 104 T extends { 105 id: number; 106 maintenancesToPageComponents: Array<{ 107 pageComponent: PageComponent | null; 108 }>; 109 }, 110>( 111 maintenance: T, 112 monitorByIdMap: Map< 113 number, 114 NonNullable<PageComponentWithMonitorRelation["monitor"]> 115 >, 116) { 117 return { 118 ...maintenance, 119 maintenancesToMonitors: maintenance.maintenancesToPageComponents.flatMap( 120 (mp) => { 121 const pc = mp.pageComponent; 122 if (!pc?.monitorId) return []; 123 const monitor = monitorByIdMap.get(pc.monitorId); 124 if (!monitor) return []; 125 return [ 126 { maintenanceId: maintenance.id, monitorId: pc.monitorId, monitor }, 127 ]; 128 }, 129 ), 130 }; 131} 132 133export type StatusData = { 134 day: string; 135 count: number; 136 ok: number; 137 degraded: number; 138 error: number; 139 monitorId: string; 140}; 141 142export function fillStatusDataFor45Days( 143 data: Array<StatusData>, 144 monitorId: string, 145 lookbackPeriod = 45, 146): Array<StatusData> { 147 const result = []; 148 const dataByDay = new Map(); 149 150 // Index existing data by day 151 data.forEach((item) => { 152 const dayKey = new Date(item.day).toISOString().split("T")[0]; // YYYY-MM-DD format 153 dataByDay.set(dayKey, item); 154 }); 155 156 // Generate all 45 days from today backwards 157 const now = new Date(); 158 for (let i = 0; i < lookbackPeriod; i++) { 159 const date = new Date(now); 160 date.setUTCDate(date.getUTCDate() - i); 161 date.setUTCHours(0, 0, 0, 0); // Set to start of day in UTC 162 163 const dayKey = date.toISOString().split("T")[0]; // YYYY-MM-DD format 164 const isoString = date.toISOString(); 165 166 if (dataByDay.has(dayKey)) { 167 // Use existing data but ensure the day is properly formatted 168 const existingData = dataByDay.get(dayKey); 169 result.push({ 170 ...existingData, 171 day: isoString, 172 }); 173 } else { 174 // Fill missing day with default values 175 result.push({ 176 day: isoString, 177 count: 0, 178 ok: 0, 179 degraded: 0, 180 error: 0, 181 monitorId, 182 }); 183 } 184 } 185 186 // Sort by day (oldest first) 187 return result.sort( 188 (a, b) => new Date(a.day).getTime() - new Date(b.day).getTime(), 189 ); 190} 191 192export function fillStatusDataFor45DaysNoop({ 193 errorDays, 194 degradedDays, 195 lookbackPeriod = 45, 196}: { 197 errorDays: number[]; 198 degradedDays: number[]; 199 lookbackPeriod?: number; 200}): Array<StatusData> { 201 const issueDays = [...errorDays, ...degradedDays]; 202 const data: StatusData[] = Array.from({ length: 45 }, (_, i) => { 203 return { 204 day: new Date(new Date().setDate(new Date().getDate() - i)).toISOString(), 205 count: 1, 206 ok: issueDays.includes(i) ? 0 : 1, 207 degraded: degradedDays.includes(i) ? 1 : 0, 208 error: errorDays.includes(i) ? 1 : 0, 209 monitorId: "1", 210 }; 211 }); 212 return fillStatusDataFor45Days(data, "1", lookbackPeriod); 213} 214 215type Event = { 216 id: number; 217 name: string; 218 from: Date; 219 to: Date | null; 220 type: "maintenance" | "incident" | "report"; 221 status: "success" | "degraded" | "error" | "info"; 222}; 223 224export function getEvents({ 225 maintenances, 226 incidents, 227 reports, 228 pageComponentId, 229 monitorId, 230 componentType, 231 pastDays = 45, 232}: { 233 maintenances: (Maintenance & { 234 maintenancesToPageComponents: { 235 pageComponent: PageComponent | null; 236 }[]; 237 })[]; 238 incidents: Incident[]; 239 reports: (StatusReport & { 240 statusReportsToPageComponents: { 241 pageComponent: PageComponent | null; 242 }[]; 243 statusReportUpdates: StatusReportUpdate[]; 244 })[]; 245 pageComponentId?: number; 246 monitorId?: number; 247 componentType?: PageComponentType; 248 pastDays?: number; 249}): Event[] { 250 const events: Event[] = []; 251 const pastThreshod = new Date(); 252 pastThreshod.setDate(pastThreshod.getDate() - pastDays); 253 254 // Filter maintenances - prioritize pageComponentId, fallback to monitorId for backward compatibility 255 maintenances 256 .filter((maintenance) => { 257 if (pageComponentId) { 258 return maintenance.maintenancesToPageComponents.some( 259 (m) => m.pageComponent?.id === pageComponentId, 260 ); 261 } 262 if (monitorId) { 263 return maintenance.maintenancesToPageComponents.some( 264 (m) => m.pageComponent?.monitorId === monitorId, 265 ); 266 } 267 return true; 268 }) 269 .forEach((maintenance) => { 270 if (maintenance.from < pastThreshod) return; 271 events.push({ 272 id: maintenance.id, 273 name: maintenance.title, 274 from: maintenance.from, 275 to: maintenance.to, 276 type: "maintenance", 277 status: "info" as const, 278 }); 279 }); 280 281 // Filter incidents - only for monitor-type components 282 // Static components don't have incidents 283 if (componentType !== "static") { 284 incidents 285 .filter((incident) => 286 monitorId ? incident.monitorId === monitorId : true, 287 ) 288 .forEach((incident) => { 289 if (!incident.createdAt || incident.createdAt < pastThreshod) return; 290 events.push({ 291 id: incident.id, 292 name: "Downtime", 293 from: incident.createdAt, 294 to: incident.resolvedAt, 295 type: "incident", 296 status: "error" as const, 297 }); 298 }); 299 } 300 301 // Filter reports - prioritize pageComponentId, fallback to monitorId for backward compatibility 302 reports 303 .filter((report) => { 304 if (pageComponentId) { 305 return report.statusReportsToPageComponents.some( 306 (r) => r.pageComponent?.id === pageComponentId, 307 ); 308 } 309 if (monitorId) { 310 return report.statusReportsToPageComponents.some( 311 (r) => r.pageComponent?.monitorId === monitorId, 312 ); 313 } 314 return true; 315 }) 316 .map((report) => { 317 const updates = report.statusReportUpdates.sort( 318 (a, b) => a.date.getTime() - b.date.getTime(), 319 ); 320 if (updates.length === 0) return; 321 322 const firstUpdate = updates[0]; 323 const lastUpdate = updates[updates.length - 1]; 324 325 // NOTE: we don't check threshold here because we display all unresolved reports 326 if (!firstUpdate?.date) return; 327 328 // HACKY: LEGACY: we shouldn't have report.status anymore and instead use the update status for that. 329 // Ideally, we could replace the status with "downtime", "degraded", "operational" to indicate the gravity of the issue 330 if (report.status === "resolved") { 331 events.push({ 332 id: report.id, 333 name: report.title, 334 from: firstUpdate?.date, 335 to: lastUpdate?.date, 336 type: "report", 337 status: "success" as const, 338 }); 339 return; 340 } 341 342 events.push({ 343 id: report.id, 344 name: report.title, 345 from: firstUpdate?.date, 346 to: 347 lastUpdate?.status === "resolved" || 348 lastUpdate?.status === "monitoring" 349 ? lastUpdate?.date 350 : null, 351 type: "report", 352 status: "degraded" as const, 353 }); 354 }); 355 356 return events; 357} 358 359// Keep the old function name for backward compatibility 360export const getEventsByMonitorId = getEvents; 361 362export function getWorstVariant( 363 statuses: (keyof typeof STATUS_PRIORITY)[], 364): keyof typeof STATUS_PRIORITY { 365 if (statuses.length === 0) return "success"; 366 367 return statuses.reduce( 368 (worst, current) => { 369 return STATUS_PRIORITY[current] > STATUS_PRIORITY[worst] 370 ? current 371 : worst; 372 }, 373 "success" as keyof typeof STATUS_PRIORITY, 374 ); 375} 376 377type UptimeData = { 378 day: string; 379 events: Event[]; 380 bar: { 381 status: "success" | "degraded" | "error" | "info" | "empty"; 382 height: number; // percentage 383 }[]; 384 card: { 385 status: "success" | "degraded" | "error" | "info" | "empty"; 386 value: string; 387 }[]; 388}; 389 390// Priority mapping for status types (higher number = higher priority) 391const STATUS_PRIORITY = { 392 error: 3, 393 degraded: 2, 394 info: 1, 395 success: 0, 396 empty: -1, 397} as const; 398 399// Constants for time calculations 400const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; 401const MILLISECONDS_PER_MINUTE = 1000 * 60; 402 403// Helper to get highest priority status from data 404function getHighestPriorityStatus( 405 item: StatusData, 406): keyof typeof STATUS_PRIORITY { 407 if (item.error > 0) return "error"; 408 if (item.degraded > 0) return "degraded"; 409 if (item.ok > 0) return "success"; 410 411 return "empty"; 412} 413 414// Helper to format numbers 415function formatNumber(num: number): string { 416 if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; 417 if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; 418 return num.toString(); 419} 420 421// Helper to check if date is today 422function isToday(date: Date): boolean { 423 const today = new Date(); 424 return ( 425 date.getDate() === today.getDate() && 426 date.getMonth() === today.getMonth() && 427 date.getFullYear() === today.getFullYear() 428 ); 429} 430 431// Helper to format duration from minutes 432function formatDuration(minutes: number): string { 433 if (minutes < 60) return `${minutes}m`; 434 const hours = Math.floor(minutes / 60); 435 const remainingMinutes = minutes % 60; 436 if (remainingMinutes === 0) return `${hours}h`; 437 return `${hours}h ${remainingMinutes}m`; 438} 439 440// Helper to check if date is within event range 441function isDateWithinEvent(date: Date, event: Event): boolean { 442 const startOfDay = new Date(date); 443 startOfDay.setUTCHours(0, 0, 0, 0); 444 445 const endOfDay = new Date(date); 446 endOfDay.setUTCHours(23, 59, 59, 999); 447 448 const eventStart = new Date(event.from); 449 const eventEnd = event.to ? new Date(event.to) : new Date(); 450 451 return ( 452 eventStart.getTime() <= endOfDay.getTime() && 453 eventEnd.getTime() >= startOfDay.getTime() 454 ); 455} 456 457// Helper to calculate total minutes in a day (handles today vs past days) 458function getTotalMinutesInDay(date: Date): number { 459 const now = new Date(); 460 const startOfDay = new Date(date); 461 startOfDay.setUTCHours(0, 0, 0, 0); 462 463 if (isToday(date)) { 464 const minutesElapsed = Math.floor( 465 (now.getTime() - startOfDay.getTime()) / MILLISECONDS_PER_MINUTE, 466 ); 467 return minutesElapsed; 468 } 469 return 24 * 60; 470} 471 472// Helper to calculate duration in minutes for a specific event type 473function calculateEventDurationMinutes(events: Event[], date: Date): number { 474 const totalDuration = getTotalEventsDurationMs(events, date); 475 return Math.round(totalDuration / MILLISECONDS_PER_MINUTE); 476} 477 478// Helper to calculate maintenance duration in minutes for a specific day 479function getMaintenanceDurationMinutes( 480 maintenances: Event[], 481 date: Date, 482): number { 483 return calculateEventDurationMinutes(maintenances, date); 484} 485 486// Helper to get adjusted total minutes accounting for maintenance 487function getAdjustedTotalMinutesInDay( 488 date: Date, 489 maintenances: Event[], 490): number { 491 const totalMinutes = getTotalMinutesInDay(date); 492 const maintenanceMinutes = getMaintenanceDurationMinutes(maintenances, date); 493 return Math.max(0, totalMinutes - maintenanceMinutes); 494} 495 496function getTotalEventsDurationMs(events: Event[], date: Date): number { 497 if (events.length === 0) return 0; 498 499 const startOfDay = new Date(date); 500 startOfDay.setUTCHours(0, 0, 0, 0); 501 502 const endOfDay = new Date(date); 503 endOfDay.setUTCHours(23, 59, 59, 999); 504 505 const total = events.reduce((acc, curr) => { 506 if (!curr.from) return acc; 507 508 const eventStart = new Date(curr.from); 509 const eventEnd = curr.to ? new Date(curr.to) : new Date(); 510 511 // Only count events that overlap with this date 512 if ( 513 eventEnd.getTime() < startOfDay.getTime() || 514 eventStart.getTime() > endOfDay.getTime() 515 ) { 516 return acc; 517 } 518 519 // Calculate the overlapping duration within the date boundaries 520 const overlapStart = Math.max(eventStart.getTime(), startOfDay.getTime()); 521 const overlapEnd = Math.min(eventEnd.getTime(), endOfDay.getTime()); 522 523 const duration = overlapEnd - overlapStart; 524 return acc + Math.max(0, duration); 525 }, 0); 526 527 // Cap at 24 hours per day 528 return Math.min(total, MILLISECONDS_PER_DAY); 529} 530 531export function setDataByType({ 532 events, 533 data, 534 cardType, 535 barType, 536}: { 537 events: Event[]; 538 data: StatusData[]; 539 cardType: "requests" | "duration" | "dominant" | "manual"; 540 barType: "absolute" | "dominant" | "manual"; 541}): UptimeData[] { 542 // Helper functions moved inside to share inputs and avoid parameter passing 543 function createEventSegments( 544 incidents: Event[], 545 reports: Event[], 546 maintenances: Event[], 547 date: Date, 548 ): Array<{ status: "info" | "degraded" | "error"; count: number }> { 549 const eventTypes = [ 550 { status: "info" as const, events: maintenances }, 551 { status: "degraded" as const, events: reports }, 552 { status: "error" as const, events: incidents }, 553 ]; 554 555 return eventTypes 556 .filter(({ events }) => events.length > 0) 557 .map(({ status, events }) => ({ 558 status, 559 count: getTotalEventsDurationMs(events, date), 560 })); 561 } 562 563 function createErrorOnlyBarData( 564 errorSegmentCount: number, 565 ): UptimeData["bar"] { 566 return [ 567 { 568 status: "success" as const, 569 height: 570 ((MILLISECONDS_PER_DAY - errorSegmentCount) / MILLISECONDS_PER_DAY) * 571 100, 572 }, 573 { 574 status: "error" as const, 575 height: (errorSegmentCount / MILLISECONDS_PER_DAY) * 100, 576 }, 577 ]; 578 } 579 580 function createProportionalBarData( 581 segments: Array<{ status: "info" | "degraded" | "error"; count: number }>, 582 ): UptimeData["bar"] { 583 const totalDuration = segments.reduce( 584 (sum, segment) => sum + segment.count, 585 0, 586 ); 587 588 return segments.map((segment) => ({ 589 status: segment.status, 590 // NOTE: if totalDuration is 0 (single event without duration), we want to show 100% for the segment 591 height: totalDuration > 0 ? (segment.count / totalDuration) * 100 : 100, 592 })); 593 } 594 595 function createStatusSegments( 596 dayData: StatusData, 597 ): Array<{ status: "success" | "degraded" | "error"; count: number }> { 598 return [ 599 { status: "success" as const, count: dayData.ok }, 600 { status: "degraded" as const, count: dayData.degraded }, 601 { status: "error" as const, count: dayData.error }, 602 ]; 603 } 604 605 function segmentsToBarData( 606 segments: Array<{ 607 status: "success" | "degraded" | "error"; 608 count: number; 609 }>, 610 total: number, 611 ): UptimeData["bar"] { 612 return segments 613 .filter((segment) => segment.count > 0) 614 .map((segment) => ({ 615 status: segment.status, 616 height: (segment.count / total) * 100, 617 })); 618 } 619 620 function createOperationalBarData(): UptimeData["bar"] { 621 return [ 622 { 623 status: "success", 624 height: 100, 625 }, 626 ]; 627 } 628 629 function createEmptyBarData(): UptimeData["bar"] { 630 return [ 631 { 632 status: "empty", 633 height: 100, 634 }, 635 ]; 636 } 637 638 function createEmptyCardData( 639 eventStatus?: "error" | "degraded" | "info" | "success" | "empty", 640 ): UptimeData["card"] { 641 return [{ status: eventStatus ?? "empty", value: "" }]; 642 } 643 644 function createRequestEntries(dayData: StatusData): Array<{ 645 status: "success" | "degraded" | "error" | "info"; 646 count: number; 647 }> { 648 return [ 649 { status: "success" as const, count: dayData.ok }, 650 { status: "degraded" as const, count: dayData.degraded }, 651 { status: "error" as const, count: dayData.error }, 652 { status: "info" as const, count: 0 }, 653 ]; 654 } 655 656 function createDurationEntries(dayData: StatusData): Array<{ 657 status: "success" | "degraded" | "error" | "info"; 658 count: number; 659 }> { 660 return [ 661 { status: "error" as const, count: dayData.error }, 662 { status: "degraded" as const, count: dayData.degraded }, 663 { status: "success" as const, count: dayData.ok }, 664 { status: "info" as const, count: 0 }, 665 ]; 666 } 667 668 function entriesToRequestCardData( 669 entries: Array<{ 670 status: "success" | "degraded" | "error" | "info"; 671 count: number; 672 }>, 673 ): UptimeData["card"] { 674 return entries 675 .filter((entry) => entry.count > 0) 676 .map((entry) => ({ 677 status: entry.status, 678 value: `${formatNumber(entry.count)} reqs`, 679 })); 680 } 681 682 // Helper to calculate duration in minutes for a specific event type 683 function calculateEventDurationMinutes(events: Event[], date: Date): number { 684 const totalDuration = getTotalEventsDurationMs(events, date); 685 return Math.round(totalDuration / MILLISECONDS_PER_MINUTE); 686 } 687 688 // Helper to create duration card data for a specific status 689 function createDurationCardEntry( 690 status: "error" | "degraded" | "info" | "success", 691 events: Event[], 692 date: Date, 693 durationMap: Map<string, number>, 694 maintenances: Event[] = [], 695 ): { 696 status: "error" | "degraded" | "info" | "success"; 697 value: string; 698 } | null { 699 if (status === "success") { 700 // Calculate success duration as remaining time 701 let totalEventMinutes = 0; 702 // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> 703 durationMap.forEach((minutes) => (totalEventMinutes += minutes)); 704 705 // Use adjusted total minutes accounting for maintenance 706 const totalMinutesInDay = getAdjustedTotalMinutesInDay( 707 date, 708 maintenances, 709 ); 710 const successMinutes = Math.max(totalMinutesInDay - totalEventMinutes, 0); 711 712 if (successMinutes === 0) return null; 713 return { 714 status, 715 value: formatDuration(successMinutes), 716 }; 717 } 718 719 // For error, degraded, info - calculate from events 720 const minutes = calculateEventDurationMinutes(events, date); 721 durationMap.set(status, minutes); 722 723 if (minutes === 0) return null; 724 return { 725 status, 726 value: formatDuration(minutes), 727 }; 728 } 729 730 return data.map((dayData) => { 731 const date = new Date(dayData.day); 732 733 // Find events for this day 734 const dayEvents = events.filter((event) => isDateWithinEvent(date, event)); 735 736 // Determine status override based on events 737 const incidents = dayEvents.filter((e) => e.type === "incident"); 738 const reports = dayEvents.filter((e) => e.type === "report"); 739 const maintenances = dayEvents.filter((e) => e.type === "maintenance"); 740 741 const hasIncidents = incidents.length > 0; 742 const hasReports = reports.length > 0; 743 const hasMaintenances = maintenances.length > 0; 744 745 const eventStatus = hasIncidents 746 ? "error" 747 : hasReports 748 ? "degraded" 749 : hasMaintenances 750 ? "info" 751 : undefined; 752 753 // Calculate bar data based on barType 754 // TODO: transform into a new Map<type, number>(); 755 let barData: UptimeData["bar"]; 756 757 const total = dayData.ok + dayData.degraded + dayData.error; 758 const dataStatus = getHighestPriorityStatus(dayData); 759 760 switch (barType) { 761 case "absolute": 762 if (eventStatus) { 763 // Create segments based on event durations for the day 764 const eventSegments = createEventSegments( 765 incidents, 766 reports, 767 maintenances, 768 date, 769 ); 770 771 // Special case: if only errors exist, show uptime vs downtime 772 if ( 773 eventSegments.length === 1 && 774 eventSegments[0].status === "error" 775 ) { 776 barData = createErrorOnlyBarData(eventSegments[0].count); 777 } else { 778 // Multiple segments: show proportional distribution 779 barData = createProportionalBarData(eventSegments); 780 } 781 } else if (total === 0) { 782 // Empty day - no data available 783 barData = createEmptyBarData(); 784 } else { 785 if (cardType === "duration") { 786 // If no eventStatus and cardType is duration, show operational bar 787 barData = createOperationalBarData(); 788 } else { 789 // Multiple segments for absolute view - show proportional distribution of status data 790 const statusSegments = createStatusSegments(dayData); 791 barData = segmentsToBarData(statusSegments, total); 792 } 793 } 794 break; 795 case "dominant": 796 barData = [ 797 { 798 status: eventStatus ?? dataStatus, 799 height: 100, 800 }, 801 ]; 802 break; 803 case "manual": 804 const manualEventStatus = hasReports 805 ? "degraded" 806 : hasMaintenances 807 ? "info" 808 : undefined; 809 barData = [ 810 { 811 status: manualEventStatus || "success", 812 height: 100, 813 }, 814 ]; 815 break; 816 default: 817 // Default to dominant behavior 818 barData = [ 819 { 820 status: eventStatus ?? dataStatus, 821 height: 100, 822 }, 823 ]; 824 break; 825 } 826 827 // Calculate card data based on cardType 828 // TODO: transform into a new Map<type, number>(); 829 let cardData: UptimeData["card"] = []; 830 831 switch (cardType) { 832 case "requests": 833 if (total === 0) { 834 cardData = createEmptyCardData(eventStatus); 835 } else { 836 const requestEntries = createRequestEntries(dayData); 837 cardData = entriesToRequestCardData(requestEntries); 838 } 839 break; 840 841 case "duration": 842 if (total === 0) { 843 cardData = createEmptyCardData(eventStatus); 844 } else { 845 const entries = createDurationEntries(dayData); 846 const durationMap = new Map<string, number>(); 847 848 cardData = entries 849 .map((entry) => { 850 // Map each entry status to its corresponding events 851 const eventMap = { 852 error: incidents, 853 degraded: reports, 854 info: maintenances, 855 success: [], // Success is calculated differently 856 }; 857 858 const events = eventMap[entry.status as keyof typeof eventMap]; 859 return createDurationCardEntry( 860 entry.status, 861 events, 862 date, 863 durationMap, 864 maintenances, 865 ); 866 }) 867 .filter((item): item is NonNullable<typeof item> => item !== null); 868 } 869 break; 870 871 case "dominant": 872 cardData = [ 873 { 874 status: eventStatus ?? dataStatus, 875 value: "", 876 }, 877 ]; 878 break; 879 880 case "manual": 881 const manualEventStatus = hasReports 882 ? "degraded" 883 : hasMaintenances 884 ? "info" 885 : undefined; 886 cardData = [ 887 { 888 status: manualEventStatus || "success", 889 value: "", 890 }, 891 ]; 892 break; 893 default: 894 // Default to requests behavior 895 if (total === 0) { 896 cardData = createEmptyCardData(eventStatus); 897 } else { 898 const defaultEntries = createRequestEntries(dayData); 899 cardData = entriesToRequestCardData(defaultEntries); 900 } 901 break; 902 } 903 904 // Bundle incidents that occur on the same day if there are more than 4 905 const bundledIncidents = 906 incidents.length > 4 907 ? [ 908 { 909 id: -1, // Use -1 to indicate bundled incidents 910 name: `Downtime (${incidents.length} incidents)`, 911 from: new Date( 912 Math.min(...incidents.map((i) => i.from.getTime())), 913 ), 914 to: new Date( 915 Math.max( 916 ...incidents.map((i) => (i.to || new Date()).getTime()), 917 ), 918 ), 919 type: "incident" as const, 920 status: "error" as const, 921 }, 922 ] 923 : incidents; 924 925 return { 926 day: dayData.day, 927 events: [ 928 ...reports, 929 ...maintenances, 930 ...(barType === "absolute" ? bundledIncidents : []), 931 ], 932 bar: barData, 933 card: cardData, 934 }; 935 }); 936} 937 938export function getUptime({ 939 data, 940 events, 941 barType, 942 cardType, 943}: { 944 data: StatusData[]; 945 events: Event[]; 946 barType: "absolute" | "dominant" | "manual"; 947 cardType: "requests" | "duration" | "dominant" | "manual"; 948}): string { 949 if (barType === "manual") { 950 const duration = events 951 // NOTE: we want only user events 952 .filter((e) => e.type === "report") 953 .reduce((acc, item) => { 954 if (!item.from) return acc; 955 return acc + ((item.to || new Date()).getTime() - item.from.getTime()); 956 }, 0); 957 958 const total = data.length * MILLISECONDS_PER_DAY; 959 960 return `${Math.floor(((total - duration) / total) * 10000) / 100}%`; 961 } 962 963 if (cardType === "duration") { 964 const duration = events 965 .filter((e) => e.type === "incident") 966 .reduce((acc, item) => { 967 if (!item.from) return acc; 968 return acc + ((item.to || new Date()).getTime() - item.from.getTime()); 969 }, 0); 970 971 const total = data.length * MILLISECONDS_PER_DAY; 972 return `${Math.floor(((total - duration) / total) * 10000) / 100}%`; 973 } 974 975 const { ok, total } = data.reduce( 976 (acc, item) => ({ 977 ok: acc.ok + item.ok + item.degraded, 978 total: acc.total + item.ok + item.degraded + item.error, 979 }), 980 { 981 ok: 0, 982 total: 0, 983 }, 984 ); 985 986 if (total === 0) return "100%"; 987 return `${Math.floor((ok / total) * 10000) / 100}%`; 988}