mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
91d950fc2e
commit
b7581db806
9 changed files with 686 additions and 32 deletions
7
.changeset/itchy-houses-cross.md
Normal file
7
.changeset/itchy-houses-cross.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add more chart display units
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' && {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue