Compare commits

...

12 Commits

Author SHA1 Message Date
Amlan Kumar Nandy
0ac4d0f99d Merge branch 'main' into SIG-7727 2025-11-15 14:19:44 +07:00
amlannandy
7503cbf902 chore: move util 2025-10-31 12:25:28 +07:00
amlannandy
13bf14474d Merge branch 'main' into SIG-7727 2025-10-31 12:22:50 +07:00
amlannandy
c7ea032298 chore: code cleanup 2025-10-31 11:55:05 +07:00
Srikanth Chekuri
30776d6179 Merge branch 'main' into SIG-7727 2025-10-29 16:28:43 +05:30
Amlan Kumar Nandy
66564c807d Merge branch 'main' into SIG-7727 2025-10-28 15:30:01 +07:00
amlannandy
c40120dd3a chore: minor fixes 2025-10-28 14:48:27 +07:00
amlannandy
c1309072de chore: update unit formatting 2025-10-28 12:46:13 +07:00
amlannandy
3278af0327 chore: add unit tests 2025-10-27 15:52:47 +07:00
amlannandy
ccfcec73e9 chore: add unit tests 2025-10-27 15:52:47 +07:00
amlannandy
5ffd717953 chore: minor change 2025-10-27 15:52:47 +07:00
amlannandy
8cd9f5f25a chore: y axis unit management in explorer section 2025-10-27 15:52:47 +07:00
15 changed files with 1000 additions and 80 deletions

View File

