From 9f09eacf251ba2146191296b645b0074e7f4f24e Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Mon, 30 Mar 2026 14:25:37 +0100 Subject: [PATCH] feat: add reference line support to charts and implement value formatting utility --- .../src/Components/Metrics/MetricAlias.tsx | 53 +++--- .../src/Components/Metrics/MetricCharts.tsx | 61 +++++-- .../Components/Metrics/MetricQueryConfig.tsx | 50 +++--- .../UI/Components/Charts/Area/AreaChart.tsx | 3 + Common/UI/Components/Charts/Bar/BarChart.tsx | 3 + .../ChartLibrary/AreaChart/AreaChart.tsx | 29 +++ .../Charts/ChartLibrary/BarChart/BarChart.tsx | 29 +++ .../ChartLibrary/LineChart/LineChart.tsx | 29 +++ .../UI/Components/Charts/Line/LineChart.tsx | 3 + .../Charts/Types/ReferenceLineProps.ts | 6 + Common/Utils/ValueFormatter.ts | 166 ++++++++++++++++++ 11 files changed, 373 insertions(+), 59 deletions(-) create mode 100644 Common/UI/Components/Charts/Types/ReferenceLineProps.ts create mode 100644 Common/Utils/ValueFormatter.ts diff --git a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricAlias.tsx b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricAlias.tsx index 65a790acd8..325b7c6a0b 100644 --- a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricAlias.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricAlias.tsx @@ -16,18 +16,29 @@ const MetricAlias: FunctionComponent = ( return (
-
- {!props.isFormula && props.data.metricVariable && ( -
- {props.data.metricVariable} -
- )} - {props.isFormula && ( -
- -
- )} -
+ {/* Variable badge row */} + {((!props.isFormula && props.data.metricVariable) || + props.isFormula) && ( +
+ {!props.isFormula && props.data.metricVariable && ( +
+ {props.data.metricVariable} +
+ )} + {props.isFormula && ( +
+ +
+ )} + + Display Settings + +
+ )} + + {/* Title and Description */} +
+
@@ -40,10 +51,10 @@ const MetricAlias: FunctionComponent = ( title: value, }); }} - placeholder="Title..." + placeholder="Chart title..." />
-
+
@@ -56,12 +67,14 @@ const MetricAlias: FunctionComponent = ( description: value, }); }} - placeholder="Description..." + placeholder="Chart description..." />
-
-
+ + {/* Legend and Unit */} +
+
@@ -74,10 +87,10 @@ const MetricAlias: FunctionComponent = ( legend: value, }); }} - placeholder="Legend (e.g. Response Time)" + placeholder="e.g. Response Time" />
-
+
@@ -90,7 +103,7 @@ const MetricAlias: FunctionComponent = ( legendUnit: value, }); }} - placeholder="Unit (e.g. ms)" + placeholder="e.g. bytes, ms, %" />
diff --git a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx index 1c402ff2b4..9a5ce5f175 100644 --- a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricCharts.tsx @@ -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 = ( 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 = ( continue; } - let xAxisAggregationType: XAxisAggregateType = XAxisAggregateType.Average; + let xAxisAggregationType: XAxisAggregateType = + XAxisAggregateType.Average; if ( queryConfig.metricQueryData.filterData.aggegationType === @@ -113,10 +115,6 @@ const MetricCharts: FunctionComponent = ( 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 = ( 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 = []; + + 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 = ( }, }, 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 = ( 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 = ( }, curve: ChartCurve.MONOTONE, sync: true, + referenceLines: + referenceLines.length > 0 ? referenceLines : undefined, }, }; diff --git a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricQueryConfig.tsx b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricQueryConfig.tsx index 27a4102dfd..db924296de 100644 --- a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricQueryConfig.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricQueryConfig.tsx @@ -45,18 +45,8 @@ const MetricGraphConfig: FunctionComponent = ( const getContent: GetReactElementFunction = (): ReactElement => { return ( -
- { - props.onBlur?.(); - props.onFocus?.(); - if (props.onChange) { - props.onChange({ ...props.data, metricAliasData: data }); - } - }} - isFormula={false} - /> +
+ {/* Metric query selection — always on top */} {props.data?.metricQueryData && ( = ( 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 = ( 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 = ( onAttributesRetry={props.onAttributesRetry} /> )} -
+ + {/* Display settings — title, description, legend, unit */} +
+ { + props.onBlur?.(); + props.onFocus?.(); + if (props.onChange) { + props.onChange({ ...props.data, metricAliasData: data }); + } + }} + isFormula={false} + /> +
+ + {/* Thresholds */} +
+ + {/* Remove button */} {props.onRemove && ( -
+
diff --git a/Common/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx b/Common/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx index 5ca33a742b..8c03d620bd 100644 --- a/Common/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +++ b/Common/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx @@ -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 { tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void; customTooltip?: React.ComponentType; syncid?: string | undefined; + referenceLines?: Array | undefined; } const BarChart: React.ForwardRefExoticComponent< @@ -1010,6 +1013,32 @@ const BarChart: React.ForwardRefExoticComponent< /> ); })} + {props.referenceLines?.map( + ( + refLine: ChartReferenceLineProps, + refIndex: number, + ) => { + return ( + + {refLine.label && ( + + ); + }, + )}
diff --git a/Common/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx b/Common/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx index 9eb575dad2..a22c01a41c 100644 --- a/Common/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +++ b/Common/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx @@ -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 { tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void; customTooltip?: React.ComponentType; syncid?: string | undefined; + referenceLines?: Array | undefined; } const LineChart: React.ForwardRefExoticComponent< @@ -992,6 +995,32 @@ const LineChart: React.ForwardRefExoticComponent< ); }) : null} + {props.referenceLines?.map( + ( + refLine: ChartReferenceLineProps, + refIndex: number, + ) => { + return ( + + {refLine.label && ( + + ); + }, + )}
diff --git a/Common/UI/Components/Charts/Line/LineChart.tsx b/Common/UI/Components/Charts/Line/LineChart.tsx index 6249a6dc63..0a2065fdc3 100644 --- a/Common/UI/Components/Charts/Line/LineChart.tsx +++ b/Common/UI/Components/Charts/Line/LineChart.tsx @@ -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; @@ -14,6 +15,7 @@ export interface ComponentProps { curve: ChartCurve; sync: boolean; heightInPx?: number | undefined; + referenceLines?: Array | undefined; } export interface LineInternalProps extends ComponentProps { @@ -74,6 +76,7 @@ const LineChartElement: FunctionComponent = ( syncid={props.sync ? props.syncid : undefined} yAxisWidth={60} onValueChange={() => {}} + referenceLines={props.referenceLines} /> ); }; diff --git a/Common/UI/Components/Charts/Types/ReferenceLineProps.ts b/Common/UI/Components/Charts/Types/ReferenceLineProps.ts new file mode 100644 index 0000000000..74addd258a --- /dev/null +++ b/Common/UI/Components/Charts/Types/ReferenceLineProps.ts @@ -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 +} diff --git a/Common/Utils/ValueFormatter.ts b/Common/Utils/ValueFormatter.ts new file mode 100644 index 0000000000..61953c091e --- /dev/null +++ b/Common/Utils/ValueFormatter.ts @@ -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 = [ + { 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 = [ + { 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 = [ + { 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 = [ + { 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 = [ + { 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> = { + // 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, +): 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 | 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; + } +}