feat: add onIncidentClick handler to various components for incident navigation; enhance Tooltip with animation support

This commit is contained in:
Nawaz Dhandala
2026-04-01 14:58:58 +01:00
parent 505c143ddf
commit ecbca3208f
9 changed files with 441 additions and 132 deletions

View File

@@ -33,6 +33,9 @@ export interface ComponentProps {
onBarClick?:
| ((date: Date, incidents: Array<UptimeBarTooltipIncident>) => void)
| undefined;
onIncidentClick?:
| ((incidentId: string) => void)
| undefined;
}
const DayUptimeGraph: FunctionComponent<ComponentProps> = (
@@ -252,6 +255,7 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
hasEvents={hasEvents}
statusDurations={statusDurations}
incidents={dayIncidents}
onIncidentClick={props.onIncidentClick}
/>
}
>

View File

@@ -16,6 +16,9 @@ export interface ComponentProps {
hasEvents: boolean;
statusDurations: Array<StatusDuration>;
incidents: Array<UptimeBarTooltipIncident>;
onIncidentClick?:
| ((incidentId: string) => void)
| undefined;
}
const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
@@ -45,35 +48,60 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
? "#fef9c3"
: "#fee2e2";
// Sort: downtime statuses first so they're prominent
const sortedDurations: Array<StatusDuration> = [
...props.statusDurations,
].sort((a: StatusDuration, b: StatusDuration) => {
if (a.isDowntime && !b.isDowntime) {
return -1;
}
if (!a.isDowntime && b.isDowntime) {
return 1;
}
return b.seconds - a.seconds;
});
return (
<div style={{ minWidth: "260px", maxWidth: "340px" }}>
{/* Date header */}
<div style={{ minWidth: "270px", maxWidth: "340px" }}>
{/* ── Header ── */}
<div
style={{
paddingBottom: "8px",
marginBottom: "8px",
borderBottom: "1px solid #f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingBottom: "10px",
marginBottom: props.hasEvents ? "0" : "8px",
borderBottom: props.hasEvents ? "none" : "1px solid #e5e7eb",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "13px",
color: "#111827",
}}
>
<span style={{ fontWeight: 600, fontSize: "13px", color: "#111827" }}>
{dateStr}
</div>
</span>
{props.hasEvents && props.incidents.length === 0 && (
<span
style={{
fontSize: "10px",
fontWeight: 600,
color: uptimeColor,
backgroundColor: uptimeBgColor,
padding: "2px 8px",
borderRadius: "9999px",
lineHeight: "1.5",
}}
>
{props.uptimePercent >= 100 ? "100%" : props.uptimePercent.toFixed(2) + "%"}
</span>
)}
</div>
{/* Uptime card */}
{/* ── Uptime meter ── */}
{props.hasEvents && (
<div
style={{
backgroundColor: uptimeBgColor,
borderRadius: "8px",
padding: "10px 12px",
marginBottom: "10px",
borderRadius: "10px",
padding: "12px 14px",
marginBottom: sortedDurations.length > 0 || props.incidents.length > 0 ? "12px" : "0",
}}
>
<div
@@ -81,24 +109,33 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "6px",
marginBottom: "8px",
}}
>
<span
style={{ fontSize: "11px", color: "#6b7280", fontWeight: 500 }}
style={{
fontSize: "11px",
color: "#6b7280",
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Uptime
</span>
<span
style={{
fontSize: "18px",
fontSize: "20px",
fontWeight: 700,
color: uptimeColor,
fontVariantNumeric: "tabular-nums",
lineHeight: 1,
}}
>
{props.uptimePercent.toFixed(2)}%
{props.uptimePercent >= 100
? "100"
: props.uptimePercent.toFixed(2)}
<span style={{ fontSize: "13px", fontWeight: 600 }}>%</span>
</span>
</div>
<div
@@ -106,7 +143,7 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
width: "100%",
height: "6px",
backgroundColor: uptimeTrackColor,
borderRadius: "3px",
borderRadius: "100px",
overflow: "hidden",
}}
>
@@ -115,34 +152,43 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
width: `${Math.min(props.uptimePercent, 100)}%`,
height: "100%",
backgroundColor: uptimeColor,
borderRadius: "3px",
borderRadius: "100px",
}}
/>
</div>
</div>
)}
{/* ── No data ── */}
{!props.hasEvents && (
<div
style={{
backgroundColor: "#f9fafb",
borderRadius: "8px",
padding: "12px",
borderRadius: "10px",
padding: "16px",
textAlign: "center",
marginBottom: "4px",
}}
>
<div style={{ fontSize: "12px", color: "#9ca3af", fontWeight: 500 }}>
No data available for this day
<div
style={{
fontSize: "12px",
color: "#9ca3af",
fontWeight: 500,
}}
>
No monitoring data for this day
</div>
</div>
)}
{/* Status breakdown */}
{props.statusDurations.length > 0 && (
{/* ── Status breakdown ── */}
{sortedDurations.length > 0 && (
<div
style={{
marginBottom: props.incidents.length > 0 ? "10px" : "0",
marginBottom: props.incidents.length > 0 ? "0" : "0",
paddingBottom: props.incidents.length > 0 ? "10px" : "0",
borderBottom:
props.incidents.length > 0 ? "1px solid #e5e7eb" : "none",
}}
>
<div
@@ -152,78 +198,71 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
textTransform: "uppercase",
letterSpacing: "0.06em",
fontWeight: 600,
marginBottom: "4px",
marginBottom: "6px",
}}
>
Status Breakdown
</div>
{props.statusDurations.map(
(status: StatusDuration, index: number) => {
return (
{sortedDurations.map((status: StatusDuration, index: number) => {
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "4px 0",
}}
>
<div
key={index}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "4px 0",
gap: "8px",
}}
>
<div
<span
style={{
display: "flex",
alignItems: "center",
gap: "8px",
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: status.color.toString(),
display: "inline-block",
flexShrink: 0,
boxShadow: `0 0 0 2px ${status.color.toString()}25`,
}}
>
<span
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: status.color.toString(),
display: "inline-block",
flexShrink: 0,
boxShadow: `0 0 0 2px ${status.color.toString()}30`,
}}
/>
<span
style={{
fontSize: "12px",
color: "#374151",
fontWeight: 500,
}}
>
{status.label}
</span>
</div>
/>
<span
style={{
fontSize: "12px",
color: status.isDowntime ? "#dc2626" : "#6b7280",
fontWeight: status.isDowntime ? 600 : 400,
fontVariantNumeric: "tabular-nums",
color: "#374151",
fontWeight: 500,
}}
>
{OneUptimeDate.secondsToFormattedFriendlyTimeString(
status.seconds,
)}
{status.label}
</span>
</div>
);
},
)}
<span
style={{
fontSize: "12px",
color: status.isDowntime ? "#dc2626" : "#6b7280",
fontWeight: status.isDowntime ? 600 : 400,
fontVariantNumeric: "tabular-nums",
}}
>
{OneUptimeDate.secondsToFormattedFriendlyTimeString(
status.seconds,
)}
</span>
</div>
);
})}
</div>
)}
{/* Incidents section */}
{/* ── Incidents ── */}
{props.incidents.length > 0 && (
<div
style={{
borderTop: "1px solid #f3f4f6",
paddingTop: "10px",
}}
>
<div style={{ paddingTop: "10px" }}>
<div
style={{
display: "flex",
@@ -246,47 +285,121 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
<div
style={{
fontSize: "10px",
fontWeight: 600,
fontWeight: 700,
color: "#dc2626",
backgroundColor: "#fef2f2",
padding: "1px 8px",
borderRadius: "9999px",
lineHeight: "1.6",
minWidth: "20px",
textAlign: "center",
}}
>
{props.incidents.length}
</div>
</div>
{props.incidents.slice(0, 3).map(
(incident: UptimeBarTooltipIncident) => {
const isClickable: boolean = Boolean(props.onIncidentClick);
return (
<div
key={incident.id}
onClick={
isClickable
? (e: React.MouseEvent) => {
e.stopPropagation();
props.onIncidentClick!(incident.id);
}
: undefined
}
style={{
backgroundColor: "#f9fafb",
border: "1px solid #f3f4f6",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "8px 10px",
marginBottom: "6px",
cursor: isClickable ? "pointer" : "default",
transition: "all 0.15s ease",
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => {
if (isClickable) {
(e.currentTarget as HTMLDivElement).style.backgroundColor =
"#f3f4f6";
(e.currentTarget as HTMLDivElement).style.borderColor =
"#d1d5db";
}
}}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => {
if (isClickable) {
(e.currentTarget as HTMLDivElement).style.backgroundColor =
"#f9fafb";
(e.currentTarget as HTMLDivElement).style.borderColor =
"#e5e7eb";
}
}}
>
<div
style={{
fontSize: "12px",
color: "#111827",
fontWeight: 600,
marginBottom: "4px",
lineHeight: "1.4",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: "8px",
}}
>
{incident.title}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "12px",
color: isClickable ? "#2563eb" : "#111827",
fontWeight: 600,
lineHeight: "1.4",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{incident.title}
</div>
<div
style={{
fontSize: "10px",
color: "#9ca3af",
marginTop: "2px",
}}
>
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
incident.declaredAt,
false,
)}
</div>
</div>
{isClickable && (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
style={{ flexShrink: 0, marginTop: "2px" }}
>
<path
d="M6 3l5 5-5 5"
stroke="#9ca3af"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
gap: "5px",
flexWrap: "wrap",
marginTop: "5px",
}}
>
{incident.incidentSeverity && (
@@ -296,9 +409,9 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
fontWeight: 600,
color: incident.incidentSeverity.color.toString(),
backgroundColor:
incident.incidentSeverity.color.toString() + "15",
border: `1px solid ${incident.incidentSeverity.color.toString()}30`,
padding: "1px 8px",
incident.incidentSeverity.color.toString() + "12",
border: `1px solid ${incident.incidentSeverity.color.toString()}25`,
padding: "1px 7px",
borderRadius: "9999px",
lineHeight: "1.6",
}}
@@ -315,9 +428,9 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
incident.currentIncidentState.color.toString(),
backgroundColor:
incident.currentIncidentState.color.toString() +
"15",
border: `1px solid ${incident.currentIncidentState.color.toString()}30`,
padding: "1px 8px",
"12",
border: `1px solid ${incident.currentIncidentState.color.toString()}25`,
padding: "1px 7px",
borderRadius: "9999px",
lineHeight: "1.6",
}}
@@ -330,13 +443,14 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
);
},
)}
{props.incidents.length > 3 && (
<div
style={{
fontSize: "11px",
color: "#9ca3af",
color: "#6b7280",
textAlign: "center",
padding: "2px 0",
padding: "4px 0 2px",
fontWeight: 500,
}}
>
@@ -344,18 +458,6 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
{props.incidents.length - 3 !== 1 ? "s" : ""}
</div>
)}
<div
style={{
fontSize: "10px",
color: "#9ca3af",
textAlign: "center",
marginTop: "8px",
fontWeight: 500,
letterSpacing: "0.02em",
}}
>
Click bar to view details
</div>
</div>
)}
</div>

