feat: add reference line support to charts and implement value formatting utility

This commit is contained in:
Nawaz Dhandala
2026-03-30 14:25:37 +01:00
parent 809a85c91d
commit 9f09eacf25
11 changed files with 373 additions and 59 deletions

View File

@@ -16,18 +16,29 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
return (
<Fragment>
<div className="space-y-3">
<div className="flex space-x-3 items-start">
{!props.isFormula && props.data.metricVariable && (
<div className="bg-indigo-500 h-9 rounded w-9 min-w-9 p-3 pt-2 mt-5 font-medium text-white text-center text-sm">
{props.data.metricVariable}
</div>
)}
{props.isFormula && (
<div className="bg-indigo-500 h-9 p-2 pt-2.5 rounded w-9 min-w-9 mt-5 font-bold text-white">
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
</div>
)}
<div className="flex-1">
{/* Variable badge row */}
{((!props.isFormula && props.data.metricVariable) ||
props.isFormula) && (
<div className="flex items-center space-x-2">
{!props.isFormula && props.data.metricVariable && (
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-xs font-semibold text-white">
{props.data.metricVariable}
</div>
)}
{props.isFormula && (
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-white">
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
</div>
)}
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Display Settings
</span>
</div>
)}
{/* Title and Description */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Title
</label>
@@ -40,10 +51,10 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
title: value,
});
}}
placeholder="Title..."
placeholder="Chart title..."
/>
</div>
<div className="flex-1">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Description
</label>
@@ -56,12 +67,14 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
description: value,
});
}}
placeholder="Description..."
placeholder="Chart description..."
/>
</div>
</div>
<div className="flex space-x-3">
<div className="flex-1">
{/* Legend and Unit */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Legend
</label>
@@ -74,10 +87,10 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
legend: value,
});
}}
placeholder="Legend (e.g. Response Time)"
placeholder="e.g. Response Time"
/>
</div>
<div className="w-1/3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Unit
</label>
@@ -90,7 +103,7 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
legendUnit: value,
});
}}
placeholder="Unit (e.g. ms)"
placeholder="e.g. bytes, ms, %"
/>
</div>
</div>

View File

@@ -19,6 +19,8 @@ import YAxisType from "Common/UI/Components/Charts/Types/YAxis/YAxisType";
import { YAxisPrecision } from "Common/UI/Components/Charts/Types/YAxis/YAxis";
import ChartCurve from "Common/UI/Components/Charts/Types/ChartCurve";
import MetricType from "Common/Models/DatabaseModels/MetricType";
import ChartReferenceLineProps from "Common/UI/Components/Charts/Types/ReferenceLineProps";
import ValueFormatter from "Common/Utils/ValueFormatter";
export interface ComponentProps {
metricViewData: MetricViewData;
@@ -39,7 +41,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
props.metricViewData.startAndEndDate?.startValue &&
props.metricViewData.startAndEndDate?.endValue
) {
// if these are less than a day then we can use time
const hourDifference: number = OneUptimeDate.getHoursBetweenTwoDates(
props.metricViewData.startAndEndDate.startValue as Date,
props.metricViewData.startAndEndDate.endValue as Date,
@@ -69,7 +70,8 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
continue;
}
let xAxisAggregationType: XAxisAggregateType = XAxisAggregateType.Average;
let xAxisAggregationType: XAxisAggregateType =
XAxisAggregateType.Average;
if (
queryConfig.metricQueryData.filterData.aggegationType ===
@@ -113,10 +115,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
const series: ChartSeries = queryConfig.getSeries(item);
const seriesName: string = series.title;
//check if the series already exists if it does then add the data to the existing series
// if it does not exist then create a new series and add the data to it
const existingSeries: SeriesPoint | undefined = chartSeries.find(
(s: SeriesPoint) => {
return s.seriesName === seriesName;
@@ -170,6 +168,42 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
chartType = ChartType.AREA;
}
// Resolve the unit for formatting
const metricType: MetricType | undefined = props.metricTypes.find(
(m: MetricType) => {
return (
m.name === queryConfig.metricQueryData.filterData.metricName
);
},
);
const unit: string =
queryConfig.metricAliasData?.legendUnit || metricType?.unit || "";
// Build reference lines from thresholds
const referenceLines: Array<ChartReferenceLineProps> = [];
if (
queryConfig.warningThreshold !== undefined &&
queryConfig.warningThreshold !== null
) {
referenceLines.push({
value: queryConfig.warningThreshold,
label: `Warning: ${ValueFormatter.formatValue(queryConfig.warningThreshold, unit)}`,
color: "#f59e0b", // amber
});
}
if (
queryConfig.criticalThreshold !== undefined &&
queryConfig.criticalThreshold !== null
) {
referenceLines.push({
value: queryConfig.criticalThreshold,
label: `Critical: ${ValueFormatter.formatValue(queryConfig.criticalThreshold, unit)}`,
color: "#ef4444", // red
});
}
const chart: Chart = {
id: index.toString(),
type: chartType,
@@ -197,8 +231,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
},
},
yAxis: {
// legend is the unit of the metric
legend: queryConfig.metricAliasData?.legendUnit || "",
legend: unit,
options: {
type: YAxisType.Number,
formatter: (value: number) => {
@@ -206,15 +239,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
return queryConfig.yAxisValueFormatter(value);
}
const metricType: MetricType | undefined =
props.metricTypes.find((m: MetricType) => {
return (
m.name ===
queryConfig.metricQueryData.filterData.metricName
);
});
return `${value} ${queryConfig.metricAliasData?.legendUnit || metricType?.unit || ""}`;
return ValueFormatter.formatValue(value, unit);
},
precision: YAxisPrecision.NoDecimals,
max: "auto",
@@ -223,6 +248,8 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
},
curve: ChartCurve.MONOTONE,
sync: true,
referenceLines:
referenceLines.length > 0 ? referenceLines : undefined,
},
};

