feat: implement incident handling in uptime graphs with tooltips and modals for better user experience

This commit is contained in:
Nawaz Dhandala
2026-04-01 12:42:00 +01:00
parent 678e9614bf
commit 832b87e6d5
13 changed files with 543 additions and 40 deletions

View File

@@ -2169,6 +2169,48 @@ export default class StatusPageAPI extends BaseAPI<
},
});
// Fetch all incidents (active + resolved) in the timeline date range
// for the uptime bar tooltip and click-through
let timelineIncidents: Array<Incident> = [];
if (
monitorsOnStatusPage.length > 0 &&
statusPage.showIncidentsOnStatusPage
) {
timelineIncidents = await IncidentService.findBy({
query: {
monitors: monitorsOnStatusPage as any,
declaredAt: QueryHelper.inBetween(startDate, endDate),
isVisibleOnStatusPage: true,
projectId: statusPage.projectId!,
},
select: {
_id: true,
title: true,
declaredAt: true,
incidentSeverity: {
name: true,
color: true,
},
currentIncidentState: {
_id: true,
name: true,
color: true,
},
monitors: {
_id: true,
},
},
sort: {
declaredAt: SortOrder.Descending,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
}
const overallStatus: MonitorStatus | null =
StatusPageService.getOverallMonitorStatus({
statusPageResources,
@@ -2251,6 +2293,10 @@ export default class StatusPageAPI extends BaseAPI<
monitorGroupCurrentStatuses,
),
monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
timelineIncidents: BaseModel.toJSONArray(
timelineIncidents,
Incident,
),
};
return Response.sendJsonObjectResponse(req, res, response);

View File

@@ -0,0 +1,21 @@
import Color from "../Color";
import ObjectID from "../ObjectID";
export default interface UptimeBarTooltipIncident {
id: string;
title: string;
declaredAt: Date;
incidentSeverity?:
| {
name: string;
color: Color;
}
| undefined;
currentIncidentState?:
| {
name: string;
color: Color;
}
| undefined;
monitorIds: Array<ObjectID>;
}

View File

@@ -1,9 +1,11 @@
import Tooltip from "../Tooltip/Tooltip";
import UptimeBarTooltip from "./UptimeBarTooltip";
import { Green } from "../../../Types/BrandColors";
import Color from "../../../Types/Color";
import OneUptimeDate from "../../../Types/Date";
import Dictionary from "../../../Types/Dictionary";
import ObjectID from "../../../Types/ObjectID";
import UptimeBarTooltipIncident from "../../../Types/Monitor/UptimeBarTooltipIncident";
import React, {
FunctionComponent,
ReactElement,
@@ -27,6 +29,10 @@ export interface ComponentProps {
barColorRules?: Array<BarChartRule> | undefined;
downtimeEventStatusIds?: Array<ObjectID> | undefined;
defaultBarColor: Color;
incidents?: Array<UptimeBarTooltipIncident> | undefined;
onBarClick?:
| ((date: Date, incidents: Array<UptimeBarTooltipIncident>) => void)
| undefined;
}
const DayUptimeGraph: FunctionComponent<ComponentProps> = (
@@ -43,6 +49,28 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
);
}, [props.startDate, props.endDate]);
type GetIncidentsForDayFunction = (
startOfDay: Date,
endOfDay: Date,
) => Array<UptimeBarTooltipIncident>;
const getIncidentsForDay: GetIncidentsForDayFunction = (
startOfDay: Date,
endOfDay: Date,
): Array<UptimeBarTooltipIncident> => {
if (!props.incidents || props.incidents.length === 0) {
return [];
}
return props.incidents.filter((incident: UptimeBarTooltipIncident) => {
return OneUptimeDate.isBetween(
incident.declaredAt,
startOfDay,
endOfDay,
);
});
};
type GetUptimeBarFunction = (dayNumber: number) => ReactElement;
const getUptimeBar: GetUptimeBarFunction = (
@@ -55,11 +83,6 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
dayNumber,
);
let toolTipText: string = `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
todaysDay,
true,
)}`;
const startOfTheDay: Date = OneUptimeDate.getStartOfDay(todaysDay);
const endOfTheDay: Date = OneUptimeDate.getEndOfDay(todaysDay);
@@ -140,33 +163,20 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
let totalUptimeInSeconds: number = 0;
const downtimeStatusIds: Array<string> = (
props.downtimeEventStatusIds || []
).map((id: ObjectID) => {
return id.toString();
});
for (const key in secondsOfEvent) {
hasEvents = true;
const eventStatusId: string = key;
// if this is downtime state then, include tooltip.
if (
(props.downtimeEventStatusIds?.filter((id: ObjectID) => {
return id.toString() === eventStatusId.toString();
}).length || 0) > 0
) {
toolTipText += `, ${
eventLabels[key]
} for ${OneUptimeDate.secondsToFormattedFriendlyTimeString(
secondsOfEvent[key] || 0,
)}`;
}
const isDowntimeEvent: boolean = Boolean(
props.downtimeEventStatusIds?.find((id: ObjectID) => {
return id.toString() === eventStatusId;
}),
);
const isDowntimeEvent: boolean = downtimeStatusIds.includes(eventStatusId);
if (isDowntimeEvent) {
// remove the seconds from total uptime.
const secondsOfDowntime: number = secondsOfEvent[key] || 0;
totalDowntimeInSeconds += secondsOfDowntime;
} else {
@@ -177,8 +187,11 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
// now check bar rules and finalize the color of the bar
const uptimePercentForTheDay: number =
(totalUptimeInSeconds / (totalDowntimeInSeconds + totalUptimeInSeconds)) *
100;
totalUptimeInSeconds + totalDowntimeInSeconds > 0
? (totalUptimeInSeconds /
(totalDowntimeInSeconds + totalUptimeInSeconds)) *
100
: 100;
for (const rules of props.barColorRules || []) {
if (uptimePercentForTheDay >= rules.uptimePercentGreaterThanOrEqualTo) {
@@ -187,31 +200,62 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
}
}
if (todaysEvents.length === 1) {
if (todaysEvents.length === 1 && !hasEvents) {
hasEvents = true;
toolTipText = `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
todaysDay,
true,
)} - 100% ${todaysEvents[0]?.label || "Operational"}.`;
}
if (!hasEvents) {
toolTipText += ` - No data for this day.`;
if (todaysEvents.length === 1) {
hasEvents = true;
}
if (todaysEvents.length === 0) {
hasEvents = false;
color = props.defaultBarColor || Green;
}
// Get incidents for this day
const dayIncidents: Array<UptimeBarTooltipIncident> = getIncidentsForDay(
startOfTheDay,
endOfTheDay,
);
let className: string = "h-20 w-20";
if (props.height) {
className = "w-20 h-" + props.height;
}
const hasDayIncidents: boolean = dayIncidents.length > 0;
const isClickable: boolean =
hasDayIncidents && Boolean(props.onBarClick);
return (
<Tooltip key={dayNumber} text={toolTipText || "100% Operational"}>
<Tooltip
key={dayNumber}
richContent={
<UptimeBarTooltip
date={todaysDay}
uptimePercent={uptimePercentForTheDay}
hasEvents={hasEvents}
eventLabels={eventLabels}
secondsOfEvent={secondsOfEvent}
downtimeEventStatusIds={downtimeStatusIds}
incidents={dayIncidents}
/>
}
>
<div
className={className}
className={`${className}${isClickable ? " cursor-pointer hover:opacity-80" : ""}`}
style={{
backgroundColor: color.toString(),
}}
onClick={
isClickable
? () => {
props.onBarClick!(todaysDay, dayIncidents);
}
: undefined
}
></div>
</Tooltip>
);

View File

@@ -0,0 +1,100 @@
import OneUptimeDate from "../../../Types/Date";
import Dictionary from "../../../Types/Dictionary";
import UptimeBarTooltipIncident from "../../../Types/Monitor/UptimeBarTooltipIncident";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
date: Date;
uptimePercent: number;
hasEvents: boolean;
eventLabels: Dictionary<string>;
secondsOfEvent: Dictionary<number>;
downtimeEventStatusIds: Array<string>;
incidents: Array<UptimeBarTooltipIncident>;
}
const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const dateStr: string =
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(props.date, true);
return (
<div className="text-left text-xs" style={{ minWidth: "200px" }}>
<div className="font-semibold text-sm mb-1">{dateStr}</div>
{props.hasEvents && (
<div className="mb-1">
<span className="font-medium">
{props.uptimePercent.toFixed(2)}% uptime
</span>
</div>
)}
{!props.hasEvents && (
<div className="text-gray-300 mb-1">No data for this day.</div>
)}
{/* Status durations */}
{Object.keys(props.secondsOfEvent).length > 0 && (
<div className="mb-1">
{Object.keys(props.secondsOfEvent).map((key: string) => {
const isDowntime: boolean = props.downtimeEventStatusIds.includes(key);
if (!isDowntime) {
return null;
}
return (
<div key={key} className="text-gray-300">
{props.eventLabels[key]} for{" "}
{OneUptimeDate.secondsToFormattedFriendlyTimeString(
props.secondsOfEvent[key] || 0,
)}
</div>
);
})}
</div>
)}
{/* Incidents */}
{props.incidents.length > 0 && (
<div className="mt-1 border-t border-gray-600 pt-1">
<div className="font-medium text-gray-300 mb-0.5">
{props.incidents.length} Incident
{props.incidents.length > 1 ? "s" : ""}:
</div>
{props.incidents.slice(0, 5).map(
(incident: UptimeBarTooltipIncident) => {
return (
<div key={incident.id} className="mb-0.5 flex items-start">
{incident.incidentSeverity?.color && (
<span
className="inline-block w-2 h-2 rounded-full mt-1 mr-1 flex-shrink-0"
style={{
backgroundColor:
incident.incidentSeverity.color.toString(),
}}
></span>
)}
<span className="text-gray-200">{incident.title}</span>
</div>
);
},
)}
{props.incidents.length > 5 && (
<div className="text-gray-400">
+{props.incidents.length - 5} more...
</div>
)}
</div>
)}
{props.incidents.length > 0 && (
<div className="mt-1 text-gray-400 text-[10px]">
Click for details
</div>
)}
</div>
);
};
export default UptimeBarTooltip;

View File

@@ -7,6 +7,7 @@ import CommonMonitorEvent from "../../../Utils/Uptime/MonitorEvent";
import MonitorStatus from "../../../Models/DatabaseModels/MonitorStatus";
import MonitorStatusTimeline from "../../../Models/DatabaseModels/MonitorStatusTimeline";
import StatusPageHistoryChartBarColorRule from "../../../Models/DatabaseModels/StatusPageHistoryChartBarColorRule";
import UptimeBarTooltipIncident from "../../../Types/Monitor/UptimeBarTooltipIncident";
import React, {
FunctionComponent,
ReactElement,
@@ -27,6 +28,8 @@ export interface ComponentProps {
barColorRules?: Array<StatusPageHistoryChartBarColorRule> | undefined;
downtimeMonitorStatuses: Array<MonitorStatus> | undefined;
defaultBarColor: Color;
incidents?: Array<UptimeBarTooltipIncident> | undefined;
onBarClick?: (date: Date, incidents: Array<UptimeBarTooltipIncident>) => void;
}
const MonitorUptimeGraph: FunctionComponent<ComponentProps> = (
@@ -83,6 +86,8 @@ const MonitorUptimeGraph: FunctionComponent<ComponentProps> = (
return status.id!;
}) || []
}
incidents={props.incidents}
onBarClick={props.onBarClick}
/>
);
};

View File

@@ -0,0 +1,85 @@
import Modal, { ModalWidth } from "../Modal/Modal";
import OneUptimeDate from "../../../Types/Date";
import UptimeBarTooltipIncident from "../../../Types/Monitor/UptimeBarTooltipIncident";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
date: Date;
incidents: Array<UptimeBarTooltipIncident>;
onClose: () => void;
}
const UptimeBarDayModal: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const dateStr: string =
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(props.date, true);
return (
<Modal
title={`Incidents on ${dateStr}`}
onClose={props.onClose}
modalWidth={ModalWidth.Medium}
closeButtonText="Close"
>
<div>
{props.incidents.length === 0 && (
<div className="text-gray-500 text-sm py-4 text-center">
No incidents on this day.
</div>
)}
{props.incidents.map((incident: UptimeBarTooltipIncident) => {
return (
<div
key={incident.id}
className="border border-gray-200 rounded-lg p-4 mb-3"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="font-medium text-base text-gray-900">
{incident.title}
</div>
<div className="text-sm text-gray-500 mt-1">
Declared at{" "}
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
incident.declaredAt,
false,
)}
</div>
</div>
</div>
<div className="flex items-center gap-3 mt-2">
{incident.incidentSeverity && (
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
style={{
backgroundColor:
incident.incidentSeverity.color.toString() + "20",
color: incident.incidentSeverity.color.toString(),
}}
>
{incident.incidentSeverity.name}
</span>
)}
{incident.currentIncidentState && (
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
style={{
backgroundColor:
incident.currentIncidentState.color.toString() + "20",
color: incident.currentIncidentState.color.toString(),
}}
>
{incident.currentIncidentState.name}
</span>
)}
</div>
</div>
);
})}
</div>
</Modal>
);
};
export default UptimeBarDayModal;

View File

@@ -3,24 +3,32 @@ import React, { FunctionComponent, ReactElement } from "react";
import "tippy.js/dist/tippy.css";
export interface ComponentProps {
text: string;
text?: string | undefined;
children: ReactElement;
richContent?: ReactElement | undefined;
}
const Tooltip: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
if (!props.text) {
if (!props.text && !props.richContent) {
return props.children;
}
const tooltipContent: ReactElement = props.richContent ? (
props.richContent
) : (
<span>{props.text}</span>
);
return (
<Tippy
key={Math.random()}
content={<span>{props.text}</span>}
content={tooltipContent}
interactive={true}
trigger="mouseenter focus"
hideOnClick={false}
maxWidth={350}
aria={{
content: "describedby",
expanded: "auto",