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