View File

@@ -30,6 +30,9 @@ export interface ComponentProps {
defaultBarColor: Color;
incidents?: Array<UptimeBarTooltipIncident> | undefined;
onBarClick?: (date: Date, incidents: Array<UptimeBarTooltipIncident>) => void;
onIncidentClick?:
| ((incidentId: string) => void)
| undefined;
}
const MonitorUptimeGraph: FunctionComponent<ComponentProps> = (
@@ -88,6 +91,7 @@ const MonitorUptimeGraph: FunctionComponent<ComponentProps> = (
}
incidents={props.incidents}
onBarClick={props.onBarClick}
onIncidentClick={props.onIncidentClick}
/>
);
};

View File

@@ -7,6 +7,9 @@ export interface ComponentProps {
date: Date;
incidents: Array<UptimeBarTooltipIncident>;
onClose: () => void;
onIncidentClick?:
| ((incidentId: string) => void)
| undefined;
}
const UptimeBarDayModal: FunctionComponent<ComponentProps> = (
@@ -17,45 +20,179 @@ const UptimeBarDayModal: FunctionComponent<ComponentProps> = (
return (
<Modal
title={`Incidents on ${dateStr}`}
title={`Incidents - ${dateStr}`}
description={
props.incidents.length > 0
? `${props.incidents.length} incident${props.incidents.length !== 1 ? "s" : ""} reported on this day`
: undefined
}
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
style={{
textAlign: "center",
padding: "32px 16px",
}}
>
<div
style={{
width: "48px",
height: "48px",
borderRadius: "50%",
backgroundColor: "#f0fdf4",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 12px",
}}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#16a34a"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6L9 17l-5-5" />
</svg>
</div>
<div
style={{
fontSize: "14px",
fontWeight: 600,
color: "#111827",
marginBottom: "4px",
}}
>
No incidents
</div>
<div style={{ fontSize: "13px", color: "#6b7280" }}>
No incidents were reported on this day.
</div>
</div>
)}
{props.incidents.map((incident: UptimeBarTooltipIncident) => {
const isClickable: boolean = Boolean(props.onIncidentClick);
return (
<div
key={incident.id}
className="border border-gray-200 rounded-lg p-4 mb-3"
onClick={
isClickable
? () => {
props.onIncidentClick!(incident.id);
}
: undefined
}
style={{
border: "1px solid #e5e7eb",
borderRadius: "10px",
padding: "14px 16px",
marginBottom: "10px",
cursor: isClickable ? "pointer" : "default",
transition: "all 0.15s ease",
backgroundColor: "#ffffff",
}}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => {
if (isClickable) {
(e.currentTarget as HTMLDivElement).style.backgroundColor =
"#f9fafb";
(e.currentTarget as HTMLDivElement).style.borderColor =
"#d1d5db";
(e.currentTarget as HTMLDivElement).style.boxShadow =
"0 1px 3px rgba(0,0,0,0.06)";
}
}}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => {
if (isClickable) {
(e.currentTarget as HTMLDivElement).style.backgroundColor =
"#ffffff";
(e.currentTarget as HTMLDivElement).style.borderColor =
"#e5e7eb";
(e.currentTarget as HTMLDivElement).style.boxShadow = "none";
}
}}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="font-medium text-base text-gray-900">
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: "12px",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "14px",
fontWeight: 600,
color: isClickable ? "#2563eb" : "#111827",
lineHeight: "1.4",
marginBottom: "4px",
}}
>
{incident.title}
</div>
<div className="text-sm text-gray-500 mt-1">
Declared at{" "}
<div
style={{
fontSize: "12px",
color: "#6b7280",
}}
>
Declared{" "}
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
incident.declaredAt,
false,
)}
</div>
</div>
{isClickable && (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
style={{ flexShrink: 0, marginTop: "3px" }}
>
<path
d="M6 3l5 5-5 5"
stroke="#9ca3af"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
<div className="flex items-center gap-3 mt-2">
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
flexWrap: "wrap",
marginTop: "8px",
}}
>
{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",
fontSize: "11px",
fontWeight: 600,
color: incident.incidentSeverity.color.toString(),
backgroundColor:
incident.incidentSeverity.color.toString() + "12",
border: `1px solid ${incident.incidentSeverity.color.toString()}25`,
padding: "2px 10px",
borderRadius: "9999px",
lineHeight: "1.6",
}}
>
{incident.incidentSeverity.name}
@@ -63,11 +200,16 @@ const UptimeBarDayModal: FunctionComponent<ComponentProps> = (
)}
{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",
fontSize: "11px",
fontWeight: 600,
color: incident.currentIncidentState.color.toString(),
backgroundColor:
incident.currentIncidentState.color.toString() + "12",
border: `1px solid ${incident.currentIncidentState.color.toString()}25`,
padding: "2px 10px",
borderRadius: "9999px",
lineHeight: "1.6",
}}
>
{incident.currentIncidentState.name}