View File

@@ -45,18 +45,8 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
const getContent: GetReactElementFunction = (): ReactElement => {
return (
<div>
<MetricAlias
data={props.data?.metricAliasData || defaultAliasData}
onDataChanged={(data: MetricAliasData) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({ ...props.data, metricAliasData: data });
}
}}
isFormula={false}
/>
<div className="space-y-4">
{/* Metric query selection — always on top */}
{props.data?.metricQueryData && (
<MetricQuery
data={props.data?.metricQueryData || {}}
@@ -69,7 +59,7 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
const previousMetricName: string | undefined =
props.data?.metricQueryData?.filterData?.metricName?.toString();
// If metric changed, prefill legend and unit from MetricType
// If metric changed, prefill all alias fields from MetricType
if (
selectedMetricName &&
selectedMetricName !== previousMetricName
@@ -88,13 +78,10 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
metricQueryData: data,
metricAliasData: {
...currentAlias,
legend: currentAlias.legend || metricType.name || "",
legendUnit:
currentAlias.legendUnit || metricType.unit || "",
description:
currentAlias.description ||
metricType.description ||
"",
title: metricType.name || "",
description: metricType.description || "",
legend: metricType.name || "",
legendUnit: metricType.unit || "",
},
});
return;
@@ -112,7 +99,24 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
onAttributesRetry={props.onAttributesRetry}
/>
)}
<div className="flex space-x-3 mt-3">
{/* Display settings — title, description, legend, unit */}
<div className="border-t border-gray-200 pt-3">
<MetricAlias
data={props.data?.metricAliasData || defaultAliasData}
onDataChanged={(data: MetricAliasData) => {
props.onBlur?.();
props.onFocus?.();
if (props.onChange) {
props.onChange({ ...props.data, metricAliasData: data });
}
}}
isFormula={false}
/>
</div>
{/* Thresholds */}
<div className="flex space-x-3">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">
Warning Threshold
@@ -154,8 +158,10 @@ const MetricGraphConfig: FunctionComponent<ComponentProps> = (
/>
</div>
</div>
{/* Remove button */}
{props.onRemove && (
<div className="-ml-3">
<div>
<Button
title={"Remove"}
onClick={() => {

View File

@@ -6,6 +6,7 @@ import YAxis from "../Types/YAxis/YAxis";
import ChartCurve from "../Types/ChartCurve";
import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
import DataPointUtil from "../Utils/DataPoint";
import ChartReferenceLineProps from "../Types/ReferenceLineProps";
export interface ComponentProps {
data: Array<SeriesPoint>;
@@ -14,6 +15,7 @@ export interface ComponentProps {
curve: ChartCurve;
sync: boolean;
heightInPx?: number | undefined;
referenceLines?: Array<ChartReferenceLineProps> | undefined;
}
export interface AreaInternalProps extends ComponentProps {
@@ -75,6 +77,7 @@ const AreaChartElement: FunctionComponent<AreaInternalProps> = (
syncid={props.sync ? props.syncid : undefined}
yAxisWidth={60}
onValueChange={() => {}}
referenceLines={props.referenceLines}
/>
);
};

View File

@@ -5,6 +5,7 @@ import { XAxis } from "../Types/XAxis/XAxis";
import YAxis from "../Types/YAxis/YAxis";
import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
import DataPointUtil from "../Utils/DataPoint";
import ChartReferenceLineProps from "../Types/ReferenceLineProps";
export interface ComponentProps {
data: Array<SeriesPoint>;
@@ -12,6 +13,7 @@ export interface ComponentProps {
yAxis: YAxis;
sync: boolean;
heightInPx?: number | undefined;
referenceLines?: Array<ChartReferenceLineProps> | undefined;
}
export interface BarInternalProps extends ComponentProps {
@@ -70,6 +72,7 @@ const BarChartElement: FunctionComponent<BarInternalProps> = (
yAxisWidth={60}
syncid={props.sync ? props.syncid : undefined}
onValueChange={() => {}}
referenceLines={props.referenceLines}
/>
);
};

View File

@@ -11,11 +11,13 @@ import {
Dot,
Label,
Legend as RechartsLegend,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import ChartReferenceLineProps from "../../Types/ReferenceLineProps";
import { AxisDomain } from "recharts/types/util/types";
import { useOnWindowResize } from "../Utils/UseWindowOnResize";
@@ -554,6 +556,7 @@ interface AreaChartProps extends React.HTMLAttributes<HTMLDivElement> {
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
customTooltip?: React.ComponentType<TooltipProps>;
syncid?: string | undefined;
referenceLines?: Array<ChartReferenceLineProps> | undefined;
}
const AreaChart: React.ForwardRefExoticComponent<
@@ -974,6 +977,32 @@ const AreaChart: React.ForwardRefExoticComponent<
/>
);
})}
{props.referenceLines?.map(
(
refLine: ChartReferenceLineProps,
refIndex: number,
) => {
return (
<ReferenceLine
key={`ref-${refIndex}`}
y={refLine.value}
stroke={refLine.color}
strokeDasharray={refLine.strokeDasharray || "4 4"}
strokeWidth={1.5}
>
{refLine.label && (
<Label
value={refLine.label}
position="insideTopRight"
fill={refLine.color}
fontSize={11}
fontWeight={500}
/>
)}
</ReferenceLine>
);
},
)}
</RechartsAreaChart>
</ResponsiveContainer>
</div>

View File

@@ -11,11 +11,13 @@ import {
Label,
BarChart as RechartsBarChart,
Legend as RechartsLegend,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import ChartReferenceLineProps from "../../Types/ReferenceLineProps";
import type { AxisDomain } from "recharts/types/util/types";
import {
@@ -646,6 +648,7 @@ interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
customTooltip?: React.ComponentType<TooltipProps>;
syncid?: string | undefined;
referenceLines?: Array<ChartReferenceLineProps> | undefined;
}
const BarChart: React.ForwardRefExoticComponent<
@@ -1010,6 +1013,32 @@ const BarChart: React.ForwardRefExoticComponent<
/>
);
})}
{props.referenceLines?.map(
(
refLine: ChartReferenceLineProps,
refIndex: number,
) => {
return (
<ReferenceLine
key={`ref-${refIndex}`}
y={refLine.value}
stroke={refLine.color}
strokeDasharray={refLine.strokeDasharray || "4 4"}
strokeWidth={1.5}
>
{refLine.label && (
<Label
value={refLine.label}
position="insideTopRight"
fill={refLine.color}
fontSize={11}
fontWeight={500}
/>
)}
</ReferenceLine>
);
},
)}
</RechartsBarChart>
</ResponsiveContainer>
</div>

View File

@@ -12,11 +12,13 @@ import {
Line,
Legend as RechartsLegend,
LineChart as RechartsLineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import ChartReferenceLineProps from "../../Types/ReferenceLineProps";
import { AxisDomain } from "recharts/types/util/types";
import { useOnWindowResize } from "../Utils/UseWindowOnResize";
@@ -571,6 +573,7 @@ interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
customTooltip?: React.ComponentType<TooltipProps>;
syncid?: string | undefined;
referenceLines?: Array<ChartReferenceLineProps> | undefined;
}
const LineChart: React.ForwardRefExoticComponent<
@@ -992,6 +995,32 @@ const LineChart: React.ForwardRefExoticComponent<
);
})
: null}
{props.referenceLines?.map(
(
refLine: ChartReferenceLineProps,
refIndex: number,
) => {
return (
<ReferenceLine
key={`ref-${refIndex}`}
y={refLine.value}
stroke={refLine.color}
strokeDasharray={refLine.strokeDasharray || "4 4"}
strokeWidth={1.5}
>
{refLine.label && (
<Label
value={refLine.label}
position="insideTopRight"
fill={refLine.color}
fontSize={11}
fontWeight={500}
/>
)}
</ReferenceLine>
);
},
)}
</RechartsLineChart>
</ResponsiveContainer>
</div>

