mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Allow customizing empty-period fill value (#1617)
Closes HDX-3220 Closes HDX-1718 Closes HDX-3205 # Summary This PR adds an option that allows users to customize the 0-fill behavior on time charts. The default behavior remains to fill all empty intervals with 0. The user can now disable the filling behavior. When fill is disabled, series will appear to be interpolated. This PR also consolidates various display settings into a drawer, replacing the existing Number Format drawer. In the process, various form-related bugs were fixed in the drawer, and micro/nano second input factors were added. ## New Chart Display Settings Drawer <img width="1697" height="979" alt="Screenshot 2026-01-20 at 9 10 59 AM" src="https://github.com/user-attachments/assets/1683666a-7c56-4018-8e5b-2c6c814f0cd2" /> ## Zero-fill behavior Enabled (default): <img width="1458" height="494" alt="Screenshot 2026-01-20 at 9 12 45 AM" src="https://github.com/user-attachments/assets/0306644e-d2ff-46d6-998b-eb458d5c9ccc" /> Disabled: <img width="1456" height="505" alt="Screenshot 2026-01-20 at 9 12 37 AM" src="https://github.com/user-attachments/assets/f084887e-4099-4365-af4f-73eceaf5dc3d" />
This commit is contained in:
parent
418828e89d
commit
ddc54e43f0
10 changed files with 494 additions and 258 deletions
5
.changeset/clean-jeans-complain.md
Normal file
5
.changeset/clean-jeans-complain.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Allow customizing zero-fill behavior
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import {
|
||||
AggregateFunction as AggFnV2,
|
||||
ChartConfigWithDateRange,
|
||||
ChartConfigWithOptDateRange,
|
||||
ChartConfigWithOptTimestamp,
|
||||
DisplayType,
|
||||
Filter,
|
||||
|
|
@ -1157,3 +1158,10 @@ export function buildMVDateRangeIndicator({
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldFillNullsWithZero(
|
||||
fillNulls: ChartConfigWithOptDateRange['fillNulls'],
|
||||
): boolean {
|
||||
// To match legacy behavior, fill nulls with 0 unless explicitly disabled
|
||||
return fillNulls !== false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -500,6 +500,7 @@ export const MemoChart = memo(function MemoChart({
|
|||
})}
|
||||
name={seriesName}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ describe('ChartUtils', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('should zero-fill missing time buckets', () => {
|
||||
it('should zero-fill missing time buckets when generateEmptyBuckets is undefined', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
|
|
@ -372,7 +372,6 @@ describe('ChartUtils', () => {
|
|||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(1764159780000), new Date(1764159900000)],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: true,
|
||||
});
|
||||
|
||||
expect(actual.graphResults).toEqual([
|
||||
|
|
@ -442,6 +441,97 @@ describe('ChartUtils', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('should fill missing time buckets with zero when generateEmptyBuckets is true', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'count()': 10,
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'count()': 20,
|
||||
__hdx_time_bucket: '2025-11-26T12:25:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'count()',
|
||||
type: 'UInt64',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(1764159780000), new Date(1764159900000)],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: true,
|
||||
});
|
||||
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
__hdx_time_bucket: 1764159780,
|
||||
'count()': 10,
|
||||
},
|
||||
{
|
||||
__hdx_time_bucket: 1764159840,
|
||||
'count()': 0,
|
||||
},
|
||||
{
|
||||
__hdx_time_bucket: 1764159900,
|
||||
'count()': 20,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not fill missing time buckets when generateEmptyBuckets is false', () => {
|
||||
const res = {
|
||||
data: [
|
||||
{
|
||||
'count()': 10,
|
||||
__hdx_time_bucket: '2025-11-26T12:23:00Z',
|
||||
},
|
||||
{
|
||||
'count()': 20,
|
||||
__hdx_time_bucket: '2025-11-26T12:25:00Z',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
name: 'count()',
|
||||
type: 'UInt64',
|
||||
},
|
||||
{
|
||||
name: '__hdx_time_bucket',
|
||||
type: 'DateTime',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const actual = formatResponseForTimeChart({
|
||||
currentPeriodResponse: res,
|
||||
dateRange: [new Date(1764159780000), new Date(1764159900000)],
|
||||
granularity: '1 minute',
|
||||
generateEmptyBuckets: false,
|
||||
});
|
||||
|
||||
// Should only have the two data points, no filled buckets
|
||||
expect(actual.graphResults).toEqual([
|
||||
{
|
||||
__hdx_time_bucket: 1764159780,
|
||||
'count()': 10,
|
||||
},
|
||||
{
|
||||
__hdx_time_bucket: 1764159900,
|
||||
'count()': 20,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should plot previous period data when provided, shifted to align with current period', () => {
|
||||
const currentPeriodResponse = {
|
||||
data: [
|
||||
|
|
|
|||
|
|
@ -149,12 +149,12 @@ describe('formatNumber', () => {
|
|||
expect(formatNumber(1234567, format)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('applies factor multiplication', () => {
|
||||
it('does not apply factor multiplication', () => {
|
||||
const format: NumberFormat = {
|
||||
output: 'number',
|
||||
factor: 0.001, // Convert to milliseconds
|
||||
};
|
||||
expect(formatNumber(1000, format)).toBe('1');
|
||||
expect(formatNumber(1000, format)).toBe('1000');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -211,6 +211,40 @@ describe('formatNumber', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('time format', () => {
|
||||
it('formats seconds input', () => {
|
||||
const format: NumberFormat = {
|
||||
output: 'time',
|
||||
factor: 1, // seconds
|
||||
};
|
||||
expect(formatNumber(3661, format)).toBe('1:01:01');
|
||||
});
|
||||
|
||||
it('formats milliseconds input', () => {
|
||||
const format: NumberFormat = {
|
||||
output: 'time',
|
||||
factor: 0.001, // milliseconds
|
||||
};
|
||||
expect(formatNumber(61000, format)).toBe('0:01:01');
|
||||
});
|
||||
|
||||
it('formats microseconds input', () => {
|
||||
const format: NumberFormat = {
|
||||
output: 'time',
|
||||
factor: 0.000001, // microseconds
|
||||
};
|
||||
expect(formatNumber(1000000, format)).toBe('0:00:01');
|
||||
});
|
||||
|
||||
it('formats nanoseconds input', () => {
|
||||
const format: NumberFormat = {
|
||||
output: 'time',
|
||||
factor: 0.000000001, // nanoseconds
|
||||
};
|
||||
expect(formatNumber(1000000001, format)).toBe('0:00:01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unit handling', () => {
|
||||
it('appends unit to formatted number', () => {
|
||||
const format: NumberFormat = {
|
||||
|
|
|
|||
144
packages/app/src/components/ChartDisplaySettingsDrawer.tsx
Normal file
144
packages/app/src/components/ChartDisplaySettingsDrawer.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import {
|
||||
ChartConfigWithDateRange,
|
||||
DisplayType,
|
||||
} from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { shouldFillNullsWithZero } from '@/ChartUtils';
|
||||
import { FormatTime } from '@/useFormatTime';
|
||||
|
||||
import { DEFAULT_NUMBER_FORMAT, NumberFormatForm } from './NumberFormat';
|
||||
|
||||
export type ChartConfigDisplaySettings = Pick<
|
||||
ChartConfigWithDateRange,
|
||||
| 'numberFormat'
|
||||
| 'alignDateRangeToGranularity'
|
||||
| 'fillNulls'
|
||||
| 'compareToPreviousPeriod'
|
||||
>;
|
||||
|
||||
interface ChartDisplaySettingsDrawerProps {
|
||||
opened: boolean;
|
||||
settings: ChartConfigDisplaySettings;
|
||||
displayType: DisplayType;
|
||||
previousDateRange?: [Date, Date];
|
||||
onChange: (settings: ChartConfigDisplaySettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function applyDefaultSettings({
|
||||
numberFormat,
|
||||
alignDateRangeToGranularity,
|
||||
compareToPreviousPeriod,
|
||||
fillNulls,
|
||||
}: ChartConfigDisplaySettings): ChartConfigDisplaySettings {
|
||||
return {
|
||||
numberFormat: numberFormat ?? DEFAULT_NUMBER_FORMAT,
|
||||
alignDateRangeToGranularity:
|
||||
alignDateRangeToGranularity == null ? true : alignDateRangeToGranularity,
|
||||
fillNulls: fillNulls ?? 0,
|
||||
compareToPreviousPeriod: compareToPreviousPeriod ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ChartDisplaySettingsDrawer({
|
||||
settings,
|
||||
opened,
|
||||
displayType,
|
||||
onChange,
|
||||
onClose,
|
||||
previousDateRange,
|
||||
}: ChartDisplaySettingsDrawerProps) {
|
||||
const { control, handleSubmit, register, reset, setValue } =
|
||||
useForm<ChartConfigDisplaySettings>({
|
||||
defaultValues: applyDefaultSettings(settings),
|
||||
});
|
||||
|
||||
const fillNulls = useWatch({ control, name: 'fillNulls' });
|
||||
const isFillNullsEnabled = shouldFillNullsWithZero(fillNulls);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset(applyDefaultSettings(settings)); // Reset to default values, without saving
|
||||
onClose();
|
||||
}, [onClose, reset, settings]);
|
||||
|
||||
const applyChanges = useCallback(() => {
|
||||
handleSubmit(onChange)();
|
||||
onClose();
|
||||
}, [onChange, handleSubmit, onClose]);
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
reset(applyDefaultSettings({}));
|
||||
}, [reset]);
|
||||
|
||||
const isTimeChart =
|
||||
displayType === DisplayType.Line || displayType === DisplayType.StackedBar;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Display Settings"
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
position="right"
|
||||
>
|
||||
<Stack>
|
||||
{isTimeChart && (
|
||||
<>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label="Show Complete Intervals"
|
||||
{...register('alignDateRangeToGranularity')}
|
||||
/>
|
||||
<Box>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label="Fill Missing Intervals with Zero"
|
||||
checked={isFillNullsEnabled}
|
||||
onChange={e => {
|
||||
setValue('fillNulls', e.currentTarget.checked ? 0 : false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label="Compare to Previous Period"
|
||||
description={
|
||||
previousDateRange && (
|
||||
<>
|
||||
(
|
||||
<FormatTime value={previousDateRange[0]} format="short" />
|
||||
{' - '}
|
||||
<FormatTime value={previousDateRange[1]} format="short" />)
|
||||
</>
|
||||
)
|
||||
}
|
||||
{...register('compareToPreviousPeriod')}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<NumberFormatForm control={control} />
|
||||
<Divider />
|
||||
<Group gap="xs" mt="xs" justify="space-between">
|
||||
<Button type="submit" variant="secondary" onClick={resetToDefaults}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" onClick={applyChanges}>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ import {
|
|||
Text,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
|
|
@ -82,7 +83,6 @@ import { GranularityPickerControlled } from '@/GranularityPicker';
|
|||
import { useFetchMetricResourceAttrs } from '@/hooks/useFetchMetricResourceAttrs';
|
||||
import SearchInputV2 from '@/SearchInputV2';
|
||||
import { getFirstTimestampValueExpression, useSource } from '@/source';
|
||||
import { FormatTime } from '@/useFormatTime';
|
||||
import {
|
||||
getMetricTableName,
|
||||
optionsToSelectData,
|
||||
|
|
@ -99,20 +99,20 @@ import {
|
|||
} from '@/utils/alerts';
|
||||
|
||||
import HDXMarkdownChart from '../HDXMarkdownChart';
|
||||
import type { NumberFormat } from '../types';
|
||||
|
||||
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
|
||||
import { AggFnSelectControlled } from './AggFnSelect';
|
||||
import ChartDisplaySettingsDrawer, {
|
||||
ChartConfigDisplaySettings,
|
||||
} from './ChartDisplaySettingsDrawer';
|
||||
import DBNumberChart from './DBNumberChart';
|
||||
import DBSqlRowTableWithSideBar from './DBSqlRowTableWithSidebar';
|
||||
import {
|
||||
CheckBoxControlled,
|
||||
InputControlled,
|
||||
SwitchControlled,
|
||||
TextInputControlled,
|
||||
} from './InputControlled';
|
||||
import { MetricNameSelect } from './MetricNameSelect';
|
||||
import { NumberFormatInput } from './NumberFormat';
|
||||
import SaveToDashboardModal from './SaveToDashboardModal';
|
||||
import SourceSchemaPreview from './SourceSchemaPreview';
|
||||
import { SourceSelectControlled } from './SourceSelect';
|
||||
|
|
@ -160,30 +160,6 @@ const validateMetricNames = (
|
|||
return false;
|
||||
};
|
||||
|
||||
const NumberFormatInputControlled = ({
|
||||
control,
|
||||
onSubmit,
|
||||
}: {
|
||||
control: Control<any>;
|
||||
onSubmit: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="numberFormat"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberFormatInput
|
||||
onChange={(newValue?: NumberFormat) => {
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type SeriesItem = NonNullable<
|
||||
SavedChartConfigWithSelectArray['select']
|
||||
>[number];
|
||||
|
|
@ -537,14 +513,6 @@ export default function EditTimeChartForm({
|
|||
const whereLanguage = useWatch({ control, name: 'whereLanguage' });
|
||||
const alert = useWatch({ control, name: 'alert' });
|
||||
const seriesReturnType = useWatch({ control, name: 'seriesReturnType' });
|
||||
const compareToPreviousPeriod = useWatch({
|
||||
control,
|
||||
name: 'compareToPreviousPeriod',
|
||||
});
|
||||
const alignDateRangeToGranularity = useWatch({
|
||||
control,
|
||||
name: 'alignDateRangeToGranularity',
|
||||
});
|
||||
const groupBy = useWatch({ control, name: 'groupBy' });
|
||||
const displayType =
|
||||
useWatch({ control, name: 'displayType' }) ?? DisplayType.Line;
|
||||
|
|
@ -580,6 +548,41 @@ export default function EditTimeChartForm({
|
|||
const showGeneratedSql = ['table', 'time', 'number'].includes(activeTab); // Whether to show the generated SQL preview
|
||||
const showSampleEvents = tableSource?.kind !== SourceKind.Metric;
|
||||
|
||||
const [
|
||||
alignDateRangeToGranularity,
|
||||
fillNulls,
|
||||
compareToPreviousPeriod,
|
||||
numberFormat,
|
||||
] = useWatch({
|
||||
control,
|
||||
name: [
|
||||
'alignDateRangeToGranularity',
|
||||
'fillNulls',
|
||||
'compareToPreviousPeriod',
|
||||
'numberFormat',
|
||||
],
|
||||
});
|
||||
|
||||
const displaySettings: ChartConfigDisplaySettings = useMemo(
|
||||
() => ({
|
||||
alignDateRangeToGranularity,
|
||||
fillNulls,
|
||||
compareToPreviousPeriod,
|
||||
numberFormat,
|
||||
}),
|
||||
[
|
||||
alignDateRangeToGranularity,
|
||||
fillNulls,
|
||||
compareToPreviousPeriod,
|
||||
numberFormat,
|
||||
],
|
||||
);
|
||||
|
||||
const [
|
||||
displaySettingsOpened,
|
||||
{ open: openDisplaySettings, close: closeDisplaySettings },
|
||||
] = useDisclosure(false);
|
||||
|
||||
// Only update this on submit, otherwise we'll have issues
|
||||
// with using the source value from the last submit
|
||||
// (ex. ignoring local custom source updates)
|
||||
|
|
@ -762,34 +765,6 @@ export default function EditTimeChartForm({
|
|||
});
|
||||
}, [dateRange]);
|
||||
|
||||
// Trigger a search when "Show Complete Intervals" changes
|
||||
useEffect(() => {
|
||||
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
|
||||
if (config == null) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
alignDateRangeToGranularity,
|
||||
};
|
||||
});
|
||||
}, [alignDateRangeToGranularity]);
|
||||
|
||||
// Trigger a search when "compare to previous period" changes
|
||||
useEffect(() => {
|
||||
setQueriedConfig((config: ChartConfigWithDateRange | undefined) => {
|
||||
if (config == null) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
compareToPreviousPeriod,
|
||||
};
|
||||
});
|
||||
}, [compareToPreviousPeriod]);
|
||||
|
||||
const queryReady = isQueryReady(queriedConfig);
|
||||
|
||||
// The chart config to use when showing the user the generated SQL
|
||||
|
|
@ -877,6 +852,22 @@ export default function EditTimeChartForm({
|
|||
// Need to force a rerender on change as the modal will not be mounted when initially rendered
|
||||
const [parentRef, setParentRef] = useState<HTMLElement | null>(null);
|
||||
|
||||
const handleUpdateDisplaySettings = useCallback(
|
||||
({
|
||||
numberFormat,
|
||||
alignDateRangeToGranularity,
|
||||
fillNulls,
|
||||
compareToPreviousPeriod,
|
||||
}: ChartConfigDisplaySettings) => {
|
||||
setValue('numberFormat', numberFormat);
|
||||
setValue('alignDateRangeToGranularity', alignDateRangeToGranularity);
|
||||
setValue('fillNulls', fillNulls);
|
||||
setValue('compareToPreviousPeriod', compareToPreviousPeriod);
|
||||
onSubmit();
|
||||
},
|
||||
[setValue, onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={setParentRef} data-testid={dataTestId}>
|
||||
<Controller
|
||||
|
|
@ -1091,10 +1082,13 @@ export default function EditTimeChartForm({
|
|||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<NumberFormatInputControlled
|
||||
control={control}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<Button
|
||||
onClick={openDisplaySettings}
|
||||
size="compact-sm"
|
||||
variant="secondary"
|
||||
>
|
||||
Display Settings
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -1288,33 +1282,6 @@ export default function EditTimeChartForm({
|
|||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
{activeTab === 'time' && (
|
||||
<Group justify="end" mb="xs">
|
||||
<SwitchControlled
|
||||
control={control}
|
||||
name="alignDateRangeToGranularity"
|
||||
label="Show Complete Intervals"
|
||||
/>
|
||||
<SwitchControlled
|
||||
control={control}
|
||||
name="compareToPreviousPeriod"
|
||||
label={
|
||||
<>
|
||||
Compare to Previous Period{' '}
|
||||
{!dashboardId && (
|
||||
<>
|
||||
(
|
||||
<FormatTime value={previousDateRange?.[0]} format="short" />
|
||||
{' - '}
|
||||
<FormatTime value={previousDateRange?.[1]} format="short" />
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{!queryReady && activeTab !== 'markdown' ? (
|
||||
<Paper shadow="xs" p="xl">
|
||||
<Center mih={400}>
|
||||
|
|
@ -1462,6 +1429,14 @@ export default function EditTimeChartForm({
|
|||
opened={saveToDashboardModalOpen}
|
||||
onClose={() => setSaveToDashboardModalOpen(false)}
|
||||
/>
|
||||
<ChartDisplaySettingsDrawer
|
||||
opened={displaySettingsOpened}
|
||||
settings={displaySettings}
|
||||
previousDateRange={!dashboardId ? previousDateRange : undefined}
|
||||
displayType={displayType}
|
||||
onChange={handleUpdateDisplaySettings}
|
||||
onClose={closeDisplaySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
formatResponseForTimeChart,
|
||||
getPreviousDateRange,
|
||||
PreviousPeriodSuffix,
|
||||
shouldFillNullsWithZero,
|
||||
useTimeChartSettings,
|
||||
} from '@/ChartUtils';
|
||||
import { MemoChart } from '@/HDXMultiSeriesTimeChart';
|
||||
|
|
@ -395,7 +396,7 @@ function DBTimeChartComponent({
|
|||
: undefined,
|
||||
dateRange,
|
||||
granularity,
|
||||
generateEmptyBuckets: fillNulls !== false,
|
||||
generateEmptyBuckets: shouldFillNullsWithZero(fillNulls),
|
||||
source,
|
||||
hiddenSeries,
|
||||
previousPeriodOffsetSeconds,
|
||||
|
|
|
|||
|
|
@ -1,36 +1,26 @@
|
|||
import * as React from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useMemo } from 'react';
|
||||
import { Control, Controller, useWatch } from 'react-hook-form';
|
||||
import { NumberFormat } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Checkbox as MCheckbox,
|
||||
Drawer,
|
||||
NativeSelect,
|
||||
Paper,
|
||||
Slider,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconClock,
|
||||
IconCurrencyDollar,
|
||||
IconDatabase,
|
||||
IconNumbers,
|
||||
IconPercentage,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { NumberFormat } from '../types';
|
||||
import { formatNumber } from '../utils';
|
||||
|
||||
const FORMAT_NAMES: Record<string, string> = {
|
||||
number: 'Number',
|
||||
currency: 'Currency',
|
||||
percent: 'Percentage',
|
||||
byte: 'Bytes',
|
||||
time: 'Time',
|
||||
};
|
||||
import { ChartConfigDisplaySettings } from './ChartDisplaySettingsDrawer';
|
||||
|
||||
const FORMAT_ICONS: Record<string, React.ReactNode> = {
|
||||
number: <IconNumbers size={14} />,
|
||||
|
|
@ -40,48 +30,26 @@ const FORMAT_ICONS: Record<string, React.ReactNode> = {
|
|||
time: <IconClock size={14} />,
|
||||
};
|
||||
|
||||
const DEFAULT_NUMBER_FORMAT: NumberFormat = {
|
||||
const TEST_NUMBER = 1234;
|
||||
|
||||
export const DEFAULT_NUMBER_FORMAT: NumberFormat = {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
output: 'number' as const,
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
};
|
||||
|
||||
const TEST_NUMBER = 1234;
|
||||
|
||||
export const NumberFormatForm: React.FC<{
|
||||
value?: NumberFormat;
|
||||
onApply: (value: NumberFormat) => void;
|
||||
onClose: () => void;
|
||||
}> = ({ value, onApply, onClose }) => {
|
||||
const { register, handleSubmit, control, setValue } = useForm<NumberFormat>({
|
||||
defaultValues: value ?? DEFAULT_NUMBER_FORMAT,
|
||||
});
|
||||
|
||||
const format = useWatch({ control });
|
||||
control: Control<ChartConfigDisplaySettings>;
|
||||
}> = ({ control }) => {
|
||||
const format =
|
||||
useWatch({ control, name: 'numberFormat' }) ?? DEFAULT_NUMBER_FORMAT;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack style={{ flex: 1 }}>
|
||||
{/* <TextInput
|
||||
label="Coefficient"
|
||||
type="number"
|
||||
description="Multiply number by this value before formatting. You can use it to convert source value to seconds, bytes, base currency, etc."
|
||||
{...register('factor', { valueAsNumber: true })}
|
||||
rightSectionWidth={70}
|
||||
rightSection={
|
||||
<Button
|
||||
variant="default"
|
||||
compact
|
||||
size="sm"
|
||||
onClick={() => setValue('factor', 1)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -91,25 +59,34 @@ export const NumberFormatForm: React.FC<{
|
|||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<NativeSelect
|
||||
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' },
|
||||
]}
|
||||
{...register('output')}
|
||||
<Controller
|
||||
control={control}
|
||||
key="numberFormat.output"
|
||||
name="numberFormat.output"
|
||||
render={({ 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' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{format.output === 'currency' && (
|
||||
<TextInput
|
||||
w={80}
|
||||
label="Symbol"
|
||||
placeholder="$"
|
||||
{...register('currencySymbol')}
|
||||
<Controller
|
||||
control={control}
|
||||
key="numberFormat.currencySymbol"
|
||||
name="numberFormat.currencySymbol"
|
||||
render={({ field }) => (
|
||||
<TextInput {...field} w={80} label="Symbol" placeholder="$" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -123,124 +100,121 @@ export const NumberFormatForm: React.FC<{
|
|||
>
|
||||
Example
|
||||
</div>
|
||||
{formatNumber(TEST_NUMBER, format as NumberFormat)}
|
||||
{formatNumber(TEST_NUMBER || 0, format)}
|
||||
</Paper>
|
||||
</div>
|
||||
|
||||
{format.output !== 'time' && (
|
||||
<div>
|
||||
<div className="fs-8 mt-2 fw-bold mb-1">Decimals</div>
|
||||
<Slider
|
||||
mb="xl"
|
||||
min={0}
|
||||
max={10}
|
||||
label={value => `Decimals: ${value}`}
|
||||
marks={[
|
||||
{ value: 0, label: '0' },
|
||||
{ value: 10, label: '10' },
|
||||
]}
|
||||
value={format.mantissa}
|
||||
onChange={value => {
|
||||
setValue('mantissa', value);
|
||||
}}
|
||||
<Controller
|
||||
control={control}
|
||||
key="numberFormat.mantissa"
|
||||
name="numberFormat.mantissa"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Slider
|
||||
mb="xl"
|
||||
min={0}
|
||||
max={10}
|
||||
label={val => `Decimals: ${val}`}
|
||||
marks={[
|
||||
{ value: 0, label: '0' },
|
||||
{ value: 10, label: '10' },
|
||||
]}
|
||||
value={value ?? 2}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Stack gap="xs">
|
||||
{format.output === 'byte' ? (
|
||||
<MCheckbox
|
||||
size="xs"
|
||||
label="Decimal base"
|
||||
description="Use 1KB = 1000 bytes"
|
||||
{...register('decimalBytes')}
|
||||
<Controller
|
||||
control={control}
|
||||
key="numberFormat.decimalBytes"
|
||||
name="numberFormat.decimalBytes"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<MCheckbox
|
||||
{...field}
|
||||
size="xs"
|
||||
label="Decimal base"
|
||||
description="Use 1KB = 1000 bytes"
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : format.output === 'time' ? (
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
label="Input unit"
|
||||
{...register('factor', {
|
||||
setValueAs: value => parseFloat(value),
|
||||
})}
|
||||
data={[
|
||||
{ value: '1', label: 'Seconds' },
|
||||
{ value: '0.001', label: 'Milliseconds' },
|
||||
]}
|
||||
<Controller
|
||||
control={control}
|
||||
key="numberFormat.factor"
|
||||
name="numberFormat.factor"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{ value: '1', label: 'Seconds' },
|
||||
{ value: '0.001', label: 'Milliseconds' },
|
||||
{ value: '0.000001', label: 'Microseconds' },
|
||||
{ value: '0.000000001', label: 'Nanoseconds' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const stringValue =
|
||||
options.find(option => parseFloat(option.value) === value)
|
||||
?.value ?? '1';
|
||||
|
||||
return (
|
||||
<NativeSelect
|
||||
{...field}
|
||||
size="sm"
|
||||
label="Input unit"
|
||||
value={stringValue}
|
||||
onChange={e => onChange(parseFloat(e.target.value))}
|
||||
data={options}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MCheckbox
|
||||
size="xs"
|
||||
label="Separate thousands"
|
||||
description="For example: 1,234,567"
|
||||
{...register('thousandSeparated')}
|
||||
<Controller
|
||||
control={control}
|
||||
key="numberFormat.thousandSeparated"
|
||||
name="numberFormat.thousandSeparated"
|
||||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<MCheckbox
|
||||
{...field}
|
||||
size="xs"
|
||||
label="Separate thousands"
|
||||
description="For example: 1,234,567"
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<MCheckbox
|
||||
size="xs"
|
||||
label="Large number format"
|
||||
description="For example: 1.2m"
|
||||
{...register('average')}
|
||||
<Controller
|
||||
control={control}
|
||||
key="numberFormat.average"
|
||||
name="numberFormat.average"
|
||||
render={({ field: { value, onChange, ...field } }) => (
|
||||
<MCheckbox
|
||||
{...field}
|
||||
size="xs"
|
||||
label="Large number format"
|
||||
description="For example: 1.2m"
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Button type="submit" onClick={handleSubmit(onApply)}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const NumberFormatInput: React.FC<{
|
||||
value?: NumberFormat;
|
||||
onChange: (value?: NumberFormat) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const handleApply = React.useCallback(
|
||||
(value?: NumberFormat) => {
|
||||
onChange(value);
|
||||
close();
|
||||
},
|
||||
[onChange, close],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title="Number format"
|
||||
position="right"
|
||||
padding="lg"
|
||||
zIndex={100000}
|
||||
>
|
||||
<NumberFormatForm value={value} onApply={handleApply} onClose={close} />
|
||||
</Drawer>
|
||||
<Button.Group>
|
||||
<Button
|
||||
onClick={open}
|
||||
size="compact-sm"
|
||||
variant="secondary"
|
||||
leftSection={value?.output && FORMAT_ICONS[value.output]}
|
||||
>
|
||||
{value?.output ? FORMAT_NAMES[value.output] : 'Set number format'}
|
||||
</Button>
|
||||
{value?.output && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleApply(undefined)}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Button.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -630,8 +630,12 @@ export const formatNumber = (
|
|||
currencySymbol: options.currencySymbol || '$',
|
||||
}),
|
||||
};
|
||||
|
||||
// Factor is only currently available for the time output
|
||||
const factor = options.output === 'time' ? (options.factor ?? 1) : 1;
|
||||
|
||||
return (
|
||||
numbro(value * (options.factor ?? 1)).format(numbroFormat) +
|
||||
numbro(value * factor).format(numbroFormat) +
|
||||
(options.unit ? ` ${options.unit}` : '')
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue