Compare commits

...

3 Commits

Author SHA1 Message Date
Abhi Kumar
cd6cb2704b feat(dashboard-v2): group panel values into thousands
Applies `groupThousands` inside `formatPanelValue`, the single seam through
which V2 panels reach `getYAxisFormattedValue`. Large scalars now read as
`1,234,567` instead of an undelimited digit run.

Unitless values were the visible gap: they format through the `'none'` unit,
whose `Intl.NumberFormat` sets `useGrouping: false`. Units that scale their
own value never reach four integer digits, so they are unaffected.

Grouping at the seam covers every consumer — the Number panel, Table value
cells, the threshold-row previews and the table CSV export — rather than one
call site. Table sorting and threshold evaluation read raw cell values, so
neither is affected.

Closes #7669
2026-07-29 13:38:06 +05:30
Abhi Kumar
67c6f447ac fix(dashboard-v2): keep the unit split for thousand-grouped values
The numeric core of `parseFormattedValue` rejected `,`, so a grouped value
matched nothing and fell through to the whole-string fallback, losing its
prefix/suffix unit split: the Number panel would have rendered
`1,234,567 ms` as one undifferentiated blob instead of value + `ms`.

Accepts separators in the numeric core, and adds the suite this util was
missing.
2026-07-29 13:37:43 +05:30
Abhi Kumar
7f6b8abd52 feat(dashboard-v2): add a thousand-separator formatter for panel values
Introduces `groupThousands`, which inserts separators into the integer part
of an already-formatted value so large scalars read as `1,234,567`.

It groups only the first numeric token's integer digits, leaving fractions,
unit labels and the formatter's own scaling (`1.18 MiB`, `1.23 Mil`) intact,
and skips exponent notation where grouping a mantissa would read as noise.
2026-07-29 13:37:07 +05:30
8 changed files with 203 additions and 10 deletions

View File