@@ -1,8 +1,12 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { formattedValueToString, getValueFormat } from '@grafana/data';
import * as Sentry from '@sentry/react';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { isUniversalUnit } from 'components/YAxisUnitSelector/utils';
import { isNaN } from 'lodash-es';
import { formatUniversalUnit } from '../YAxisUnitSelector/formatter';
const DEFAULT_SIGNIFICANT_DIGITS = 15;
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const MAX_DECIMALS = 15;
@@ -138,6 +142,12 @@ export const getYAxisFormattedValue = (
precision,
);
}
// Separate logic for universal units
if (format && isUniversalUnit(format)) {
return formatUniversalUnit(parseFloat(value), format as UniversalYAxisUnit);
}
return formattedValueToString(formattedValue);
} catch (error) {
Sentry.captureEvent({

View File

@@ -0,0 +1,327 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import {
AdditionalLabelsMappingForGrafanaUnits,
UniversalUnitToGrafanaUnit,
} from '../constants';
import { formatUniversalUnit } from '../formatter';
const VALUE_BELOW_THRESHOLD = 900;
describe('formatUniversalUnit', () => {
describe('Time', () => {
it('formats time values below conversion threshold', () => {
expect(formatUniversalUnit(31, UniversalYAxisUnit.DAYS)).toBe('4.43 weeks');
expect(formatUniversalUnit(25, UniversalYAxisUnit.HOURS)).toBe('1.04 days');
expect(formatUniversalUnit(61, UniversalYAxisUnit.MINUTES)).toBe(
'1.02 hours',
);
expect(formatUniversalUnit(61, UniversalYAxisUnit.SECONDS)).toBe(
'1.02 mins',
);
expect(formatUniversalUnit(1006, UniversalYAxisUnit.MILLISECONDS)).toBe(
'1.01 s',
);
expect(formatUniversalUnit(100006, UniversalYAxisUnit.MICROSECONDS)).toBe(
'100 ms',
);
expect(formatUniversalUnit(1006, UniversalYAxisUnit.NANOSECONDS)).toBe(
'1.01 µs',
);
});
});
describe('Data', () => {
it('formats data values below conversion threshold', () => {
expect(
formatUniversalUnit(VALUE_BELOW_THRESHOLD, UniversalYAxisUnit.BYTES),
).toBe('900 B');
expect(formatUniversalUnit(900, UniversalYAxisUnit.KILOBYTES)).toBe(
'900 kB',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.MEGABYTES)).toBe(
'900 MB',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.GIGABYTES)).toBe(
'900 GB',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.TERABYTES)).toBe(
'900 TB',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.PETABYTES)).toBe(
'900 PB',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.EXABYTES)).toBe('900 EB');
expect(formatUniversalUnit(900, UniversalYAxisUnit.ZETTABYTES)).toBe(
'900 ZB',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.YOTTABYTES)).toBe(
'900 YB',
);
});
it('formats data values above conversion threshold with scaling', () => {
expect(formatUniversalUnit(1000, UniversalYAxisUnit.BYTES)).toBe('1 kB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.KILOBYTES)).toBe('1 MB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.MEGABYTES)).toBe('1 GB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.GIGABYTES)).toBe('1 TB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.TERABYTES)).toBe('1 PB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.PETABYTES)).toBe('1 EB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.EXABYTES)).toBe('1 ZB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.ZETTABYTES)).toBe(
'1 YB',
);
});
it('formats data values above conversion threshold with decimals', () => {
expect(formatUniversalUnit(1034, UniversalYAxisUnit.BYTES)).toBe('1.03 kB');
expect(formatUniversalUnit(1034, UniversalYAxisUnit.KILOBYTES)).toBe(
'1.03 MB',
);
expect(formatUniversalUnit(1034, UniversalYAxisUnit.MEGABYTES)).toBe(
'1.03 GB',
);
expect(formatUniversalUnit(1034, UniversalYAxisUnit.TERABYTES)).toBe(
'1.03 PB',
);
expect(formatUniversalUnit(1034, UniversalYAxisUnit.PETABYTES)).toBe(
'1.03 EB',
);
expect(formatUniversalUnit(1034, UniversalYAxisUnit.EXABYTES)).toBe(
'1.03 ZB',
);
expect(formatUniversalUnit(1034, UniversalYAxisUnit.ZETTABYTES)).toBe(
'1.03 YB',
);
expect(formatUniversalUnit(1034, UniversalYAxisUnit.YOTTABYTES)).toBe(
'1034 YB',
);
});
});
describe('Data rate', () => {
it('formats data rate values below conversion threshold', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.BYTES_SECOND)).toBe(
'900 B/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.KILOBYTES_SECOND)).toBe(
'900 kB/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.MEGABYTES_SECOND)).toBe(
'900 MB/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.GIGABYTES_SECOND)).toBe(
'900 GB/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.TERABYTES_SECOND)).toBe(
'900 TB/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.PETABYTES_SECOND)).toBe(
'900 PB/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.EXABYTES_SECOND)).toBe(
'900 EB/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.ZETTABYTES_SECOND)).toBe(
'900 ZB/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.YOTTABYTES_SECOND)).toBe(
'900 YB/s',
);
});
it('formats data rate values above conversion threshold with scaling (1000)', () => {
expect(formatUniversalUnit(1000, UniversalYAxisUnit.BYTES_SECOND)).toBe(
'1 kB/s',
);
expect(formatUniversalUnit(1000, UniversalYAxisUnit.KILOBYTES_SECOND)).toBe(
'1 MB/s',
);
expect(formatUniversalUnit(1000, UniversalYAxisUnit.MEGABYTES_SECOND)).toBe(
'1 GB/s',
);
expect(formatUniversalUnit(1000, UniversalYAxisUnit.GIGABYTES_SECOND)).toBe(
'1 TB/s',
);
expect(formatUniversalUnit(1000, UniversalYAxisUnit.TERABYTES_SECOND)).toBe(
'1 PB/s',
);
});
});
describe('Bit', () => {
it('formats bit values below conversion threshold', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.BITS)).toBe('900 b');
expect(formatUniversalUnit(900, UniversalYAxisUnit.KILOBITS)).toBe('900 kb');
expect(formatUniversalUnit(900, UniversalYAxisUnit.MEGABITS)).toBe('900 Mb');
expect(formatUniversalUnit(900, UniversalYAxisUnit.GIGABITS)).toBe('900 Gb');
expect(formatUniversalUnit(900, UniversalYAxisUnit.TERABITS)).toBe('900 Tb');
expect(formatUniversalUnit(900, UniversalYAxisUnit.PETABITS)).toBe('900 Pb');
expect(formatUniversalUnit(900, UniversalYAxisUnit.EXABITS)).toBe('900 Eb');
expect(formatUniversalUnit(900, UniversalYAxisUnit.ZETTABITS)).toBe(
'900 Zb',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.YOTTABITS)).toBe(
'900 Yb',
);
});
it('formats bit values above conversion threshold with scaling (1000)', () => {
expect(formatUniversalUnit(1000, UniversalYAxisUnit.BITS)).toBe('1 kb');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.KILOBITS)).toBe('1 Mb');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.MEGABITS)).toBe('1 Gb');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.GIGABITS)).toBe('1 Tb');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.TERABITS)).toBe('1 Pb');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.PETABITS)).toBe('1 Eb');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.EXABITS)).toBe('1 Zb');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.ZETTABITS)).toBe('1 Yb');
});
it('formats bit values below conversion threshold with decimals (0.5)', () => {
expect(formatUniversalUnit(0.5, UniversalYAxisUnit.KILOBITS)).toBe('500 b');
expect(formatUniversalUnit(0.001, UniversalYAxisUnit.MEGABITS)).toBe('1 kb');
});
});
describe('Bit rate', () => {
it('formats bit rate values below conversion threshold', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.BITS_SECOND)).toBe(
'900 b/s',
);
});
it('formats bit rate values above conversion threshold with scaling (1000)', () => {
expect(formatUniversalUnit(1000, UniversalYAxisUnit.BITS_SECOND)).toBe(
'1 kb/s',
);
expect(formatUniversalUnit(1000, UniversalYAxisUnit.KILOBITS_SECOND)).toBe(
'1 Mb/s',
);
});
});
describe('Count', () => {
it('formats small values without abbreviation', () => {
expect(formatUniversalUnit(100, UniversalYAxisUnit.COUNT)).toBe('100');
expect(formatUniversalUnit(900, UniversalYAxisUnit.COUNT)).toBe('900');
});
it('formats count values above conversion threshold with scaling (1000)', () => {
expect(formatUniversalUnit(1000, UniversalYAxisUnit.COUNT)).toBe('1 K');
expect(formatUniversalUnit(10000, UniversalYAxisUnit.COUNT)).toBe('10 K');
expect(formatUniversalUnit(100000, UniversalYAxisUnit.COUNT)).toBe('100 K');
expect(formatUniversalUnit(1000000, UniversalYAxisUnit.COUNT)).toBe('1 Mil');
expect(formatUniversalUnit(10000000, UniversalYAxisUnit.COUNT)).toBe(
'10 Mil',
);
expect(formatUniversalUnit(100000000, UniversalYAxisUnit.COUNT)).toBe(
'100 Mil',
);
expect(formatUniversalUnit(1000000000, UniversalYAxisUnit.COUNT)).toBe(
'1 Bil',
);
expect(formatUniversalUnit(1000000000000, UniversalYAxisUnit.COUNT)).toBe(
'1 Tri',
);
});
it('formats count per time units', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.COUNT_SECOND)).toBe(
'900 c/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.COUNT_MINUTE)).toBe(
'900 c/m',
);
});
});
describe('Operations units', () => {
it('formats operations per time', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.OPS_SECOND)).toBe(
'900 ops/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.OPS_MINUTE)).toBe(
'900 ops/m',
);
});
});
describe('Request units', () => {
it('formats requests per time', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.REQUESTS_SECOND)).toBe(
'900 req/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.REQUESTS_MINUTE)).toBe(
'900 req/m',
);
});
});
describe('Read/Write units', () => {
it('formats reads and writes per time', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.READS_SECOND)).toBe(
'900 rd/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.WRITES_SECOND)).toBe(
'900 wr/s',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.READS_MINUTE)).toBe(
'900 rd/m',
);
expect(formatUniversalUnit(900, UniversalYAxisUnit.WRITES_MINUTE)).toBe(
'900 wr/m',
);
});
});
describe('IO Operations units', () => {
it('formats IOPS', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.IOOPS_SECOND)).toBe(
'900 io/s',
);
});
});
describe('Percent units', () => {
it('formats percent as-is', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.PERCENT)).toBe('900%');
});
it('multiplies percent_unit by 100', () => {
expect(formatUniversalUnit(9, UniversalYAxisUnit.PERCENT_UNIT)).toBe('900%');
});
});
describe('None unit', () => {
it('formats as plain number', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.NONE)).toBe('900');
});
});
describe('High-order byte scaling', () => {
it('scales between EB, ZB, YB', () => {
expect(formatUniversalUnit(1000, UniversalYAxisUnit.EXABYTES)).toBe('1 ZB');
expect(formatUniversalUnit(1000, UniversalYAxisUnit.ZETTABYTES)).toBe(
'1 YB',
);
});
});
});
describe('Mapping Validator', () => {
it('validates that all units have a mapping', () => {
// Each universal unit should have a mapping to a 1:1 Grafana unit in UniversalUnitToGrafanaUnit or an additional mapping in AdditionalLabelsMappingForGrafanaUnits
const units = Object.values(UniversalYAxisUnit);
expect(
units.every((unit) => {
const hasBaseMapping = unit in UniversalUnitToGrafanaUnit;
const hasAdditionalMapping = unit in AdditionalLabelsMappingForGrafanaUnits;
const hasMapping = hasBaseMapping || hasAdditionalMapping;
if (!hasMapping) {
console.error(`Unit ${unit} does not have a mapping`);
}
return hasMapping;
}),
).toBe(true);
});
});

