mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-03 08:33:26 +00:00
feat: Improved UX for Date Time Picker (#9944)
* feat: enable inline edit for selected time * feat: handle absolute and relative time formats * feat: support epoch timeranges * feat: match styles for datetime shorthand pill * feat: handle time range error flows * feat: hide last refreshed for custom time ranges * feat: cleanup * feat: use calendar range for custom date time picker * fix: update tests to check for run query rather than stage & run query * feat: pass modalStartTime & modalEndTime in cases where isModalSelection is true * feat: pass minTime & maxTime for CustomPicker in topnav component * fix: add safety check for selected time * feat: show recently use custom time ranges * fix: cancel query button light mode text color * feat: move calendar content to a container * feat: address review comments
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { Calendar } from '@signozhq/calendar';
|
||||
import { Button } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { CalendarIcon, Check, X } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { DateRange } from './CustomTimePickerPopoverContent';
|
||||
|
||||
function CalendarContainer({
|
||||
dateRange,
|
||||
onSelectDateRange,
|
||||
onCancel,
|
||||
onApply,
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
onSelectDateRange: (dateRange: DateRange) => void;
|
||||
onCancel: () => void;
|
||||
onApply: () => void;
|
||||
}): JSX.Element {
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
return (
|
||||
<div className="calendar-container">
|
||||
<div className="calendar-container-header">
|
||||
<CalendarIcon size={12} />
|
||||
<div className="calendar-container-header-title">
|
||||
{dayjs(dateRange?.from)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATE_SHORT)}{' '}
|
||||
-{' '}
|
||||
{dayjs(dateRange?.to)
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATE_SHORT)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="calendar-container-body">
|
||||
<Calendar
|
||||
mode="range"
|
||||
required
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
disabled={{
|
||||
after: dayjs().toDate(),
|
||||
}}
|
||||
onSelect={onSelectDateRange}
|
||||
/>
|
||||
|
||||
<div className="calendar-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn secondary cancel-btn"
|
||||
onClick={onCancel}
|
||||
icon={<X size={12} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary apply-btn"
|
||||
onClick={onApply}
|
||||
icon={<Check size={12} />}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarContainer;
|
||||
@@ -36,7 +36,6 @@
|
||||
}
|
||||
|
||||
.time-selection-dropdown-content {
|
||||
min-width: 172px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -48,18 +47,16 @@
|
||||
padding: 4px 8px;
|
||||
padding-left: 0px !important;
|
||||
|
||||
&.custom-time {
|
||||
input:not(:focus) {
|
||||
min-width: 280px;
|
||||
input {
|
||||
width: 280px;
|
||||
|
||||
&::placeholder {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: white;
|
||||
}
|
||||
|
||||
input:focus::placeholder {
|
||||
color: rgba($color: #ffffff, $alpha: 0.4);
|
||||
&:focus::placeholder {
|
||||
color: rgba($color: #ffffff, $alpha: 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +172,26 @@
|
||||
}
|
||||
|
||||
.time-input-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
|
||||
width: 36px;
|
||||
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: var(--bg-ink-200);
|
||||
|
||||
&.is-live {
|
||||
background-color: transparent;
|
||||
color: var(--bg-forest-500);
|
||||
}
|
||||
|
||||
.live-dot-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-forest-500);
|
||||
animation: ripple 1s infinite;
|
||||
@@ -191,7 +205,7 @@
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
70% {
|
||||
60% {
|
||||
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
|
||||
}
|
||||
100% {
|
||||
@@ -251,6 +265,11 @@
|
||||
background: rgb(179 179 179 / 15%);
|
||||
}
|
||||
|
||||
.time-input-prefix {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.time-input-suffix-icon-badge {
|
||||
color: var(--bg-ink-100);
|
||||
background: rgb(179 179 179 / 15%);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import './CustomTimePicker.styles.scss';
|
||||
|
||||
import { Input, Popover, Tooltip, Typography } from 'antd';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -13,10 +14,9 @@ import {
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import dayjs from 'dayjs';
|
||||
import { isValidTimeFormat } from 'lib/getMinMax';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { CheckCircle, ChevronDown, Clock } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
ChangeEvent,
|
||||
@@ -25,20 +25,26 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { TimeRangeValidationResult, validateTimeRange } from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent';
|
||||
|
||||
const maxAllowedMinTimeInMonths = 6;
|
||||
const maxAllowedMinTimeInMonths = 15;
|
||||
type ViewType = 'datetime' | 'timezone';
|
||||
const DEFAULT_VIEW: ViewType = 'datetime';
|
||||
|
||||
export enum CustomTimePickerInputStatus {
|
||||
SUCCESS = 'success',
|
||||
ERROR = 'error',
|
||||
UNSET = '',
|
||||
}
|
||||
|
||||
interface CustomTimePickerProps {
|
||||
onSelect: (value: string) => void;
|
||||
onError: (value: boolean) => void;
|
||||
@@ -64,6 +70,8 @@ interface CustomTimePickerProps {
|
||||
onExitLiveLogs?: () => void;
|
||||
/** When false, hides the "Recently Used" time ranges section */
|
||||
showRecentlyUsed?: boolean;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -84,23 +92,24 @@ function CustomTimePicker({
|
||||
onExitLiveLogs,
|
||||
showLiveLogs,
|
||||
showRecentlyUsed = true,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
setSelectedTimePlaceholderValue,
|
||||
] = useState('Select / Enter Time Range');
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [inputStatus, setInputStatus] = useState<'' | 'error' | 'success'>('');
|
||||
const [inputErrorMessage, setInputErrorMessage] = useState<string | null>(
|
||||
null,
|
||||
const [inputStatus, setInputStatus] = useState<CustomTimePickerInputStatus>(
|
||||
CustomTimePickerInputStatus.UNSET,
|
||||
);
|
||||
const [inputErrorDetails, setInputErrorDetails] = useState<
|
||||
TimeRangeValidationResult['errorDetails'] | null
|
||||
>(null);
|
||||
const location = useLocation();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
|
||||
|
||||
@@ -123,12 +132,48 @@ function CustomTimePicker({
|
||||
|
||||
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
|
||||
|
||||
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
|
||||
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
|
||||
const getSelectedTimeRangeLabelInRelativeFormat = (
|
||||
selectedTime: string,
|
||||
): string => {
|
||||
if (!selectedTime || selectedTime === 'custom') {
|
||||
return selectedTime || '';
|
||||
}
|
||||
|
||||
// Check if the format matches the relative time format (e.g., 1m, 2h, 3d, 4w)
|
||||
const match = selectedTime.match(/^(\d+)([mhdw])$/);
|
||||
if (!match) {
|
||||
// If it doesn't match the format, return as is
|
||||
return `Last ${selectedTime}`;
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
// Map unit abbreviations to full words
|
||||
const unitMap: Record<string, { singular: string; plural: string }> = {
|
||||
m: { singular: 'minute', plural: 'minutes' },
|
||||
h: { singular: 'hour', plural: 'hours' },
|
||||
d: { singular: 'day', plural: 'days' },
|
||||
w: { singular: 'week', plural: 'weeks' },
|
||||
};
|
||||
|
||||
const unitLabel = value === 1 ? unitMap[unit].singular : unitMap[unit].plural;
|
||||
|
||||
return `Last ${value} ${unitLabel}`;
|
||||
};
|
||||
|
||||
const getSelectedTimeRangeLabel = (
|
||||
selectedTime: string,
|
||||
selectedTimeValue: string,
|
||||
): string => {
|
||||
if (!selectedTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (selectedTime === 'custom') {
|
||||
// TODO(shaheer): if the user preference is 12 hour format, then convert the date range string to 12-hour format (pick this up while working on 12/24 hour preference feature)
|
||||
// TODO: if the user preference is 12 hour format, then convert the date range string to 12-hour format (pick this up while working on 12/24 hour preference feature)
|
||||
// // Convert the date range string to 12-hour format
|
||||
// const dates = selectedTimeValue.split(' - ');
|
||||
// if (dates.length === 2) {
|
||||
@@ -164,42 +209,90 @@ function CustomTimePicker({
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidTimeFormat(selectedTime)) {
|
||||
return selectedTime;
|
||||
if (isValidShortHandDateTimeFormat(selectedTime)) {
|
||||
return getSelectedTimeRangeLabelInRelativeFormat(selectedTime);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const resetErrorStatus = (): void => {
|
||||
setInputStatus(CustomTimePickerInputStatus.UNSET);
|
||||
onError(false);
|
||||
setInputErrorDetails(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showLiveLogs) {
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
setInputValue('Live');
|
||||
resetErrorStatus();
|
||||
} else {
|
||||
const value = getSelectedTimeRangeLabel(selectedTime, selectedValue);
|
||||
setSelectedTimePlaceholderValue(value);
|
||||
setInputValue(value);
|
||||
resetErrorStatus();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTime, selectedValue, showLiveLogs]);
|
||||
|
||||
const hide = (): void => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const getInputPrefix = (): JSX.Element => {
|
||||
if (showLiveLogs) {
|
||||
return (
|
||||
<span className="time-input-prefix is-live">
|
||||
<span className="live-dot-icon" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const timeDifference = getTimeDifference(
|
||||
Number(minTime / 1000_000),
|
||||
Number(maxTime / 1000_000),
|
||||
);
|
||||
|
||||
return <span className="time-input-prefix">{timeDifference}</span>;
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean): void => {
|
||||
setOpen(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
setCustomDTPickerVisible?.(false);
|
||||
setActiveView('datetime');
|
||||
|
||||
if (showLiveLogs) {
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
setInputValue('Live');
|
||||
return;
|
||||
}
|
||||
|
||||
// set the input value to a relative format if the selected time is not custom
|
||||
const inputValue = getSelectedTimeRangeLabel(selectedTime, selectedValue);
|
||||
setInputValue(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedHandleInputChange = debounce((inputValue): void => {
|
||||
const isValidFormat = /^(\d+)([mhdw])$/.test(inputValue);
|
||||
if (isValidFormat) {
|
||||
setInputStatus('success');
|
||||
onError(false);
|
||||
setInputErrorMessage(null);
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const inputValue = event.target.value;
|
||||
setInputValue(inputValue);
|
||||
|
||||
const match = inputValue.match(/^(\d+)([mhdw])$/);
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
const handleInputPressEnter = (): void => {
|
||||
// check if the entered time is in the format of 1m, 2h, 3d, 4w
|
||||
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);
|
||||
|
||||
if (isTimeDurationShortHandFormat) {
|
||||
setInputStatus(CustomTimePickerInputStatus.SUCCESS);
|
||||
onError(false);
|
||||
setInputErrorDetails(null);
|
||||
|
||||
const match = inputValue.match(/^(\d+)([mhdw])$/) as RegExpMatchArray;
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
@@ -230,9 +323,13 @@ function CustomTimePicker({
|
||||
}
|
||||
|
||||
if (minTime && (!minTime.isValid() || minTime < maxAllowedMinTime)) {
|
||||
setInputStatus('error');
|
||||
setInputStatus(CustomTimePickerInputStatus.ERROR);
|
||||
onError(true);
|
||||
setInputErrorMessage('Please enter time less than 6 months');
|
||||
setInputErrorDetails({
|
||||
message: `Please enter time less than ${maxAllowedMinTimeInMonths} months`,
|
||||
code: 'TIME_LESS_THAN_MAX_ALLOWED_TIME_IN_MONTHS',
|
||||
description: `Please enter time less than ${maxAllowedMinTimeInMonths} months`,
|
||||
});
|
||||
if (isFunction(onCustomTimeStatusUpdate)) {
|
||||
onCustomTimeStatusUpdate(true);
|
||||
}
|
||||
@@ -241,44 +338,64 @@ function CustomTimePicker({
|
||||
time: [minTime, currentTime],
|
||||
timeStr: inputValue,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}
|
||||
} else {
|
||||
setInputStatus('error');
|
||||
onError(true);
|
||||
setInputErrorMessage(null);
|
||||
if (isFunction(onCustomTimeStatusUpdate)) {
|
||||
onCustomTimeStatusUpdate(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const inputValue = event.target.value;
|
||||
// parse the input value to get the start and end time
|
||||
const [startTime, endTime] = inputValue.split(/\s[-–]\s/);
|
||||
|
||||
// check if startTime and endTime are epoch format
|
||||
const { isValid: isValidStartTime, range: epochRange } = validateEpochRange(
|
||||
Number(startTime),
|
||||
Number(endTime),
|
||||
);
|
||||
|
||||
if (isValidStartTime && epochRange?.startTime && epochRange?.endTime) {
|
||||
onCustomDateHandler?.([epochRange?.startTime, epochRange?.endTime]);
|
||||
|
||||
if (inputValue.length > 0) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
setOpen(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setInputValue(inputValue);
|
||||
const {
|
||||
isValid: isValidTimeRange,
|
||||
errorDetails,
|
||||
startTimeMs,
|
||||
endTimeMs,
|
||||
} = validateTimeRange(
|
||||
startTime,
|
||||
endTime,
|
||||
DATE_TIME_FORMATS.UK_DATETIME_SECONDS,
|
||||
);
|
||||
|
||||
// Call the debounced function with the input value
|
||||
debouncedHandleInputChange(inputValue);
|
||||
if (!isValidTimeRange) {
|
||||
setInputStatus(CustomTimePickerInputStatus.ERROR);
|
||||
onError(true);
|
||||
setInputErrorDetails(errorDetails || null);
|
||||
return;
|
||||
}
|
||||
|
||||
onCustomDateHandler?.([dayjs(startTimeMs), dayjs(endTimeMs)]);
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSelect = (label: string, value: string): void => {
|
||||
if (label === 'Custom') {
|
||||
if (value === 'custom') {
|
||||
setCustomDTPickerVisible?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(value);
|
||||
setSelectedTimePlaceholderValue(label);
|
||||
setInputStatus('');
|
||||
onError(false);
|
||||
setInputErrorMessage(null);
|
||||
resetErrorStatus();
|
||||
setInputValue('');
|
||||
|
||||
if (value !== 'custom') {
|
||||
hide();
|
||||
}
|
||||
@@ -305,20 +422,48 @@ function CustomTimePicker({
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleFocus = (): void => {
|
||||
setIsInputFocused(true);
|
||||
setActiveView('datetime');
|
||||
const handleOpen = (e: React.SyntheticEvent): void => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (showLiveLogs) {
|
||||
setOpen(true);
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
setInputValue('Live');
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
// reset the input status and error message as we reset the time to previous correct value
|
||||
resetErrorStatus();
|
||||
|
||||
const startTime = dayjs(minTime / 1000_000).format(
|
||||
DATE_TIME_FORMATS.UK_DATETIME_SECONDS,
|
||||
);
|
||||
const endTime = dayjs(maxTime / 1000_000).format(
|
||||
DATE_TIME_FORMATS.UK_DATETIME_SECONDS,
|
||||
);
|
||||
|
||||
setInputValue(`${startTime} - ${endTime}`);
|
||||
};
|
||||
|
||||
const handleBlur = (): void => {
|
||||
setIsInputFocused(false);
|
||||
const handleClose = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
setCustomDTPickerVisible?.(false);
|
||||
|
||||
if (showLiveLogs) {
|
||||
setInputValue('Live');
|
||||
return;
|
||||
}
|
||||
|
||||
// set the input value to a relative format if the selected time is not custom
|
||||
const inputValue = getSelectedTimeRangeLabel(selectedTime, selectedValue);
|
||||
setInputValue(inputValue);
|
||||
};
|
||||
|
||||
// this is required as TopNav component wraps the components and we need to clear the state on path change
|
||||
useEffect(() => {
|
||||
setInputStatus('');
|
||||
onError(false);
|
||||
setInputErrorMessage(null);
|
||||
resetErrorStatus();
|
||||
setInputValue('');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname]);
|
||||
@@ -335,6 +480,10 @@ function CustomTimePicker({
|
||||
);
|
||||
};
|
||||
|
||||
const handleInputBlur = (): void => {
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
const getTooltipTitle = (): string => {
|
||||
if (selectedTime === 'custom' && inputValue === '' && !open) {
|
||||
return `${dayjs(minTime / 1000_000)
|
||||
@@ -349,27 +498,19 @@ function CustomTimePicker({
|
||||
return '';
|
||||
};
|
||||
|
||||
const getInputPrefix = (): JSX.Element => {
|
||||
if (showLiveLogs) {
|
||||
return (
|
||||
<div className="time-input-prefix">
|
||||
<div className="live-dot-icon" />
|
||||
</div>
|
||||
);
|
||||
// Focus and select input text when popover opens
|
||||
useEffect(() => {
|
||||
if (open && inputRef.current) {
|
||||
// Use setTimeout to wait for React to update the DOM and make input editable
|
||||
setTimeout(() => {
|
||||
const inputElement = inputRef.current?.input;
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
inputElement.select();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-input-prefix">
|
||||
{inputValue && inputStatus === 'success' ? (
|
||||
<CheckCircle size={14} color="#51E7A8" />
|
||||
) : (
|
||||
<Tooltip title="Enter time in format (e.g., 1m, 2h, 3d, 4w)">
|
||||
<Clock size={14} className="cursor-pointer" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="custom-time-picker">
|
||||
@@ -385,9 +526,10 @@ function CustomTimePicker({
|
||||
content={
|
||||
newPopover ? (
|
||||
<CustomTimePickerPopoverContent
|
||||
isLiveLogsEnabled={!!showLiveLogs}
|
||||
setIsOpen={setOpen}
|
||||
customDateTimeVisible={defaultTo(customDateTimeVisible, false)}
|
||||
setCustomDTPickerVisible={defaultTo(setCustomDTPickerVisible, noop)}
|
||||
customDateTimeVisible={defaultTo(customDateTimeVisible, false)}
|
||||
onCustomDateHandler={defaultTo(onCustomDateHandler, noop)}
|
||||
onSelectHandler={handleSelect}
|
||||
onGoLive={defaultTo(onGoLive, noop)}
|
||||
@@ -399,6 +541,10 @@ function CustomTimePicker({
|
||||
setIsOpenedFromFooter={setIsOpenedFromFooter}
|
||||
isOpenedFromFooter={isOpenedFromFooter}
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
customDateTimeInputStatus={inputStatus}
|
||||
inputErrorDetails={inputErrorDetails}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
) : (
|
||||
content
|
||||
@@ -407,25 +553,32 @@ function CustomTimePicker({
|
||||
arrow={false}
|
||||
trigger="click"
|
||||
open={open}
|
||||
destroyTooltipOnHide
|
||||
onOpenChange={handleOpenChange}
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="timeSelection-input"
|
||||
ref={inputRef}
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
inputStatus === CustomTimePickerInputStatus.ERROR ? 'error' : '',
|
||||
)}
|
||||
type="text"
|
||||
status={inputValue && inputStatus === 'error' ? 'error' : ''}
|
||||
placeholder={
|
||||
isInputFocused
|
||||
? 'Time Format (1m or 2h or 3d or 4w)'
|
||||
: selectedTimePlaceholderValue
|
||||
status={
|
||||
inputValue && inputStatus === CustomTimePickerInputStatus.ERROR
|
||||
? 'error'
|
||||
: ''
|
||||
}
|
||||
readOnly={!open || showLiveLogs}
|
||||
placeholder={selectedTimePlaceholderValue}
|
||||
value={inputValue}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleInputPressEnter}
|
||||
onBlur={handleInputBlur}
|
||||
data-1p-ignore
|
||||
prefix={getInputPrefix()}
|
||||
suffix={
|
||||
@@ -435,24 +588,25 @@ function CustomTimePicker({
|
||||
<span>{activeTimezoneOffset}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="cursor-pointer time-input-suffix-icon-badge"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleViewChange('datetime');
|
||||
}}
|
||||
/>
|
||||
|
||||
{open ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className="cursor-pointer time-input-suffix-icon-badge"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="cursor-pointer time-input-suffix-icon-badge"
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{inputStatus === 'error' && inputErrorMessage && (
|
||||
<Typography.Title level={5} className="valid-format-error">
|
||||
{inputErrorMessage}
|
||||
</Typography.Title>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import DatePickerV2 from 'components/DatePickerV2/DatePickerV2';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import dayjs from 'dayjs';
|
||||
import { Clock, PenLine } from 'lucide-react';
|
||||
import { Clock, PenLine, TriangleAlertIcon } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
Dispatch,
|
||||
@@ -27,10 +26,23 @@ import {
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getCustomTimeRanges } from 'utils/customTimeRangeUtils';
|
||||
import { TimeRangeValidationResult } from 'utils/timeUtils';
|
||||
|
||||
import CalendarContainer from './CalendarContainer';
|
||||
import { CustomTimePickerInputStatus } from './CustomTimePicker';
|
||||
import TimezonePicker from './TimezonePicker';
|
||||
|
||||
const TO_MILLISECONDS_FACTOR = 1000_000;
|
||||
|
||||
export type DateRange = {
|
||||
from: Date | undefined;
|
||||
to?: Date | undefined;
|
||||
};
|
||||
|
||||
interface CustomTimePickerPopoverContentProps {
|
||||
isLiveLogsEnabled: boolean;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
options: any[];
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
customDateTimeVisible: boolean;
|
||||
@@ -48,6 +60,8 @@ interface CustomTimePickerPopoverContentProps {
|
||||
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
|
||||
onExitLiveLogs: () => void;
|
||||
showRecentlyUsed: boolean;
|
||||
customDateTimeInputStatus: CustomTimePickerInputStatus;
|
||||
inputErrorDetails: TimeRangeValidationResult['errorDetails'] | null;
|
||||
}
|
||||
|
||||
interface RecentlyUsedDateTimeRange {
|
||||
@@ -58,8 +72,29 @@ interface RecentlyUsedDateTimeRange {
|
||||
to: string;
|
||||
}
|
||||
|
||||
const getDateRange = (
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
timezone: string,
|
||||
): DateRange => {
|
||||
const from = dayjs(minTime / TO_MILLISECONDS_FACTOR)
|
||||
.tz(timezone)
|
||||
.startOf('day')
|
||||
.toDate();
|
||||
|
||||
const to = dayjs(maxTime / TO_MILLISECONDS_FACTOR)
|
||||
.tz(timezone)
|
||||
.endOf('day')
|
||||
.toDate();
|
||||
|
||||
return { from, to };
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function CustomTimePickerPopoverContent({
|
||||
isLiveLogsEnabled,
|
||||
minTime,
|
||||
maxTime,
|
||||
options,
|
||||
setIsOpen,
|
||||
customDateTimeVisible,
|
||||
@@ -74,6 +109,8 @@ function CustomTimePickerPopoverContent({
|
||||
setIsOpenedFromFooter,
|
||||
onExitLiveLogs,
|
||||
showRecentlyUsed = true,
|
||||
customDateTimeInputStatus = CustomTimePickerInputStatus.UNSET,
|
||||
inputErrorDetails,
|
||||
}: CustomTimePickerPopoverContentProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -83,6 +120,9 @@ function CustomTimePickerPopoverContent({
|
||||
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
const activeTimezoneOffset = timezone.offset;
|
||||
|
||||
let panelTypeFromURL = url.get(QueryParams.panelTypes);
|
||||
|
||||
try {
|
||||
@@ -94,8 +134,9 @@ function CustomTimePickerPopoverContent({
|
||||
const isLogsListView =
|
||||
panelTypeFromURL !== 'table' && panelTypeFromURL !== 'graph'; // we do not select list view in the url
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
const activeTimezoneOffset = timezone.offset;
|
||||
const [dateRange, setDateRange] = useState<DateRange>(() =>
|
||||
getDateRange(minTime, maxTime, timezone.value),
|
||||
);
|
||||
|
||||
const [recentlyUsedTimeRanges, setRecentlyUsedTimeRanges] = useState<
|
||||
RecentlyUsedDateTimeRange[]
|
||||
@@ -177,36 +218,66 @@ function CustomTimePickerPopoverContent({
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectDateRange = (dateRange: DateRange): void => {
|
||||
setDateRange(dateRange);
|
||||
};
|
||||
|
||||
const handleCalendarRangeApply = (): void => {
|
||||
if (dateRange) {
|
||||
const from = dayjs(dateRange.from)
|
||||
.tz(timezone.value)
|
||||
.startOf('day')
|
||||
.toDate();
|
||||
const to = dayjs(dateRange.to).tz(timezone.value).endOf('day').toDate();
|
||||
|
||||
onCustomDateHandler([dayjs(from), dayjs(to)]);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCalendarRangeCancel = (): void => {
|
||||
setCustomDTPickerVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="date-time-popover">
|
||||
{!customDateTimeVisible && (
|
||||
<div className="date-time-options">
|
||||
{isLogsExplorerPage && isLogsListView && (
|
||||
<Button className="data-time-live" type="text" onClick={handleGoLive}>
|
||||
Live
|
||||
</Button>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
}}
|
||||
className={cx(
|
||||
'date-time-options-btn',
|
||||
customDateTimeVisible
|
||||
? option.value === 'custom' && 'active'
|
||||
: selectedTime === option.value && 'active',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="date-time-options">
|
||||
{isLogsExplorerPage && isLogsListView && (
|
||||
<Button
|
||||
className={cx('data-time-live', isLiveLogsEnabled ? 'active' : '')}
|
||||
type="text"
|
||||
onClick={handleGoLive}
|
||||
>
|
||||
Live
|
||||
</Button>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
}}
|
||||
className={cx(
|
||||
'date-time-options-btn',
|
||||
customDateTimeVisible
|
||||
? option.value === 'custom' && !isLiveLogsEnabled && 'active'
|
||||
: selectedTime === option.value && !isLiveLogsEnabled && 'active',
|
||||
)}
|
||||
>
|
||||
<span className="time-label">{option.label}</span>
|
||||
|
||||
{option.value !== 'custom' && option.value !== '1month' && (
|
||||
<span className="time-value">{option.value}</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'relative-date-time',
|
||||
@@ -214,19 +285,38 @@ function CustomTimePickerPopoverContent({
|
||||
)}
|
||||
>
|
||||
{customDateTimeVisible ? (
|
||||
<DatePickerV2
|
||||
onSetCustomDTPickerVisible={setCustomDTPickerVisible}
|
||||
setIsOpen={setIsOpen}
|
||||
onCustomDateHandler={onCustomDateHandler}
|
||||
<CalendarContainer
|
||||
dateRange={dateRange}
|
||||
onSelectDateRange={handleSelectDateRange}
|
||||
onCancel={handleCalendarRangeCancel}
|
||||
onApply={handleCalendarRangeApply}
|
||||
/>
|
||||
) : (
|
||||
<div className="time-selector-container">
|
||||
{customDateTimeInputStatus === CustomTimePickerInputStatus.ERROR &&
|
||||
inputErrorDetails && (
|
||||
<div className="input-error-message-container">
|
||||
<div className="input-error-message-title">
|
||||
<TriangleAlertIcon color={Color.BG_CHERRY_400} size={16} />
|
||||
<span className="input-error-message-text">
|
||||
{inputErrorDetails.message}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{inputErrorDetails.description && (
|
||||
<p className="input-error-message-description">
|
||||
{inputErrorDetails.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative-times-container">
|
||||
<div className="time-heading">RELATIVE TIMES</div>
|
||||
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
|
||||
</div>
|
||||
|
||||
{showRecentlyUsed && (
|
||||
{showRecentlyUsed && recentlyUsedTimeRanges.length > 0 && (
|
||||
<div className="recently-used-container">
|
||||
<div className="time-heading">RECENTLY USED</div>
|
||||
<div className="recently-used-range">
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
.date-picker-v2-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.custom-date-time-picker-v2 {
|
||||
padding: 12px;
|
||||
|
||||
.periscope-calendar {
|
||||
border-radius: 4px;
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
|
||||
.periscope-calendar-day {
|
||||
background: none !important;
|
||||
|
||||
&.periscope-calendar-today {
|
||||
&.text-accent-foreground {
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&:hover {
|
||||
background-color: var(--bg-robin-500) !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-time-selector {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.time-input {
|
||||
border-radius: 4px;
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
padding: 8px 4px !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-date-time-picker-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
|
||||
.next-btn {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invalid-date-range-tooltip {
|
||||
.ant-tooltip-inner {
|
||||
color: var(--bg-sakura-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.custom-date-time-picker-v2 {
|
||||
.periscope-calendar-day {
|
||||
&.periscope-calendar-today {
|
||||
&.text-accent-foreground {
|
||||
color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&:hover {
|
||||
background-color: var(--bg-robin-500) !important;
|
||||
color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-time-selector {
|
||||
.time-input {
|
||||
color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import './DatePickerV2.styles.scss';
|
||||
|
||||
import { Calendar } from '@signozhq/calendar';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { CornerUpLeft, MoveRight } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
|
||||
function DatePickerV2({
|
||||
onSetCustomDTPickerVisible,
|
||||
setIsOpen,
|
||||
onCustomDateHandler,
|
||||
}: {
|
||||
onSetCustomDTPickerVisible: (visible: boolean) => void;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
onCustomDateHandler: (
|
||||
dateTimeRange: DateTimeRangeType,
|
||||
lexicalContext?: LexicalContext,
|
||||
) => void;
|
||||
}): JSX.Element {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const timeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const [selectedDateTimeFor, setSelectedDateTimeFor] = useState<'to' | 'from'>(
|
||||
'from',
|
||||
);
|
||||
|
||||
const [selectedFromDateTime, setSelectedFromDateTime] = useState<Dayjs | null>(
|
||||
dayjs(minTime / 1000_000).tz(timezone.value),
|
||||
);
|
||||
|
||||
const [selectedToDateTime, setSelectedToDateTime] = useState<Dayjs | null>(
|
||||
dayjs(maxTime / 1000_000).tz(timezone.value),
|
||||
);
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (selectedDateTimeFor === 'to') {
|
||||
onCustomDateHandler([selectedFromDateTime, selectedToDateTime]);
|
||||
|
||||
addCustomTimeRange([selectedFromDateTime, selectedToDateTime]);
|
||||
|
||||
setIsOpen(false);
|
||||
onSetCustomDTPickerVisible(false);
|
||||
setSelectedDateTimeFor('from');
|
||||
} else {
|
||||
setSelectedDateTimeFor('to');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = (date: Date | undefined): void => {
|
||||
if (!date) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDateTimeFor === 'from') {
|
||||
const prevFromDateTime = selectedFromDateTime;
|
||||
|
||||
const newDate = dayjs(date);
|
||||
|
||||
const updatedFromDateTime = prevFromDateTime
|
||||
? prevFromDateTime
|
||||
.year(newDate.year())
|
||||
.month(newDate.month())
|
||||
.date(newDate.date())
|
||||
: dayjs(date).tz(timezone.value);
|
||||
|
||||
setSelectedFromDateTime(updatedFromDateTime);
|
||||
} else {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
setSelectedToDateTime((prev) => {
|
||||
const newDate = dayjs(date);
|
||||
|
||||
// Update only the date part, keeping time from existing state
|
||||
return prev
|
||||
? prev.year(newDate.year()).month(newDate.month()).date(newDate.date())
|
||||
: dayjs(date).tz(timezone.value);
|
||||
});
|
||||
}
|
||||
|
||||
// focus the time input
|
||||
timeInputRef?.current?.focus();
|
||||
};
|
||||
|
||||
const handleTimeChange = (time: string): void => {
|
||||
// time should have format HH:mm:ss
|
||||
if (!/^\d{2}:\d{2}:\d{2}$/.test(time)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDateTimeFor === 'from') {
|
||||
setSelectedFromDateTime((prev) => {
|
||||
if (prev) {
|
||||
return prev
|
||||
.set('hour', parseInt(time.split(':')[0], 10))
|
||||
.set('minute', parseInt(time.split(':')[1], 10))
|
||||
.set('second', parseInt(time.split(':')[2], 10));
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
if (selectedDateTimeFor === 'to') {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
setSelectedToDateTime((prev) => {
|
||||
if (prev) {
|
||||
return prev
|
||||
.set('hour', parseInt(time.split(':')[0], 10))
|
||||
.set('minute', parseInt(time.split(':')[1], 10))
|
||||
.set('second', parseInt(time.split(':')[2], 10));
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultMonth = (): Date => {
|
||||
let defaultDate = null;
|
||||
|
||||
if (selectedDateTimeFor === 'from') {
|
||||
defaultDate = selectedFromDateTime?.toDate();
|
||||
} else if (selectedDateTimeFor === 'to') {
|
||||
defaultDate = selectedToDateTime?.toDate();
|
||||
}
|
||||
|
||||
return defaultDate ?? new Date();
|
||||
};
|
||||
|
||||
const isValidRange = (): boolean => {
|
||||
if (selectedDateTimeFor === 'to') {
|
||||
return selectedToDateTime?.isAfter(selectedFromDateTime) ?? false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleBack = (): void => {
|
||||
setSelectedDateTimeFor('from');
|
||||
};
|
||||
|
||||
const handleHideCustomDTPicker = (): void => {
|
||||
onSetCustomDTPickerVisible(false);
|
||||
};
|
||||
|
||||
const handleSelectDateTimeFor = (selectedDateTimeFor: 'to' | 'from'): void => {
|
||||
setSelectedDateTimeFor(selectedDateTimeFor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="date-picker-v2-container">
|
||||
<div className="date-time-custom-options-container">
|
||||
<div
|
||||
className="back-btn"
|
||||
onClick={handleHideCustomDTPicker}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleHideCustomDTPicker();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CornerUpLeft size={16} />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
|
||||
<div className="date-time-custom-options">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSelectDateTimeFor('from');
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
'date-time-custom-option-from',
|
||||
selectedDateTimeFor === 'from' && 'active',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
handleSelectDateTimeFor('from');
|
||||
}}
|
||||
>
|
||||
<div className="date-time-custom-option-from-title">FROM</div>
|
||||
<div className="date-time-custom-option-from-value">
|
||||
{selectedFromDateTime?.format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSelectDateTimeFor('to');
|
||||
}
|
||||
}}
|
||||
className={cx(
|
||||
'date-time-custom-option-to',
|
||||
selectedDateTimeFor === 'to' && 'active',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
handleSelectDateTimeFor('to');
|
||||
}}
|
||||
>
|
||||
<div className="date-time-custom-option-to-title">TO</div>
|
||||
<div className="date-time-custom-option-to-value">
|
||||
{selectedToDateTime?.format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="custom-date-time-picker-v2">
|
||||
<Calendar
|
||||
mode="single"
|
||||
required
|
||||
selected={
|
||||
selectedDateTimeFor === 'from'
|
||||
? selectedFromDateTime?.toDate()
|
||||
: selectedToDateTime?.toDate()
|
||||
}
|
||||
key={selectedDateTimeFor + selectedDateTimeFor}
|
||||
onSelect={handleDateChange}
|
||||
defaultMonth={getDefaultMonth()}
|
||||
disabled={(current): boolean => {
|
||||
if (selectedDateTimeFor === 'to') {
|
||||
// disable dates after today and before selectedFromDateTime
|
||||
const currentDay = dayjs(current);
|
||||
return currentDay.isAfter(dayjs()) || false;
|
||||
}
|
||||
|
||||
if (selectedDateTimeFor === 'from') {
|
||||
// disable dates after selectedToDateTime
|
||||
|
||||
return dayjs(current).isAfter(dayjs()) || false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}}
|
||||
className="rounded-md border"
|
||||
navLayout="after"
|
||||
/>
|
||||
|
||||
<div className="custom-time-selector">
|
||||
<label className="text-xs font-normal block" htmlFor="time-picker">
|
||||
Timestamp
|
||||
</label>
|
||||
|
||||
<MoveRight size={16} />
|
||||
|
||||
<div className="time-input-container">
|
||||
<Input
|
||||
type="time"
|
||||
ref={timeInputRef}
|
||||
className="time-input"
|
||||
value={
|
||||
selectedDateTimeFor === 'from'
|
||||
? selectedFromDateTime?.format('HH:mm:ss')
|
||||
: selectedToDateTime?.format('HH:mm:ss')
|
||||
}
|
||||
onChange={(e): void => handleTimeChange(e.target.value)}
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-date-time-picker-footer">
|
||||
{selectedDateTimeFor === 'to' && (
|
||||
<Button
|
||||
className="periscope-btn secondary clear-btn"
|
||||
type="default"
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip
|
||||
title={
|
||||
!isValidRange() ? 'Invalid range: TO date should be after FROM date' : ''
|
||||
}
|
||||
overlayClassName="invalid-date-range-tooltip"
|
||||
>
|
||||
<Button
|
||||
className="periscope-btn primary next-btn"
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
disabled={!isValidRange()}
|
||||
>
|
||||
{selectedDateTimeFor === 'from' ? 'Next' : 'Apply'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatePickerV2;
|
||||
@@ -176,6 +176,8 @@ function HostMetricTraces({
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,8 @@ function HostMetricLogsDetailedView({
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,6 +211,8 @@ function Metrics({
|
||||
defaultRelativeTime="5m"
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ jest.mock('lib/getMinMax', () => ({
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
minTime: 1713734400000,
|
||||
maxTime: 1713738000000,
|
||||
isValidTimeFormat: jest.fn().mockReturnValue(true),
|
||||
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
|
||||
})),
|
||||
}));
|
||||
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
|
||||
|
||||
@@ -266,6 +266,8 @@ export default function Events({
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,6 +93,8 @@ function EntityLogsDetailedView({
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,6 +258,8 @@ function EntityMetrics<T>({
|
||||
defaultRelativeTime="5m"
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,6 +188,8 @@ function EntityTraces({
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ const setupCommonMocks = (): void => {
|
||||
minTime: 1713734400000,
|
||||
maxTime: 1713738000000,
|
||||
})),
|
||||
isValidTimeFormat: jest.fn().mockReturnValue(true),
|
||||
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
|
||||
@@ -518,15 +518,15 @@ describe('Logs Explorer -> stage and run query', () => {
|
||||
const initialStart = initialPayload.start;
|
||||
const initialEnd = initialPayload.end;
|
||||
|
||||
// Click the Stage & Run Query button
|
||||
// Click the Run Query button
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: /stage & run query/i,
|
||||
name: /run query/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for additional API calls to be made after clicking Stage & Run Query
|
||||
// Wait for additional API calls to be made after clicking Run Query
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(capturedPayloads.length).toBeGreaterThan(1);
|
||||
|
||||
@@ -158,6 +158,8 @@ function PublicDashboardContainer({
|
||||
modalSelectedInterval={selectedTimeRangeLabel as Time}
|
||||
disableUrlSync
|
||||
showRecentlyUsed={false}
|
||||
modalInitialStartTime={selectedTimeRange.startTime * 1000}
|
||||
modalInitialEndTime={selectedTimeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import './ToolbarActions.styles.scss';
|
||||
import { Button } from 'antd';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { Play, X } from 'lucide-react';
|
||||
import { Loader2, Play } from 'lucide-react';
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
@@ -37,37 +37,49 @@ export default function RightToolbarActions({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onStageRunQuery, showLiveLogs]);
|
||||
|
||||
if (showLiveLogs) return <div />;
|
||||
if (showLiveLogs)
|
||||
return (
|
||||
<div className="right-toolbar-actions-container">
|
||||
<Button
|
||||
type="primary"
|
||||
className="run-query-btn periscope-btn primary"
|
||||
disabled
|
||||
icon={<Play size={14} />}
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleCancelQuery = (): void => {
|
||||
if (listQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
if (chartQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(chartQueryKeyRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="right-toolbar-actions-container">
|
||||
{isLoadingQueries ? (
|
||||
<div className="loading-container">
|
||||
<Button className="loading-btn" loading={isLoadingQueries} />
|
||||
<Button
|
||||
icon={<X size={14} />}
|
||||
className="cancel-run"
|
||||
onClick={(): void => {
|
||||
if (listQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
if (chartQueryKeyRef?.current) {
|
||||
queryClient.cancelQueries(chartQueryKeyRef.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel Run
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<Loader2 size={14} className="loading-icon animate-spin" />}
|
||||
className="cancel-query-btn periscope-btn danger"
|
||||
onClick={handleCancelQuery}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
className="right-toolbar"
|
||||
className="run-query-btn periscope-btn primary"
|
||||
disabled={isLoadingQueries}
|
||||
onClick={onStageRunQuery}
|
||||
icon={<Play size={14} />}
|
||||
>
|
||||
Stage & Run Query
|
||||
Run Query
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -97,9 +97,9 @@ describe('ToolbarActions', () => {
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
|
||||
const stageNRunBtn = queryByText('Stage & Run Query');
|
||||
expect(stageNRunBtn).toBeInTheDocument();
|
||||
await userEvent.click(stageNRunBtn as HTMLElement);
|
||||
const runQueryBtn = queryByText('Run Query');
|
||||
expect(runQueryBtn).toBeInTheDocument();
|
||||
await userEvent.click(runQueryBtn as HTMLElement);
|
||||
expect(onStageRunQuery).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,26 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.right-toolbar-actions-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.cancel-query-btn {
|
||||
min-width: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.run-query-btn {
|
||||
min-width: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeRange {
|
||||
|
||||
@@ -366,6 +366,8 @@ function DateTimeSelection({
|
||||
)}
|
||||
data-testid="dropDown"
|
||||
items={options}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
|
||||
<FormItem hidden={refreshButtonHidden}>
|
||||
|
||||
@@ -139,7 +139,22 @@
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
.time-value {
|
||||
font-size: 11px;
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: var(--bg-ink-200);
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-time-options-btn:hover {
|
||||
&.ant-btn-text {
|
||||
background-color: rgba(171, 189, 255, 0.04) !important;
|
||||
@@ -245,6 +260,107 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
|
||||
.input-error-message-container {
|
||||
color: var(--bg-cherry-400);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
background-color: rgba(255, 219, 218, 0.1);
|
||||
border-radius: 3px;
|
||||
|
||||
.input-error-message-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-error-message-description {
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.calendar-container-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 24px;
|
||||
width: 100%;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
line-height: 19px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.calendar-container-body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.periscope-calendar {
|
||||
border-radius: 4px;
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
padding: 0px !important;
|
||||
padding-top: 8px !important;
|
||||
}
|
||||
|
||||
.periscope-calendar-day {
|
||||
background: none !important;
|
||||
|
||||
&.periscope-calendar-today {
|
||||
&.text-accent-foreground {
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&:hover {
|
||||
background-color: var(--bg-robin-500) !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.apply-btn,
|
||||
.cancel-btn {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recently-used-container {
|
||||
@@ -335,6 +451,11 @@
|
||||
|
||||
.date-time-options-btn {
|
||||
color: var(--bg-slate-400);
|
||||
|
||||
.time-value {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
@@ -432,6 +553,23 @@
|
||||
background: var(--bg-vanilla-100);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
.periscope-calendar-day {
|
||||
&.periscope-calendar-today {
|
||||
&.text-accent-foreground {
|
||||
color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&:hover {
|
||||
background-color: var(--bg-robin-500) !important;
|
||||
color: var(--bg-ink-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
|
||||
@@ -60,7 +60,7 @@ export const Options: Option[] = [
|
||||
{ value: '3d', label: 'Last 3 days' },
|
||||
{ value: '1w', label: 'Last 1 week' },
|
||||
{ value: '1month', label: 'Last 1 month' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
{ value: 'custom', label: 'Custom Date Range' },
|
||||
];
|
||||
|
||||
export interface Option {
|
||||
|
||||
@@ -14,7 +14,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isValidTimeFormat } from 'lib/getMinMax';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
import { cloneDeep, isObject } from 'lodash-es';
|
||||
import { Undo } from 'lucide-react';
|
||||
@@ -29,6 +29,7 @@ import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -73,6 +74,11 @@ function DateTimeSelection({
|
||||
const navigationType = useNavigationType(); // Returns 'POP' for back/forward navigation
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { maxTime, minTime, selectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const [hasSelectedTimeError, setHasSelectedTimeError] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
@@ -174,11 +180,6 @@ function DateTimeSelection({
|
||||
|
||||
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const { maxTime, minTime, selectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const getInputLabel = (
|
||||
startTime?: Dayjs,
|
||||
endTime?: Dayjs,
|
||||
@@ -194,16 +195,6 @@ function DateTimeSelection({
|
||||
return timeInterval;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTime === 'custom') {
|
||||
setRefreshButtonHidden(true);
|
||||
setCustomDTPickerVisible(true);
|
||||
} else {
|
||||
setRefreshButtonHidden(false);
|
||||
setCustomDTPickerVisible(false);
|
||||
}
|
||||
}, [selectedTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalTimeSelection && modalSelectedInterval === 'custom') {
|
||||
setCustomDTPickerVisible(true);
|
||||
@@ -408,12 +399,15 @@ function DateTimeSelection({
|
||||
const startTime = startTimeMoment;
|
||||
const endTime = endTimeMoment;
|
||||
setCustomDTPickerVisible(false);
|
||||
setRefreshButtonHidden(true);
|
||||
|
||||
updateTimeInterval('custom', [
|
||||
startTime.toDate().getTime(),
|
||||
endTime.toDate().getTime(),
|
||||
]);
|
||||
|
||||
addCustomTimeRange([startTime, endTime]);
|
||||
|
||||
setLocalStorageKey('startTime', startTime.toString());
|
||||
setLocalStorageKey('endTime', endTime.toString());
|
||||
|
||||
@@ -469,7 +463,10 @@ function DateTimeSelection({
|
||||
): Time | CustomTimeType => {
|
||||
// if the relativeTime param is present in the url give top most preference to the same
|
||||
// if the relativeTime param is not valid then move to next preference
|
||||
if (relativeTimeFromUrl != null && isValidTimeFormat(relativeTimeFromUrl)) {
|
||||
if (
|
||||
relativeTimeFromUrl != null &&
|
||||
isValidShortHandDateTimeFormat(relativeTimeFromUrl)
|
||||
) {
|
||||
return relativeTimeFromUrl as Time;
|
||||
}
|
||||
|
||||
@@ -544,7 +541,7 @@ function DateTimeSelection({
|
||||
|
||||
if (
|
||||
relativeTimeFromUrl &&
|
||||
isValidTimeFormat(relativeTimeFromUrl) &&
|
||||
isValidShortHandDateTimeFormat(relativeTimeFromUrl) &&
|
||||
relativeTimeFromUrl !== selectedTime
|
||||
) {
|
||||
handleRelativeTimeSync(relativeTimeFromUrl);
|
||||
@@ -588,7 +585,7 @@ function DateTimeSelection({
|
||||
!searchStartTime &&
|
||||
!searchEndTime &&
|
||||
relativeTimeFromUrl &&
|
||||
isValidTimeFormat(relativeTimeFromUrl)
|
||||
isValidShortHandDateTimeFormat(relativeTimeFromUrl)
|
||||
) {
|
||||
handleRelativeTimeSync(relativeTimeFromUrl);
|
||||
}
|
||||
@@ -660,6 +657,14 @@ function DateTimeSelection({
|
||||
);
|
||||
};
|
||||
|
||||
const minTimeForDateTimePicker = isModalTimeSelection
|
||||
? modalStartTime * 1000000
|
||||
: minTime;
|
||||
|
||||
const maxTimeForDateTimePicker = isModalTimeSelection
|
||||
? modalEndTime * 1000000
|
||||
: maxTime;
|
||||
|
||||
return (
|
||||
<div className="date-time-selector">
|
||||
{showResetButton && selectedTime !== defaultRelativeTime && (
|
||||
@@ -692,6 +697,7 @@ function DateTimeSelection({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={formSelector}
|
||||
layout="inline"
|
||||
@@ -724,6 +730,8 @@ function DateTimeSelection({
|
||||
setCustomDTPickerVisible={setCustomDTPickerVisible}
|
||||
onExitLiveLogs={onExitLiveLogs}
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
minTime={minTimeForDateTimePicker}
|
||||
maxTime={maxTimeForDateTimePicker}
|
||||
/>
|
||||
|
||||
{showAutoRefresh && selectedTime !== 'custom' && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import getMinAgo from './getStartAndEndTime/getMinAgo';
|
||||
|
||||
const validCustomTimeRegex = /^(\d+)([mhdw])$/;
|
||||
|
||||
export const isValidTimeFormat = (time: string): boolean =>
|
||||
export const isValidShortHandDateTimeFormat = (time: string): boolean =>
|
||||
validCustomTimeRegex.test(time);
|
||||
|
||||
const extractTimeAndUnit = (time: string): { time: number; unit: string } => {
|
||||
@@ -114,7 +114,7 @@ const GetMinMax = (
|
||||
} else if (interval === 'custom') {
|
||||
maxTime = (dateTimeRange || [])[1] || 0;
|
||||
minTime = (dateTimeRange || [])[0] || 0;
|
||||
} else if (isString(interval) && isValidTimeFormat(interval)) {
|
||||
} else if (isString(interval) && isValidShortHandDateTimeFormat(interval)) {
|
||||
const { time, unit } = extractTimeAndUnit(interval);
|
||||
|
||||
minTime = getMinTimeForRelativeTimes(time, unit);
|
||||
|
||||
@@ -95,17 +95,9 @@
|
||||
&.danger {
|
||||
border-radius: 2px;
|
||||
color: var(--text-vanilla-100) !important;
|
||||
background: var(--bg-cherry-500) !important;
|
||||
text-align: center;
|
||||
font-variant-numeric: slashed-zero;
|
||||
|
||||
/* Label/Small/500 */
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 100%; /* 11px */
|
||||
background: var(--bg-cherry-500);
|
||||
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover {
|
||||
|
||||
122
frontend/src/utils/epochUtils.ts
Normal file
122
frontend/src/utils/epochUtils.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
import { roundHalfUp } from './round';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
type EpochInput = number;
|
||||
|
||||
interface NormalizedRange {
|
||||
startEpochSeconds: number;
|
||||
endEpochSeconds: number;
|
||||
startTime: Dayjs;
|
||||
endTime: Dayjs;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string | null;
|
||||
range?: NormalizedRange | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether an epoch value is in milliseconds or seconds
|
||||
* and normalizes it to epoch seconds.
|
||||
*/
|
||||
function normalizeToSeconds(epoch: EpochInput): number {
|
||||
if (!Number.isFinite(epoch)) {
|
||||
throw new Error('Epoch value must be a finite number');
|
||||
}
|
||||
|
||||
// Heuristic:
|
||||
// Seconds ≈ 1e9–1e10 (2001–2286)
|
||||
// Milliseconds ≈ 1e12–1e13
|
||||
return epoch > 1e11
|
||||
? Math.floor(epoch / 1000) // milliseconds → seconds
|
||||
: Math.floor(epoch); // already seconds
|
||||
}
|
||||
|
||||
export function validateEpochRange(
|
||||
startInput: EpochInput,
|
||||
endInput: EpochInput,
|
||||
): ValidationResult {
|
||||
let startSeconds: number;
|
||||
let endSeconds: number;
|
||||
|
||||
try {
|
||||
startSeconds = normalizeToSeconds(startInput);
|
||||
endSeconds = normalizeToSeconds(endInput);
|
||||
} catch (e) {
|
||||
return { isValid: false, error: (e as Error).message };
|
||||
}
|
||||
|
||||
const startTime = dayjs.unix(startSeconds);
|
||||
const endTime = dayjs.unix(endSeconds);
|
||||
|
||||
if (!startTime.isValid()) {
|
||||
return { isValid: false, error: 'Invalid startTime epoch', range: null };
|
||||
}
|
||||
|
||||
if (!endTime.isValid()) {
|
||||
return { isValid: false, error: 'Invalid endTime epoch', range: null };
|
||||
}
|
||||
|
||||
if (!endTime.isAfter(startTime)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'endTime must be after startTime',
|
||||
range: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
error: null,
|
||||
range: {
|
||||
startEpochSeconds: startSeconds,
|
||||
endEpochSeconds: endSeconds,
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time difference between two epoch timestamps
|
||||
* in a human-readable format: `X m`, `X h`, `X d`, or `X w`.
|
||||
*
|
||||
* Rounding behavior:
|
||||
* - Uses half-up rounding (≥ 0.5 rounds up).
|
||||
*
|
||||
* Unit selection:
|
||||
* - Chooses the largest applicable unit (weeks → days → hours → minutes).
|
||||
* - `X` is always a whole number.
|
||||
*
|
||||
* Assumptions:
|
||||
* - `minTime` and `maxTime` are epoch timestamps in **milliseconds**.
|
||||
*
|
||||
* @param minTime - Start time as epoch milliseconds
|
||||
* @param maxTime - End time as epoch milliseconds
|
||||
* @returns A formatted duration string or an empty string for invalid ranges
|
||||
*/
|
||||
export const getTimeDifference = (minTime: number, maxTime: number): string => {
|
||||
if (!minTime || !maxTime || maxTime <= minTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Difference is already in milliseconds
|
||||
const diffInMs = maxTime - minTime;
|
||||
const diff = dayjs.duration(diffInMs, 'milliseconds');
|
||||
|
||||
const weeks = diff.asWeeks();
|
||||
if (weeks >= 1) return `${roundHalfUp(weeks)}w`;
|
||||
|
||||
const days = diff.asDays();
|
||||
if (days >= 1) return `${roundHalfUp(days)}d`;
|
||||
|
||||
const hours = diff.asHours();
|
||||
if (hours >= 1) return `${roundHalfUp(hours)}h`;
|
||||
|
||||
return `${roundHalfUp(diff.asMinutes())}m`;
|
||||
};
|
||||
5
frontend/src/utils/round.ts
Normal file
5
frontend/src/utils/round.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Applies half-up rounding to ensure a whole number.
|
||||
* Example: 1.5 → 2, 1.49 → 1
|
||||
*/
|
||||
export const roundHalfUp = (value: number): number => Math.floor(value + 0.5);
|
||||
@@ -170,3 +170,96 @@ export const getDaysUntilExpiry = (expiresAt: string): number => {
|
||||
if (!date.isValid()) return 0;
|
||||
return date.diff(dayjs(), 'day');
|
||||
};
|
||||
|
||||
export interface TimeRangeValidationResult {
|
||||
isValid: boolean;
|
||||
errorDetails?: {
|
||||
message: string;
|
||||
code: string;
|
||||
description: string;
|
||||
};
|
||||
startTimeMs?: number;
|
||||
endTimeMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a start and end datetime string.
|
||||
*
|
||||
* Validation rules:
|
||||
* 1. Both start and end must be valid date-time values
|
||||
* 2. End time must be after start time
|
||||
* 3. End time must not be in the future
|
||||
*
|
||||
* Assumptions:
|
||||
* - Input values follow the provided date-time format
|
||||
* - All comparisons are performed in epoch milliseconds
|
||||
*
|
||||
* @param startTime - Start datetime string
|
||||
* @param endTime - End datetime string
|
||||
* @param format - Expected date-time format (e.g. DD/MM/YYYY HH:mm:ss)
|
||||
* @returns Validation result with parsed epoch milliseconds
|
||||
*/
|
||||
export const validateTimeRange = (
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
format: string,
|
||||
): TimeRangeValidationResult => {
|
||||
const start = dayjs(startTime, format, true);
|
||||
const end = dayjs(endTime, format, true);
|
||||
const now = dayjs();
|
||||
const startTimeMs = start.valueOf();
|
||||
const endTimeMs = end.valueOf();
|
||||
|
||||
// Invalid format or parsing failure
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorDetails: {
|
||||
message: 'Invalid date/time format',
|
||||
code: 'INVALID_DATE_TIME_FORMAT',
|
||||
description: `
|
||||
Enter a valid date/time. e.g.
|
||||
|
||||
|
||||
Range:
|
||||
${now.subtract(1, 'hour').format(format)} - ${now.format(format)}
|
||||
|
||||
Shortcuts:
|
||||
15m, 2h, 2d, 2w
|
||||
`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// dates must not be in the future
|
||||
if (start.isAfter(now) || end.isAfter(now)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorDetails: {
|
||||
message: 'Dates in the future',
|
||||
code: 'DATES_IN_THE_FUTURE',
|
||||
description:
|
||||
'Dates must not be in the future. Enter a past or current date/time.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// start time must be before end time
|
||||
if (startTimeMs >= endTimeMs) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorDetails: {
|
||||
message: 'Start time after end time',
|
||||
code: 'START_TIME_AFTER_END_TIME',
|
||||
description:
|
||||
'Start time must be before end time. Change the start or end so the range is chronological.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
startTimeMs,
|
||||
endTimeMs,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user