diff --git a/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx b/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx
index de0f41b8117..6ad9206d586 100644
--- a/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx
+++ b/ui/packages/shared/components/src/DateTimeRangePicker/AbsoluteDatePicker/index.tsx
@@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {useMemo, useState} from 'react';
+import {useEffect, useMemo, useState} from 'react';
import {DateTimePicker} from '../../DateTimePicker';
import {AbsoluteDate, DateTimeRange, RelativeDate, getHistoricalDate} from '../utils';
@@ -46,6 +46,29 @@ const AbsoluteDatePicker = ({range, onChange}: AbsoluteDatePickerProps): JSX.Ele
: (range.to as AbsoluteDate)
);
+ useEffect(() => {
+ setFrom(
+ range.from.isRelative()
+ ? new AbsoluteDate(
+ getHistoricalDate({
+ unit: dateFromInRelative.unit,
+ value: dateFromInRelative.value,
+ })
+ )
+ : (range.from as AbsoluteDate)
+ );
+ setTo(
+ range.to.isRelative()
+ ? new AbsoluteDate(
+ getHistoricalDate({
+ unit: dateToInRelative.unit,
+ value: dateToInRelative.value,
+ })
+ )
+ : (range.to as AbsoluteDate)
+ );
+ }, [dateFromInRelative, dateToInRelative, range.from, range.to]);
+
return (
diff --git a/ui/packages/shared/components/src/DateTimeRangePicker/RelativeDatePicker/index.tsx b/ui/packages/shared/components/src/DateTimeRangePicker/RelativeDatePicker/index.tsx
index b148b855772..2d31287d9f0 100644
--- a/ui/packages/shared/components/src/DateTimeRangePicker/RelativeDatePicker/index.tsx
+++ b/ui/packages/shared/components/src/DateTimeRangePicker/RelativeDatePicker/index.tsx
@@ -19,10 +19,16 @@ import {Button} from '../../Button';
import {
AbsoluteDate,
DateTimeRange,
+ NOW,
RelativeDate,
UNITS,
UNIT_TYPE,
+ formatRange,
getHistoricalDate,
+ getRelativeTimeRangeBetweenDates,
+ parseInput,
+ presetRanges,
+ unitShort,
} from '../utils';
interface RelativeDatePickerProps {
@@ -31,66 +37,6 @@ interface RelativeDatePickerProps {
toggleRangePickerPanel: () => void;
}
-interface UnitsMap {
- [key: string]: string;
-}
-
-const unitLong: UnitsMap = {
- m: UNITS.MINUTE,
- h: UNITS.HOUR,
- d: UNITS.DAY,
- w: UNITS.WEEK,
- y: UNITS.YEAR,
-};
-const unitShort: UnitsMap = {
- [UNITS.MINUTE]: 'm',
- [UNITS.HOUR]: 'h',
- [UNITS.DAY]: 'd',
- [UNITS.WEEK]: 'w',
- [UNITS.YEAR]: 'y',
-};
-
-const presetRanges = [
- {value: 1, unit: UNITS.MINUTE},
- {value: 5, unit: UNITS.MINUTE},
- {value: 15, unit: UNITS.MINUTE},
- {value: 30, unit: UNITS.MINUTE},
- {value: 1, unit: UNITS.HOUR},
- {value: 3, unit: UNITS.HOUR},
- {value: 6, unit: UNITS.HOUR},
- {value: 12, unit: UNITS.HOUR},
- {value: 1, unit: UNITS.DAY},
- {value: 2, unit: UNITS.DAY},
- {value: 1, unit: UNITS.WEEK},
- {value: 2, unit: UNITS.WEEK},
- {value: 4, unit: UNITS.WEEK},
- {value: 8, unit: UNITS.WEEK},
- {value: 16, unit: UNITS.WEEK},
- {value: 26, unit: UNITS.WEEK},
- {value: 1, unit: UNITS.YEAR},
- {value: 2, unit: UNITS.YEAR},
-];
-
-const NOW = new RelativeDate(UNITS.MINUTE, 0);
-
-const parseInput = (input: string): {value: number; unit: string} | null => {
- // Ensure the input is not too long to mitigate potential DoS attacks
- if (input.length > 100) {
- return null; // Input is too long
- }
-
- const value = parseFloat(input);
- const match = input.match(/^(\d+)([mhdw])$/);
-
- // handle parseFloat edge cases and non-valid input
- if (Number.isNaN(value) || input.includes('Infinity') || input.includes('e') || match == null) {
- return null;
- }
-
- const unit = match[2];
- return {value, unit: unitLong[unit]};
-};
-
export const RelativeDatePickerForPanel = ({
onChange = () => null,
range,
@@ -124,29 +70,6 @@ export const RelativeDatePickerForPanel = ({
: (range.to as AbsoluteDate)
);
- const getRelativeTimeRangeBetweenDates = (
- timeRange: number
- ): {unit: UNIT_TYPE; value: number} => {
- const roundToHundredth = (value: number): number => {
- return Number(value.toFixed(2));
- };
-
- if (timeRange < 1000 * 60 * 60) {
- const timeRangeToMinutes = timeRange / 1000 / 60;
- return {unit: UNITS.MINUTE, value: roundToHundredth(timeRangeToMinutes)};
- }
- if (timeRange < 1000 * 60 * 60 * 24) {
- const timeRangeToHours = timeRange / 1000 / 60 / 60;
- return {unit: UNITS.HOUR, value: roundToHundredth(timeRangeToHours)};
- }
- if (timeRange < 1000 * 60 * 60 * 24 * 7) {
- const timeRangeToDays = timeRange / 1000 / 60 / 60 / 24;
- return {unit: UNITS.DAY, value: roundToHundredth(timeRangeToDays)};
- }
- const timeRangeToWeeks = timeRange / 1000 / 60 / 60 / 24 / 7;
- return {unit: UNITS.WEEK, value: roundToHundredth(timeRangeToWeeks)};
- };
-
const {unit, value} = useMemo(
() => getRelativeTimeRangeBetweenDates(to.getTime().getTime() - from.getTime().getTime()),
[from, to]
@@ -156,7 +79,7 @@ export const RelativeDatePickerForPanel = ({
// absolute date range is converted to a relative date range and we then use the `onChange` prop to
// update the range in the `RelativeDatePicker` component below.
useEffect(() => {
- onChange(new RelativeDate(unit, value), new RelativeDate(unit, 0));
+ onChange(new RelativeDate(unit, value), NOW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unit, value]);
@@ -205,19 +128,20 @@ const RelativeDatePicker = ({
const [validRange, setValidRange] = useState<{
value: number;
- unit: string;
+ unit: UNIT_TYPE;
}>({
value: date.value,
unit: date.unit,
});
useEffect(() => {
- setRangeInputString(`${date.value}${unitShort[date.unit]}`);
+ setRangeInputString(formatRange(date.value, date.unit));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [range]);
useEffect(() => {
- setRangeInputString(`${validRange.value}${unitShort[validRange.unit]}`);
+ const formattedRange = formatRange(validRange.value, validRange.unit);
+ setRangeInputString(formattedRange);
onChange(new RelativeDate(validRange.unit, validRange.value), NOW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [validRange]);
@@ -300,13 +224,10 @@ const RelativeDatePicker = ({
// if parsed input is not valid, set input to the previous valid value
if (parsedInput === null) {
- setRangeInputString(`${validRange.value}${unitShort[validRange.unit]}`);
- return;
+ setRangeInputString(formatRange(validRange.value, validRange.unit));
+ } else {
+ setValidRange(parsedInput);
}
-
- // if parsed input is valid, set valid range state
- const {value, unit} = parsedInput;
- setValidRange({value, unit});
}}
onKeyDown={e => {
// if enter key is pressed, blur the input
diff --git a/ui/packages/shared/components/src/DateTimeRangePicker/utils.test.ts b/ui/packages/shared/components/src/DateTimeRangePicker/utils.test.ts
new file mode 100644
index 00000000000..d64be297f9f
--- /dev/null
+++ b/ui/packages/shared/components/src/DateTimeRangePicker/utils.test.ts
@@ -0,0 +1,107 @@
+// Copyright 2022 The Parca Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {describe, expect, it} from 'vitest';
+
+import {UNITS, formatRange, getRelativeTimeRangeBetweenDates, parseInput} from './utils';
+
+describe('parseInput', () => {
+ it('should parse single unit inputs correctly', () => {
+ expect(parseInput('30s')).toEqual({value: 30, unit: UNITS.SECOND});
+ expect(parseInput('45m')).toEqual({value: 45, unit: UNITS.MINUTE});
+ expect(parseInput('2h')).toEqual({value: 2, unit: UNITS.HOUR});
+ expect(parseInput('3d')).toEqual({value: 3, unit: UNITS.DAY});
+ expect(parseInput('1w')).toEqual({value: 1, unit: UNITS.WEEK});
+ expect(parseInput('2y')).toEqual({value: 2, unit: UNITS.YEAR});
+ });
+
+ it('should parse multi-unit inputs correctly', () => {
+ expect(parseInput('1h30m')).toEqual({value: 1.5, unit: UNITS.HOUR});
+ expect(parseInput('2d12h')).toEqual({value: 2.5, unit: UNITS.DAY});
+ expect(parseInput('1w2d')).toEqual({value: 1.2857142857142858, unit: UNITS.WEEK});
+ });
+
+ it('should return null for invalid inputs', () => {
+ expect(parseInput('')).toBeNull();
+ expect(parseInput('invalid')).toBeNull();
+ expect(parseInput('1x')).toBeNull();
+ });
+
+ it('should return null for inputs longer than 100 characters', () => {
+ const longInput = '1h'.repeat(51); // 102 characters
+ expect(parseInput(longInput)).toBeNull();
+ });
+});
+
+describe('getRelativeTimeRangeBetweenDates', () => {
+ it('should return correct unit and value for various time ranges', () => {
+ expect(getRelativeTimeRangeBetweenDates(30 * 1000)).toEqual({unit: UNITS.MINUTE, value: 0.5});
+ expect(getRelativeTimeRangeBetweenDates(45 * 60 * 1000)).toEqual({
+ unit: UNITS.MINUTE,
+ value: 45,
+ });
+ expect(getRelativeTimeRangeBetweenDates(2 * 60 * 60 * 1000)).toEqual({
+ unit: UNITS.HOUR,
+ value: 2,
+ });
+ expect(getRelativeTimeRangeBetweenDates(3 * 24 * 60 * 60 * 1000)).toEqual({
+ unit: UNITS.DAY,
+ value: 3,
+ });
+ expect(getRelativeTimeRangeBetweenDates(10 * 24 * 60 * 60 * 1000)).toEqual({
+ unit: UNITS.WEEK,
+ value: 1.43,
+ });
+ });
+
+ it('should round values to two decimal places', () => {
+ expect(getRelativeTimeRangeBetweenDates(2 * 60 * 60 * 1000 + 5 * 60 * 1000)).toEqual({
+ unit: UNITS.HOUR,
+ value: 2.08,
+ });
+ });
+});
+
+describe('formatRange', () => {
+ it('should format single unit ranges correctly', () => {
+ expect(formatRange(30, UNITS.SECOND)).toBe('30s');
+ expect(formatRange(45, UNITS.MINUTE)).toBe('45m');
+ expect(formatRange(2, UNITS.HOUR)).toBe('2h');
+ expect(formatRange(3, UNITS.DAY)).toBe('3d');
+ expect(formatRange(1, UNITS.WEEK)).toBe('1w');
+ expect(formatRange(2, UNITS.YEAR)).toBe('2y');
+ });
+
+ it('should preserve original units when possible', () => {
+ expect(formatRange(25, UNITS.HOUR)).toBe('25h');
+ expect(formatRange(36, UNITS.HOUR)).toBe('36h');
+ expect(formatRange(1.5, UNITS.DAY)).toBe('1d12h');
+ expect(formatRange(1.25, UNITS.HOUR)).toBe('1h15m');
+ });
+
+ it('should handle fractional values correctly', () => {
+ expect(formatRange(1.75, UNITS.HOUR)).toBe('1h45m');
+ expect(formatRange(2.5, UNITS.DAY)).toBe('2d12h');
+ });
+
+ it('should return 0s for zero values', () => {
+ expect(formatRange(0, UNITS.SECOND)).toBe('0s');
+ expect(formatRange(0, UNITS.HOUR)).toBe('0s');
+ });
+
+ it('should handle edge cases correctly', () => {
+ expect(formatRange(0.1, UNITS.MINUTE)).toBe('0m6s');
+ expect(formatRange(1.99, UNITS.DAY)).toBe('1d23h45m');
+ expect(formatRange(1.001, UNITS.HOUR)).toBe('1h');
+ });
+});
diff --git a/ui/packages/shared/components/src/DateTimeRangePicker/utils.ts b/ui/packages/shared/components/src/DateTimeRangePicker/utils.ts
index 12b769334ff..fa6b377a8e2 100644
--- a/ui/packages/shared/components/src/DateTimeRangePicker/utils.ts
+++ b/ui/packages/shared/components/src/DateTimeRangePicker/utils.ts
@@ -16,6 +16,7 @@ import moment from 'moment-timezone';
import {ABSOLUTE_TIME_ALIASES, AbsoluteDateValue, DATE_FORMAT} from '../DateTimePicker';
export const UNITS = {
+ SECOND: 'second',
MINUTE: 'minute',
HOUR: 'hour',
DAY: 'day',
@@ -31,6 +32,58 @@ export const POSITIONS = {
export type UNIT_TYPE = (typeof UNITS)[keyof typeof UNITS];
export type POSITION_TYPE = (typeof POSITIONS)[keyof typeof POSITIONS];
+export interface UnitsMap {
+ [key: string]: string;
+}
+
+export const unitLong: UnitsMap = {
+ s: UNITS.SECOND,
+ m: UNITS.MINUTE,
+ h: UNITS.HOUR,
+ d: UNITS.DAY,
+ w: UNITS.WEEK,
+ y: UNITS.YEAR,
+};
+
+export const unitShort: UnitsMap = {
+ [UNITS.SECOND]: 's',
+ [UNITS.MINUTE]: 'm',
+ [UNITS.HOUR]: 'h',
+ [UNITS.DAY]: 'd',
+ [UNITS.WEEK]: 'w',
+ [UNITS.YEAR]: 'y',
+};
+
+export const units: Array<[UNIT_TYPE, number]> = [
+ [UNITS.YEAR, 31536000],
+ [UNITS.WEEK, 604800],
+ [UNITS.DAY, 86400],
+ [UNITS.HOUR, 3600],
+ [UNITS.MINUTE, 60],
+ [UNITS.SECOND, 1],
+];
+
+export const presetRanges = [
+ {value: 1, unit: UNITS.MINUTE},
+ {value: 5, unit: UNITS.MINUTE},
+ {value: 15, unit: UNITS.MINUTE},
+ {value: 30, unit: UNITS.MINUTE},
+ {value: 1, unit: UNITS.HOUR},
+ {value: 3, unit: UNITS.HOUR},
+ {value: 6, unit: UNITS.HOUR},
+ {value: 12, unit: UNITS.HOUR},
+ {value: 1, unit: UNITS.DAY},
+ {value: 2, unit: UNITS.DAY},
+ {value: 1, unit: UNITS.WEEK},
+ {value: 2, unit: UNITS.WEEK},
+ {value: 4, unit: UNITS.WEEK},
+ {value: 8, unit: UNITS.WEEK},
+ {value: 16, unit: UNITS.WEEK},
+ {value: 26, unit: UNITS.WEEK},
+ {value: 1, unit: UNITS.YEAR},
+ {value: 2, unit: UNITS.YEAR},
+];
+
interface BaseDate {
isRelative: () => boolean;
lastEvaluated: number;
@@ -246,22 +299,22 @@ export const getDateHoursAgo = (hours = 1): Date => {
export const getHistoricalDate = ({value, unit}: {value: number; unit: string}): Date => {
const now = new Date();
- switch (unit) {
- case UNITS.MINUTE:
- now.setMinutes(now.getMinutes() - value);
- return now;
- case UNITS.HOUR:
- now.setHours(now.getHours() - value);
- return now;
- case UNITS.DAY:
- now.setDate(now.getDate() - value);
- return now;
- case UNITS.WEEK:
- now.setDate(now.getDate() - value * 7);
- return now;
- default:
- return now;
- }
+ const msToSubtract = (() => {
+ switch (unit) {
+ case UNITS.MINUTE:
+ return value * 60 * 1000;
+ case UNITS.HOUR:
+ return value * 60 * 60 * 1000;
+ case UNITS.DAY:
+ return value * 24 * 60 * 60 * 1000;
+ case UNITS.WEEK:
+ return value * 7 * 24 * 60 * 60 * 1000;
+ default:
+ return 0;
+ }
+ })();
+
+ return new Date(now.getTime() - msToSubtract);
};
const getRelativeDateMs = (date: RelativeDate): number => {
@@ -297,3 +350,118 @@ export const getStringForDateInTimezone = (
): string => {
return moment.tz(date.getTime().toISOString(), timezone).format(format);
};
+
+export const parseInput = (input: string): {value: number; unit: UNIT_TYPE} | null => {
+ if (input.length > 100) return null;
+
+ const parts = input.match(/(\d+[smhdwy])/g);
+ if (parts === null) return null;
+
+ let totalSeconds = 0;
+ for (const part of parts) {
+ const value = parseInt(part.slice(0, -1));
+ const unit = part.slice(-1) as keyof typeof unitLong;
+ if (isNaN(value) || unitLong[unit] === '') return null;
+
+ const unitInSeconds = {
+ s: 1,
+ m: 60,
+ h: 3600,
+ d: 86400,
+ w: 604800,
+ y: 31536000,
+ }[unit] as number;
+
+ totalSeconds += value * unitInSeconds;
+ }
+
+ for (const [unit, seconds] of units) {
+ if (totalSeconds >= seconds) {
+ return {value: totalSeconds / seconds, unit};
+ }
+ }
+
+ return {value: totalSeconds, unit: UNITS.SECOND};
+};
+
+export const getRelativeTimeRangeBetweenDates = (
+ timeRange: number
+): {unit: UNIT_TYPE; value: number} => {
+ const roundToHundredth = (value: number): number => {
+ return Number(value.toFixed(2));
+ };
+
+ if (timeRange < 1000 * 60 * 60) {
+ const timeRangeToMinutes = timeRange / 1000 / 60;
+ return {unit: UNITS.MINUTE, value: roundToHundredth(timeRangeToMinutes)};
+ }
+ if (timeRange < 1000 * 60 * 60 * 24) {
+ const timeRangeToHours = timeRange / 1000 / 60 / 60;
+ return {unit: UNITS.HOUR, value: roundToHundredth(timeRangeToHours)};
+ }
+ if (timeRange < 1000 * 60 * 60 * 24 * 7) {
+ const timeRangeToDays = timeRange / 1000 / 60 / 60 / 24;
+ return {unit: UNITS.DAY, value: roundToHundredth(timeRangeToDays)};
+ }
+ const timeRangeToWeeks = timeRange / 1000 / 60 / 60 / 24 / 7;
+ return {unit: UNITS.WEEK, value: roundToHundredth(timeRangeToWeeks)};
+};
+
+export const formatRange = (value: number, unit: UNIT_TYPE): string => {
+ if (value === 0) return `0${unitShort[UNITS.SECOND]}`;
+
+ const parts: string[] = [];
+ let remainingValue = value;
+
+ const addPart = (currentUnit: UNIT_TYPE, nextUnit: UNIT_TYPE | null, divisor: number): void => {
+ if (remainingValue > 0) {
+ const wholePart = Math.floor(remainingValue);
+ const fraction = remainingValue - wholePart;
+
+ if (wholePart > 0 || currentUnit === unit) {
+ parts.push(`${wholePart}${unitShort[currentUnit]}`);
+ }
+
+ if (fraction > 0 && nextUnit !== null) {
+ remainingValue = Math.round(fraction * divisor * 100) / 100; // Round to 2 decimal places
+ } else {
+ remainingValue = 0;
+ }
+ }
+ };
+
+ switch (unit) {
+ case UNITS.YEAR:
+ addPart(UNITS.YEAR, UNITS.DAY, 365);
+ addPart(UNITS.DAY, UNITS.HOUR, 24);
+ addPart(UNITS.HOUR, UNITS.MINUTE, 60);
+ addPart(UNITS.MINUTE, null, 60);
+ break;
+ case UNITS.WEEK:
+ addPart(UNITS.WEEK, UNITS.DAY, 7);
+ addPart(UNITS.DAY, UNITS.HOUR, 24);
+ addPart(UNITS.HOUR, UNITS.MINUTE, 60);
+ addPart(UNITS.MINUTE, null, 60);
+ break;
+ case UNITS.DAY:
+ addPart(UNITS.DAY, UNITS.HOUR, 24);
+ addPart(UNITS.HOUR, UNITS.MINUTE, 60);
+ addPart(UNITS.MINUTE, null, 60);
+ break;
+ case UNITS.HOUR:
+ addPart(UNITS.HOUR, UNITS.MINUTE, 60);
+ addPart(UNITS.MINUTE, null, 60);
+ break;
+ case UNITS.MINUTE:
+ addPart(UNITS.MINUTE, UNITS.SECOND, 60);
+ addPart(UNITS.SECOND, null, 1);
+ break;
+ case UNITS.SECOND:
+ addPart(UNITS.SECOND, null, 1);
+ break;
+ }
+
+ return parts.join('');
+};
+
+export const NOW = new RelativeDate(UNITS.MINUTE, 0);
diff --git a/ui/packages/shared/profile/src/components/VisualisationToolbar/index.tsx b/ui/packages/shared/profile/src/components/VisualisationToolbar/index.tsx
index 2564a0987c9..3adfcf4d8c8 100644
--- a/ui/packages/shared/profile/src/components/VisualisationToolbar/index.tsx
+++ b/ui/packages/shared/profile/src/components/VisualisationToolbar/index.tsx
@@ -69,8 +69,6 @@ const VisualisationToolbar = ({
const {profileViewExternalMainActions, profileViewExternalSubActions, preferencesModal} =
useParcaContext();
- console.log('🚀 ~ preferencesModal:', preferencesModal);
-
const [groupBy, setStoreGroupBy] = useURLState('group_by', {
defaultValue: [FIELD_FUNCTION_NAME],
alwaysReturnArray: true,