View File

@@ -2,6 +2,7 @@ import Tippy from "@tippyjs/react";
import React, { FunctionComponent, ReactElement } from "react";
import "tippy.js/dist/tippy.css";
import "tippy.js/themes/light-border.css";
import "tippy.js/animations/shift-away-subtle.css";
export interface ComponentProps {
text?: string | undefined;
@@ -22,20 +23,29 @@ const Tooltip: FunctionComponent<ComponentProps> = (
<span>{props.text}</span>
);
const themeProps: { theme: string } | Record<string, never> =
props.richContent ? { theme: "light-border" } : {};
const isRich: boolean = Boolean(props.richContent);
const themeProps: { theme: string } | Record<string, never> = isRich
? { theme: "light-border" }
: {};
const animationProps: { animation: string } | Record<string, never> = isRich
? { animation: "shift-away-subtle" }
: {};
return (
<Tippy
key={Math.random()}
content={tooltipContent}
interactive={true}
interactive={isRich}
trigger="mouseenter focus"
hideOnClick={false}
maxWidth={props.richContent ? 380 : 350}
delay={[80, 0]}
duration={[150, 100]}
maxWidth={isRich ? 380 : 350}
delay={isRich ? [120, 80] : [0, 0]}
duration={[200, 150]}
placement={isRich ? "top" : "top"}
{...themeProps}
{...animationProps}
aria={{
content: "describedby",
expanded: "auto",