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,