Alerting: Auto-format numeric values in Alert Rule History (#115708)
* Add helper function to format numeric values in alert rule history * Use formatting function in LogRecordViewer * Refactor numerical formatting logic * Handle edge cases when counting decimal places * Cleanup tests and numberFormatter code
This commit is contained in:
+72
@@ -60,4 +60,76 @@ describe('LogRecordViewerByTimestamp', () => {
|
||||
expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument();
|
||||
expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Numeric Value Formatting', () => {
|
||||
it('should format numeric values correctly in AlertInstanceValues', () => {
|
||||
const records: LogRecord[] = [
|
||||
{
|
||||
timestamp: 1681739580000,
|
||||
line: {
|
||||
current: 'Alerting',
|
||||
previous: 'Pending',
|
||||
labels: {},
|
||||
values: {
|
||||
cpu_usage: 42.987654321,
|
||||
memory_mb: 1234567.89,
|
||||
disk_io: 0.001234,
|
||||
request_count: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
|
||||
|
||||
expect(screen.getByText(/cpu_usage/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/memory_mb/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/disk_io/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/request_count/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format various numeric ranges correctly', () => {
|
||||
const records: LogRecord[] = [
|
||||
{
|
||||
timestamp: 1681739580000,
|
||||
line: {
|
||||
current: 'Alerting',
|
||||
previous: 'Pending',
|
||||
labels: {},
|
||||
values: {
|
||||
small: 0.001,
|
||||
normal: 42.5,
|
||||
large: 123456,
|
||||
boundary_low: 0.01,
|
||||
boundary_high: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
|
||||
|
||||
expect(screen.getByText(/small/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/normal/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/42\.5/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/large/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/boundary_low/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/0\.01/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/boundary_high/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ import { AlertStateTag } from '../AlertStateTag';
|
||||
|
||||
import { ErrorMessageRow } from './ErrorMessageRow';
|
||||
import { LogRecord, omitLabels } from './common';
|
||||
import { formatNumericValue } from './numberFormatter';
|
||||
|
||||
type LogRecordViewerProps = {
|
||||
records: LogRecord[];
|
||||
@@ -182,7 +183,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record<string, number> }
|
||||
return (
|
||||
<>
|
||||
{values.map(([key, value]) => (
|
||||
<AlertLabel key={key} labelKey={key} value={String(value)} />
|
||||
<AlertLabel key={key} labelKey={key} value={formatNumericValue(value)} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { formatNumericValue } from './numberFormatter';
|
||||
|
||||
describe('formatNumericValue', () => {
|
||||
describe('Zero and special values', () => {
|
||||
it('should format zero correctly', () => {
|
||||
expect(formatNumericValue(0)).toBe('0');
|
||||
expect(formatNumericValue(-0)).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle NaN', () => {
|
||||
expect(formatNumericValue(NaN)).toBe('NaN');
|
||||
});
|
||||
|
||||
it('should handle Infinity', () => {
|
||||
expect(formatNumericValue(Infinity)).toBe('Infinity');
|
||||
expect(formatNumericValue(-Infinity)).toBe('-Infinity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very small numbers (scientific notation)', () => {
|
||||
it('should use scientific notation for values less than 1e-2', () => {
|
||||
const result1 = formatNumericValue(1e-3);
|
||||
expect(result1).toMatch(/^1\.000e-3$/i);
|
||||
|
||||
const result2 = formatNumericValue(0.001);
|
||||
expect(result2).toMatch(/^1\.000e-3$/i);
|
||||
|
||||
const result3 = formatNumericValue(0.009);
|
||||
expect(result3).toMatch(/^9\.000e-3$/i);
|
||||
});
|
||||
|
||||
it('should use scientific notation for values just below 1e-2', () => {
|
||||
const result = formatNumericValue(0.00999);
|
||||
expect(result).toMatch(/^9\.990e-3$/i);
|
||||
});
|
||||
|
||||
it('should format the example from requirements correctly', () => {
|
||||
// 1.4153928131348452 has > 4 decimal places, so should use scientific notation
|
||||
const result = formatNumericValue(1.4153928131348452);
|
||||
expect(result).toMatch(/^1\.415e\+0$/i);
|
||||
});
|
||||
|
||||
it('should handle negative very small numbers', () => {
|
||||
const result = formatNumericValue(-1e-3);
|
||||
expect(result).toMatch(/^-1\.000e-3$/i);
|
||||
|
||||
const result2 = formatNumericValue(-0.001);
|
||||
expect(result2).toMatch(/^-1\.000e-3$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Human-readable range (standard notation)', () => {
|
||||
it('should use standard notation for boundary value 1e-2', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
});
|
||||
|
||||
it('should use standard notation for values in readable range', () => {
|
||||
expect(formatNumericValue(0.1)).toBe('0.1');
|
||||
expect(formatNumericValue(1)).toBe('1');
|
||||
expect(formatNumericValue(1.234)).toBe('1.234');
|
||||
expect(formatNumericValue(42.5)).toBe('42.5');
|
||||
});
|
||||
|
||||
it('should limit to 4 decimal places without rounding integer parts', () => {
|
||||
expect(formatNumericValue(123.456)).toBe('123.456');
|
||||
expect(formatNumericValue(1234.567)).toBe('1234.567');
|
||||
expect(formatNumericValue(9999.9)).toBe('9999.9');
|
||||
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
|
||||
});
|
||||
|
||||
it('should use scientific notation for numbers with more than 4 decimal places', () => {
|
||||
// Numbers with > 4 decimals should use scientific notation even in readable range
|
||||
const result1 = formatNumericValue(123.456789);
|
||||
expect(result1).toMatch(/^1\.235e\+2$/i);
|
||||
|
||||
const result2 = formatNumericValue(1.23456789);
|
||||
expect(result2).toMatch(/^1\.235e\+0$/i);
|
||||
|
||||
const result3 = formatNumericValue(42.987654321);
|
||||
expect(result3).toMatch(/^4\.299e\+1$/i);
|
||||
});
|
||||
|
||||
it('should use standard notation for boundary value 1e4', () => {
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
});
|
||||
|
||||
it('should handle negative numbers in readable range', () => {
|
||||
expect(formatNumericValue(-0.1)).toBe('-0.1');
|
||||
expect(formatNumericValue(-123.456)).toBe('-123.456');
|
||||
expect(formatNumericValue(-9999.9)).toBe('-9999.9');
|
||||
});
|
||||
|
||||
it('should use scientific notation for negative numbers with excessive precision', () => {
|
||||
const result = formatNumericValue(-42.987654321);
|
||||
expect(result).toMatch(/^-4\.299e\+1$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very large numbers (scientific notation)', () => {
|
||||
it('should use scientific notation for values greater than 1e4', () => {
|
||||
const result1 = formatNumericValue(10001);
|
||||
expect(result1).toMatch(/^1\.000e\+4$/i);
|
||||
|
||||
const result2 = formatNumericValue(123456);
|
||||
expect(result2).toMatch(/^1\.235e\+5$/i);
|
||||
});
|
||||
|
||||
it('should handle negative very large numbers', () => {
|
||||
const result = formatNumericValue(-1e5);
|
||||
expect(result).toMatch(/^-1\.000e\+5$/i);
|
||||
|
||||
const result2 = formatNumericValue(-123456);
|
||||
expect(result2).toMatch(/^-1\.235e\+5$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle numbers exactly at boundaries', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
|
||||
const justBelow = formatNumericValue(0.009999);
|
||||
expect(justBelow).toMatch(/^9\.999e-3$/i);
|
||||
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
|
||||
const justAbove = formatNumericValue(10001);
|
||||
expect(justAbove).toMatch(/^1\.000e\+4$/i);
|
||||
});
|
||||
|
||||
it('should use scientific notation for very precise decimals with > 4 decimal places', () => {
|
||||
expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i);
|
||||
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
|
||||
expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i);
|
||||
});
|
||||
|
||||
it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => {
|
||||
expect(formatNumericValue(1.2345)).toBe('1.2345');
|
||||
expect(formatNumericValue(0.1234)).toBe('0.1234');
|
||||
expect(formatNumericValue(123.4567)).toBe('123.4567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countDecimalPlaces edge cases', () => {
|
||||
it('should handle numbers that toString() would convert to scientific notation', () => {
|
||||
const result = formatNumericValue(1e-10);
|
||||
expect(result).toMatch(/^1\.000e-10$/i);
|
||||
|
||||
const result2 = formatNumericValue(1e10);
|
||||
expect(result2).toMatch(/^1\.000e\+10$/i);
|
||||
});
|
||||
|
||||
it('should correctly count decimals for numbers with trailing zeros', () => {
|
||||
expect(formatNumericValue(1.234)).toBe('1.234');
|
||||
expect(formatNumericValue(1.2)).toBe('1.2');
|
||||
expect(formatNumericValue(1.0)).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle boundary values correctly', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
|
||||
expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i);
|
||||
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
|
||||
expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i);
|
||||
});
|
||||
|
||||
it('should handle numbers in readable range that have many decimals', () => {
|
||||
expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i);
|
||||
expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i);
|
||||
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2;
|
||||
const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4;
|
||||
const MAX_DECIMAL_PLACES = 4;
|
||||
const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals
|
||||
|
||||
const readableRangeFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: MAX_DECIMAL_PLACES,
|
||||
useGrouping: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Counts the number of decimal places in a number.
|
||||
* Only processes numbers in readable range (1e-2 to 1e4) to avoid
|
||||
* toString() scientific notation issues for very large/small numbers.
|
||||
*
|
||||
* Uses toFixed(10) to ensure standard notation representation.
|
||||
* 10 decimal places is sufficient to detect if a number has > 4 decimal places.
|
||||
*/
|
||||
function countDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
// Only count decimals for numbers in readable range
|
||||
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const str = value.toFixed(10);
|
||||
const decimalIndex = str.indexOf('.');
|
||||
|
||||
if (decimalIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Count decimal places, removing trailing zeros
|
||||
const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, '');
|
||||
return decimalPart.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a numeric value for display in alert rule history.
|
||||
* - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places
|
||||
* - For very small values (< 1e-2): uses scientific notation with 4 significant digits
|
||||
* - For very large values (> 1e4): uses scientific notation with 4 significant digits
|
||||
* - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits
|
||||
*
|
||||
* @param value - The number to format
|
||||
* @returns A formatted string representation of the number
|
||||
*/
|
||||
export function formatNumericValue(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
|
||||
return value.toExponential(EXPONENTIAL_DECIMALS);
|
||||
}
|
||||
|
||||
const decimalPlaces = countDecimalPlaces(value);
|
||||
|
||||
if (decimalPlaces > MAX_DECIMAL_PLACES) {
|
||||
return value.toExponential(EXPONENTIAL_DECIMALS);
|
||||
}
|
||||
|
||||
return readableRangeFormatter.format(value);
|
||||
}
|
||||
Reference in New Issue
Block a user