@@ -125,6 +125,37 @@ describe('NumberPanelRenderer', () => {
expect(queryByText('3.14159')).not.toBeInTheDocument();
});
// #7669: large scalars are unreadable as an undelimited digit run.
it('groups large values into thousands', () => {
const { getByText, queryByText } = renderPanel({
panel: panelWith({}),
data: dataWith('1234567'),
});
expect(getByText('1,234,567')).toBeInTheDocument();
expect(queryByText('1234567')).not.toBeInTheDocument();
});
it('groups the value while keeping its unit separate', () => {
const { getByText } = renderPanel({
panel: panelWith({ formatting: { unit: 'percent' } }),
data: dataWith('1234567'),
});
expect(getByText('1,234,567')).toBeInTheDocument();
expect(getByText('%')).toBeInTheDocument();
});
it('leaves a unit-scaled value ungrouped', () => {
const { getByText } = renderPanel({
panel: panelWith({ formatting: { unit: 'bytes' } }),
data: dataWith('1234567'),
});
expect(getByText('1.18')).toBeInTheDocument();
expect(getByText('MiB')).toBeInTheDocument();
});
it('renders No Data when the response has no scalar results', () => {
const { getByTestId } = renderPanel({ data: emptyData });

View File

@@ -93,6 +93,15 @@ describe('TablePanelRenderer', () => {
expect(getByText('cartservice')).toBeInTheDocument();
});
// Value cells share `formatPanelValue`, so they group like the Number panel.
it('groups large value cells into thousands', () => {
const { getByText } = renderPanel({
data: dataWith([['frontend', 1234567]]),
});
expect(getByText('1,234,567')).toBeInTheDocument();
});
it('renders No Data when the response has no scalar results', () => {
const { getByTestId } = renderPanel({ data: emptyData });

View File

@@ -30,4 +30,14 @@ describe('formatPanelValue', () => {
it('renders whole numbers without a trailing decimal', () => {
expect(formatPanelValue(5, undefined, 2)).toBe('5');
});
it('groups the integer part into thousands', () => {
expect(formatPanelValue(1234567, undefined, 2)).toBe('1,234,567');
expect(formatPanelValue(1234567, 'percent', 2)).toBe('1,234,567%');
expect(formatPanelValue(1234567.891, undefined, 2)).toBe('1,234,567.89');
});
it('leaves unit-scaled values ungrouped', () => {
expect(formatPanelValue(1234567, 'bytes', 2)).toBe('1.18 MiB');
});
});

View File

@@ -0,0 +1,55 @@
import { groupThousands } from '../groupThousands';
describe('groupThousands', () => {
it('groups the integer digits of a plain number', () => {
expect(groupThousands('1234567')).toBe('1,234,567');
expect(groupThousands('1000')).toBe('1,000');
expect(groupThousands('1000000000000000000000')).toBe(
'1,000,000,000,000,000,000,000',
);
});
it('leaves values below a thousand alone', () => {
expect(groupThousands('0')).toBe('0');
expect(groupThousands('999')).toBe('999');
expect(groupThousands('295.43')).toBe('295.43');
});
it('groups only the integer part', () => {
expect(groupThousands('1234567.891')).toBe('1,234,567.891');
expect(groupThousands('1234.0001234')).toBe('1,234.0001234');
});
it('keeps the sign outside the first group', () => {
expect(groupThousands('-1234567')).toBe('-1,234,567');
expect(groupThousands('-1234567.891')).toBe('-1,234,567.891');
});
it('preserves suffix and prefix unit decoration', () => {
expect(groupThousands('1234567 ms')).toBe('1,234,567 ms');
expect(groupThousands('1234567%')).toBe('1,234,567%');
expect(groupThousands('$ 1234567')).toBe('$ 1,234,567');
});
it('leaves formatter-scaled values untouched', () => {
expect(groupThousands('1.18 MiB')).toBe('1.18 MiB');
expect(groupThousands('1.23 Mil')).toBe('1.23 Mil');
expect(groupThousands('20.58 mins')).toBe('20.58 mins');
});
it('leaves exponent notation untouched', () => {
expect(groupThousands('1.234567e+21')).toBe('1.234567e+21');
expect(groupThousands('1234567e-8')).toBe('1234567e-8');
});
it('returns non-numeric output unchanged', () => {
expect(groupThousands('∞')).toBe('∞');
expect(groupThousands('-∞')).toBe('-∞');
expect(groupThousands('NaN')).toBe('NaN');
expect(groupThousands('')).toBe('');
});
it('is idempotent on already-grouped input', () => {
expect(groupThousands(groupThousands('1234567'))).toBe('1,234,567');
});
});

View File

@@ -0,0 +1,60 @@
import { parseFormattedValue } from '../parseFormattedValue';
describe('parseFormattedValue', () => {
it('splits a trailing unit label off the numeric core', () => {
expect(parseFormattedValue('295.43 ms')).toStrictEqual({
numericValue: '295.43',
prefixUnit: '',
suffixUnit: 'ms',
});
});
it('splits a leading currency symbol off the numeric core', () => {
expect(parseFormattedValue('$ 1.2K')).toStrictEqual({
numericValue: '1.2K',
prefixUnit: '$',
suffixUnit: '',
});
});
// Regression: the numeric core used to reject `,`, so a grouped value fell
// through to the whole-string fallback and lost its unit split.
it('keeps the unit split for grouped values', () => {
expect(parseFormattedValue('1,234,567 ms')).toStrictEqual({
numericValue: '1,234,567',
prefixUnit: '',
suffixUnit: 'ms',
});
expect(parseFormattedValue('1,234,567%')).toStrictEqual({
numericValue: '1,234,567',
prefixUnit: '',
suffixUnit: '%',
});
expect(parseFormattedValue('$ 1,234,567.89')).toStrictEqual({
numericValue: '1,234,567.89',
prefixUnit: '$',
suffixUnit: '',
});
});
it('treats a unitless value as the numeric core', () => {
expect(parseFormattedValue('1,234,567')).toStrictEqual({
numericValue: '1,234,567',
prefixUnit: '',
suffixUnit: '',
});
});
it('falls back to the whole string when nothing matches', () => {
expect(parseFormattedValue('∞')).toStrictEqual({
numericValue: '∞',
prefixUnit: '',
suffixUnit: '',
});
expect(parseFormattedValue('NaN')).toStrictEqual({
numericValue: 'NaN',
prefixUnit: '',
suffixUnit: '',
});
});
});

View File

@@ -1,16 +1,21 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { groupThousands } from './groupThousands';
/**
* Formats a scalar for display in a V2 panel, honoring decimal precision. The
* single seam through which panels touch `getYAxisFormattedValue`. Unitless
* values format through the `'none'` unit, which still respects precision — so
* precision isn't silently dropped when no unit is set.
* Formats a scalar for display in a V2 panel, honoring decimal precision and
* grouping the integer part into thousands. The single seam through which panels
* touch `getYAxisFormattedValue`. Unitless values format through the `'none'`
* unit, which still respects precision — so precision isn't silently dropped
* when no unit is set.
*/
export function formatPanelValue(
value: number,
unit?: string,
precision?: PrecisionOption,
): string {
return getYAxisFormattedValue(String(value), unit || 'none', precision);
return groupThousands(
getYAxisFormattedValue(String(value), unit || 'none', precision),
);
}

View File

@@ -0,0 +1,22 @@
const THOUSANDS_BOUNDARY = /(\d)(?=(?:\d{3})+$)/g;
/** Leading number of a formatted value; unit decoration falls outside the match. */
const NUMERIC_TOKEN = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
/**
* Inserts thousand separators into the integer part of an already-formatted value,
* so large scalars read as `1,234,567`. Fractions, unit labels and formatter-scaled
* values (`1.18 MiB`) are left as-is, as is exponent notation.
*/
export function groupThousands(formatted: string): string {
return formatted.replace(NUMERIC_TOKEN, (token) => {
if (token.includes('e') || token.includes('E')) {
return token;
}
const [integerPart, fraction] = token.split('.');
const grouped = integerPart.replace(THOUSANDS_BOUNDARY, '$1,');
return fraction === undefined ? grouped : `${grouped}.${fraction}`;
});
}

View File

@@ -1,5 +1,5 @@
export interface ParsedFormattedValue {
/** The numeric portion (e.g. "295.43", "1.2K"). */
/** The numeric portion (e.g. "295.43", "1,234,567", "1.2K"). */
numericValue: string;
/** A leading unit symbol such as a currency prefix, if any. */
prefixUnit: string;
@@ -8,13 +8,14 @@ export interface ParsedFormattedValue {
}
/**
* Splits a formatted value (e.g. "$ 1.2K", "295.43 ms") into its numeric core
* and prefix/suffix unit for independent styling. Non-matching input falls back
* to the whole string as the numeric value.
* Splits a formatted value (e.g. "$ 1.2K", "295.43 ms", "1,234,567") into its
* numeric core and prefix/suffix unit for independent styling. The core accepts
* thousand separators, so a grouped value keeps its unit split. Non-matching
* input falls back to the whole string as the numeric value.
*/
export function parseFormattedValue(value: string): ParsedFormattedValue {
const matches = value.match(
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
/^([^\d.]*)?([\d.,]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
);
return {