View File

@@ -1,4 +1,4 @@
import { UniversalYAxisUnit, YAxisUnit } from './types';
import { UnitFamilyConfig, UniversalYAxisUnit, YAxisUnit } from './types';
// Mapping of universal y-axis units to their AWS, UCUM, and OpenMetrics equivalents
export const UniversalYAxisUnitMappings: Record<
@@ -625,3 +625,152 @@ export const Y_AXIS_CATEGORIES = [
],
},
];
export const UniversalUnitToGrafanaUnit: Partial<
Record<UniversalYAxisUnit, string>
> = {
// Time
[UniversalYAxisUnit.WEEKS]: 'wk',
[UniversalYAxisUnit.DAYS]: 'd',
[UniversalYAxisUnit.HOURS]: 'h',
[UniversalYAxisUnit.MINUTES]: 'm',
[UniversalYAxisUnit.SECONDS]: 's',
[UniversalYAxisUnit.MILLISECONDS]: 'ms',
[UniversalYAxisUnit.MICROSECONDS]: 'µs',
[UniversalYAxisUnit.NANOSECONDS]: 'ns',
// Data (Grafana uses 1024-based IEC format)
[UniversalYAxisUnit.BYTES]: 'decbytes',
[UniversalYAxisUnit.KILOBYTES]: 'deckbytes',
[UniversalYAxisUnit.MEGABYTES]: 'decmbytes',
[UniversalYAxisUnit.GIGABYTES]: 'decgbytes',
[UniversalYAxisUnit.TERABYTES]: 'dectbytes',
[UniversalYAxisUnit.PETABYTES]: 'decpbytes',
// Data Rate
[UniversalYAxisUnit.BYTES_SECOND]: 'Bps',
[UniversalYAxisUnit.KILOBYTES_SECOND]: 'KBs',
[UniversalYAxisUnit.MEGABYTES_SECOND]: 'MBs',
[UniversalYAxisUnit.GIGABYTES_SECOND]: 'GBs',
[UniversalYAxisUnit.TERABYTES_SECOND]: 'TBs',
[UniversalYAxisUnit.PETABYTES_SECOND]: 'PBs',
// Bits
[UniversalYAxisUnit.BITS]: 'bits',
// Bit Rate
[UniversalYAxisUnit.BITS_SECOND]: 'bps',
[UniversalYAxisUnit.KILOBITS_SECOND]: 'Kbits',
[UniversalYAxisUnit.MEGABITS_SECOND]: 'Mbits',
[UniversalYAxisUnit.GIGABITS_SECOND]: 'Gbits',
[UniversalYAxisUnit.TERABITS_SECOND]: 'Tbits',
[UniversalYAxisUnit.PETABITS_SECOND]: 'Pbits',
// Count
[UniversalYAxisUnit.COUNT]: 'short',
[UniversalYAxisUnit.COUNT_SECOND]: 'cps',
[UniversalYAxisUnit.COUNT_MINUTE]: 'cpm',
// Operations
[UniversalYAxisUnit.OPS_SECOND]: 'ops',
[UniversalYAxisUnit.OPS_MINUTE]: 'opm',
// Requests
[UniversalYAxisUnit.REQUESTS_SECOND]: 'reqps',
[UniversalYAxisUnit.REQUESTS_MINUTE]: 'reqpm',
// Reads/Writes
[UniversalYAxisUnit.READS_SECOND]: 'rps',
[UniversalYAxisUnit.WRITES_SECOND]: 'wps',
[UniversalYAxisUnit.READS_MINUTE]: 'rpm',
[UniversalYAxisUnit.WRITES_MINUTE]: 'wpm',
// IO Operations
[UniversalYAxisUnit.IOOPS_SECOND]: 'iops',
// Percent
[UniversalYAxisUnit.PERCENT]: 'percent',
[UniversalYAxisUnit.PERCENT_UNIT]: 'percentunit',
// None
[UniversalYAxisUnit.NONE]: 'none',
};
export const AdditionalLabelsMappingForGrafanaUnits: Partial<
Record<UniversalYAxisUnit, string>
> = {
// Data
[UniversalYAxisUnit.EXABYTES]: 'EB',
[UniversalYAxisUnit.ZETTABYTES]: 'ZB',
[UniversalYAxisUnit.YOTTABYTES]: 'YB',
// Data Rate
[UniversalYAxisUnit.EXABYTES_SECOND]: 'EB/s',
[UniversalYAxisUnit.ZETTABYTES_SECOND]: 'ZB/s',
[UniversalYAxisUnit.YOTTABYTES_SECOND]: 'YB/s',
// Bits
[UniversalYAxisUnit.BITS]: 'b',
[UniversalYAxisUnit.KILOBITS]: 'kb',
[UniversalYAxisUnit.MEGABITS]: 'Mb',
[UniversalYAxisUnit.GIGABITS]: 'Gb',
[UniversalYAxisUnit.TERABITS]: 'Tb',
[UniversalYAxisUnit.PETABITS]: 'Pb',
[UniversalYAxisUnit.EXABITS]: 'Eb',
[UniversalYAxisUnit.ZETTABITS]: 'Zb',
[UniversalYAxisUnit.YOTTABITS]: 'Yb',
// Bit Rate
[UniversalYAxisUnit.EXABITS_SECOND]: 'Eb/s',
[UniversalYAxisUnit.ZETTABITS_SECOND]: 'Zb/s',
[UniversalYAxisUnit.YOTTABITS_SECOND]: 'Yb/s',
};
/**
* Configuration for unit families that need custom scaling
* These are units where Grafana doesn't auto-scale between levels
*/
export const CUSTOM_SCALING_FAMILIES: UnitFamilyConfig[] = [
// Bits (b → kb → Mb → Gb → Tb → Pb → Eb → Zb → Yb)
{
units: [
UniversalYAxisUnit.BITS,
UniversalYAxisUnit.KILOBITS,
UniversalYAxisUnit.MEGABITS,
UniversalYAxisUnit.GIGABITS,
UniversalYAxisUnit.TERABITS,
UniversalYAxisUnit.PETABITS,
UniversalYAxisUnit.EXABITS,
UniversalYAxisUnit.ZETTABITS,
UniversalYAxisUnit.YOTTABITS,
],
scaleFactor: 1000,
},
// High-order bit rates (Eb/s → Zb/s → Yb/s)
{
units: [
UniversalYAxisUnit.EXABITS_SECOND,
UniversalYAxisUnit.ZETTABITS_SECOND,
UniversalYAxisUnit.YOTTABITS_SECOND,
],
scaleFactor: 1000,
},
// High-order bytes (EB → ZB → YB)
{
units: [
UniversalYAxisUnit.EXABYTES,
UniversalYAxisUnit.ZETTABYTES,
UniversalYAxisUnit.YOTTABYTES,
],
scaleFactor: 1000,
},
// High-order byte rates (EB/s → ZB/s → YB/s)
{
units: [
UniversalYAxisUnit.EXABYTES_SECOND,
UniversalYAxisUnit.ZETTABYTES_SECOND,
UniversalYAxisUnit.YOTTABYTES_SECOND,
],
scaleFactor: 1000,
},
];

View File

@@ -0,0 +1,70 @@
import { formattedValueToString, getValueFormat } from '@grafana/data';
import {
AdditionalLabelsMappingForGrafanaUnits,
CUSTOM_SCALING_FAMILIES,
UniversalUnitToGrafanaUnit,
} from 'components/YAxisUnitSelector/constants';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
const format = (
formatStr: string,
value: number,
): ReturnType<ReturnType<typeof getValueFormat>> =>
getValueFormat(formatStr)(value, undefined, undefined, undefined);
function scaleValue(
value: number,
unit: UniversalYAxisUnit,
family: UniversalYAxisUnit[],
factor: number,
): { value: number; label: string } {
let idx = family.indexOf(unit);
// If the unit is not in the family, return the unit with the additional label
if (idx === -1) {
return { value, label: AdditionalLabelsMappingForGrafanaUnits[unit] || '' };
}
// Scale the value up or down to the nearest unit in the family
let scaled = value;
// Scale up
while (scaled >= factor && idx < family.length - 1) {
scaled /= factor;
idx += 1;
}
// Scale down
while (scaled < 1 && idx > 0) {
scaled *= factor;
idx -= 1;
}
// Return the scaled value and the label of the nearest unit in the family
return {
value: scaled,
label: AdditionalLabelsMappingForGrafanaUnits[family[idx]] || '',
};
}
export function formatUniversalUnit(
value: number,
unit: UniversalYAxisUnit,
): string {
// Check if this unit belongs to a family that needs custom scaling
const family = CUSTOM_SCALING_FAMILIES.find((family) =>
family.units.includes(unit),
);
if (family) {
const scaled = scaleValue(value, unit, family.units, family.scaleFactor);
const formatted = format('none', scaled.value);
return `${formatted.text} ${scaled.label}`;
}
// Use Grafana formatting with custom label mappings
const grafanaFormat = UniversalUnitToGrafanaUnit[unit];
if (grafanaFormat) {
const formatted = format(grafanaFormat, value);
return formattedValueToString(formatted);
}
// Fallback to short format for other units
return `${format('short', value).text} ${unit}`;
}

