more units for charts (#2004)

Co-authored-by: Drew Davis <drew.davis@clickhouse.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Dale McDiarmid 2026-04-02 16:16:57 +01:00 committed by GitHub
parent 91d950fc2e
commit b7581db806
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 686 additions and 32 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Add more chart display units

View file

@ -519,9 +519,11 @@
"percent",
"byte",
"time",
"number"
"number",
"data_rate",
"throughput"
],
"description": "Output format type (currency, percent, byte, time, number)."
"description": "Output format type (currency, percent, byte, time, number, data_rate, throughput)."
},
"AggregationFunction": {
"type": "string",
@ -644,6 +646,62 @@
"description": "Currency symbol for currency format.",
"example": "$"
},
"numericUnit": {
"type": "string",
"enum": [
"bytes_iec",
"bytes_si",
"bits_iec",
"bits_si",
"kibibytes",
"kilobytes",
"mebibytes",
"megabytes",
"gibibytes",
"gigabytes",
"tebibytes",
"terabytes",
"pebibytes",
"petabytes",
"packets_sec",
"bytes_sec_iec",
"bytes_sec_si",
"bits_sec_iec",
"bits_sec_si",
"kibibytes_sec",
"kibibits_sec",
"kilobytes_sec",
"kilobits_sec",
"mebibytes_sec",
"mebibits_sec",
"megabytes_sec",
"megabits_sec",
"gibibytes_sec",
"gibibits_sec",
"gigabytes_sec",
"gigabits_sec",
"tebibytes_sec",
"tebibits_sec",
"terabytes_sec",
"terabits_sec",
"pebibytes_sec",
"pebibits_sec",
"petabytes_sec",
"petabits_sec",
"cps",
"ops",
"rps",
"reads_sec",
"wps",
"iops",
"cpm",
"opm",
"rpm_reads",
"wpm"
],
"description": "Numeric unit for data, data rate, or throughput formats.",
"example": "bytes_iec"
},
"unit": {
"type": "string",
"description": "Custom unit label.",

View file

@ -185,8 +185,8 @@ const updateDashboardBodySchema = buildDashboardBodySchema(
* schemas:
* NumberFormatOutput:
* type: string
* enum: [currency, percent, byte, time, number]
* description: Output format type (currency, percent, byte, time, number).
* enum: [currency, percent, byte, time, number, data_rate, throughput]
* description: Output format type (currency, percent, byte, time, number, data_rate, throughput).
* AggregationFunction:
* type: string
* enum: [avg, count, count_distinct, last_value, max, min, quantile, sum, any, none]
@ -256,6 +256,11 @@ const updateDashboardBodySchema = buildDashboardBodySchema(
* type: string
* description: Currency symbol for currency format.
* example: "$"
* numericUnit:
* type: string
* enum: [bytes_iec, bytes_si, bits_iec, bits_si, kibibytes, kilobytes, mebibytes, megabytes, gibibytes, gigabytes, tebibytes, terabytes, pebibytes, petabytes, packets_sec, bytes_sec_iec, bytes_sec_si, bits_sec_iec, bits_sec_si, kibibytes_sec, kibibits_sec, kilobytes_sec, kilobits_sec, mebibytes_sec, mebibits_sec, megabytes_sec, megabits_sec, gibibytes_sec, gibibits_sec, gigabytes_sec, gigabits_sec, tebibytes_sec, tebibits_sec, terabytes_sec, terabits_sec, pebibytes_sec, pebibits_sec, petabytes_sec, petabits_sec, cps, ops, rps, reads_sec, wps, iops, cpm, opm, rpm_reads, wpm]
* description: Numeric unit for data, data rate, or throughput formats.
* example: "bytes_iec"
* unit:
* type: string
* description: Custom unit label.

View file

@ -1,4 +1,4 @@
import { TSource } from '@hyperdx/common-utils/dist/types';
import { NumericUnit, TSource } from '@hyperdx/common-utils/dist/types';
import { SortingState } from '@tanstack/react-table';
import { act, renderHook } from '@testing-library/react';
@ -377,6 +377,209 @@ describe('formatNumber', () => {
});
});
describe('numericUnit with data output (byte)', () => {
it('formats with fixed unit suffix', () => {
expect(
formatNumber(500, {
output: 'byte',
numericUnit: NumericUnit.Kibibytes,
}),
).toBe('500 KiB');
expect(
formatNumber(500, {
output: 'byte',
numericUnit: NumericUnit.Megabytes,
mantissa: 1,
}),
).toBe('500.0 MB');
});
it('auto-scales IEC bytes', () => {
expect(
formatNumber(0, {
output: 'byte',
numericUnit: NumericUnit.BytesIEC,
}),
).toBe('0 B');
expect(
formatNumber(1024, {
output: 'byte',
numericUnit: NumericUnit.BytesIEC,
}),
).toBe('1 KiB');
expect(
formatNumber(1048576, {
output: 'byte',
numericUnit: NumericUnit.BytesIEC,
mantissa: 2,
}),
).toBe('1.00 MiB');
});
it('auto-scales SI bytes', () => {
expect(
formatNumber(1000, {
output: 'byte',
numericUnit: NumericUnit.BytesSI,
}),
).toBe('1 KB');
expect(
formatNumber(1000000, {
output: 'byte',
numericUnit: NumericUnit.BytesSI,
}),
).toBe('1 MB');
});
it('auto-scales IEC bits', () => {
expect(
formatNumber(1024, {
output: 'byte',
numericUnit: NumericUnit.BitsIEC,
}),
).toBe('1 Kibit');
});
it('auto-scales SI bits', () => {
expect(
formatNumber(1000, {
output: 'byte',
numericUnit: NumericUnit.BitsSI,
}),
).toBe('1 Kbit');
});
it('handles negative values in auto-scale', () => {
expect(
formatNumber(-1024, {
output: 'byte',
numericUnit: NumericUnit.BytesIEC,
}),
).toBe('-1 KiB');
expect(
formatNumber(-1500000, {
output: 'byte',
numericUnit: NumericUnit.BytesSI,
mantissa: 2,
}),
).toBe('-1.50 MB');
});
it('falls back to numbro for byte output without numericUnit', () => {
// Without numericUnit, the legacy numbro byte formatting is used
expect(formatNumber(1024, { output: 'byte', decimalBytes: false })).toBe(
'1 KB',
);
});
});
describe('numericUnit with data_rate output', () => {
it('formats fixed data rate units', () => {
expect(
formatNumber(42, {
output: 'data_rate',
numericUnit: NumericUnit.PacketsSec,
}),
).toBe('42 pkt/s');
expect(
formatNumber(100, {
output: 'data_rate',
numericUnit: NumericUnit.KilobytesSec,
mantissa: 1,
}),
).toBe('100.0 KB/s');
});
it('auto-scales data rate (IEC bytes/s)', () => {
expect(
formatNumber(1024, {
output: 'data_rate',
numericUnit: NumericUnit.BytesSecIEC,
}),
).toBe('1 KiB/s');
});
it('auto-scales data rate (SI bits/s)', () => {
expect(
formatNumber(1000, {
output: 'data_rate',
numericUnit: NumericUnit.BitsSecSI,
}),
).toBe('1 Kbit/s');
});
it('falls back to plain toFixed for data_rate without numericUnit', () => {
expect(formatNumber(1234.567, { output: 'data_rate', mantissa: 2 })).toBe(
'1234.57',
);
});
it('handles string-type numeric values', () => {
expect(
formatNumber('500', {
output: 'byte',
numericUnit: NumericUnit.Kibibytes,
}),
).toBe('500 KiB');
expect(
formatNumber('1024', {
output: 'data_rate',
numericUnit: NumericUnit.BytesSecIEC,
}),
).toBe('1 KiB/s');
});
});
describe('numericUnit with throughput output', () => {
it('formats fixed throughput units', () => {
expect(
formatNumber(100, {
output: 'throughput',
numericUnit: NumericUnit.Rps,
}),
).toBe('100 rps');
expect(
formatNumber(50, {
output: 'throughput',
numericUnit: NumericUnit.Iops,
}),
).toBe('50 iops');
expect(
formatNumber(200, {
output: 'throughput',
numericUnit: NumericUnit.Opm,
mantissa: 1,
}),
).toBe('200.0 opm');
});
it('falls back to plain toFixed for throughput without numericUnit', () => {
expect(formatNumber(9999, { output: 'throughput' })).toBe('9999');
});
});
describe('numericUnit ignored for non-data outputs', () => {
it('ignores numericUnit for number output', () => {
// numericUnit is only checked for byte/data_rate/throughput
expect(
formatNumber(1024, {
output: 'number',
numericUnit: NumericUnit.BytesIEC,
}),
).toBe('1024');
});
it('ignores numericUnit for percent output', () => {
expect(
formatNumber(0.5, {
output: 'percent',
numericUnit: NumericUnit.BytesIEC,
}),
).toBe('50%');
});
});
describe('NaN handling', () => {
it('returns "N/A" for NaN without options', () => {
expect(formatNumber(NaN)).toBe('N/A');
@ -384,11 +587,9 @@ describe('formatNumber', () => {
});
it('returns a string unchanged if a number cannot be parsed from it', () => {
// @ts-expect-error not passing a number
expect(formatNumber('not a number')).toBe('not a number');
expect(
// @ts-expect-error not passing a number
formatNumber('not a number', { output: 'number', mantissa: 2 }),
).toBe('not a number');
});

View file

@ -128,7 +128,7 @@ export default function ChartDisplaySettingsDrawer({
</>
)}
<NumberFormatForm control={control} />
<NumberFormatForm control={control} setValue={setValue} />
<Divider />
<Group gap="xs" mt="xs" justify="space-between">
<Button type="submit" variant="secondary" onClick={resetToDefaults}>

View file

@ -1,7 +1,12 @@
import * as React from 'react';
import { useMemo } from 'react';
import { Control, Controller, useWatch } from 'react-hook-form';
import { NumberFormat } from '@hyperdx/common-utils/dist/types';
import {
Control,
Controller,
UseFormSetValue,
useWatch,
} from 'react-hook-form';
import { NumberFormat, NumericUnit } from '@hyperdx/common-utils/dist/types';
import {
Checkbox as MCheckbox,
NativeSelect,
@ -28,6 +33,8 @@ const FORMAT_ICONS: Record<string, React.ReactNode> = {
percent: <IconPercentage size={14} />,
byte: <IconDatabase size={14} />,
time: <IconClock size={14} />,
data_rate: <IconDatabase size={14} />,
throughput: <IconNumbers size={14} />,
};
const TEST_NUMBER = 1234;
@ -41,12 +48,121 @@ export const DEFAULT_NUMBER_FORMAT: NumberFormat = {
decimalBytes: false,
};
type UnitOption = { value: NumericUnit; label: string };
type OutputOption = { value: NumberFormat['output']; label: string };
type OutputGroup = { group: string; items: OutputOption[] };
const DATA_UNIT_OPTIONS: UnitOption[] = [
{ value: NumericUnit.BytesIEC, label: 'bytes (IEC)' },
{ value: NumericUnit.BytesSI, label: 'bytes (SI)' },
{ value: NumericUnit.BitsIEC, label: 'bits (IEC)' },
{ value: NumericUnit.BitsSI, label: 'bits (SI)' },
{ value: NumericUnit.Kibibytes, label: 'kibibytes' },
{ value: NumericUnit.Kilobytes, label: 'kilobytes' },
{ value: NumericUnit.Mebibytes, label: 'mebibytes' },
{ value: NumericUnit.Megabytes, label: 'megabytes' },
{ value: NumericUnit.Gibibytes, label: 'gibibytes' },
{ value: NumericUnit.Gigabytes, label: 'gigabytes' },
{ value: NumericUnit.Tebibytes, label: 'tebibytes' },
{ value: NumericUnit.Terabytes, label: 'terabytes' },
{ value: NumericUnit.Pebibytes, label: 'pebibytes' },
{ value: NumericUnit.Petabytes, label: 'petabytes' },
];
const DATA_RATE_UNIT_OPTIONS: UnitOption[] = [
{ value: NumericUnit.PacketsSec, label: 'packets/sec' },
{ value: NumericUnit.BytesSecIEC, label: 'bytes/sec (IEC)' },
{ value: NumericUnit.BytesSecSI, label: 'bytes/sec (SI)' },
{ value: NumericUnit.BitsSecIEC, label: 'bits/sec (IEC)' },
{ value: NumericUnit.BitsSecSI, label: 'bits/sec (SI)' },
{ value: NumericUnit.KibibytesSec, label: 'kibibytes/sec' },
{ value: NumericUnit.KibibitsSec, label: 'kibibits/sec' },
{ value: NumericUnit.KilobytesSec, label: 'kilobytes/sec' },
{ value: NumericUnit.KilobitsSec, label: 'kilobits/sec' },
{ value: NumericUnit.MebibytesSec, label: 'mebibytes/sec' },
{ value: NumericUnit.MebibitsSec, label: 'mebibits/sec' },
{ value: NumericUnit.MegabytesSec, label: 'megabytes/sec' },
{ value: NumericUnit.MegabitsSec, label: 'megabits/sec' },
{ value: NumericUnit.GibibytesSec, label: 'gibibytes/sec' },
{ value: NumericUnit.GibibitsSec, label: 'gibibits/sec' },
{ value: NumericUnit.GigabytesSec, label: 'gigabytes/sec' },
{ value: NumericUnit.GigabitsSec, label: 'gigabits/sec' },
{ value: NumericUnit.TebibytesSec, label: 'tebibytes/sec' },
{ value: NumericUnit.TebibitsSec, label: 'tebibits/sec' },
{ value: NumericUnit.TerabytesSec, label: 'terabytes/sec' },
{ value: NumericUnit.TerabitsSec, label: 'terabits/sec' },
{ value: NumericUnit.PebibytesSec, label: 'pebibytes/sec' },
{ value: NumericUnit.PebibitsSec, label: 'pebibits/sec' },
{ value: NumericUnit.PetabytesSec, label: 'petabytes/sec' },
{ value: NumericUnit.PetabitsSec, label: 'petabits/sec' },
];
const THROUGHPUT_UNIT_OPTIONS: UnitOption[] = [
{ value: NumericUnit.Cps, label: 'counts/sec (cps)' },
{ value: NumericUnit.Ops, label: 'ops/sec (ops)' },
{ value: NumericUnit.Rps, label: 'requests/sec (rps)' },
{ value: NumericUnit.ReadsSec, label: 'reads/sec (rps)' },
{ value: NumericUnit.Wps, label: 'writes/sec (wps)' },
{ value: NumericUnit.Iops, label: 'I/O ops/sec (iops)' },
{ value: NumericUnit.Cpm, label: 'counts/min (cpm)' },
{ value: NumericUnit.Opm, label: 'ops/min (opm)' },
{ value: NumericUnit.RpmReads, label: 'reads/min (rpm)' },
{ value: NumericUnit.Wpm, label: 'writes/min (wpm)' },
];
const UNIT_OPTIONS_BY_OUTPUT: Record<string, UnitOption[]> = {
byte: DATA_UNIT_OPTIONS,
data_rate: DATA_RATE_UNIT_OPTIONS,
throughput: THROUGHPUT_UNIT_OPTIONS,
};
const DEFAULT_NUMERIC_UNIT_BY_OUTPUT: Partial<
Record<NumberFormat['output'], NumericUnit>
> = {
byte: NumericUnit.BytesIEC,
data_rate: NumericUnit.BytesSecIEC,
throughput: NumericUnit.Cps,
};
const OUTPUT_CATEGORY_OPTIONS: OutputGroup[] = [
{
group: 'Basic',
items: [
{ value: 'number', label: 'Number' },
{ value: 'currency', label: 'Currency' },
{ value: 'percent', label: 'Percentage' },
{ value: 'time', label: 'Time' },
],
},
{
group: 'Data',
items: [{ value: 'byte', label: 'Data' }],
},
{
group: 'Network',
items: [
{ value: 'data_rate', label: 'Data rate' },
{ value: 'throughput', label: 'Throughput' },
],
},
];
const hasNumericUnit = (output: string) =>
output === 'byte' || output === 'data_rate' || output === 'throughput';
export const NumberFormatForm: React.FC<{
control: Control<ChartConfigDisplaySettings>;
}> = ({ control }) => {
setValue: UseFormSetValue<ChartConfigDisplaySettings>;
}> = ({ control, setValue }) => {
const format =
useWatch({ control, name: 'numberFormat' }) ?? DEFAULT_NUMBER_FORMAT;
const unitOptions = useMemo(
() =>
format.output ? (UNIT_OPTIONS_BY_OUTPUT[format.output] ?? null) : null,
[format.output],
);
return (
<>
<Stack style={{ flex: 1 }}>
@ -63,19 +179,22 @@ export const NumberFormatForm: React.FC<{
control={control}
key="numberFormat.output"
name="numberFormat.output"
render={({ field }) => (
render={({ field: { onChange, ...field } }) => (
<NativeSelect
{...field}
label="Output format"
leftSection={format.output && FORMAT_ICONS[format.output]}
style={{ flex: 1 }}
data={[
{ value: 'number', label: 'Number' },
{ value: 'currency', label: 'Currency' },
{ value: 'byte', label: 'Bytes' },
{ value: 'percent', label: 'Percentage' },
{ value: 'time', label: 'Time' },
]}
data={OUTPUT_CATEGORY_OPTIONS}
onChange={e => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newOutput = e.target.value as NumberFormat['output'];
onChange(newOutput);
setValue(
'numberFormat.numericUnit',
DEFAULT_NUMERIC_UNIT_BY_OUTPUT[newOutput] ?? undefined,
);
}}
/>
)}
/>
@ -91,6 +210,25 @@ export const NumberFormatForm: React.FC<{
)}
</div>
{unitOptions && (
<Controller
control={control}
key="numberFormat.numericUnit"
name="numberFormat.numericUnit"
render={({ field: { value, onChange, ...field } }) => (
<NativeSelect
{...field}
label="Unit"
value={
value ?? DEFAULT_NUMERIC_UNIT_BY_OUTPUT[format.output ?? '']
}
onChange={e => onChange(e.target.value)}
data={unitOptions}
/>
)}
/>
)}
<div style={{ marginTop: -6 }}>
<Paper p="xs" py={4}>
<div
@ -100,7 +238,14 @@ export const NumberFormatForm: React.FC<{
>
Example
</div>
{formatNumber(TEST_NUMBER || 0, format)}
{formatNumber(TEST_NUMBER || 0, {
...format,
numericUnit:
format.numericUnit ??
(format.output
? DEFAULT_NUMERIC_UNIT_BY_OUTPUT[format.output]
: undefined),
})}
</Paper>
</div>
@ -128,8 +273,9 @@ export const NumberFormatForm: React.FC<{
/>
</div>
)}
<Stack gap="xs">
{format.output === 'byte' ? (
{format.output === 'byte' && !format.numericUnit ? (
<Controller
control={control}
key="numberFormat.decimalBytes"
@ -176,7 +322,7 @@ export const NumberFormatForm: React.FC<{
);
}}
/>
) : (
) : !hasNumericUnit(format.output ?? '') ? (
<>
<Controller
control={control}
@ -209,7 +355,7 @@ export const NumberFormatForm: React.FC<{
)}
/>
</>
)}
) : null}
</Stack>
</Stack>
</>

View file

@ -34,7 +34,7 @@ import { usePrevious } from './utils';
const LIVE_TAIL_TIME_QUERY = 'Live Tail';
const LIVE_TAIL_REFRESH_INTERVAL_MS = 1000;
export const dateRangeToString = (range: [Date, Date], isUTC: boolean) => {
const dateRangeToString = (range: [Date, Date], isUTC: boolean) => {
return `${formatDate(range[0], {
isUTC,
format: 'normal',

View file

@ -2,17 +2,16 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import { formatDistanceToNowStrict } from 'date-fns';
import numbro from 'numbro';
import type { MutableRefObject, SetStateAction } from 'react';
import type { SetStateAction } from 'react';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import {
NumericUnit,
SourceKind,
TMetricSource,
TSource,
} from '@hyperdx/common-utils/dist/types';
import { SortingState } from '@tanstack/react-table';
import { NOW } from './config';
import { dateRangeToString } from './timeQuery';
import { MetricsDataType, NumberFormat } from './types';
export function omit<T extends object, K extends keyof T>(
@ -648,8 +647,150 @@ export const usePrevious = <T>(value: T): T | undefined => {
return ref.current;
};
type AutoScaleUnitConfig = {
type: 'auto_scale';
base: 'iec' | 'si';
isBits: boolean;
perSec: boolean;
};
type FixedUnitConfig = {
type: 'fixed';
suffix: string;
};
type UnitFormatConfig = AutoScaleUnitConfig | FixedUnitConfig;
const NUMERIC_UNIT_CONFIGS: Record<NumericUnit, UnitFormatConfig> = {
// Data
[NumericUnit.BytesIEC]: {
type: 'auto_scale',
base: 'iec',
isBits: false,
perSec: false,
},
[NumericUnit.BytesSI]: {
type: 'auto_scale',
base: 'si',
isBits: false,
perSec: false,
},
[NumericUnit.BitsIEC]: {
type: 'auto_scale',
base: 'iec',
isBits: true,
perSec: false,
},
[NumericUnit.BitsSI]: {
type: 'auto_scale',
base: 'si',
isBits: true,
perSec: false,
},
[NumericUnit.Kibibytes]: { type: 'fixed', suffix: 'KiB' },
[NumericUnit.Kilobytes]: { type: 'fixed', suffix: 'KB' },
[NumericUnit.Mebibytes]: { type: 'fixed', suffix: 'MiB' },
[NumericUnit.Megabytes]: { type: 'fixed', suffix: 'MB' },
[NumericUnit.Gibibytes]: { type: 'fixed', suffix: 'GiB' },
[NumericUnit.Gigabytes]: { type: 'fixed', suffix: 'GB' },
[NumericUnit.Tebibytes]: { type: 'fixed', suffix: 'TiB' },
[NumericUnit.Terabytes]: { type: 'fixed', suffix: 'TB' },
[NumericUnit.Pebibytes]: { type: 'fixed', suffix: 'PiB' },
[NumericUnit.Petabytes]: { type: 'fixed', suffix: 'PB' },
// Data Rate
[NumericUnit.PacketsSec]: { type: 'fixed', suffix: 'pkt/s' },
[NumericUnit.BytesSecIEC]: {
type: 'auto_scale',
base: 'iec',
isBits: false,
perSec: true,
},
[NumericUnit.BytesSecSI]: {
type: 'auto_scale',
base: 'si',
isBits: false,
perSec: true,
},
[NumericUnit.BitsSecIEC]: {
type: 'auto_scale',
base: 'iec',
isBits: true,
perSec: true,
},
[NumericUnit.BitsSecSI]: {
type: 'auto_scale',
base: 'si',
isBits: true,
perSec: true,
},
[NumericUnit.KibibytesSec]: { type: 'fixed', suffix: 'KiB/s' },
[NumericUnit.KibibitsSec]: { type: 'fixed', suffix: 'Kibit/s' },
[NumericUnit.KilobytesSec]: { type: 'fixed', suffix: 'KB/s' },
[NumericUnit.KilobitsSec]: { type: 'fixed', suffix: 'Kbit/s' },
[NumericUnit.MebibytesSec]: { type: 'fixed', suffix: 'MiB/s' },
[NumericUnit.MebibitsSec]: { type: 'fixed', suffix: 'Mibit/s' },
[NumericUnit.MegabytesSec]: { type: 'fixed', suffix: 'MB/s' },
[NumericUnit.MegabitsSec]: { type: 'fixed', suffix: 'Mbit/s' },
[NumericUnit.GibibytesSec]: { type: 'fixed', suffix: 'GiB/s' },
[NumericUnit.GibibitsSec]: { type: 'fixed', suffix: 'Gibit/s' },
[NumericUnit.GigabytesSec]: { type: 'fixed', suffix: 'GB/s' },
[NumericUnit.GigabitsSec]: { type: 'fixed', suffix: 'Gbit/s' },
[NumericUnit.TebibytesSec]: { type: 'fixed', suffix: 'TiB/s' },
[NumericUnit.TebibitsSec]: { type: 'fixed', suffix: 'Tibit/s' },
[NumericUnit.TerabytesSec]: { type: 'fixed', suffix: 'TB/s' },
[NumericUnit.TerabitsSec]: { type: 'fixed', suffix: 'Tbit/s' },
[NumericUnit.PebibytesSec]: { type: 'fixed', suffix: 'PiB/s' },
[NumericUnit.PebibitsSec]: { type: 'fixed', suffix: 'Pibit/s' },
[NumericUnit.PetabytesSec]: { type: 'fixed', suffix: 'PB/s' },
[NumericUnit.PetabitsSec]: { type: 'fixed', suffix: 'Pbit/s' },
// Throughput
[NumericUnit.Cps]: { type: 'fixed', suffix: 'cps' },
[NumericUnit.Ops]: { type: 'fixed', suffix: 'ops' },
[NumericUnit.Rps]: { type: 'fixed', suffix: 'rps' },
[NumericUnit.ReadsSec]: { type: 'fixed', suffix: 'rps' },
[NumericUnit.Wps]: { type: 'fixed', suffix: 'wps' },
[NumericUnit.Iops]: { type: 'fixed', suffix: 'iops' },
[NumericUnit.Cpm]: { type: 'fixed', suffix: 'cpm' },
[NumericUnit.Opm]: { type: 'fixed', suffix: 'opm' },
[NumericUnit.RpmReads]: { type: 'fixed', suffix: 'rpm' },
[NumericUnit.Wpm]: { type: 'fixed', suffix: 'wpm' },
};
const IEC_BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
const SI_BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const IEC_BIT_UNITS = ['b', 'Kibit', 'Mibit', 'Gibit', 'Tibit', 'Pibit'];
const SI_BIT_UNITS = ['b', 'Kbit', 'Mbit', 'Gbit', 'Tbit', 'Pbit'];
const formatAutoScaleData = (
value: number,
base: 'iec' | 'si',
isBits: boolean,
perSec: boolean,
mantissa: number,
): string => {
const divisor = base === 'iec' ? 1024 : 1000;
const units =
base === 'iec'
? isBits
? IEC_BIT_UNITS
: IEC_BYTE_UNITS
: isBits
? SI_BIT_UNITS
: SI_BYTE_UNITS;
const rateSuffix = perSec ? '/s' : '';
let absVal = Math.abs(value);
let i = 0;
while (absVal >= divisor && i < units.length - 1) {
absVal /= divisor;
i++;
}
const scaledValue = value < 0 ? -absVal : absVal;
return `${scaledValue.toFixed(mantissa)} ${units[i]}${rateSuffix}`;
};
export const formatNumber = (
value?: number,
value?: string | number,
options?: NumberFormat,
): string => {
if (!value && value !== 0) {
@ -658,17 +799,49 @@ export const formatNumber = (
// Guard against NaN only - ClickHouse can return numbers as strings, which
// we should still format. Only truly non-numeric values (NaN) get passed through.
if (isNaN(value as number)) {
return String(value);
if (typeof value !== 'number') {
if (isNaN(Number(value))) {
return String(value);
}
value = Number(value);
}
if (!options) {
return value.toString();
}
const mantissa = options.mantissa ?? 0;
// Handle new unit categories with numericUnit
if (
options.numericUnit &&
(options.output === 'byte' ||
options.output === 'data_rate' ||
options.output === 'throughput')
) {
const config = NUMERIC_UNIT_CONFIGS[options.numericUnit];
if (config) {
if (config.type === 'auto_scale') {
return formatAutoScaleData(
value,
config.base,
config.isBits,
config.perSec,
mantissa,
);
}
return `${value.toFixed(mantissa)} ${config.suffix}`;
}
}
// Handle data_rate / throughput without a numericUnit — fall through to number
if (options.output === 'data_rate' || options.output === 'throughput') {
return value.toFixed(mantissa);
}
const numbroFormat: numbro.Format = {
output: options.output || 'number',
mantissa: options.mantissa || 0,
mantissa: mantissa,
thousandSeparated: options.thousandSeparated || false,
average: options.average || false,
...(options.output === 'byte' && {

View file

@ -490,8 +490,72 @@ export type SavedSearch = z.infer<typeof SavedSearchSchema>;
// --------------------------
// DASHBOARDS
// --------------------------
export enum NumericUnit {
// Data
BytesIEC = 'bytes_iec',
BytesSI = 'bytes_si',
BitsIEC = 'bits_iec',
BitsSI = 'bits_si',
Kibibytes = 'kibibytes',
Kilobytes = 'kilobytes',
Mebibytes = 'mebibytes',
Megabytes = 'megabytes',
Gibibytes = 'gibibytes',
Gigabytes = 'gigabytes',
Tebibytes = 'tebibytes',
Terabytes = 'terabytes',
Pebibytes = 'pebibytes',
Petabytes = 'petabytes',
// Data Rate
PacketsSec = 'packets_sec',
BytesSecIEC = 'bytes_sec_iec',
BytesSecSI = 'bytes_sec_si',
BitsSecIEC = 'bits_sec_iec',
BitsSecSI = 'bits_sec_si',
KibibytesSec = 'kibibytes_sec',
KibibitsSec = 'kibibits_sec',
KilobytesSec = 'kilobytes_sec',
KilobitsSec = 'kilobits_sec',
MebibytesSec = 'mebibytes_sec',
MebibitsSec = 'mebibits_sec',
MegabytesSec = 'megabytes_sec',
MegabitsSec = 'megabits_sec',
GibibytesSec = 'gibibytes_sec',
GibibitsSec = 'gibibits_sec',
GigabytesSec = 'gigabytes_sec',
GigabitsSec = 'gigabits_sec',
TebibytesSec = 'tebibytes_sec',
TebibitsSec = 'tebibits_sec',
TerabytesSec = 'terabytes_sec',
TerabitsSec = 'terabits_sec',
PebibytesSec = 'pebibytes_sec',
PebibitsSec = 'pebibits_sec',
PetabytesSec = 'petabytes_sec',
PetabitsSec = 'petabits_sec',
// Throughput
Cps = 'cps',
Ops = 'ops',
Rps = 'rps',
ReadsSec = 'reads_sec',
Wps = 'wps',
Iops = 'iops',
Cpm = 'cpm',
Opm = 'opm',
RpmReads = 'rpm_reads',
Wpm = 'wpm',
}
export const NumberFormatSchema = z.object({
output: z.enum(['currency', 'percent', 'byte', 'time', 'number']),
output: z.enum([
'currency',
'percent',
'byte', // legacy, treated as data/bytes_iec
'time',
'number',
'data_rate',
'throughput',
]),
numericUnit: z.nativeEnum(NumericUnit).optional(),
mantissa: z.number().int().optional(),
thousandSeparated: z.boolean().optional(),
average: z.boolean().optional(),