View File

@@ -6,6 +6,7 @@ import YAxis from "../Types/YAxis/YAxis";
import ChartCurve from "../Types/ChartCurve";
import ChartDataPoint from "../ChartLibrary/Types/ChartDataPoint";
import DataPointUtil from "../Utils/DataPoint";
import ChartReferenceLineProps from "../Types/ReferenceLineProps";
export interface ComponentProps {
data: Array<SeriesPoint>;
@@ -14,6 +15,7 @@ export interface ComponentProps {
curve: ChartCurve;
sync: boolean;
heightInPx?: number | undefined;
referenceLines?: Array<ChartReferenceLineProps> | undefined;
}
export interface LineInternalProps extends ComponentProps {
@@ -74,6 +76,7 @@ const LineChartElement: FunctionComponent<LineInternalProps> = (
syncid={props.sync ? props.syncid : undefined}
yAxisWidth={60}
onValueChange={() => {}}
referenceLines={props.referenceLines}
/>
);
};

View File

@@ -0,0 +1,6 @@
export default interface ChartReferenceLineProps {
value: number;
label?: string | undefined;
color: string; // CSS color, e.g. "#f59e0b" or "red"
strokeDasharray?: string | undefined; // e.g. "4 4" for dashed
}

View File

@@ -0,0 +1,166 @@
// Human-friendly value formatting for metric units.
// Converts raw values like 1048576 bytes → "1 MB", 3661 seconds → "1.02 hr", etc.
export interface FormattedValue {
value: string; // e.g. "1.5"
unit: string; // e.g. "MB"
formatted: string; // e.g. "1.5 MB"
}
type UnitThreshold = {
threshold: number;
unit: string;
divisor: number;
};
const byteUnits: Array<UnitThreshold> = [
{ threshold: 1e15, unit: "PB", divisor: 1e15 },
{ threshold: 1e12, unit: "TB", divisor: 1e12 },
{ threshold: 1e9, unit: "GB", divisor: 1e9 },
{ threshold: 1e6, unit: "MB", divisor: 1e6 },
{ threshold: 1e3, unit: "KB", divisor: 1e3 },
{ threshold: 0, unit: "B", divisor: 1 },
];
const secondUnits: Array<UnitThreshold> = [
{ threshold: 86400, unit: "d", divisor: 86400 },
{ threshold: 3600, unit: "hr", divisor: 3600 },
{ threshold: 60, unit: "min", divisor: 60 },
{ threshold: 1, unit: "s", divisor: 1 },
{ threshold: 0.001, unit: "ms", divisor: 0.001 },
{ threshold: 0.000001, unit: "µs", divisor: 0.000001 },
{ threshold: 0, unit: "ns", divisor: 0.000000001 },
];
const millisecondUnits: Array<UnitThreshold> = [
{ threshold: 86400000, unit: "d", divisor: 86400000 },
{ threshold: 3600000, unit: "hr", divisor: 3600000 },
{ threshold: 60000, unit: "min", divisor: 60000 },
{ threshold: 1000, unit: "s", divisor: 1000 },
{ threshold: 1, unit: "ms", divisor: 1 },
{ threshold: 0.001, unit: "µs", divisor: 0.001 },
{ threshold: 0, unit: "ns", divisor: 0.000001 },
];
const microsecondUnits: Array<UnitThreshold> = [
{ threshold: 1e6, unit: "s", divisor: 1e6 },
{ threshold: 1e3, unit: "ms", divisor: 1e3 },
{ threshold: 1, unit: "µs", divisor: 1 },
{ threshold: 0, unit: "ns", divisor: 0.001 },
];
const nanosecondUnits: Array<UnitThreshold> = [
{ threshold: 1e9, unit: "s", divisor: 1e9 },
{ threshold: 1e6, unit: "ms", divisor: 1e6 },
{ threshold: 1e3, unit: "µs", divisor: 1e3 },
{ threshold: 0, unit: "ns", divisor: 1 },
];
// Maps common metric unit strings to their scaling table
const unitTableMap: Record<string, Array<UnitThreshold>> = {
// Byte variants
bytes: byteUnits,
byte: byteUnits,
by: byteUnits,
b: byteUnits,
// Second variants
seconds: secondUnits,
second: secondUnits,
sec: secondUnits,
s: secondUnits,
// Millisecond variants
milliseconds: millisecondUnits,
millisecond: millisecondUnits,
ms: millisecondUnits,
// Microsecond variants
microseconds: microsecondUnits,
microsecond: microsecondUnits,
us: microsecondUnits,
µs: microsecondUnits,
// Nanosecond variants
nanoseconds: nanosecondUnits,
nanosecond: nanosecondUnits,
ns: nanosecondUnits,
};
function formatWithThresholds(
value: number,
thresholds: Array<UnitThreshold>,
): FormattedValue {
const absValue: number = Math.abs(value);
for (const t of thresholds) {
if (absValue >= t.threshold) {
const scaled: number = value / t.divisor;
const formatted: string = formatNumber(scaled);
return {
value: formatted,
unit: t.unit,
formatted: `${formatted} ${t.unit}`,
};
}
}
// Fallback: use last threshold
const last: UnitThreshold = thresholds[thresholds.length - 1]!;
const scaled: number = value / last.divisor;
const formatted: string = formatNumber(scaled);
return {
value: formatted,
unit: last.unit,
formatted: `${formatted} ${last.unit}`,
};
}
function formatNumber(value: number): string {
if (value === 0) {
return "0";
}
const absValue: number = Math.abs(value);
if (absValue >= 100) {
return Math.round(value).toString();
}
if (absValue >= 10) {
return (Math.round(value * 10) / 10).toString();
}
return (Math.round(value * 100) / 100).toString();
}
export default class ValueFormatter {
// Format a value with a unit into a human-friendly string.
// e.g. formatValue(1048576, "bytes") → "1 MB"
// e.g. formatValue(3661, "seconds") → "1.02 hr"
// e.g. formatValue(42, "%") → "42 %" (passthrough for unknown units)
public static formatValue(value: number, unit: string): string {
if (!unit || unit.trim() === "") {
return formatNumber(value);
}
const normalizedUnit: string = unit.trim().toLowerCase();
const thresholds: Array<UnitThreshold> | undefined =
unitTableMap[normalizedUnit];
if (thresholds) {
return formatWithThresholds(value, thresholds).formatted;
}
// Unknown unit — just format the number and append the unit as-is
return `${formatNumber(value)} ${unit}`;
}
// Check if a unit is one we can auto-scale (bytes, seconds, etc.)
public static isScalableUnit(unit: string): boolean {
if (!unit || unit.trim() === "") {
return false;
}
return unitTableMap[unit.trim().toLowerCase()] !== undefined;
}
}