View File

@@ -14,7 +14,7 @@ export enum UniversalYAxisUnit {
HOURS = 'h',
MINUTES = 'min',
SECONDS = 's',
MICROSECONDS = 'us',
MICROSECONDS = 'µs',
MILLISECONDS = 'ms',
NANOSECONDS = 'ns',
@@ -364,3 +364,13 @@ export enum YAxisUnit {
OPEN_METRICS_PERCENT_UNIT = 'percentunit',
}
export interface ScaledValue {
value: number;
label: string;
}
export interface UnitFamilyConfig {
units: UniversalYAxisUnit[];
scaleFactor: number;
}

View File

@@ -31,3 +31,9 @@ export const getUniversalNameFromMetricUnit = (
return universalName || unit || '-';
};
export function isUniversalUnit(format: string): boolean {
return Object.values(UniversalYAxisUnit).includes(
format as UniversalYAxisUnit,
);
}

View File

@@ -58,6 +58,20 @@
.explore-content {
padding: 0 8px;
.y-axis-unit-selector-container {
display: flex;
align-items: center;
gap: 10px;
padding-top: 10px;
margin-bottom: 10px;
.save-unit-container {
display: flex;
align-items: center;
gap: 10px;
}
}
.ant-space {
margin-top: 10px;
margin-bottom: 20px;
@@ -75,6 +89,14 @@
.time-series-view {
min-width: 100%;
width: 100%;
position: relative;
.no-unit-warning {
position: absolute;
top: 30px;
right: 40px;
z-index: 1000;
}
}
.time-series-container {

View File

@@ -1,7 +1,7 @@
import './Explorer.styles.scss';
import * as Sentry from '@sentry/react';
import { Switch } from 'antd';
import { Switch, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -25,10 +25,10 @@ import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToD
import { v4 as uuid } from 'uuid';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
// import QuerySection from './QuerySection';
import MetricDetails from '../MetricDetails/MetricDetails';
import TimeSeries from './TimeSeries';
import { ExplorerTabs } from './types';
import { splitQueryIntoOneChartPerQuery } from './utils';
import { splitQueryIntoOneChartPerQuery, useGetMetricUnits } from './utils';
const ONE_CHART_PER_QUERY_ENABLED_KEY = 'isOneChartPerQueryEnabled';
@@ -40,6 +40,31 @@ function Explorer(): JSX.Element {
currentQuery,
} = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
const metricNames = useMemo(
() =>
stagedQuery?.builder.queryData.map(
(query) => query.aggregateAttribute?.key ?? '',
) ?? [],
[stagedQuery],
);
const {
units,
metrics,
isLoading: isMetricUnitsLoading,
isError: isMetricUnitsError,
} = useGetMetricUnits(metricNames);
const areAllMetricUnitsSame = useMemo(
() =>
!isMetricUnitsLoading &&
!isMetricUnitsError &&
units.length > 0 &&
units.every((unit) => unit === units[0]),
[units, isMetricUnitsLoading, isMetricUnitsError],
);
const [searchParams, setSearchParams] = useSearchParams();
const isOneChartPerQueryEnabled =
@@ -48,7 +73,31 @@ function Explorer(): JSX.Element {
const [showOneChartPerQuery, toggleShowOneChartPerQuery] = useState(
isOneChartPerQueryEnabled,
);
const [disableOneChartPerQuery, toggleDisableOneChartPerQuery] = useState(
false,
);
const [selectedTab] = useState<ExplorerTabs>(ExplorerTabs.TIME_SERIES);
const [yAxisUnit, setYAxisUnit] = useState<string>('');
useEffect(() => {
if (units.length === 0) {
setYAxisUnit('');
} else if (units.length === 1 && units[0] !== '') {
setYAxisUnit(units[0]);
} else if (areAllMetricUnitsSame && units[0] !== '') {
setYAxisUnit(units[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(units), areAllMetricUnitsSame]);
useEffect(() => {
if (units.length > 1 && !areAllMetricUnitsSame) {
toggleShowOneChartPerQuery(true);
toggleDisableOneChartPerQuery(true);
} else {
toggleDisableOneChartPerQuery(false);
}
}, [units, areAllMetricUnitsSame]);
const handleToggleShowOneChartPerQuery = (): void => {
toggleShowOneChartPerQuery(!showOneChartPerQuery);
@@ -68,15 +117,20 @@ function Explorer(): JSX.Element {
[updateAllQueriesOperators],
);
const exportDefaultQuery = useMemo(
() =>
updateAllQueriesOperators(
currentQuery || initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
),
[currentQuery, updateAllQueriesOperators],
);
const exportDefaultQuery = useMemo(() => {
const query = updateAllQueriesOperators(
currentQuery || initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
if (yAxisUnit) {
return {
...query,
unit: yAxisUnit,
};
}
return query;
}, [currentQuery, updateAllQueriesOperators, yAxisUnit]);
useShareBuilderUrl({ defaultValue: defaultQuery });
@@ -90,8 +144,16 @@ function Explorer(): JSX.Element {
const widgetId = uuid();
let query = queryToExport || exportDefaultQuery;
if (yAxisUnit) {
query = {
...query,
unit: yAxisUnit,
};
}
const dashboardEditView = generateExportToDashboardLink({
query: queryToExport || exportDefaultQuery,
query,
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: dashboard.id,
widgetId,
@@ -99,7 +161,7 @@ function Explorer(): JSX.Element {
safeNavigate(dashboardEditView);
},
[exportDefaultQuery, safeNavigate],
[exportDefaultQuery, safeNavigate, yAxisUnit],
);
const splitedQueries = useMemo(
@@ -129,11 +191,17 @@ function Explorer(): JSX.Element {
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Switch
checked={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
size="small"
/>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title="One chart per query cannot be disabled for multiple queries with different units."
>
<Switch
checked={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery}
size="small"
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
@@ -174,6 +242,15 @@ function Explorer(): JSX.Element {
<TimeSeries
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
setIsMetricDetailsOpen={setIsMetricDetailsOpen}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
/>
)}
{/* TODO: Enable once we have resolved all related metrics issues */}
@@ -190,6 +267,14 @@ function Explorer(): JSX.Element {
isOneChartPerQuery={false}
splitedQueries={splitedQueries}
/>
{isMetricDetailsOpen && (
<MetricDetails
metricName={metricNames[0]}
isOpen={isMetricDetailsOpen}
onClose={(): void => setIsMetricDetailsOpen(false)}
isModalTimeSelection={false}
/>
)}
</Sentry.ErrorBoundary>
);
}

View File

@@ -1,15 +1,21 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Tooltip, Typography } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { isAxiosError } from 'axios';
import classNames from 'classnames';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter/BuilderUnits';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { AlertTriangle } from 'lucide-react';
import { useMemo } from 'react';
import { useQueries, useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
@@ -24,8 +30,19 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
function TimeSeries({
showOneChartPerQuery,
setWarning,
areAllMetricUnitsSame,
isMetricUnitsLoading,
isMetricUnitsError,
metricUnits,
metricNames,
metrics,
setIsMetricDetailsOpen,
yAxisUnit,
setYAxisUnit,
}: TimeSeriesProps): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder();
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
AppState,
@@ -61,8 +78,6 @@ function TimeSeries({
[showOneChartPerQuery, stagedQuery],
);
const [yAxisUnit, setYAxisUnit] = useState<string>('');
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [
@@ -83,6 +98,7 @@ function TimeSeries({
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: DataSource.METRICS,
source: 'metrics-explorer',
},
},
// ENTITY_VERSION_V4,
@@ -126,32 +142,141 @@ function TimeSeries({
setYAxisUnit(value);
};
const goToMetricDetails = (): void => {
setIsMetricDetailsOpen(true);
};
const showYAxisUnitSelector = useMemo(() => {
if (metricUnits.length <= 1) {
return true;
}
if (areAllMetricUnitsSame) {
return metricUnits[0] !== '';
}
return false;
}, [metricUnits, areAllMetricUnitsSame]);
const showSaveUnitButton = useMemo(
() =>
metricUnits.length === 1 &&
Boolean(metrics?.[0]) &&
metricUnits[0] === '' &&
yAxisUnit !== '',
[metricUnits, metrics, yAxisUnit],
);
const {
mutate: updateMetricMetadata,
isLoading: isUpdatingMetricMetadata,
} = useUpdateMetricMetadata();
const handleSaveUnit = (): void => {
updateMetricMetadata(
{
metricName: metricNames[0],
payload: {
unit: yAxisUnit,
description: metrics[0]?.metadata?.description ?? '',
metricType: metrics[0]?.metadata?.metric_type as MetricType,
temporality: metrics[0]?.metadata?.temporality,
},
},
{
onSuccess: () => {
notifications.success({
message: 'Unit saved successfully',
});
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_METRIC_DETAILS,
metricNames[0],
]);
},
onError: () => {
notifications.error({
message: 'Failed to save unit',
});
},
},
);
};
return (
<>
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
<div className="y-axis-unit-selector-container">
{showYAxisUnitSelector && (
<>
<YAxisUnitSelector
value={yAxisUnit}
onChange={onUnitChangeHandler}
loading={isMetricUnitsLoading}
disabled={isMetricUnitsLoading || isMetricUnitsError}
data-testid="metrics-explorer-y-axis-unit-selector"
/>
{showSaveUnitButton && (
<div className="save-unit-container">
<Typography.Text>
Save the selected unit for this metric?
</Typography.Text>
<Button
type="primary"
size="small"
loading={isUpdatingMetricMetadata}
onClick={handleSaveUnit}
>
Yes
</Button>
</div>
)}
</>
)}
</div>
<div
className={classNames({
'time-series-container': changeLayoutForOneChartPerQuery,
})}
>
{responseData.map((datapoint, index) => (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
yAxisUnit={yAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
))}
{responseData.map((datapoint, index) => {
const isMetricUnitEmpty =
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
metricUnits[index] === '';
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
This metric does not have a unit. Please set one for it in the{' '}
<Typography.Link onClick={goToMetricDetails}>
metric details
</Typography.Link>{' '}
drawer.
</Typography.Text>
}
>
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
yAxisUnit={yAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
</div>
</>
);

View File

@@ -15,6 +15,7 @@ import { LicenseEvent } from 'types/api/licensesV3/getActive';
import { DataSource } from 'types/common/queryBuilder';
import Explorer from '../Explorer';
import * as useGetMetricUnitsHooks from '../utils';
const mockSetSearchParams = jest.fn();
const queryClient = new QueryClient();
@@ -126,6 +127,23 @@ jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
const Y_AXIS_UNIT_SELECTOR_TEST_ID = 'metrics-explorer-y-axis-unit-selector';
const SECONDS_UNIT_LABEL = 'Seconds (s)';
function renderExplorer(): void {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('Explorer', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -142,17 +160,7 @@ describe('Explorer', () => {
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
renderExplorer();
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
initialQueriesMap[DataSource.METRICS],
@@ -167,17 +175,7 @@ describe('Explorer', () => {
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
renderExplorer();
const toggle = screen.getByRole('switch');
expect(toggle).toBeChecked();
@@ -189,19 +187,68 @@ describe('Explorer', () => {
mockSetSearchParams,
]);
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
</ErrorModalProvider>
</Provider>
</MemoryRouter>
</QueryClientProvider>,
);
renderExplorer();
const toggle = screen.getByRole('switch');
expect(toggle).not.toBeChecked();
});
it('should render pre-populated y axis unit for single metric', () => {
jest.spyOn(useGetMetricUnitsHooks, 'useGetMetricUnits').mockReturnValue({
units: ['seconds'],
isLoading: false,
isError: false,
metrics: [],
} as any);
renderExplorer();
const yAxisUnitSelector = screen.getByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).toBeInTheDocument();
expect(yAxisUnitSelector).toHaveTextContent(SECONDS_UNIT_LABEL);
});
it('should render pre-populated y axis unit for mutliple metrics with same unit', () => {
jest.spyOn(useGetMetricUnitsHooks, 'useGetMetricUnits').mockReturnValue({
units: ['seconds', 'seconds'],
isLoading: false,
isError: false,
metrics: [],
} as any);
renderExplorer();
const yAxisUnitSelector = screen.getByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).toBeInTheDocument();
expect(yAxisUnitSelector).toHaveTextContent(SECONDS_UNIT_LABEL);
});
it('should hide y axis unit selector for multiple metrics with different units', () => {
jest.spyOn(useGetMetricUnitsHooks, 'useGetMetricUnits').mockReturnValue({
units: ['seconds', 'milliseconds'],
isLoading: false,
isError: false,
metrics: [],
} as any);
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
});
it('should render empty y axis unit selector for a single metric with no unit', () => {
jest.spyOn(useGetMetricUnitsHooks, 'useGetMetricUnits').mockReturnValue({
units: [],
isLoading: false,
isError: false,
metrics: [],
} as any);
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).toBeInTheDocument();
expect(yAxisUnitSelector).toHaveTextContent('Please select a unit');
});
});

View File

@@ -1,3 +1,4 @@
import { MetricDetails } from 'api/metricsExplorer/getMetricDetails';
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query';
@@ -12,6 +13,15 @@ export enum ExplorerTabs {
export interface TimeSeriesProps {
showOneChartPerQuery: boolean;
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
areAllMetricUnitsSame: boolean;
isMetricUnitsLoading: boolean;
isMetricUnitsError: boolean;
metricUnits: string[];
metricNames: string[];
metrics: (MetricDetails | undefined)[];
setIsMetricDetailsOpen: (isOpen: boolean) => void;
yAxisUnit: string;
setYAxisUnit: (unit: string) => void;
}
export interface RelatedMetricsProps {

View File

@@ -1,3 +1,5 @@
import { MetricDetails } from 'api/metricsExplorer/getMetricDetails';
import { useGetMultipleMetrics } from 'hooks/metricsExplorer/useGetMultipleMetrics';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
@@ -35,3 +37,25 @@ export const splitQueryIntoOneChartPerQuery = (query: Query): Query[] => {
return queries;
};
export function useGetMetricUnits(
metricNames: string[],
isEnabled = true,
): {
isLoading: boolean;
units: string[];
isError: boolean;
metrics: (MetricDetails | undefined)[];
} {
const metricsData = useGetMultipleMetrics(metricNames, {
enabled: metricNames.length > 0 && isEnabled,
});
return {
isLoading: metricsData.some((metric) => metric.isLoading),
units: metricsData.map(
(metric) => metric.data?.payload?.data?.metadata?.unit ?? '',
),
metrics: metricsData.map((metric) => metric.data?.payload?.data),
isError: metricsData.some((metric) => metric.isError),
};
}

View File

@@ -132,7 +132,7 @@ function MetricDetails({
Open in Explorer
</Button>
{/* Show the based on the feature flag. Will remove before releasing the feature */}
{showInspectFeature && (
{showInspectFeature && openInspectModal && (
<Button
className="inspect-metrics-button"
aria-label="Inspect Metric"

View File

@@ -11,7 +11,7 @@ export interface MetricDetailsProps {
isOpen: boolean;
metricName: string | null;
isModalTimeSelection: boolean;
openInspectModal: (metricName: string) => void;
openInspectModal?: (metricName: string) => void;
}
export interface DashboardsAndAlertsPopoverProps {

View File

@@ -0,0 +1,35 @@
import {
getMetricDetails,
MetricDetailsResponse,
} from 'api/metricsExplorer/getMetricDetails';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
type QueryData = SuccessResponse<MetricDetailsResponse> | ErrorResponse;
type QueryResult = UseQueryResult<QueryData, Error>;
type UseGetMultipleMetrics = (
metricNames: string[],
options?: UseQueryOptions<QueryData, Error>,
headers?: Record<string, string>,
) => QueryResult[];
export const useGetMultipleMetrics: UseGetMultipleMetrics = (
metricNames,
options,
headers,
) => {
const queries = useQueries(
metricNames.map(
(metricName) =>
({
queryKey: [REACT_QUERY_KEY.GET_METRIC_DETAILS, metricName],
queryFn: ({ signal }) => getMetricDetails(metricName, signal, headers),
...options,
} as UseQueryOptions<QueryData, Error>),
),
);
return queries as QueryResult[];
};