feat: enhance MetricCharts and ChartGroup with metric info handling and modal display; update UptimeBarTooltip styles and Tooltip theme

This commit is contained in:
Nawaz Dhandala
2026-04-01 14:45:21 +01:00
parent c4aab31056
commit 505c143ddf
4 changed files with 383 additions and 119 deletions

View File

@@ -3,8 +3,10 @@ import OneUptimeDate from "Common/Types/Date";
import XAxisType from "Common/UI/Components/Charts/Types/XAxis/XAxisType";
import ChartGroup, {
Chart,
ChartMetricInfo,
ChartType,
} from "Common/UI/Components/Charts/ChartGroup/ChartGroup";
import Dictionary from "Common/Types/Dictionary";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import { XAxisAggregateType } from "Common/UI/Components/Charts/Types/XAxis/XAxis";
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
@@ -201,6 +203,35 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
});
}
// Build metric info for the info icon modal
const metricAttributes: Dictionary<string> = {};
const filterAttributes:
| Dictionary<string | boolean | number>
| undefined = queryConfig.metricQueryData.filterData.attributes as
| Dictionary<string | boolean | number>
| undefined;
if (filterAttributes) {
for (const key of Object.keys(filterAttributes)) {
metricAttributes[key] = String(filterAttributes[key]);
}
}
const metricInfo: ChartMetricInfo = {
metricName:
queryConfig.metricQueryData.filterData.metricName?.toString() || "",
aggregationType:
queryConfig.metricQueryData.filterData.aggegationType?.toString() ||
"",
attributes:
Object.keys(metricAttributes).length > 0
? metricAttributes
: undefined,
groupByAttribute:
queryConfig.metricQueryData.filterData.groupByAttribute,
unit,
};
const chart: Chart = {
id: index.toString(),
type: chartType,
@@ -209,6 +240,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
queryConfig.metricQueryData.filterData.metricName?.toString() ||
"",
description: queryConfig.metricAliasData?.description || "",
metricInfo,
props: {
data: chartSeries,
xAxis: {

View File

@@ -1,4 +1,5 @@
import Text from "../../../../Types/Text";
import Dictionary from "../../../../Types/Dictionary";
import LineChart, { ComponentProps as LineChartProps } from "../Line/LineChart";
import BarChartElement, {
ComponentProps as BarChartProps,
@@ -6,7 +7,10 @@ import BarChartElement, {
import AreaChartElement, {
ComponentProps as AreaChartProps,
} from "../Area/AreaChart";
import React, { FunctionComponent, ReactElement } from "react";
import Icon, { SizeProp } from "../../Icon/Icon";
import IconProp from "../../../../Types/Icon/IconProp";
import Modal, { ModalWidth } from "../../Modal/Modal";
import React, { FunctionComponent, ReactElement, useState } from "react";
export enum ChartType {
LINE = "line",
@@ -14,12 +18,21 @@ export enum ChartType {
AREA = "area",
}
export interface ChartMetricInfo {
metricName: string;
aggregationType: string;
attributes?: Dictionary<string> | undefined;
groupByAttribute?: string | undefined;
unit?: string | undefined;
}
export interface Chart {
id: string;
title: string;
description?: string | undefined;
type: ChartType;
props: LineChartProps | BarChartProps | AreaChartProps;
metricInfo?: ChartMetricInfo | undefined;
}
export interface ComponentProps {
@@ -33,6 +46,8 @@ const ChartGroup: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const syncId: string = Text.generateRandomText(10);
const [metricInfoModalChart, setMetricInfoModalChart] =
useState<ChartMetricInfo | null>(null);
const isLastChart: (index: number) => boolean = (index: number): boolean => {
return index === props.charts.length - 1;
@@ -77,33 +92,157 @@ const ChartGroup: FunctionComponent<ComponentProps> = (
}
};
type GetInfoIconFunction = (chart: Chart) => ReactElement;
const getInfoIcon: GetInfoIconFunction = (chart: Chart): ReactElement => {
if (!chart.metricInfo) {
return <></>;
}
return (
<button
type="button"
className="ml-2 inline-flex items-center text-gray-400 hover:text-gray-600 transition-colors"
title="View metric details"
onClick={() => {
setMetricInfoModalChart(chart.metricInfo || null);
}}
>
<Icon
icon={IconProp.InformationCircle}
size={SizeProp.Small}
className="h-4 w-4"
/>
</button>
);
};
const renderMetricInfoModal: () => ReactElement = (): ReactElement => {
if (!metricInfoModalChart) {
return <></>;
}
const attributes: Dictionary<string> =
metricInfoModalChart.attributes || {};
const attributeKeys: Array<string> = Object.keys(attributes);
return (
<Modal
title="Metric Details"
onClose={() => {
setMetricInfoModalChart(null);
}}
onSubmit={() => {
setMetricInfoModalChart(null);
}}
submitButtonText="Close"
modalWidth={ModalWidth.Normal}
>
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
<table className="w-full text-sm">
<tbody>
<tr className="border-b border-gray-200">
<td className="py-2.5 pr-4 font-medium text-gray-500 whitespace-nowrap">
Metric Name
</td>
<td className="py-2.5 text-gray-900 font-mono text-xs">
{metricInfoModalChart.metricName}
</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2.5 pr-4 font-medium text-gray-500 whitespace-nowrap">
Aggregation
</td>
<td className="py-2.5 text-gray-900">
{metricInfoModalChart.aggregationType}
</td>
</tr>
{metricInfoModalChart.unit && (
<tr className="border-b border-gray-200">
<td className="py-2.5 pr-4 font-medium text-gray-500 whitespace-nowrap">
Unit
</td>
<td className="py-2.5 text-gray-900">
{metricInfoModalChart.unit}
</td>
</tr>
)}
{metricInfoModalChart.groupByAttribute && (
<tr className="border-b border-gray-200">
<td className="py-2.5 pr-4 font-medium text-gray-500 whitespace-nowrap">
Grouped By
</td>
<td className="py-2.5 text-gray-900 font-mono text-xs">
{metricInfoModalChart.groupByAttribute}
</td>
</tr>
)}
{attributeKeys.length > 0 && (
<tr>
<td className="py-2.5 pr-4 font-medium text-gray-500 whitespace-nowrap align-top">
Attributes
</td>
<td className="py-2.5">
<div className="space-y-1.5">
{attributeKeys.map((key: string) => {
return (
<div key={key} className="flex items-center gap-2">
<span className="inline-flex items-center rounded bg-gray-200 px-2 py-0.5 text-xs font-mono text-gray-700">
{key}
</span>
<span className="text-gray-400">=</span>
<span className="text-xs text-gray-900 font-mono">
{attributes[key]}
</span>
</div>
);
})}
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</Modal>
);
};
// When hideCard is true, render charts in a clean vertical stack with dividers
if (props.hideCard) {
return (
<div className="space-y-0">
{props.charts.map((chart: Chart, index: number) => {
return (
<div
key={index}
className={`${!isLastChart(index) ? "border-b border-gray-100" : ""} ${props.chartCssClass || ""}`}
>
<div className="px-1 pt-5 pb-4">
<div className="mb-1">
<h3 className="text-sm font-semibold text-gray-700 tracking-tight">
{chart.title}
</h3>
{chart.description && (
<p className="mt-0.5 text-xs text-gray-400 hidden md:block">
{chart.description}
</p>
)}
<>
{renderMetricInfoModal()}
<div className="space-y-0">
{props.charts.map((chart: Chart, index: number) => {
return (
<div
key={index}
className={`${!isLastChart(index) ? "border-b border-gray-100" : ""} ${props.chartCssClass || ""}`}
>
<div className="px-1 pt-5 pb-4">
<div className="mb-1">
<div className="flex items-center">
<h3 className="text-sm font-semibold text-gray-700 tracking-tight">
{chart.title}
</h3>
{getInfoIcon(chart)}
</div>
{chart.description && (
<p className="mt-0.5 text-xs text-gray-400 hidden md:block">
{chart.description}
</p>
)}
</div>
{getChartContent(chart, index)}
</div>
{getChartContent(chart, index)}
</div>
</div>
);
})}
</div>
);
})}
</div>
</>
);
}
@@ -112,35 +251,41 @@ const ChartGroup: FunctionComponent<ComponentProps> = (
props.charts.length > 1 ? "lg:grid-cols-2" : "lg:grid-cols-1";
return (
<div
className={`grid grid-cols-1 ${gridCols} gap-4 space-y-4 lg:space-y-0`}
>
{props.charts.map((chart: Chart, index: number) => {
return (
<div
key={index}
className={`p-5 rounded-lg border border-gray-200 bg-white shadow-sm ${props.chartCssClass || ""}`}
>
<h2
data-testid="card-details-heading"
id="card-details-heading"
className="text-base font-semibold leading-6 text-gray-900"
<>
{renderMetricInfoModal()}
<div
className={`grid grid-cols-1 ${gridCols} gap-4 space-y-4 lg:space-y-0`}
>
{props.charts.map((chart: Chart, index: number) => {
return (
<div
key={index}
className={`p-5 rounded-lg border border-gray-200 bg-white shadow-sm ${props.chartCssClass || ""}`}
>
{chart.title}
</h2>
{chart.description && (
<p
data-testid="card-description"
className="mt-0.5 text-sm text-gray-500 w-full hidden md:block"
>
{chart.description}
</p>
)}
{getChartContent(chart, index)}
</div>
);
})}
</div>
<div className="flex items-center">
<h2
data-testid="card-details-heading"
id="card-details-heading"
className="text-base font-semibold leading-6 text-gray-900"
>
{chart.title}
</h2>
{getInfoIcon(chart)}
</div>
{chart.description && (
<p
data-testid="card-description"
className="mt-0.5 text-sm text-gray-500 w-full hidden md:block"
>
{chart.description}
</p>
)}
{getChartContent(chart, index)}
</div>
);
})}
</div>
</>
);
};

View File

@@ -26,50 +26,76 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
const uptimeColor: string =
props.uptimePercent >= 99.9
? "#22c55e"
? "#16a34a"
: props.uptimePercent >= 99
? "#eab308"
: "#ef4444";
? "#ca8a04"
: "#dc2626";
const uptimeBgColor: string =
props.uptimePercent >= 99.9
? "#f0fdf4"
: props.uptimePercent >= 99
? "#fefce8"
: "#fef2f2";
const uptimeTrackColor: string =
props.uptimePercent >= 99.9
? "#dcfce7"
: props.uptimePercent >= 99
? "#fef9c3"
: "#fee2e2";
return (
<div style={{ minWidth: "240px", maxWidth: "320px", padding: "4px" }}>
{/* Header */}
<div style={{ minWidth: "260px", maxWidth: "340px" }}>
{/* Date header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "10px",
paddingBottom: "8px",
marginBottom: "8px",
borderBottom: "1px solid #f3f4f6",
}}
>
<span
<div
style={{
fontWeight: 600,
fontSize: "13px",
color: "#f3f4f6",
color: "#111827",
}}
>
{dateStr}
</span>
</div>
</div>
{/* Uptime bar */}
{/* Uptime card */}
{props.hasEvents && (
<div style={{ marginBottom: "10px" }}>
<div
style={{
backgroundColor: uptimeBgColor,
borderRadius: "8px",
padding: "10px 12px",
marginBottom: "10px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "4px",
alignItems: "baseline",
marginBottom: "6px",
}}
>
<span style={{ fontSize: "11px", color: "#9ca3af" }}>Uptime</span>
<span
style={{ fontSize: "11px", color: "#6b7280", fontWeight: 500 }}
>
Uptime
</span>
<span
style={{
fontSize: "13px",
fontWeight: 600,
fontSize: "18px",
fontWeight: 700,
color: uptimeColor,
fontVariantNumeric: "tabular-nums",
lineHeight: 1,
}}
>
{props.uptimePercent.toFixed(2)}%
@@ -78,9 +104,9 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
<div
style={{
width: "100%",
height: "4px",
backgroundColor: "#374151",
borderRadius: "2px",
height: "6px",
backgroundColor: uptimeTrackColor,
borderRadius: "3px",
overflow: "hidden",
}}
>
@@ -89,8 +115,7 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
width: `${Math.min(props.uptimePercent, 100)}%`,
height: "100%",
backgroundColor: uptimeColor,
borderRadius: "2px",
transition: "width 0.3s ease",
borderRadius: "3px",
}}
/>
</div>
@@ -100,19 +125,38 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
{!props.hasEvents && (
<div
style={{
fontSize: "12px",
color: "#6b7280",
backgroundColor: "#f9fafb",
borderRadius: "8px",
padding: "12px",
textAlign: "center",
padding: "6px 0",
marginBottom: "4px",
}}
>
No data available for this day
<div style={{ fontSize: "12px", color: "#9ca3af", fontWeight: 500 }}>
No data available for this day
</div>
</div>
)}
{/* Status breakdown */}
{props.statusDurations.length > 0 && (
<div style={{ marginBottom: props.incidents.length > 0 ? "8px" : "0" }}>
<div
style={{
marginBottom: props.incidents.length > 0 ? "10px" : "0",
}}
>
<div
style={{
fontSize: "10px",
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.06em",
fontWeight: 600,
marginBottom: "4px",
}}
>
Status Breakdown
</div>
{props.statusDurations.map(
(status: StatusDuration, index: number) => {
return (
@@ -122,11 +166,15 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "3px 0",
padding: "4px 0",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: "6px" }}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span
style={{
@@ -136,17 +184,24 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
backgroundColor: status.color.toString(),
display: "inline-block",
flexShrink: 0,
boxShadow: `0 0 0 2px ${status.color.toString()}30`,
}}
/>
<span style={{ fontSize: "11px", color: "#d1d5db" }}>
<span
style={{
fontSize: "12px",
color: "#374151",
fontWeight: 500,
}}
>
{status.label}
</span>
</div>
<span
style={{
fontSize: "11px",
color: status.isDowntime ? "#fbbf24" : "#9ca3af",
fontWeight: status.isDowntime ? 500 : 400,
fontSize: "12px",
color: status.isDowntime ? "#dc2626" : "#6b7280",
fontWeight: status.isDowntime ? 600 : 400,
fontVariantNumeric: "tabular-nums",
}}
>
@@ -165,22 +220,42 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
{props.incidents.length > 0 && (
<div
style={{
borderTop: "1px solid #374151",
paddingTop: "8px",
borderTop: "1px solid #f3f4f6",
paddingTop: "10px",
}}
>
<div
style={{
fontSize: "11px",
color: "#9ca3af",
marginBottom: "6px",
textTransform: "uppercase",
letterSpacing: "0.05em",
fontWeight: 500,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "8px",
}}
>
{props.incidents.length} Incident
{props.incidents.length !== 1 ? "s" : ""}
<div
style={{
fontSize: "10px",
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.06em",
fontWeight: 600,
}}
>
Incidents
</div>
<div
style={{
fontSize: "10px",
fontWeight: 600,
color: "#dc2626",
backgroundColor: "#fef2f2",
padding: "1px 8px",
borderRadius: "9999px",
lineHeight: "1.6",
}}
>
{props.incidents.length}
</div>
</div>
{props.incidents.slice(0, 3).map(
(incident: UptimeBarTooltipIncident) => {
@@ -188,19 +263,20 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
<div
key={incident.id}
style={{
backgroundColor: "rgba(255,255,255,0.05)",
borderRadius: "6px",
padding: "6px 8px",
marginBottom: "4px",
backgroundColor: "#f9fafb",
border: "1px solid #f3f4f6",
borderRadius: "8px",
padding: "8px 10px",
marginBottom: "6px",
}}
>
<div
style={{
fontSize: "12px",
color: "#e5e7eb",
fontWeight: 500,
marginBottom: "3px",
lineHeight: "1.3",
color: "#111827",
fontWeight: 600,
marginBottom: "4px",
lineHeight: "1.4",
}}
>
{incident.title}
@@ -217,13 +293,14 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
<span
style={{
fontSize: "10px",
fontWeight: 500,
fontWeight: 600,
color: incident.incidentSeverity.color.toString(),
backgroundColor:
incident.incidentSeverity.color.toString() + "20",
padding: "1px 6px",
incident.incidentSeverity.color.toString() + "15",
border: `1px solid ${incident.incidentSeverity.color.toString()}30`,
padding: "1px 8px",
borderRadius: "9999px",
lineHeight: "1.5",
lineHeight: "1.6",
}}
>
{incident.incidentSeverity.name}
@@ -233,15 +310,16 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
<span
style={{
fontSize: "10px",
fontWeight: 500,
fontWeight: 600,
color:
incident.currentIncidentState.color.toString(),
backgroundColor:
incident.currentIncidentState.color.toString() +
"20",
padding: "1px 6px",
"15",
border: `1px solid ${incident.currentIncidentState.color.toString()}30`,
padding: "1px 8px",
borderRadius: "9999px",
lineHeight: "1.5",
lineHeight: "1.6",
}}
>
{incident.currentIncidentState.name}
@@ -256,23 +334,27 @@ const UptimeBarTooltip: FunctionComponent<ComponentProps> = (
<div
style={{
fontSize: "11px",
color: "#6b7280",
color: "#9ca3af",
textAlign: "center",
paddingTop: "2px",
padding: "2px 0",
fontWeight: 500,
}}
>
+{props.incidents.length - 3} more
+{props.incidents.length - 3} more incident
{props.incidents.length - 3 !== 1 ? "s" : ""}
</div>
)}
<div
style={{
fontSize: "10px",
color: "#6b7280",
color: "#9ca3af",
textAlign: "center",
marginTop: "6px",
marginTop: "8px",
fontWeight: 500,
letterSpacing: "0.02em",
}}
>
Click bar for full details
Click bar to view details
</div>
</div>
)}

View File

@@ -1,6 +1,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";
export interface ComponentProps {
text?: string | undefined;
@@ -21,6 +22,9 @@ const Tooltip: FunctionComponent<ComponentProps> = (
<span>{props.text}</span>
);
const themeProps: { theme: string } | Record<string, never> =
props.richContent ? { theme: "light-border" } : {};
return (
<Tippy
key={Math.random()}
@@ -29,8 +33,9 @@ const Tooltip: FunctionComponent<ComponentProps> = (
trigger="mouseenter focus"
hideOnClick={false}
maxWidth={props.richContent ? 380 : 350}
delay={[100, 0]}
delay={[80, 0]}
duration={[150, 100]}
{...themeProps}
aria={{
content: "describedby",
expanded: "auto",