mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: implement incident handling in uptime graphs with tooltips and modals for better user experience
This commit is contained in:
@@ -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);
|
||||
|
||||
21
Common/Types/Monitor/UptimeBarTooltipIncident.ts
Normal file
21
Common/Types/Monitor/UptimeBarTooltipIncident.ts
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
100
Common/UI/Components/Graphs/UptimeBarTooltip.tsx
Normal file
100
Common/UI/Components/Graphs/UptimeBarTooltip.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
85
Common/UI/Components/MonitorGraphs/UptimeBarDayModal.tsx
Normal file
85
Common/UI/Components/MonitorGraphs/UptimeBarDayModal.tsx
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user