Feat: Timezone picker feature (#6474)

* feat: time picker hint and timezone picker UI with basic functionality + helper to get timezones

* feat: add support for esc keypress to close the timezone picker

* chore: add the selected timezone as url param and close timezone picker on select

* fix: overall improvement + add searchIndex to timezone

* feat: timezone preferences UI

* chore: improve timezone utils

* chore: change timezone item from div to button

* feat: display timezone in timepicker input

* chore: fix the typo

* fix: don't focus on time picker when timezone is clicked

* fix: fix the issue of timezone breaking for browser and utc timezones

* fix: display the timezone in timepicker hint 'You are at'

* feat: timezone basic functionality (#6492)

* chore: change div to fragment + change type to any as the ESLint complains otherwise

* chore: manage etc timezone filtering with an arg

* chore: update timezone wrapper class name

* fix: add timezone support to downloaded logs

* feat: add current timezone to dashboard list and configure metadata modal

* fix: add pencil icon next to timezone hint + change the copy to Current timezone

* fix: properly handle the escape button behavior for timezone picker

* chore: replace @vvo/tzdb with native Intl API for timezones

* feat: lightmode for timezone picker and timezone adaptation components

* fix: use normald tz in browser timezone

* fix: timezone picker lightmode fixes

* feat: display selected time range in 12 hour format

* chore: remove unnecessary optional chaining

* fix: fix the typo in css variable

* chore: add em dash and change icon for timezone hint in date/time picker

* chore: move pen line icon to the right of timezone offset

* fix: fix the failing tests

* feat: handle switching off the timezone adaptation
This commit is contained in:
Shaheer Kochai 2024-12-16 10:27:20 +04:30 committed by GitHub
parent e3caa6a8f5
commit b333aa3775
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1682 additions and 299 deletions

View File

@ -40,7 +40,7 @@
&.custom-time { &.custom-time {
input:not(:focus) { input:not(:focus) {
min-width: 240px; min-width: 280px;
} }
} }
@ -119,3 +119,69 @@
color: var(--bg-slate-400) !important; color: var(--bg-slate-400) !important;
} }
} }
.date-time-popover__footer {
border-top: 1px solid var(--bg-ink-200);
padding: 8px 14px;
.timezone-container {
&,
.timezone {
font-family: Inter;
font-size: 12px;
line-height: 16px;
letter-spacing: -0.06px;
}
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
gap: 6px;
.timezone {
display: flex;
align-items: center;
gap: 4px;
border-radius: 2px;
background: rgba(171, 189, 255, 0.04);
cursor: pointer;
padding: 0px 4px;
color: var(--bg-vanilla-100);
border: none;
}
}
}
.timezone-badge {
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 2px;
background: rgba(171, 189, 255, 0.04);
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
cursor: pointer;
}
.lightMode {
.date-time-popover__footer {
border-color: var(--bg-vanilla-400);
}
.timezone-container {
color: var(--bg-ink-400);
&__clock-icon {
stroke: var(--bg-ink-400);
}
.timezone {
color: var(--bg-ink-100);
background: rgb(179 179 179 / 15%);
&__icon {
stroke: var(--bg-ink-100);
}
}
}
.timezone-badge {
color: var(--bg-ink-100);
background: rgb(179 179 179 / 15%);
}
}

View File

@ -15,11 +15,14 @@ import { isValidTimeFormat } from 'lib/getMinMax';
import { defaultTo, isFunction, noop } from 'lodash-es'; import { defaultTo, isFunction, noop } from 'lodash-es';
import debounce from 'lodash-es/debounce'; import debounce from 'lodash-es/debounce';
import { CheckCircle, ChevronDown, Clock } from 'lucide-react'; import { CheckCircle, ChevronDown, Clock } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { import {
ChangeEvent, ChangeEvent,
Dispatch, Dispatch,
SetStateAction, SetStateAction,
useCallback,
useEffect, useEffect,
useMemo,
useState, useState,
} from 'react'; } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@ -28,6 +31,8 @@ import { popupContainer } from 'utils/selectPopupContainer';
import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent'; import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent';
const maxAllowedMinTimeInMonths = 6; const maxAllowedMinTimeInMonths = 6;
type ViewType = 'datetime' | 'timezone';
const DEFAULT_VIEW: ViewType = 'datetime';
interface CustomTimePickerProps { interface CustomTimePickerProps {
onSelect: (value: string) => void; onSelect: (value: string) => void;
@ -81,11 +86,42 @@ function CustomTimePicker({
const location = useLocation(); const location = useLocation();
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
const { timezone, browserTimezone } = useTimezone();
const activeTimezoneOffset = timezone.offset;
const isTimezoneOverridden = useMemo(
() => timezone.offset !== browserTimezone.offset,
[timezone, browserTimezone],
);
const handleViewChange = useCallback(
(newView: 'timezone' | 'datetime'): void => {
if (activeView !== newView) {
setActiveView(newView);
}
setOpen(true);
},
[activeView, setOpen],
);
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
const getSelectedTimeRangeLabel = ( const getSelectedTimeRangeLabel = (
selectedTime: string, selectedTime: string,
selectedTimeValue: string, selectedTimeValue: string,
): string => { ): string => {
if (selectedTime === 'custom') { if (selectedTime === 'custom') {
// Convert the date range string to 12-hour format
const dates = selectedTimeValue.split(' - ');
if (dates.length === 2) {
const startDate = dayjs(dates[0], 'DD/MM/YYYY HH:mm');
const endDate = dayjs(dates[1], 'DD/MM/YYYY HH:mm');
return `${startDate.format('DD/MM/YYYY hh:mm A')} - ${endDate.format(
'DD/MM/YYYY hh:mm A',
)}`;
}
return selectedTimeValue; return selectedTimeValue;
} }
@ -131,6 +167,7 @@ function CustomTimePicker({
setOpen(newOpen); setOpen(newOpen);
if (!newOpen) { if (!newOpen) {
setCustomDTPickerVisible?.(false); setCustomDTPickerVisible?.(false);
setActiveView('datetime');
} }
}; };
@ -244,6 +281,7 @@ function CustomTimePicker({
const handleFocus = (): void => { const handleFocus = (): void => {
setIsInputFocused(true); setIsInputFocused(true);
setActiveView('datetime');
}; };
const handleBlur = (): void => { const handleBlur = (): void => {
@ -280,6 +318,10 @@ function CustomTimePicker({
handleGoLive={defaultTo(handleGoLive, noop)} handleGoLive={defaultTo(handleGoLive, noop)}
options={items} options={items}
selectedTime={selectedTime} selectedTime={selectedTime}
activeView={activeView}
setActiveView={setActiveView}
setIsOpenedFromFooter={setIsOpenedFromFooter}
isOpenedFromFooter={isOpenedFromFooter}
/> />
) : ( ) : (
content content
@ -316,12 +358,24 @@ function CustomTimePicker({
) )
} }
suffix={ suffix={
<ChevronDown <>
size={14} {!!isTimezoneOverridden && activeTimezoneOffset && (
onClick={(): void => { <div
setOpen(!open); className="timezone-badge"
}} onClick={(e): void => {
/> e.stopPropagation();
handleViewChange('timezone');
setIsOpenedFromFooter(false);
}}
>
<span>{activeTimezoneOffset}</span>
</div>
)}
<ChevronDown
size={14}
onClick={(): void => handleViewChange('datetime')}
/>
</>
} }
/> />
</Popover> </Popover>

View File

@ -1,5 +1,6 @@
import './CustomTimePicker.styles.scss'; import './CustomTimePicker.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd'; import { Button } from 'antd';
import cx from 'classnames'; import cx from 'classnames';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
@ -9,10 +10,13 @@ import {
Option, Option,
RelativeDurationSuggestionOptions, RelativeDurationSuggestionOptions,
} from 'container/TopNav/DateTimeSelectionV2/config'; } from 'container/TopNav/DateTimeSelectionV2/config';
import { Clock, PenLine } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { Dispatch, SetStateAction, useMemo } from 'react'; import { Dispatch, SetStateAction, useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import RangePickerModal from './RangePickerModal'; import RangePickerModal from './RangePickerModal';
import TimezonePicker from './TimezonePicker';
interface CustomTimePickerPopoverContentProps { interface CustomTimePickerPopoverContentProps {
options: any[]; options: any[];
@ -26,8 +30,13 @@ interface CustomTimePickerPopoverContentProps {
onSelectHandler: (label: string, value: string) => void; onSelectHandler: (label: string, value: string) => void;
handleGoLive: () => void; handleGoLive: () => void;
selectedTime: string; selectedTime: string;
activeView: 'datetime' | 'timezone';
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
isOpenedFromFooter: boolean;
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
} }
// eslint-disable-next-line sonarjs/cognitive-complexity
function CustomTimePickerPopoverContent({ function CustomTimePickerPopoverContent({
options, options,
setIsOpen, setIsOpen,
@ -37,12 +46,18 @@ function CustomTimePickerPopoverContent({
onSelectHandler, onSelectHandler,
handleGoLive, handleGoLive,
selectedTime, selectedTime,
activeView,
setActiveView,
isOpenedFromFooter,
setIsOpenedFromFooter,
}: CustomTimePickerPopoverContentProps): JSX.Element { }: CustomTimePickerPopoverContentProps): JSX.Element {
const { pathname } = useLocation(); const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [ const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [
pathname, pathname,
]); ]);
const { timezone } = useTimezone();
const activeTimezoneOffset = timezone.offset;
function getTimeChips(options: Option[]): JSX.Element { function getTimeChips(options: Option[]): JSX.Element {
return ( return (
@ -63,55 +78,99 @@ function CustomTimePickerPopoverContent({
); );
} }
const handleTimezoneHintClick = (): void => {
setActiveView('timezone');
setIsOpenedFromFooter(true);
};
if (activeView === 'timezone') {
return (
<div className="date-time-popover">
<TimezonePicker
setActiveView={setActiveView}
setIsOpen={setIsOpen}
isOpenedFromFooter={isOpenedFromFooter}
/>
</div>
);
}
return ( return (
<div className="date-time-popover"> <>
<div className="date-time-options"> <div className="date-time-popover">
{isLogsExplorerPage && ( <div className="date-time-options">
<Button className="data-time-live" type="text" onClick={handleGoLive}> {isLogsExplorerPage && (
Live <Button className="data-time-live" type="text" onClick={handleGoLive}>
</Button> Live
)} </Button>
{options.map((option) => ( )}
<Button {options.map((option) => (
type="text" <Button
key={option.label + option.value} type="text"
onClick={(): void => { key={option.label + option.value}
onSelectHandler(option.label, option.value); onClick={(): void => {
}} onSelectHandler(option.label, option.value);
className={cx( }}
'date-time-options-btn', className={cx(
customDateTimeVisible 'date-time-options-btn',
? option.value === 'custom' && 'active' customDateTimeVisible
: selectedTime === option.value && 'active', ? option.value === 'custom' && 'active'
)} : selectedTime === option.value && 'active',
> )}
{option.label} >
</Button> {option.label}
))} </Button>
))}
</div>
<div
className={cx(
'relative-date-time',
selectedTime === 'custom' || customDateTimeVisible
? 'date-picker'
: 'relative-times',
)}
>
{selectedTime === 'custom' || customDateTimeVisible ? (
<RangePickerModal
setCustomDTPickerVisible={setCustomDTPickerVisible}
setIsOpen={setIsOpen}
onCustomDateHandler={onCustomDateHandler}
selectedTime={selectedTime}
/>
) : (
<div className="relative-times-container">
<div className="time-heading">RELATIVE TIMES</div>
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
</div>
)}
</div>
</div> </div>
<div
className={cx( <div className="date-time-popover__footer">
'relative-date-time', <div className="timezone-container">
selectedTime === 'custom' || customDateTimeVisible <Clock
? 'date-picker' color={Color.BG_VANILLA_400}
: 'relative-times', className="timezone-container__clock-icon"
)} height={12}
> width={12}
{selectedTime === 'custom' || customDateTimeVisible ? (
<RangePickerModal
setCustomDTPickerVisible={setCustomDTPickerVisible}
setIsOpen={setIsOpen}
onCustomDateHandler={onCustomDateHandler}
selectedTime={selectedTime}
/> />
) : ( <span className="timezone__icon">Current timezone</span>
<div className="relative-times-container"> <div></div>
<div className="time-heading">RELATIVE TIMES</div> <button
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div> type="button"
</div> className="timezone"
)} onClick={handleTimezoneHintClick}
>
<span>{activeTimezoneOffset}</span>
<PenLine
color={Color.BG_VANILLA_100}
className="timezone__icon"
size={10}
/>
</button>
</div>
</div> </div>
</div> </>
); );
} }

View File

@ -3,7 +3,8 @@ import './RangePickerModal.styles.scss';
import { DatePicker } from 'antd'; import { DatePicker } from 'antd';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config'; import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config';
import dayjs, { Dayjs } from 'dayjs'; import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
@ -31,7 +32,10 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element {
(state) => state.globalTime, (state) => state.globalTime,
); );
const disabledDate = (current: Dayjs): boolean => { // Using any type here because antd's DatePicker expects its own internal Dayjs type
// which conflicts with our project's Dayjs type that has additional plugins (tz, utc etc).
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
const disabledDate = (current: any): boolean => {
const currentDay = dayjs(current); const currentDay = dayjs(current);
return currentDay.isAfter(dayjs()); return currentDay.isAfter(dayjs());
}; };
@ -49,16 +53,22 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element {
} }
onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER); onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER);
}; };
const { timezone } = useTimezone();
return ( return (
<div className="custom-date-picker"> <div className="custom-date-picker">
<RangePicker <RangePicker
disabledDate={disabledDate} disabledDate={disabledDate}
allowClear allowClear
showTime showTime
format="YYYY-MM-DD hh:mm A"
onOk={onModalOkHandler} onOk={onModalOkHandler}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...(selectedTime === 'custom' && { {...(selectedTime === 'custom' && {
defaultValue: [dayjs(minTime / 1000000), dayjs(maxTime / 1000000)], defaultValue: [
dayjs(minTime / 1000000).tz(timezone.value),
dayjs(maxTime / 1000000).tz(timezone.value),
],
})} })}
/> />
</div> </div>

View File

@ -0,0 +1,166 @@
// Variables
$font-family: 'Inter';
$item-spacing: 8px;
:root {
--border-color: var(--bg-slate-400);
}
.lightMode {
--border-color: var(--bg-vanilla-400);
}
// Mixins
@mixin text-style-base {
font-family: $font-family;
font-style: normal;
font-weight: 400;
}
@mixin flex-center {
display: flex;
align-items: center;
}
.timezone-picker {
width: 532px;
color: var(--bg-vanilla-400);
font-family: $font-family;
&__search {
@include flex-center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
}
&__input-container {
@include flex-center;
gap: 6px;
width: -webkit-fill-available;
}
&__input {
@include text-style-base;
width: 100%;
background: transparent;
border: none;
outline: none;
color: var(--bg-vanilla-100);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
padding: 0;
&.ant-input:focus {
box-shadow: none;
}
&::placeholder {
color: var(--bg-vanilla-400);
}
}
&__esc-key {
@include text-style-base;
font-size: 8px;
color: var(--bg-vanilla-400);
letter-spacing: -0.04px;
border-radius: 2.286px;
border: 1.143px solid var(--bg-ink-200);
border-bottom-width: 2.286px;
background: var(--bg-ink-400);
padding: 0 1px;
}
&__list {
max-height: 310px;
overflow-y: auto;
}
&__item {
@include flex-center;
justify-content: space-between;
padding: 7.5px 6px 7.5px $item-spacing;
margin: 4px $item-spacing;
cursor: pointer;
background: transparent;
border: none;
width: -webkit-fill-available;
color: var(--bg-vanilla-400);
font-family: $font-family;
&:hover,
&.selected {
border-radius: 2px;
background: rgba(171, 189, 255, 0.04);
color: var(--bg-vanilla-100);
}
&.has-divider {
position: relative;
&::after {
content: '';
position: absolute;
bottom: -2px;
left: -$item-spacing;
right: -$item-spacing;
border-bottom: 1px solid var(--border-color);
}
}
}
&__name {
@include text-style-base;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
&__offset {
color: var(--bg-vanilla-100);
font-size: 12px;
line-height: 16px;
letter-spacing: -0.06px;
}
}
.timezone-name-wrapper {
@include flex-center;
gap: 6px;
&__selected-icon {
height: 15px;
width: 15px;
}
}
.lightMode {
.timezone-picker {
&__search {
.search-icon {
stroke: var(--bg-ink-400);
}
}
&__input {
color: var(--bg-ink-100);
}
&__esc-key {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-400);
color: var(--bg-ink-400);
}
&__item {
color: var(--bg-ink-400);
}
&__offset {
color: var(--bg-ink-100);
}
}
.timezone-name-wrapper {
&__selected-icon {
.check-icon {
stroke: var(--bg-ink-100);
}
}
}
}

View File

@ -0,0 +1,201 @@
import './TimezonePicker.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Input } from 'antd';
import cx from 'classnames';
import { TimezonePickerShortcuts } from 'constants/shortcuts/TimezonePickerShortcuts';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { Check, Search } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import { Timezone, TIMEZONE_DATA } from './timezoneUtils';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
isOpenedFromFooter: boolean;
}
interface TimezoneItemProps {
timezone: Timezone;
isSelected?: boolean;
onClick?: () => void;
}
const ICON_SIZE = 14;
function SearchBar({
value,
onChange,
setIsOpen,
setActiveView,
isOpenedFromFooter = false,
}: SearchBarProps): JSX.Element {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent): void => {
if (e.key === 'Escape') {
if (isOpenedFromFooter) {
setActiveView('datetime');
} else {
setIsOpen(false);
}
}
},
[setActiveView, setIsOpen, isOpenedFromFooter],
);
return (
<div className="timezone-picker__search">
<div className="timezone-picker__input-container">
<Search
color={Color.BG_VANILLA_400}
className="search-icon"
height={ICON_SIZE}
width={ICON_SIZE}
/>
<Input
type="text"
className="timezone-picker__input"
placeholder="Search timezones..."
value={value}
onChange={(e): void => onChange(e.target.value)}
onKeyDown={handleKeyDown}
tabIndex={0}
autoFocus
/>
</div>
<kbd className="timezone-picker__esc-key">esc</kbd>
</div>
);
}
function TimezoneItem({
timezone,
isSelected = false,
onClick,
}: TimezoneItemProps): JSX.Element {
return (
<button
type="button"
className={cx('timezone-picker__item', {
selected: isSelected,
'has-divider': timezone.hasDivider,
})}
onClick={onClick}
>
<div className="timezone-name-wrapper">
<div className="timezone-name-wrapper__selected-icon">
{isSelected && (
<Check
className="check-icon"
color={Color.BG_VANILLA_100}
height={ICON_SIZE}
width={ICON_SIZE}
/>
)}
</div>
<div className="timezone-picker__name">{timezone.name}</div>
</div>
<div className="timezone-picker__offset">{timezone.offset}</div>
</button>
);
}
TimezoneItem.defaultProps = {
isSelected: false,
onClick: undefined,
};
interface TimezonePickerProps {
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
setIsOpen: Dispatch<SetStateAction<boolean>>;
isOpenedFromFooter: boolean;
}
function TimezonePicker({
setActiveView,
setIsOpen,
isOpenedFromFooter,
}: TimezonePickerProps): JSX.Element {
console.log({ isOpenedFromFooter });
const [searchTerm, setSearchTerm] = useState('');
const { timezone, updateTimezone } = useTimezone();
const [selectedTimezone, setSelectedTimezone] = useState<string>(
timezone.name ?? TIMEZONE_DATA[0].name,
);
const getFilteredTimezones = useCallback((searchTerm: string): Timezone[] => {
const normalizedSearch = searchTerm.toLowerCase();
return TIMEZONE_DATA.filter(
(tz) =>
tz.name.toLowerCase().includes(normalizedSearch) ||
tz.offset.toLowerCase().includes(normalizedSearch) ||
tz.searchIndex.toLowerCase().includes(normalizedSearch),
);
}, []);
const handleCloseTimezonePicker = useCallback(() => {
if (isOpenedFromFooter) {
setActiveView('datetime');
} else {
setIsOpen(false);
}
}, [isOpenedFromFooter, setActiveView, setIsOpen]);
const handleTimezoneSelect = useCallback(
(timezone: Timezone) => {
setSelectedTimezone(timezone.name);
updateTimezone(timezone);
handleCloseTimezonePicker();
setIsOpen(false);
},
[handleCloseTimezonePicker, setIsOpen, updateTimezone],
);
// Register keyboard shortcuts
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
useEffect(() => {
registerShortcut(
TimezonePickerShortcuts.CloseTimezonePicker,
handleCloseTimezonePicker,
);
return (): void => {
deregisterShortcut(TimezonePickerShortcuts.CloseTimezonePicker);
};
}, [deregisterShortcut, handleCloseTimezonePicker, registerShortcut]);
return (
<div className="timezone-picker">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
setIsOpen={setIsOpen}
setActiveView={setActiveView}
isOpenedFromFooter={isOpenedFromFooter}
/>
<div className="timezone-picker__list">
{getFilteredTimezones(searchTerm).map((timezone) => (
<TimezoneItem
key={timezone.value}
timezone={timezone}
isSelected={timezone.name === selectedTimezone}
onClick={(): void => handleTimezoneSelect(timezone)}
/>
))}
</div>
</div>
);
}
export default TimezonePicker;

View File

@ -0,0 +1,152 @@
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
dayjs.extend(timezone);
export interface Timezone {
name: string;
value: string;
offset: string;
searchIndex: string;
hasDivider?: boolean;
}
const TIMEZONE_TYPES = {
BROWSER: 'BROWSER',
UTC: 'UTC',
STANDARD: 'STANDARD',
} as const;
type TimezoneType = typeof TIMEZONE_TYPES[keyof typeof TIMEZONE_TYPES];
export const UTC_TIMEZONE: Timezone = {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
hasDivider: true,
};
const normalizeTimezoneName = (timezone: string): string => {
// https://github.com/tc39/proposal-temporal/issues/1076
if (timezone === 'Asia/Calcutta') {
return 'Asia/Kolkata';
}
return timezone;
};
const formatOffset = (offsetMinutes: number): string => {
if (offsetMinutes === 0) return 'UTC';
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const minutes = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes > 0 ? '+' : '-';
return `UTC ${sign} ${hours}${
minutes ? `:${minutes.toString().padStart(2, '0')}` : ':00'
}`;
};
const createTimezoneEntry = (
name: string,
offsetMinutes: number,
type: TimezoneType = TIMEZONE_TYPES.STANDARD,
hasDivider = false,
): Timezone => {
const offset = formatOffset(offsetMinutes);
let value = name;
let displayName = name;
switch (type) {
case TIMEZONE_TYPES.BROWSER:
displayName = `Browser time — ${name}`;
value = name;
break;
case TIMEZONE_TYPES.UTC:
displayName = 'Coordinated Universal Time — UTC, GMT';
value = 'UTC';
break;
case TIMEZONE_TYPES.STANDARD:
displayName = name;
value = name;
break;
default:
console.error(`Invalid timezone type: ${type}`);
}
return {
name: displayName,
value,
offset,
searchIndex: offset.replace(/ /g, ''),
...(hasDivider && { hasDivider }),
};
};
const getOffsetByTimezone = (timezone: string): number => {
const dayjsTimezone = dayjs().tz(timezone);
return dayjsTimezone.utcOffset();
};
export const getBrowserTimezone = (): Timezone => {
const browserTz = dayjs.tz.guess();
const normalizedTz = normalizeTimezoneName(browserTz);
const browserOffset = getOffsetByTimezone(normalizedTz);
return createTimezoneEntry(
normalizedTz,
browserOffset,
TIMEZONE_TYPES.BROWSER,
);
};
const filterAndSortTimezones = (
allTimezones: string[],
browserTzName?: string,
includeEtcTimezones = false,
): Timezone[] =>
allTimezones
.filter((tz) => {
const isNotBrowserTz = tz !== browserTzName;
const isNotEtcTz = includeEtcTimezones || !tz.startsWith('Etc/');
return isNotBrowserTz && isNotEtcTz;
})
.sort((a, b) => a.localeCompare(b))
.map((tz) => {
const normalizedTz = normalizeTimezoneName(tz);
const offset = getOffsetByTimezone(normalizedTz);
return createTimezoneEntry(normalizedTz, offset);
});
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
const timezones: Timezone[] = [];
// Add browser timezone
const browserTzObject = getBrowserTimezone();
timezones.push(browserTzObject);
// Add UTC timezone with divider
timezones.push(UTC_TIMEZONE);
timezones.push(
...filterAndSortTimezones(
allTimezones,
browserTzObject.value,
includeEtcTimezones,
),
);
return timezones;
};
export const getTimezoneObjectByTimezoneString = (
timezone: string,
): Timezone => {
const utcOffset = getOffsetByTimezone(timezone);
return createTimezoneEntry(timezone, utcOffset);
};
export const TIMEZONE_DATA = generateTimezoneData();

View File

@ -1,4 +1,5 @@
import { import {
_adapters,
BarController, BarController,
BarElement, BarElement,
CategoryScale, CategoryScale,
@ -18,8 +19,10 @@ import {
} from 'chart.js'; } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { generateGridTitle } from 'container/GridPanelSwitch/utils'; import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import { useTimezone } from 'providers/Timezone';
import { import {
forwardRef, forwardRef,
memo, memo,
@ -62,6 +65,17 @@ Chart.register(
Tooltip.positioners.custom = TooltipPositionHandler; Tooltip.positioners.custom = TooltipPositionHandler;
// Map of Chart.js time formats to dayjs format strings
const formatMap = {
'HH:mm:ss': 'HH:mm:ss',
'HH:mm': 'HH:mm',
'MM/DD HH:mm': 'MM/DD HH:mm',
'MM/dd HH:mm': 'MM/DD HH:mm',
'MM/DD': 'MM/DD',
'YY-MM': 'YY-MM',
YY: 'YY',
};
const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>( const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
( (
{ {
@ -80,11 +94,13 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
dragSelectColor, dragSelectColor,
}, },
ref, ref,
// eslint-disable-next-line sonarjs/cognitive-complexity
): JSX.Element => { ): JSX.Element => {
const nearestDatasetIndex = useRef<null | number>(null); const nearestDatasetIndex = useRef<null | number>(null);
const chartRef = useRef<HTMLCanvasElement>(null); const chartRef = useRef<HTMLCanvasElement>(null);
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const gridTitle = useMemo(() => generateGridTitle(title), [title]); const gridTitle = useMemo(() => generateGridTitle(title), [title]);
const { timezone } = useTimezone();
const currentTheme = isDarkMode ? 'dark' : 'light'; const currentTheme = isDarkMode ? 'dark' : 'light';
const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data
@ -112,6 +128,22 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
return 'rgba(231,233,237,0.8)'; return 'rgba(231,233,237,0.8)';
}, [currentTheme]); }, [currentTheme]);
// Override Chart.js date adapter to use dayjs with timezone support
useEffect(() => {
_adapters._date.override({
format(time: number | Date, fmt: string) {
const dayjsTime = dayjs(time).tz(timezone.value);
const format = formatMap[fmt as keyof typeof formatMap];
if (!format) {
console.warn(`Missing datetime format for ${fmt}`);
return dayjsTime.format('YYYY-MM-DD HH:mm:ss'); // fallback format
}
return dayjsTime.format(format);
},
});
}, [timezone]);
const buildChart = useCallback(() => { const buildChart = useCallback(() => {
if (lineChartRef.current !== undefined) { if (lineChartRef.current !== undefined) {
lineChartRef.current.destroy(); lineChartRef.current.destroy();
@ -132,6 +164,7 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
isStacked, isStacked,
onClickHandler, onClickHandler,
data, data,
timezone,
); );
const chartHasData = hasData(data); const chartHasData = hasData(data);
@ -166,6 +199,7 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
isStacked, isStacked,
onClickHandler, onClickHandler,
data, data,
timezone,
name, name,
type, type,
]); ]);

View File

@ -1,5 +1,6 @@
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js'; import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
import * as chartjsAdapter from 'chartjs-adapter-date-fns'; import * as chartjsAdapter from 'chartjs-adapter-date-fns';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { MutableRefObject } from 'react'; import { MutableRefObject } from 'react';
@ -50,6 +51,7 @@ export const getGraphOptions = (
isStacked: boolean | undefined, isStacked: boolean | undefined,
onClickHandler: GraphOnClickHandler | undefined, onClickHandler: GraphOnClickHandler | undefined,
data: ChartData, data: ChartData,
timezone: Timezone,
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
): CustomChartOptions => ({ ): CustomChartOptions => ({
animation: { animation: {
@ -97,7 +99,7 @@ export const getGraphOptions = (
callbacks: { callbacks: {
title(context): string | string[] { title(context): string | string[] {
const date = dayjs(context[0].parsed.x); const date = dayjs(context[0].parsed.x);
return date.format('MMM DD, YYYY, HH:mm:ss'); return date.tz(timezone.value).format('MMM DD, YYYY, HH:mm:ss');
}, },
label(context): string | string[] { label(context): string | string[] {
let label = context.dataset.label || ''; let label = context.dataset.label || '';

View File

@ -8,13 +8,13 @@ import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants'; import { VIEW_TYPES } from 'components/LogDetail/constants';
import { unescapeString } from 'container/LogDetailedView/utils'; import { unescapeString } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types'; import { FontSize } from 'container/OptionsMenu/types';
import dayjs from 'dayjs';
import dompurify from 'dompurify'; import dompurify from 'dompurify';
import { useActiveLog } from 'hooks/logs/useActiveLog'; import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
// utils // utils
import { FlatLogData } from 'lib/logs/flatLogData'; import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
// interfaces // interfaces
import { IField } from 'types/api/logs/fields'; import { IField } from 'types/api/logs/fields';
@ -174,12 +174,20 @@ function ListLogView({
[selectedFields], [selectedFields],
); );
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const timestampValue = useMemo( const timestampValue = useMemo(
() => () =>
typeof flattenLogData.timestamp === 'string' typeof flattenLogData.timestamp === 'string'
? dayjs(flattenLogData.timestamp).format('YYYY-MM-DD HH:mm:ss.SSS') ? formatTimezoneAdjustedTimestamp(
: dayjs(flattenLogData.timestamp / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS'), flattenLogData.timestamp,
[flattenLogData.timestamp], 'YYYY-MM-DD HH:mm:ss.SSS',
)
: formatTimezoneAdjustedTimestamp(
flattenLogData.timestamp / 1e6,
'YYYY-MM-DD HH:mm:ss.SSS',
),
[flattenLogData.timestamp, formatTimezoneAdjustedTimestamp],
); );
const logType = getLogIndicatorType(logData); const logType = getLogIndicatorType(logData);

View File

@ -6,7 +6,6 @@ import LogDetail from 'components/LogDetail';
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants'; import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
import { unescapeString } from 'container/LogDetailedView/utils'; import { unescapeString } from 'container/LogDetailedView/utils';
import LogsExplorerContext from 'container/LogsExplorerContext'; import LogsExplorerContext from 'container/LogsExplorerContext';
import dayjs from 'dayjs';
import dompurify from 'dompurify'; import dompurify from 'dompurify';
import { useActiveLog } from 'hooks/logs/useActiveLog'; import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink'; import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
@ -14,6 +13,7 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData'; import { FlatLogData } from 'lib/logs/flatLogData';
import { isEmpty, isNumber, isUndefined } from 'lodash-es'; import { isEmpty, isNumber, isUndefined } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { import {
KeyboardEvent, KeyboardEvent,
MouseEvent, MouseEvent,
@ -89,16 +89,24 @@ function RawLogView({
attributesText += ' | '; attributesText += ' | ';
} }
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const text = useMemo(() => { const text = useMemo(() => {
const date = const date =
typeof data.timestamp === 'string' typeof data.timestamp === 'string'
? dayjs(data.timestamp) ? formatTimezoneAdjustedTimestamp(data.timestamp, 'YYYY-MM-DD HH:mm:ss.SSS')
: dayjs(data.timestamp / 1e6); : formatTimezoneAdjustedTimestamp(
data.timestamp / 1e6,
'YYYY-MM-DD HH:mm:ss.SSS',
);
return `${date.format('YYYY-MM-DD HH:mm:ss.SSS')} | ${attributesText} ${ return `${date} | ${attributesText} ${data.body}`;
data.body }, [
}`; data.timestamp,
}, [data.timestamp, data.body, attributesText]); data.body,
attributesText,
formatTimezoneAdjustedTimestamp,
]);
const handleClickExpand = useCallback(() => { const handleClickExpand = useCallback(() => {
if (activeContextLog || isReadOnly) return; if (activeContextLog || isReadOnly) return;

View File

@ -5,10 +5,10 @@ import { Typography } from 'antd';
import { ColumnsType } from 'antd/es/table'; import { ColumnsType } from 'antd/es/table';
import cx from 'classnames'; import cx from 'classnames';
import { unescapeString } from 'container/LogDetailedView/utils'; import { unescapeString } from 'container/LogDetailedView/utils';
import dayjs from 'dayjs';
import dompurify from 'dompurify'; import dompurify from 'dompurify';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData'; import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FORBID_DOM_PURIFY_TAGS } from 'utils/app'; import { FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@ -44,6 +44,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
logs, logs,
]); ]);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => { const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
const fieldColumns: ColumnsType<Record<string, unknown>> = fields const fieldColumns: ColumnsType<Record<string, unknown>> = fields
.filter((e) => e.name !== 'id') .filter((e) => e.name !== 'id')
@ -81,8 +83,11 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
render: (field, item): ColumnTypeRender<Record<string, unknown>> => { render: (field, item): ColumnTypeRender<Record<string, unknown>> => {
const date = const date =
typeof field === 'string' typeof field === 'string'
? dayjs(field).format('YYYY-MM-DD HH:mm:ss.SSS') ? formatTimezoneAdjustedTimestamp(field, 'YYYY-MM-DD HH:mm:ss.SSS')
: dayjs(field / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS'); : formatTimezoneAdjustedTimestamp(
field / 1e6,
'YYYY-MM-DD HH:mm:ss.SSS',
);
return { return {
children: ( children: (
<div className="table-timestamp"> <div className="table-timestamp">
@ -125,7 +130,15 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
}, },
...(appendTo === 'end' ? fieldColumns : []), ...(appendTo === 'end' ? fieldColumns : []),
]; ];
}, [fields, isListViewPanel, appendTo, isDarkMode, linesPerRow, fontSize]); }, [
fields,
isListViewPanel,
appendTo,
isDarkMode,
linesPerRow,
fontSize,
formatTimezoneAdjustedTimestamp,
]);
return { columns, dataSource: flattenLogData }; return { columns, dataSource: flattenLogData };
}; };

View File

@ -1,11 +1,13 @@
import { Typography } from 'antd'; import { Typography } from 'antd';
import convertDateToAmAndPm from 'lib/convertDateToAmAndPm'; import { useTimezone } from 'providers/Timezone';
import getFormattedDate from 'lib/getFormatedDate';
function Time({ CreatedOrUpdateTime }: DateProps): JSX.Element { function Time({ CreatedOrUpdateTime }: DateProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const time = new Date(CreatedOrUpdateTime); const time = new Date(CreatedOrUpdateTime);
const date = getFormattedDate(time); const timeString = formatTimezoneAdjustedTimestamp(
const timeString = `${date} ${convertDateToAmAndPm(time)}`; time,
'MM/DD/YYYY hh:mm:ss A (UTC Z)',
);
return <Typography>{timeString}</Typography>; return <Typography>{timeString}</Typography>;
} }

View File

@ -21,4 +21,5 @@ export enum LOCALSTORAGE {
THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1',
LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS', LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS',
SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS', SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS',
PREFERRED_TIMEZONE = 'PREFERRED_TIMEZONE',
} }

View File

@ -0,0 +1,3 @@
export const TimezonePickerShortcuts = {
CloseTimezonePicker: 'escape',
};

View File

@ -7,6 +7,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history'; import history from 'lib/history';
import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin'; import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin';
import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin'; import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin';
import { useTimezone } from 'providers/Timezone';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions'; import { UpdateTimeInterval } from 'store/actions';
@ -48,6 +49,7 @@ function HorizontalTimelineGraph({
const urlQuery = useUrlQuery(); const urlQuery = useUrlQuery();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { timezone } = useTimezone();
const options: uPlot.Options = useMemo( const options: uPlot.Options = useMemo(
() => ({ () => ({
@ -116,8 +118,18 @@ function HorizontalTimelineGraph({
}), }),
] ]
: [], : [],
tzDate: (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
}), }),
[width, isDarkMode, transformedData.length, urlQuery, dispatch], [
width,
isDarkMode,
transformedData.length,
urlQuery,
dispatch,
timezone.value,
],
); );
return <Uplot data={transformedData} options={options} />; return <Uplot data={transformedData} options={options} />;
} }

View File

@ -7,6 +7,7 @@ import {
useGetAlertRuleDetailsTimelineTable, useGetAlertRuleDetailsTimelineTable,
useTimelineTable, useTimelineTable,
} from 'pages/AlertDetails/hooks'; } from 'pages/AlertDetails/hooks';
import { useTimezone } from 'providers/Timezone';
import { HTMLAttributes, useMemo, useState } from 'react'; import { HTMLAttributes, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
@ -41,6 +42,8 @@ function TimelineTable(): JSX.Element {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (isError || !isValidRuleId || !ruleId) { if (isError || !isValidRuleId || !ruleId) {
return <div>{t('something_went_wrong')}</div>; return <div>{t('something_went_wrong')}</div>;
} }
@ -64,6 +67,7 @@ function TimelineTable(): JSX.Element {
filters, filters,
labels: labels ?? {}, labels: labels ?? {},
setFilters, setFilters,
formatTimezoneAdjustedTimestamp,
})} })}
onRow={handleRowClick} onRow={handleRowClick}
dataSource={timelineData} dataSource={timelineData}

View File

@ -8,6 +8,7 @@ import ClientSideQBSearch, {
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils'; import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import AlertLabels, { import AlertLabels, {
AlertLabelsProps, AlertLabelsProps,
@ -16,7 +17,6 @@ import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { formatEpochTimestamp } from 'utils/timeUtils';
const transformLabelsToQbKeys = ( const transformLabelsToQbKeys = (
labels: AlertRuleTimelineTableResponse['labels'], labels: AlertRuleTimelineTableResponse['labels'],
@ -74,10 +74,15 @@ export const timelineTableColumns = ({
filters, filters,
labels, labels,
setFilters, setFilters,
formatTimezoneAdjustedTimestamp,
}: { }: {
filters: TagFilter; filters: TagFilter;
labels: AlertLabelsProps['labels']; labels: AlertLabelsProps['labels'];
setFilters: (filters: TagFilter) => void; setFilters: (filters: TagFilter) => void;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
}): ColumnsType<AlertRuleTimelineTableResponse> => [ }): ColumnsType<AlertRuleTimelineTableResponse> => [
{ {
title: 'STATE', title: 'STATE',
@ -106,7 +111,9 @@ export const timelineTableColumns = ({
dataIndex: 'unixMilli', dataIndex: 'unixMilli',
width: 200, width: 200,
render: (value): JSX.Element => ( render: (value): JSX.Element => (
<div className="alert-rule__created-at">{formatEpochTimestamp(value)}</div> <div className="alert-rule__created-at">
{formatTimezoneAdjustedTimestamp(value, 'MMM D, YYYY ⎯ HH:mm:ss')}
</div>
), ),
}, },
{ {

View File

@ -17,14 +17,15 @@ import getAll from 'api/errors/getAll';
import getErrorCounts from 'api/errors/getErrorCounts'; import getErrorCounts from 'api/errors/getErrorCounts';
import { ResizeTable } from 'components/ResizeTable'; import { ResizeTable } from 'components/ResizeTable';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import useResourceAttribute from 'hooks/useResourceAttribute'; import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import useUrlQuery from 'hooks/useUrlQuery'; import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams'; import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history'; import history from 'lib/history';
import { isUndefined } from 'lodash-es'; import { isUndefined } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query'; import { useQueries } from 'react-query';
@ -155,8 +156,16 @@ function AllErrors(): JSX.Element {
} }
}, [data?.error, data?.payload, t, notifications]); }, [data?.error, data?.payload, t, notifications]);
const getDateValue = (value: string): JSX.Element => ( const getDateValue = (
<Typography>{dayjs(value).format('DD/MM/YYYY HH:mm:ss A')}</Typography> value: string,
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string,
): JSX.Element => (
<Typography>
{formatTimezoneAdjustedTimestamp(value, 'DD/MM/YYYY hh:mm:ss A')}
</Typography>
); );
const filterIcon = useCallback(() => <SearchOutlined />, []); const filterIcon = useCallback(() => <SearchOutlined />, []);
@ -283,6 +292,8 @@ function AllErrors(): JSX.Element {
[filterIcon, filterDropdownWrapper], [filterIcon, filterDropdownWrapper],
); );
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: ColumnsType<Exception> = [ const columns: ColumnsType<Exception> = [
{ {
title: 'Exception Type', title: 'Exception Type',
@ -342,7 +353,8 @@ function AllErrors(): JSX.Element {
dataIndex: 'lastSeen', dataIndex: 'lastSeen',
width: 80, width: 80,
key: 'lastSeen', key: 'lastSeen',
render: getDateValue, render: (value): JSX.Element =>
getDateValue(value, formatTimezoneAdjustedTimestamp),
sorter: true, sorter: true,
defaultSortOrder: getDefaultOrder( defaultSortOrder: getDefaultOrder(
getUpdatedParams, getUpdatedParams,
@ -355,7 +367,8 @@ function AllErrors(): JSX.Element {
dataIndex: 'firstSeen', dataIndex: 'firstSeen',
width: 80, width: 80,
key: 'firstSeen', key: 'firstSeen',
render: getDateValue, render: (value): JSX.Element =>
getDateValue(value, formatTimezoneAdjustedTimestamp),
sorter: true, sorter: true,
defaultSortOrder: getDefaultOrder( defaultSortOrder: getDefaultOrder(
getUpdatedParams, getUpdatedParams,

View File

@ -10,6 +10,7 @@ import getAxes from 'lib/uPlotLib/utils/getAxes';
import { getUplotChartDataForAnomalyDetection } from 'lib/uPlotLib/utils/getUplotChartData'; import { getUplotChartDataForAnomalyDetection } from 'lib/uPlotLib/utils/getUplotChartData';
import { getYAxisScaleForAnomalyDetection } from 'lib/uPlotLib/utils/getYAxisScale'; import { getYAxisScaleForAnomalyDetection } from 'lib/uPlotLib/utils/getYAxisScale';
import { LineChart } from 'lucide-react'; import { LineChart } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import uPlot from 'uplot'; import uPlot from 'uplot';
@ -148,10 +149,12 @@ function AnomalyAlertEvaluationView({
] ]
: []; : [];
const { timezone } = useTimezone();
const options = { const options = {
width: dimensions.width, width: dimensions.width,
height: dimensions.height - 36, height: dimensions.height - 36,
plugins: [bandsPlugin, tooltipPlugin(isDarkMode)], plugins: [bandsPlugin, tooltipPlugin(isDarkMode, timezone.value)],
focus: { focus: {
alpha: 0.3, alpha: 0.3,
}, },
@ -256,6 +259,8 @@ function AnomalyAlertEvaluationView({
show: true, show: true,
}, },
axes: getAxes(isDarkMode, yAxisUnit), axes: getAxes(isDarkMode, yAxisUnit),
tzDate: (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
}; };
const handleSearch = (searchText: string): void => { const handleSearch = (searchText: string): void => {

View File

@ -1,8 +1,10 @@
import { themeColors } from 'constants/theme'; import { themeColors } from 'constants/theme';
import dayjs from 'dayjs';
import { generateColor } from 'lib/uPlotLib/utils/generateColor'; import { generateColor } from 'lib/uPlotLib/utils/generateColor';
const tooltipPlugin = ( const tooltipPlugin = (
isDarkMode: boolean, isDarkMode: boolean,
timezone: string,
): { hooks: { init: (u: any) => void } } => { ): { hooks: { init: (u: any) => void } } => {
let tooltip: HTMLDivElement; let tooltip: HTMLDivElement;
const tooltipLeftOffset = 10; const tooltipLeftOffset = 10;
@ -17,7 +19,7 @@ const tooltipPlugin = (
return value.toFixed(3); return value.toFixed(3);
} }
if (value instanceof Date) { if (value instanceof Date) {
return value.toLocaleString(); return dayjs(value).tz(timezone).format('MM/DD/YYYY, h:mm:ss A');
} }
if (value == null) { if (value == null) {
return 'N/A'; return 'N/A';

View File

@ -6,12 +6,12 @@ import getNextPrevId from 'api/errors/getNextPrevId';
import Editor from 'components/Editor'; import Editor from 'components/Editor';
import { ResizeTable } from 'components/ResizeTable'; import { ResizeTable } from 'components/ResizeTable';
import { getNanoSeconds } from 'container/AllError/utils'; import { getNanoSeconds } from 'container/AllError/utils';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import createQueryParams from 'lib/createQueryParams'; import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history'; import history from 'lib/history';
import { isUndefined } from 'lodash-es'; import { isUndefined } from 'lodash-es';
import { urlKey } from 'pages/ErrorDetails/utils'; import { urlKey } from 'pages/ErrorDetails/utils';
import { useTimezone } from 'providers/Timezone';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
@ -103,8 +103,6 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
} }
}; };
const timeStamp = dayjs(errorDetail.timestamp);
const data: { key: string; value: string }[] = Object.keys(errorDetail) const data: { key: string; value: string }[] = Object.keys(errorDetail)
.filter((e) => !keyToExclude.includes(e)) .filter((e) => !keyToExclude.includes(e))
.map((key) => ({ .map((key) => ({
@ -136,6 +134,8 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]); }, [data]);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return ( return (
<> <>
<Typography>{errorDetail.exceptionType}</Typography> <Typography>{errorDetail.exceptionType}</Typography>
@ -145,7 +145,12 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
<EventContainer> <EventContainer>
<div> <div>
<Typography>Event {errorDetail.errorId}</Typography> <Typography>Event {errorDetail.errorId}</Typography>
<Typography>{timeStamp.format('MMM DD YYYY hh:mm:ss A')}</Typography> <Typography>
{formatTimezoneAdjustedTimestamp(
errorDetail.timestamp,
'DD/MM/YYYY hh:mm:ss A (UTC Z)',
)}
</Typography>
</div> </div>
<div> <div>
<Space align="end" direction="horizontal"> <Space align="end" direction="horizontal">

View File

@ -25,6 +25,7 @@ import getTimeString from 'lib/getTimeString';
import history from 'lib/history'; import history from 'lib/history';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
@ -35,6 +36,7 @@ import { AlertDef } from 'types/api/alerts/def';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getGraphType } from 'utils/getGraphType'; import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData'; import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange'; import { getTimeRange } from 'utils/getTimeRange';
@ -201,6 +203,8 @@ function ChartPreview({
[dispatch, location.pathname, urlQuery], [dispatch, location.pathname, urlQuery],
); );
const { timezone } = useTimezone();
const options = useMemo( const options = useMemo(
() => () =>
getUPlotChartOptions({ getUPlotChartOptions({
@ -236,6 +240,9 @@ function ChartPreview({
softMax: null, softMax: null,
softMin: null, softMin: null,
panelType: graphType, panelType: graphType,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
}), }),
[ [
yAxisUnit, yAxisUnit,
@ -250,6 +257,7 @@ function ChartPreview({
optionName, optionName,
alertDef?.condition.targetUnit, alertDef?.condition.targetUnit,
graphType, graphType,
timezone.value,
], ],
); );

View File

@ -4,6 +4,7 @@ import { Popover, Typography } from 'antd';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils'; import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { toFixed } from 'utils/toFixed'; import { toFixed } from 'utils/toFixed';
@ -32,13 +33,17 @@ function Span(props: SpanLengthProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { time, timeUnitName } = convertTimeToRelevantUnit(inMsCount); const { time, timeUnitName } = convertTimeToRelevantUnit(inMsCount);
const { timezone } = useTimezone();
useEffect(() => { useEffect(() => {
document.documentElement.scrollTop = document.documentElement.clientHeight; document.documentElement.scrollTop = document.documentElement.clientHeight;
document.documentElement.scrollLeft = document.documentElement.clientWidth; document.documentElement.scrollLeft = document.documentElement.clientWidth;
}, []); }, []);
const getContent = (): JSX.Element => { const getContent = (): JSX.Element => {
const timeStamp = dayjs(startTime).format('h:mm:ss:SSS A'); const timeStamp = dayjs(startTime)
.tz(timezone.value)
.format('h:mm:ss:SSS A (UTC Z)');
const startTimeInMs = startTime - globalStart; const startTimeInMs = startTime - globalStart;
return ( return (
<div> <div>

View File

@ -31,7 +31,7 @@ import { AxiosError } from 'axios';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import Tags from 'components/Tags/Tags'; import Tags from 'components/Tags/Tags';
import { SOMETHING_WENT_WRONG } from 'constants/api'; import { SOMETHING_WENT_WRONG } from 'constants/api';
import dayjs, { Dayjs } from 'dayjs'; import dayjs from 'dayjs';
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys'; import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
import useDebouncedFn from 'hooks/useDebouncedFunction'; import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
@ -51,6 +51,7 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { ChangeEvent, useEffect, useState } from 'react'; import { ChangeEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query'; import { useMutation } from 'react-query';
@ -70,7 +71,10 @@ const { Option } = Select;
const BYTES = 1073741824; const BYTES = 1073741824;
export const disabledDate = (current: Dayjs): boolean => // Using any type here because antd's DatePicker expects its own internal Dayjs type
// which conflicts with our project's Dayjs type that has additional plugins (tz, utc etc).
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export const disabledDate = (current: any): boolean =>
// Disable all dates before today // Disable all dates before today
current && current < dayjs().endOf('day'); current && current < dayjs().endOf('day');
@ -393,8 +397,11 @@ function MultiIngestionSettings(): JSX.Element {
const gbToBytes = (gb: number): number => Math.round(gb * 1024 ** 3); const gbToBytes = (gb: number): number => Math.round(gb * 1024 ** 3);
const getFormattedTime = (date: string): string => const getFormattedTime = (
dayjs(date).format('MMM DD,YYYY, hh:mm a'); date: string,
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
): string =>
formatTimezoneAdjustedTimestamp(date, 'MMM DD,YYYY, hh:mm a (UTC Z)');
const showDeleteLimitModal = ( const showDeleteLimitModal = (
APIKey: IngestionKeyProps, APIKey: IngestionKeyProps,
@ -544,17 +551,27 @@ function MultiIngestionSettings(): JSX.Element {
} }
}; };
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [ const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
{ {
title: 'Ingestion Key', title: 'Ingestion Key',
key: 'ingestion-key', key: 'ingestion-key',
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
render: (APIKey: IngestionKeyProps): JSX.Element => { render: (APIKey: IngestionKeyProps): JSX.Element => {
const createdOn = getFormattedTime(APIKey.created_at); const createdOn = getFormattedTime(
APIKey.created_at,
formatTimezoneAdjustedTimestamp,
);
const formattedDateAndTime = const formattedDateAndTime =
APIKey && APIKey?.expires_at && getFormattedTime(APIKey?.expires_at); APIKey &&
APIKey?.expires_at &&
getFormattedTime(APIKey?.expires_at, formatTimezoneAdjustedTimestamp);
const updatedOn = getFormattedTime(APIKey?.updated_at); const updatedOn = getFormattedTime(
APIKey?.updated_at,
formatTimezoneAdjustedTimestamp,
);
const limits: { [key: string]: LimitProps } = {}; const limits: { [key: string]: LimitProps } = {};

View File

@ -1,8 +1,20 @@
import { Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table'; import { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable'; import { ResizeTable } from 'components/ResizeTable';
import { useTimezone } from 'providers/Timezone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { License } from 'types/api/licenses/def'; import { License } from 'types/api/licenses/def';
function ValidityColumn({ value }: { value: string }): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return (
<Typography>
{formatTimezoneAdjustedTimestamp(value, 'YYYY-MM-DD HH:mm:ss (UTC Z)')}
</Typography>
);
}
function ListLicenses({ licenses }: ListLicensesProps): JSX.Element { function ListLicenses({ licenses }: ListLicensesProps): JSX.Element {
const { t } = useTranslation(['licenses']); const { t } = useTranslation(['licenses']);
@ -23,12 +35,14 @@ function ListLicenses({ licenses }: ListLicensesProps): JSX.Element {
title: t('column_valid_from'), title: t('column_valid_from'),
dataIndex: 'ValidFrom', dataIndex: 'ValidFrom',
key: 'valid from', key: 'valid from',
render: (value: string): JSX.Element => ValidityColumn({ value }),
width: 80, width: 80,
}, },
{ {
title: t('column_valid_until'), title: t('column_valid_until'),
dataIndex: 'ValidUntil', dataIndex: 'ValidUntil',
key: 'valid until', key: 'valid until',
render: (value: string): JSX.Element => ValidityColumn({ value }),
width: 80, width: 80,
}, },
]; ];

View File

@ -867,7 +867,7 @@
.configure-metadata-root { .configure-metadata-root {
.ant-modal-content { .ant-modal-content {
width: 400px; width: 500px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--Slate-500, #161922); border: 1px solid var(--Slate-500, #161922);
@ -1039,7 +1039,6 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 336px;
padding: 0px 0px 0px 14.634px; padding: 0px 0px 0px 14.634px;
.left { .left {

View File

@ -57,6 +57,7 @@ import {
// see more: https://github.com/lucide-icons/lucide/issues/94 // see more: https://github.com/lucide-icons/lucide/issues/94
import { handleContactSupport } from 'pages/Integrations/utils'; import { handleContactSupport } from 'pages/Integrations/utils';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { import {
ChangeEvent, ChangeEvent,
Key, Key,
@ -343,31 +344,13 @@ function DashboardsList(): JSX.Element {
} }
}, [state.error, state.value, t, notifications]); }, [state.error, state.value, t, notifications]);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
function getFormattedTime(dashboard: Dashboard, option: string): string { function getFormattedTime(dashboard: Dashboard, option: string): string {
const timeOptions: Intl.DateTimeFormatOptions = { return formatTimezoneAdjustedTimestamp(
hour: '2-digit', get(dashboard, option, ''),
minute: '2-digit', 'MMM D, YYYY ⎯ hh:mm:ss A (UTC Z)',
second: '2-digit',
hour12: false,
};
const formattedTime = new Date(get(dashboard, option, '')).toLocaleTimeString(
'en-US',
timeOptions,
); );
const dateOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
const formattedDate = new Date(get(dashboard, option, '')).toLocaleDateString(
'en-US',
dateOptions,
);
// Combine time and date
return `${formattedDate}${formattedTime}`;
} }
const onLastUpdated = (time: string): string => { const onLastUpdated = (time: string): string => {
@ -410,31 +393,11 @@ function DashboardsList(): JSX.Element {
title: 'Dashboards', title: 'Dashboards',
key: 'dashboard', key: 'dashboard',
render: (dashboard: Data, _, index): JSX.Element => { render: (dashboard: Data, _, index): JSX.Element => {
const timeOptions: Intl.DateTimeFormatOptions = { const formattedDateAndTime = formatTimezoneAdjustedTimestamp(
hour: '2-digit', dashboard.createdAt,
minute: '2-digit', 'MMM D, YYYY ⎯ hh:mm:ss A (UTC Z)',
second: '2-digit',
hour12: false,
};
const formattedTime = new Date(dashboard.createdAt).toLocaleTimeString(
'en-US',
timeOptions,
); );
const dateOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
const formattedDate = new Date(dashboard.createdAt).toLocaleDateString(
'en-US',
dateOptions,
);
// Combine time and date
const formattedDateAndTime = `${formattedDate}${formattedTime}`;
const getLink = (): string => `${ROUTES.ALL_DASHBOARD}/${dashboard.id}`; const getLink = (): string => `${ROUTES.ALL_DASHBOARD}/${dashboard.id}`;
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => { const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {

View File

@ -8,10 +8,12 @@ import { useResizeObserver } from 'hooks/useDimensions';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useTimezone } from 'providers/Timezone';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query'; import { useQueries, UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api'; import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { import {
getHostQueryPayload, getHostQueryPayload,
@ -73,6 +75,8 @@ function NodeMetrics({
[queries], [queries],
); );
const { timezone } = useTimezone();
const options = useMemo( const options = useMemo(
() => () =>
queries.map(({ data }, idx) => queries.map(({ data }, idx) =>
@ -86,6 +90,9 @@ function NodeMetrics({
minTimeScale: start, minTimeScale: start,
maxTimeScale: end, maxTimeScale: end,
verticalLineTimestamp, verticalLineTimestamp,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
}), }),
), ),
[ [
@ -96,6 +103,7 @@ function NodeMetrics({
start, start,
verticalLineTimestamp, verticalLineTimestamp,
end, end,
timezone.value,
], ],
); );

View File

@ -8,10 +8,12 @@ import { useResizeObserver } from 'hooks/useDimensions';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useTimezone } from 'providers/Timezone';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query'; import { useQueries, UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api'; import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { getPodQueryPayload, podWidgetInfo } from './constants'; import { getPodQueryPayload, podWidgetInfo } from './constants';
@ -60,6 +62,7 @@ function PodMetrics({
() => queries.map(({ data }) => getUPlotChartData(data?.payload)), () => queries.map(({ data }) => getUPlotChartData(data?.payload)),
[queries], [queries],
); );
const { timezone } = useTimezone();
const options = useMemo( const options = useMemo(
() => () =>
@ -74,9 +77,20 @@ function PodMetrics({
minTimeScale: start, minTimeScale: start,
maxTimeScale: end, maxTimeScale: end,
verticalLineTimestamp, verticalLineTimestamp,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
}), }),
), ),
[queries, isDarkMode, dimensions, start, verticalLineTimestamp, end], [
queries,
isDarkMode,
dimensions,
start,
end,
verticalLineTimestamp,
timezone.value,
],
); );
const renderCardContent = ( const renderCardContent = (

View File

@ -11,7 +11,8 @@ import ROUTES from 'constants/routes';
import dompurify from 'dompurify'; import dompurify from 'dompurify';
import { isEmpty } from 'lodash-es'; import { isEmpty } from 'lodash-es';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react'; import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useTimezone } from 'providers/Timezone';
import React, { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_TAGS } from 'utils/app'; import { FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@ -68,6 +69,8 @@ export function TableViewActions(
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (record.field === 'body') { if (record.field === 'body') {
const parsedBody = recursiveParseJSON(fieldData.value); const parsedBody = recursiveParseJSON(fieldData.value);
if (!isEmpty(parsedBody)) { if (!isEmpty(parsedBody)) {
@ -100,33 +103,44 @@ export function TableViewActions(
); );
} }
let cleanTimestamp: string;
if (record.field === 'timestamp') {
cleanTimestamp = fieldData.value.replace(/^["']|["']$/g, '');
}
const renderFieldContent = (): JSX.Element => {
const commonStyles: React.CSSProperties = {
color: Color.BG_SIENNA_400,
whiteSpace: 'pre-wrap',
tabSize: 4,
};
switch (record.field) {
case 'body':
return <span style={commonStyles} dangerouslySetInnerHTML={bodyHtml} />;
case 'timestamp':
return (
<span style={commonStyles}>
{formatTimezoneAdjustedTimestamp(
cleanTimestamp,
'MM/DD/YYYY, HH:mm:ss.SSS (UTC Z)',
)}
</span>
);
default:
return (
<span style={commonStyles}>{removeEscapeCharacters(fieldData.value)}</span>
);
}
};
return ( return (
<div className={cx('value-field', isOpen ? 'open-popover' : '')}> <div className={cx('value-field', isOpen ? 'open-popover' : '')}>
{record.field === 'body' ? ( <CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}> {renderFieldContent()}
<span </CopyClipboardHOC>
style={{
color: Color.BG_SIENNA_400,
whiteSpace: 'pre-wrap',
tabSize: 4,
}}
dangerouslySetInnerHTML={bodyHtml}
/>
</CopyClipboardHOC>
) : (
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
<span
style={{
color: Color.BG_SIENNA_400,
whiteSpace: 'pre-wrap',
tabSize: 4,
}}
>
{removeEscapeCharacters(fieldData.value)}
</span>
</CopyClipboardHOC>
)}
{!isListViewPanel && ( {!isListViewPanel && (
<span className="action-btn"> <span className="action-btn">
<Tooltip title="Filter for value"> <Tooltip title="Filter for value">

View File

@ -50,6 +50,7 @@ import {
} from 'lodash-es'; } from 'lodash-es';
import { Sliders } from 'lucide-react'; import { Sliders } from 'lucide-react';
import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils'; import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils';
import { useTimezone } from 'providers/Timezone';
import { import {
memo, memo,
MutableRefObject, MutableRefObject,
@ -669,13 +670,19 @@ function LogsExplorerViews({
setIsLoadingQueries, setIsLoadingQueries,
]); ]);
const { timezone } = useTimezone();
const flattenLogData = useMemo( const flattenLogData = useMemo(
() => () =>
logs.map((log) => { logs.map((log) => {
const timestamp = const timestamp =
typeof log.timestamp === 'string' typeof log.timestamp === 'string'
? dayjs(log.timestamp).format('YYYY-MM-DD HH:mm:ss.SSS') ? dayjs(log.timestamp)
: dayjs(log.timestamp / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS'); .tz(timezone.value)
.format('YYYY-MM-DD HH:mm:ss.SSS')
: dayjs(log.timestamp / 1e6)
.tz(timezone.value)
.format('YYYY-MM-DD HH:mm:ss.SSS');
return FlatLogData({ return FlatLogData({
timestamp, timestamp,
@ -683,7 +690,7 @@ function LogsExplorerViews({
...omit(log, 'timestamp', 'body'), ...omit(log, 'timestamp', 'body'),
}); });
}), }),
[logs], [logs, timezone.value],
); );
return ( return (

View File

@ -7,6 +7,7 @@ import { rest } from 'msw';
import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils'; import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils';
import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { QueryBuilderProvider } from 'providers/QueryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import TimezoneProvider from 'providers/Timezone';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
@ -91,17 +92,19 @@ const renderer = (): RenderResult =>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<MockQueryClientProvider> <MockQueryClientProvider>
<QueryBuilderProvider> <QueryBuilderProvider>
<VirtuosoMockContext.Provider <TimezoneProvider>
value={{ viewportHeight: 300, itemHeight: 100 }} <VirtuosoMockContext.Provider
> value={{ viewportHeight: 300, itemHeight: 100 }}
<LogsExplorerViews >
selectedView={SELECTED_VIEWS.SEARCH} <LogsExplorerViews
showFrequencyChart selectedView={SELECTED_VIEWS.SEARCH}
setIsLoadingQueries={(): void => {}} showFrequencyChart
listQueryKeyRef={{ current: {} }} setIsLoadingQueries={(): void => {}}
chartQueryKeyRef={{ current: {} }} listQueryKeyRef={{ current: {} }}
/> chartQueryKeyRef={{ current: {} }}
</VirtuosoMockContext.Provider> />
</VirtuosoMockContext.Provider>
</TimezoneProvider>
</QueryBuilderProvider> </QueryBuilderProvider>
</MockQueryClientProvider> </MockQueryClientProvider>
</I18nextProvider> </I18nextProvider>

View File

@ -15,6 +15,7 @@ import { useLogsData } from 'hooks/useLogsData';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { FlatLogData } from 'lib/logs/flatLogData'; import { FlatLogData } from 'lib/logs/flatLogData';
import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { useTimezone } from 'providers/Timezone';
import { import {
Dispatch, Dispatch,
HTMLAttributes, HTMLAttributes,
@ -76,7 +77,12 @@ function LogsPanelComponent({
}); });
}; };
const columns = getLogPanelColumnsList(widget.selectedLogFields); const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns = getLogPanelColumnsList(
widget.selectedLogFields,
formatTimezoneAdjustedTimestamp,
);
const dataLength = const dataLength =
queryResponse.data?.payload?.data?.newResult?.data?.result[0]?.list?.length; queryResponse.data?.payload?.data?.newResult?.data?.result[0]?.list?.length;

View File

@ -1,6 +1,7 @@
import { ColumnsType } from 'antd/es/table'; import { ColumnsType } from 'antd/es/table';
import { Typography } from 'antd/lib'; import { Typography } from 'antd/lib';
import { OPERATORS } from 'constants/queryBuilder'; import { OPERATORS } from 'constants/queryBuilder';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
// import Typography from 'antd/es/typography/Typography'; // import Typography from 'antd/es/typography/Typography';
import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
@ -13,18 +14,31 @@ import { v4 as uuid } from 'uuid';
export const getLogPanelColumnsList = ( export const getLogPanelColumnsList = (
selectedLogFields: Widgets['selectedLogFields'], selectedLogFields: Widgets['selectedLogFields'],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string,
): ColumnsType<RowData> => { ): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = []; const initialColumns: ColumnsType<RowData> = [];
const columns: ColumnsType<RowData> = const columns: ColumnsType<RowData> =
selectedLogFields?.map((field: IField) => { selectedLogFields?.map((field: IField) => {
const { name } = field; const { name } = field;
return { return {
title: name, title: name,
dataIndex: name, dataIndex: name,
key: name, key: name,
width: name === 'body' ? 350 : 100, width: name === 'body' ? 350 : 100,
render: (value: ReactNode): JSX.Element => { render: (value: ReactNode): JSX.Element => {
if (name === 'timestamp') {
return (
<Typography.Text>
{formatTimezoneAdjustedTimestamp(value as string)}
</Typography.Text>
);
}
if (name === 'body') { if (name === 'body') {
return ( return (
<Typography.Paragraph ellipsis={{ rows: 1 }} data-testid={name}> <Typography.Paragraph ellipsis={{ rows: 1 }} data-testid={name}>

View File

@ -0,0 +1,96 @@
.timezone-adaption {
padding: 16px;
background: var(--bg-ink-400);
border: 1px solid var(--bg-ink-500);
border-radius: 4px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__title {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
margin: 0;
}
&__description {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
margin: 0 0 12px 0;
}
&__note {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7.5px 12px;
background: rgba(78, 116, 248, 0.1);
border: 1px solid rgba(78, 116, 248, 0.1);
border-radius: 4px;
}
&__bullet {
color: var(--bg-robin-400);
font-size: 16px;
line-height: 20px;
}
&__note-text-container {
display: flex;
align-items: center;
gap: 10px;
}
&__note-text {
display: flex;
align-items: center;
gap: 4px;
color: var(--bg-robin-400);
font-size: 14px;
line-height: 20px;
}
&__note-text-overridden {
display: flex;
align-items: center;
padding: 0 2px;
background: rgba(171, 189, 255, 0.04);
border-radius: 2px;
font-size: 12px;
line-height: 16px;
color: var(--bg-vanilla-100);
}
&__clear-override {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
padding: 0;
color: var(--bg-robin-300);
font-size: 12px;
line-height: 16px; /* 133.333% */
letter-spacing: 0.12px;
cursor: pointer;
}
}
.lightMode {
.timezone-adaption {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
&__title {
color: var(--text-ink-300);
}
&__description {
color: var(--text-ink-400);
}
}
}

View File

@ -0,0 +1,82 @@
import './TimezoneAdaptation.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Switch } from 'antd';
import { Delete } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { useMemo } from 'react';
function TimezoneAdaptation(): JSX.Element {
const {
timezone,
browserTimezone,
updateTimezone,
isAdaptationEnabled,
setIsAdaptationEnabled,
} = useTimezone();
const isTimezoneOverridden = useMemo(
() => timezone.offset !== browserTimezone.offset,
[timezone, browserTimezone],
);
const getSwitchStyles = (): React.CSSProperties => ({
backgroundColor:
isAdaptationEnabled && isTimezoneOverridden ? Color.BG_AMBER_400 : undefined,
});
const handleOverrideClear = (): void => {
updateTimezone(browserTimezone);
};
return (
<div className="timezone-adaption">
<div className="timezone-adaption__header">
<h2 className="timezone-adaption__title">Adapt to my timezone</h2>
<Switch
checked={isAdaptationEnabled}
onChange={setIsAdaptationEnabled}
style={getSwitchStyles()}
/>
</div>
<p className="timezone-adaption__description">
Adapt the timestamps shown in the SigNoz console to my active timezone.
</p>
<div className="timezone-adaption__note">
<div className="timezone-adaption__note-text-container">
<span className="timezone-adaption__bullet"></span>
<span className="timezone-adaption__note-text">
{isTimezoneOverridden ? (
<>
Your current timezone is overridden to
<span className="timezone-adaption__note-text-overridden">
{timezone.offset}
</span>
</>
) : (
<>
You can override the timezone adaption for any view with the time
picker.
</>
)}
</span>
</div>
{!!isTimezoneOverridden && (
<button
type="button"
className="timezone-adaption__clear-override"
onClick={handleOverrideClear}
>
<Delete height={12} width={12} color={Color.BG_ROBIN_300} />
Clear override
</button>
)}
</div>
</div>
);
}
export default TimezoneAdaptation;

View File

@ -7,6 +7,7 @@ import { LogOut, Moon, Sun } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import Password from './Password'; import Password from './Password';
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
import UserInfo from './UserInfo'; import UserInfo from './UserInfo';
function MySettings(): JSX.Element { function MySettings(): JSX.Element {
@ -78,6 +79,8 @@ function MySettings(): JSX.Element {
<Password /> <Password />
</div> </div>
<TimezoneAdaptation />
<Button <Button
className="flexBtn" className="flexBtn"
onClick={(): void => Logout()} onClick={(): void => Logout()}

View File

@ -14,7 +14,9 @@ import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { cloneDeep, isEqual, isUndefined } from 'lodash-es'; import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
import _noop from 'lodash-es/noop'; import _noop from 'lodash-es/noop';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import uPlot from 'uplot';
import { getSortedSeriesData } from 'utils/getSortedSeriesData'; import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange'; import { getTimeRange } from 'utils/getTimeRange';
@ -105,6 +107,8 @@ function UplotPanelWrapper({
} }
}, [graphVisibility, hiddenGraph, widget.panelTypes, widget?.stackedBarChart]); }, [graphVisibility, hiddenGraph, widget.panelTypes, widget?.stackedBarChart]);
const { timezone } = useTimezone();
const options = useMemo( const options = useMemo(
() => () =>
getUPlotChartOptions({ getUPlotChartOptions({
@ -128,6 +132,9 @@ function UplotPanelWrapper({
hiddenGraph, hiddenGraph,
setHiddenGraph, setHiddenGraph,
customTooltipElement, customTooltipElement,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
}), }),
[ [
widget?.id, widget?.id,
@ -150,6 +157,7 @@ function UplotPanelWrapper({
currentQuery, currentQuery,
hiddenGraph, hiddenGraph,
customTooltipElement, customTooltipElement,
timezone.value,
], ],
); );

View File

@ -1,8 +1,14 @@
import dayjs from 'dayjs'; import { useTimezone } from 'providers/Timezone';
function DeploymentTime(deployTime: string): JSX.Element { function DeploymentTime(deployTime: string): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return ( return (
<span>{dayjs(deployTime).locale('en').format('MMMM DD, YYYY hh:mm A')}</span> <span>
{formatTimezoneAdjustedTimestamp(
deployTime,
'MMMM DD, YYYY hh:mm A (UTC Z)',
)}{' '}
</span>
); );
} }

View File

@ -1,4 +1,5 @@
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import TimezoneProvider from 'providers/Timezone';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@ -38,7 +39,9 @@ describe('ChangeHistory test', () => {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<ChangeHistory pipelineData={pipelineData} /> <TimezoneProvider>
<ChangeHistory pipelineData={pipelineData} />
</TimezoneProvider>
</I18nextProvider> </I18nextProvider>
</Provider> </Provider>
</QueryClientProvider> </QueryClientProvider>
@ -65,12 +68,14 @@ describe('ChangeHistory test', () => {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<ChangeHistory <TimezoneProvider>
pipelineData={{ <ChangeHistory
...pipelineData, pipelineData={{
history: pipelineDataHistory, ...pipelineData,
}} history: pipelineDataHistory,
/> }}
/>
</TimezoneProvider>
</I18nextProvider> </I18nextProvider>
</Provider> </Provider>
</QueryClientProvider> </QueryClientProvider>

View File

@ -3,8 +3,8 @@ import './styles.scss';
import { ExpandAltOutlined } from '@ant-design/icons'; import { ExpandAltOutlined } from '@ant-design/icons';
import LogDetail from 'components/LogDetail'; import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants'; import { VIEW_TYPES } from 'components/LogDetail/constants';
import dayjs from 'dayjs';
import { useActiveLog } from 'hooks/logs/useActiveLog'; import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log'; import { ILog } from 'types/api/logs/log';
function LogsList({ logs }: LogsListProps): JSX.Element { function LogsList({ logs }: LogsListProps): JSX.Element {
@ -18,12 +18,17 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log); const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return ( return (
<div className="logs-preview-list-container"> <div className="logs-preview-list-container">
{logs.map((log) => ( {logs.map((log) => (
<div key={log.id} className="logs-preview-list-item"> <div key={log.id} className="logs-preview-list-item">
<div className="logs-preview-list-item-timestamp"> <div className="logs-preview-list-item-timestamp">
{dayjs(log.timestamp).format('MMM DD HH:mm:ss.SSS')} {formatTimezoneAdjustedTimestamp(
log.timestamp,
'MMM DD HH:mm:ss.SSS (UTC Z)',
)}
</div> </div>
<div className="logs-preview-list-item-body">{log.body}</div> <div className="logs-preview-list-item-body">{log.body}</div>
<div <div

View File

@ -1,4 +1,4 @@
import dayjs from 'dayjs'; import { useTimezone } from 'providers/Timezone';
import React from 'react'; import React from 'react';
import { PipelineData, ProcessorData } from 'types/api/pipeline/def'; import { PipelineData, ProcessorData } from 'types/api/pipeline/def';
@ -6,13 +6,18 @@ import { PipelineIndexIcon } from '../AddNewProcessor/styles';
import { ColumnDataStyle, ListDataStyle, ProcessorIndexIcon } from '../styles'; import { ColumnDataStyle, ListDataStyle, ProcessorIndexIcon } from '../styles';
import PipelineFilterSummary from './PipelineFilterSummary'; import PipelineFilterSummary from './PipelineFilterSummary';
function CreatedAtComponent({ record }: { record: Record }): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return (
<ColumnDataStyle>
{formatTimezoneAdjustedTimestamp(record, 'MMMM DD, YYYY hh:mm A (UTC Z)')}
</ColumnDataStyle>
);
}
const componentMap: ComponentMap = { const componentMap: ComponentMap = {
orderId: ({ record }) => <PipelineIndexIcon>{record}</PipelineIndexIcon>, orderId: ({ record }) => <PipelineIndexIcon>{record}</PipelineIndexIcon>,
createdAt: ({ record }) => ( createdAt: ({ record }) => <CreatedAtComponent record={record} />,
<ColumnDataStyle>
{dayjs(record).locale('en').format('MMMM DD, YYYY hh:mm A')}
</ColumnDataStyle>
),
id: ({ record }) => <ProcessorIndexIcon>{record}</ProcessorIndexIcon>, id: ({ record }) => <ProcessorIndexIcon>{record}</ProcessorIndexIcon>,
name: ({ record }) => <ListDataStyle>{record}</ListDataStyle>, name: ({ record }) => <ListDataStyle>{record}</ListDataStyle>,
filter: ({ record }) => <PipelineFilterSummary filter={record} />, filter: ({ record }) => <PipelineFilterSummary filter={record} />,

View File

@ -1,6 +1,7 @@
/* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable sonarjs/no-duplicate-string */
import { findByText, render, waitFor } from '@testing-library/react'; import { findByText, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import TimezoneProvider from 'providers/Timezone';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@ -70,14 +71,16 @@ describe('PipelinePage container test', () => {
<MemoryRouter> <MemoryRouter>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<PipelineListsView <TimezoneProvider>
setActionType={jest.fn()} <PipelineListsView
isActionMode="viewing-mode" setActionType={jest.fn()}
setActionMode={jest.fn()} isActionMode="viewing-mode"
pipelineData={pipelineApiResponseMockData} setActionMode={jest.fn()}
isActionType="" pipelineData={pipelineApiResponseMockData}
refetchPipelineLists={jest.fn()} isActionType=""
/> refetchPipelineLists={jest.fn()}
/>
</TimezoneProvider>
</I18nextProvider> </I18nextProvider>
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@ -107,14 +110,16 @@ describe('PipelinePage container test', () => {
<MemoryRouter> <MemoryRouter>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<PipelineListsView <TimezoneProvider>
setActionType={jest.fn()} <PipelineListsView
isActionMode="editing-mode" setActionType={jest.fn()}
setActionMode={jest.fn()} isActionMode="editing-mode"
pipelineData={pipelineApiResponseMockData} setActionMode={jest.fn()}
isActionType="" pipelineData={pipelineApiResponseMockData}
refetchPipelineLists={jest.fn()} isActionType=""
/> refetchPipelineLists={jest.fn()}
/>
</TimezoneProvider>
</I18nextProvider> </I18nextProvider>
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@ -144,14 +149,16 @@ describe('PipelinePage container test', () => {
<MemoryRouter> <MemoryRouter>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<PipelineListsView <TimezoneProvider>
setActionType={jest.fn()} <PipelineListsView
isActionMode="editing-mode" setActionType={jest.fn()}
setActionMode={jest.fn()} isActionMode="editing-mode"
pipelineData={pipelineApiResponseMockData} setActionMode={jest.fn()}
isActionType="" pipelineData={pipelineApiResponseMockData}
refetchPipelineLists={jest.fn()} isActionType=""
/> refetchPipelineLists={jest.fn()}
/>
</TimezoneProvider>
</I18nextProvider> </I18nextProvider>
</Provider> </Provider>
</MemoryRouter>, </MemoryRouter>,
@ -209,14 +216,16 @@ describe('PipelinePage container test', () => {
<MemoryRouter> <MemoryRouter>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<PipelineListsView <TimezoneProvider>
setActionType={jest.fn()} <PipelineListsView
isActionMode="editing-mode" setActionType={jest.fn()}
setActionMode={jest.fn()} isActionMode="editing-mode"
pipelineData={pipelineApiResponseMockData} setActionMode={jest.fn()}
isActionType="" pipelineData={pipelineApiResponseMockData}
refetchPipelineLists={jest.fn()} isActionType=""
/> refetchPipelineLists={jest.fn()}
/>
</TimezoneProvider>
</I18nextProvider> </I18nextProvider>
</Provider> </Provider>
</MemoryRouter> </MemoryRouter>

View File

@ -17,6 +17,7 @@ import history from 'lib/history';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions'; import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { isEmpty } from 'lodash-es'; import { isEmpty } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@ -26,6 +27,7 @@ import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange'; import { getTimeRange } from 'utils/getTimeRange';
import { Container } from './styles'; import { Container } from './styles';
@ -118,6 +120,8 @@ function TimeSeriesView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const { timezone } = useTimezone();
const chartOptions = getUPlotChartOptions({ const chartOptions = getUPlotChartOptions({
onDragSelect, onDragSelect,
yAxisUnit: yAxisUnit || '', yAxisUnit: yAxisUnit || '',
@ -131,6 +135,9 @@ function TimeSeriesView({
maxTimeScale, maxTimeScale,
softMax: null, softMax: null,
softMin: null, softMin: null,
tzDate: (timestamp: number) =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
}); });
return ( return (

View File

@ -19,7 +19,10 @@ function CustomDateTimeModal({
setDateTime(date_time); setDateTime(date_time);
}; };
const disabledDate = (current: Dayjs): boolean => { // Using any type here because antd's DatePicker expects its own internal Dayjs type
// which conflicts with our project's Dayjs type that has additional plugins (tz, utc etc).
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
const disabledDate = (current: any): boolean => {
const currentDay = dayjs(current); const currentDay = dayjs(current);
return currentDay.isAfter(dayjs()); return currentDay.isAfter(dayjs());
}; };

View File

@ -28,6 +28,7 @@ import getTimeString from 'lib/getTimeString';
import history from 'lib/history'; import history from 'lib/history';
import { isObject } from 'lodash-es'; import { isObject } from 'lodash-es';
import { Check, Copy, Info, Send, Undo } from 'lucide-react'; import { Check, Copy, Info, Send, Undo } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query'; import { useQueryClient } from 'react-query';
import { connect, useSelector } from 'react-redux'; import { connect, useSelector } from 'react-redux';
@ -660,6 +661,8 @@ function DateTimeSelection({
); );
}; };
const { timezone } = useTimezone();
return ( return (
<div className="date-time-selector"> <div className="date-time-selector">
{showResetButton && selectedTime !== defaultRelativeTime && ( {showResetButton && selectedTime !== defaultRelativeTime && (
@ -713,8 +716,12 @@ function DateTimeSelection({
setIsValidteRelativeTime(isValid); setIsValidteRelativeTime(isValid);
}} }}
selectedValue={getInputLabel( selectedValue={getInputLabel(
dayjs(isModalTimeSelection ? modalStartTime : minTime / 1000000), dayjs(isModalTimeSelection ? modalStartTime : minTime / 1000000).tz(
dayjs(isModalTimeSelection ? modalEndTime : maxTime / 1000000), timezone.value,
),
dayjs(isModalTimeSelection ? modalEndTime : maxTime / 1000000).tz(
timezone.value,
),
isModalTimeSelection ? modalSelectedInterval : selectedTime, isModalTimeSelection ? modalSelectedInterval : selectedTime,
)} )}
data-testid="dropDown" data-testid="dropDown"

View File

@ -24,6 +24,7 @@ import history from 'lib/history';
import { map } from 'lodash-es'; import { map } from 'lodash-es';
import { PanelRight } from 'lucide-react'; import { PanelRight } from 'lucide-react';
import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants'; import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants';
import { useTimezone } from 'providers/Timezone';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ITraceForest, PayloadProps } from 'types/api/trace/getTraceItem'; import { ITraceForest, PayloadProps } from 'types/api/trace/getTraceItem';
import { getSpanTreeMetadata } from 'utils/getSpanTreeMetadata'; import { getSpanTreeMetadata } from 'utils/getSpanTreeMetadata';
@ -139,6 +140,8 @@ function TraceDetail({ response }: TraceDetailProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
return ( return (
<StyledRow styledclass={[Flex({ flex: 1 })]}> <StyledRow styledclass={[Flex({ flex: 1 })]}>
<StyledCol flex="auto" styledclass={styles.leftContainer}> <StyledCol flex="auto" styledclass={styles.leftContainer}>
@ -195,7 +198,9 @@ function TraceDetail({ response }: TraceDetailProps): JSX.Element {
{isGlobalTimeVisible && ( {isGlobalTimeVisible && (
<styles.TimeStampContainer flex={`${SPAN_DETAILS_LEFT_COL_WIDTH}px`}> <styles.TimeStampContainer flex={`${SPAN_DETAILS_LEFT_COL_WIDTH}px`}>
<Typography> <Typography>
{dayjs(traceMetaData.globalStart).format('hh:mm:ss a MM/DD')} {dayjs(traceMetaData.globalStart)
.tz(timezone.value)
.format('hh:mm:ss a (UTC Z) MM/DD')}
</Typography> </Typography>
</styles.TimeStampContainer> </styles.TimeStampContainer>
)} )}

View File

@ -15,6 +15,7 @@ import useDragColumns from 'hooks/useDragColumns';
import { getDraggedColumns } from 'hooks/useDragColumns/utils'; import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import useUrlQueryData from 'hooks/useUrlQueryData'; import useUrlQueryData from 'hooks/useUrlQueryData';
import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { useTimezone } from 'providers/Timezone';
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
@ -97,10 +98,15 @@ function ListView({ isFilterApplied }: ListViewProps): JSX.Element {
queryTableDataResult, queryTableDataResult,
]); ]);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns = useMemo(() => { const columns = useMemo(() => {
const updatedColumns = getListColumns(options?.selectColumns || []); const updatedColumns = getListColumns(
options?.selectColumns || [],
formatTimezoneAdjustedTimestamp,
);
return getDraggedColumns(updatedColumns, draggedColumns); return getDraggedColumns(updatedColumns, draggedColumns);
}, [options?.selectColumns, draggedColumns]); }, [options?.selectColumns, formatTimezoneAdjustedTimestamp, draggedColumns]);
const transformedQueryTableData = useMemo( const transformedQueryTableData = useMemo(
() => transformDataWithDate(queryTableData) || [], () => transformDataWithDate(queryTableData) || [],

View File

@ -3,7 +3,7 @@ import { ColumnsType } from 'antd/es/table';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils'; import { formUrlParams } from 'container/TraceDetail/utils';
import dayjs from 'dayjs'; import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ILog } from 'types/api/logs/log'; import { ILog } from 'types/api/logs/log';
@ -46,6 +46,10 @@ export const getTraceLink = (record: RowData): string =>
export const getListColumns = ( export const getListColumns = (
selectedColumns: BaseAutocompleteData[], selectedColumns: BaseAutocompleteData[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => { ): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [ const initialColumns: ColumnsType<RowData> = [
{ {
@ -56,8 +60,8 @@ export const getListColumns = (
render: (value, item): JSX.Element => { render: (value, item): JSX.Element => {
const date = const date =
typeof value === 'string' typeof value === 'string'
? dayjs(value).format('YYYY-MM-DD HH:mm:ss.SSS') ? formatTimezoneAdjustedTimestamp(value, 'YYYY-MM-DD HH:mm:ss.SSS')
: dayjs(value / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS'); : formatTimezoneAdjustedTimestamp(value / 1e6, 'YYYY-MM-DD HH:mm:ss.SSS');
return ( return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}> <BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text> <Typography.Text>{date}</Typography.Text>

View File

@ -15,6 +15,7 @@ import { Pagination } from 'hooks/queryPagination';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import history from 'lib/history'; import history from 'lib/history';
import { RowData } from 'lib/query/createTableColumnsFromQuery'; import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { useTimezone } from 'providers/Timezone';
import { import {
Dispatch, Dispatch,
HTMLAttributes, HTMLAttributes,
@ -49,7 +50,12 @@ function TracesTableComponent({
})); }));
}, [pagination, setRequestData]); }, [pagination, setRequestData]);
const columns = getListColumns(widget.selectedTracesFields || []); const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns = getListColumns(
widget.selectedTracesFields || [],
formatTimezoneAdjustedTimestamp,
);
const dataLength = const dataLength =
queryResponse.data?.payload?.data?.newResult?.data?.result[0]?.list?.length; queryResponse.data?.payload?.data?.newResult?.data?.result[0]?.list?.length;

View File

@ -1,12 +1,12 @@
import { Tag, Typography } from 'antd'; import { Tag, Typography } from 'antd';
import convertDateToAmAndPm from 'lib/convertDateToAmAndPm'; import { useTimezone } from 'providers/Timezone';
import getFormattedDate from 'lib/getFormatedDate';
import { Alerts } from 'types/api/alerts/getTriggered'; import { Alerts } from 'types/api/alerts/getTriggered';
import Status from '../TableComponents/AlertStatus'; import Status from '../TableComponents/AlertStatus';
import { TableCell, TableRow } from './styles'; import { TableCell, TableRow } from './styles';
function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element { function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return ( return (
<> <>
{allAlerts.map((alert) => { {allAlerts.map((alert) => {
@ -40,8 +40,9 @@ function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Typography>{`${getFormattedDate(formatedDate)} ${convertDateToAmAndPm( <Typography>{`${formatTimezoneAdjustedTimestamp(
formatedDate, formatedDate,
'MM/DD/YYYY hh:mm:ss A (UTC Z)',
)}`}</Typography> )}`}</Typography>
</TableCell> </TableCell>

View File

@ -4,8 +4,7 @@ import { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable'; import { ResizeTable } from 'components/ResizeTable';
import LabelColumn from 'components/TableRenderer/LabelColumn'; import LabelColumn from 'components/TableRenderer/LabelColumn';
import AlertStatus from 'container/TriggeredAlerts/TableComponents/AlertStatus'; import AlertStatus from 'container/TriggeredAlerts/TableComponents/AlertStatus';
import convertDateToAmAndPm from 'lib/convertDateToAmAndPm'; import { useTimezone } from 'providers/Timezone';
import getFormattedDate from 'lib/getFormatedDate';
import { Alerts } from 'types/api/alerts/getTriggered'; import { Alerts } from 'types/api/alerts/getTriggered';
import { Value } from './Filter'; import { Value } from './Filter';
@ -16,6 +15,7 @@ function NoFilterTable({
selectedFilter, selectedFilter,
}: NoFilterTableProps): JSX.Element { }: NoFilterTableProps): JSX.Element {
const filteredAlerts = FilterAlerts(allAlerts, selectedFilter); const filteredAlerts = FilterAlerts(allAlerts, selectedFilter);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// need to add the filter // need to add the filter
const columns: ColumnsType<Alerts> = [ const columns: ColumnsType<Alerts> = [
@ -83,15 +83,12 @@ function NoFilterTable({
width: 100, width: 100,
sorter: (a, b): number => sorter: (a, b): number =>
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(), new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
render: (date): JSX.Element => { render: (date): JSX.Element => (
const formatedDate = new Date(date); <Typography>{`${formatTimezoneAdjustedTimestamp(
date,
return ( 'MM/DD/YYYY hh:mm:ss A (UTC Z)',
<Typography>{`${getFormattedDate(formatedDate)} ${convertDateToAmAndPm( )}`}</Typography>
formatedDate, ),
)}`}</Typography>
);
},
}, },
]; ];

View File

@ -0,0 +1,103 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { useCallback, useEffect, useMemo } from 'react';
// Initialize dayjs plugins
dayjs.extend(utc);
dayjs.extend(timezone);
// Types
export type TimestampInput = string | number | Date;
interface CacheEntry {
value: string;
timestamp: number;
}
//
// Constants
const CACHE_SIZE_LIMIT = 1000;
const CACHE_CLEANUP_PERCENTAGE = 0.5; // Remove 50% when limit is reached
function useTimezoneFormatter({
userTimezone,
}: {
userTimezone: Timezone;
}): {
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
} {
// Initialize cache using useMemo to persist between renders
const cache = useMemo(() => new Map<string, CacheEntry>(), []);
// Clear cache when timezone changes
useEffect(() => {
cache.clear();
}, [cache, userTimezone]);
const clearCacheEntries = useCallback(() => {
if (cache.size <= CACHE_SIZE_LIMIT) return;
// Sort entries by timestamp (oldest first)
const sortedEntries = Array.from(cache.entries()).sort(
(a, b) => a[1].timestamp - b[1].timestamp,
);
// Calculate how many entries to remove (50% or overflow, whichever is larger)
const entriesToRemove = Math.max(
Math.floor(cache.size * CACHE_CLEANUP_PERCENTAGE),
cache.size - CACHE_SIZE_LIMIT,
);
// Remove oldest entries
sortedEntries.slice(0, entriesToRemove).forEach(([key]) => cache.delete(key));
}, [cache]);
/**
* Formats a timestamp with the user's timezone and caches the result
* @param {TimestampInput} input - The timestamp to format (string, number, or Date)
* @param {string} [format='YYYY-MM-DD HH:mm:ss'] - The desired output format
* @returns {string} The formatted timestamp string in the user's timezone
* @example
* // Input: UTC timestamp
* // User timezone: 'UTC - 4'
* // Returns: "2024-03-14 15:30:00"
* formatTimezoneAdjustedTimestamp('2024-03-14T19:30:00Z')
*/
const formatTimezoneAdjustedTimestamp = useCallback(
(input: TimestampInput, format = 'YYYY-MM-DD HH:mm:ss'): string => {
const timestamp = dayjs(input).valueOf();
const cacheKey = `${timestamp}_${userTimezone.value}`;
// Check cache first
const cachedValue = cache.get(cacheKey);
if (cachedValue) {
return cachedValue.value;
}
// Format timestamp
const formattedValue = dayjs(input).tz(userTimezone.value).format(format);
// Update cache
cache.set(cacheKey, {
value: formattedValue,
timestamp: Date.now(),
});
// Clear expired entries and enforce size limit
if (cache.size > CACHE_SIZE_LIMIT) {
clearCacheEntries();
}
return formattedValue;
},
[cache, clearCacheEntries, userTimezone.value],
);
return { formatTimezoneAdjustedTimestamp };
}
export default useTimezoneFormatter;

View File

@ -7,6 +7,7 @@ import { AxiosError } from 'axios';
import { ThemeProvider } from 'hooks/useDarkMode'; import { ThemeProvider } from 'hooks/useDarkMode';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js'; import posthog from 'posthog-js';
import TimezoneProvider from 'providers/Timezone';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { HelmetProvider } from 'react-helmet-async'; import { HelmetProvider } from 'react-helmet-async';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
@ -69,14 +70,16 @@ if (container) {
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}> <Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<HelmetProvider> <HelmetProvider>
<ThemeProvider> <ThemeProvider>
<QueryClientProvider client={queryClient}> <TimezoneProvider>
<Provider store={store}> <QueryClientProvider client={queryClient}>
<AppRoutes /> <Provider store={store}>
</Provider> <AppRoutes />
{process.env.NODE_ENV === 'development' && ( </Provider>
<ReactQueryDevtools initialIsOpen={false} /> {process.env.NODE_ENV === 'development' && (
)} <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> )}
</QueryClientProvider>
</TimezoneProvider>
</ThemeProvider> </ThemeProvider>
</HelmetProvider> </HelmetProvider>
</Sentry.ErrorBoundary>, </Sentry.ErrorBoundary>,

View File

@ -55,6 +55,8 @@ export interface GetUPlotChartOptions {
>; >;
customTooltipElement?: HTMLDivElement; customTooltipElement?: HTMLDivElement;
verticalLineTimestamp?: number; verticalLineTimestamp?: number;
tzDate?: (timestamp: number) => Date;
timezone?: string;
} }
/** the function converts series A , series B , series C to /** the function converts series A , series B , series C to
@ -158,6 +160,8 @@ export const getUPlotChartOptions = ({
setHiddenGraph, setHiddenGraph,
customTooltipElement, customTooltipElement,
verticalLineTimestamp, verticalLineTimestamp,
tzDate,
timezone,
}: GetUPlotChartOptions): uPlot.Options => { }: GetUPlotChartOptions): uPlot.Options => {
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale); const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
@ -196,6 +200,7 @@ export const getUPlotChartOptions = ({
fill: (): string => '#fff', fill: (): string => '#fff',
}, },
}, },
tzDate,
padding: [16, 16, 8, 8], padding: [16, 16, 8, 8],
bands, bands,
scales: { scales: {
@ -222,6 +227,7 @@ export const getUPlotChartOptions = ({
stackBarChart, stackBarChart,
isDarkMode, isDarkMode,
customTooltipElement, customTooltipElement,
timezone,
}), }),
onClickPlugin({ onClickPlugin({
onClick: onClickHandler, onClick: onClickHandler,

View File

@ -46,6 +46,7 @@ const generateTooltipContent = (
isHistogramGraphs?: boolean, isHistogramGraphs?: boolean,
isMergedSeries?: boolean, isMergedSeries?: boolean,
stackBarChart?: boolean, stackBarChart?: boolean,
timezone?: string,
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
): HTMLElement => { ): HTMLElement => {
const container = document.createElement('div'); const container = document.createElement('div');
@ -69,9 +70,13 @@ const generateTooltipContent = (
series.forEach((item, index) => { series.forEach((item, index) => {
if (index === 0) { if (index === 0) {
if (isBillingUsageGraphs) { if (isBillingUsageGraphs) {
tooltipTitle = dayjs(data[0][idx] * 1000).format('MMM DD YYYY'); tooltipTitle = dayjs(data[0][idx] * 1000)
.tz(timezone)
.format('MMM DD YYYY');
} else { } else {
tooltipTitle = dayjs(data[0][idx] * 1000).format('MMM DD YYYY HH:mm:ss'); tooltipTitle = dayjs(data[0][idx] * 1000)
.tz(timezone)
.format('MMM DD YYYY h:mm:ss A');
} }
} else if (item.show) { } else if (item.show) {
const { const {
@ -223,6 +228,7 @@ type ToolTipPluginProps = {
stackBarChart?: boolean; stackBarChart?: boolean;
isDarkMode: boolean; isDarkMode: boolean;
customTooltipElement?: HTMLDivElement; customTooltipElement?: HTMLDivElement;
timezone?: string;
}; };
const tooltipPlugin = ({ const tooltipPlugin = ({
@ -234,6 +240,7 @@ const tooltipPlugin = ({
stackBarChart, stackBarChart,
isDarkMode, isDarkMode,
customTooltipElement, customTooltipElement,
timezone,
}: // eslint-disable-next-line sonarjs/cognitive-complexity }: // eslint-disable-next-line sonarjs/cognitive-complexity
ToolTipPluginProps): any => { ToolTipPluginProps): any => {
let over: HTMLElement; let over: HTMLElement;
@ -300,6 +307,7 @@ ToolTipPluginProps): any => {
isHistogramGraphs, isHistogramGraphs,
isMergedSeries, isMergedSeries,
stackBarChart, stackBarChart,
timezone,
); );
if (customTooltipElement) { if (customTooltipElement) {
content.appendChild(customTooltipElement); content.appendChild(customTooltipElement);

View File

@ -14,6 +14,7 @@ import {
QueryBuilderProvider, QueryBuilderProvider,
} from 'providers/QueryBuilder'; } from 'providers/QueryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider'; import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import TimezoneProvider from 'providers/Timezone';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
@ -92,7 +93,9 @@ describe('Logs Explorer Tests', () => {
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<MockQueryClientProvider> <MockQueryClientProvider>
<QueryBuilderProvider> <QueryBuilderProvider>
<LogsExplorer /> <TimezoneProvider>
<LogsExplorer />
</TimezoneProvider>
</QueryBuilderProvider> </QueryBuilderProvider>
</MockQueryClientProvider> </MockQueryClientProvider>
</I18nextProvider> </I18nextProvider>
@ -141,7 +144,9 @@ describe('Logs Explorer Tests', () => {
<VirtuosoMockContext.Provider <VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 100 }} value={{ viewportHeight: 300, itemHeight: 100 }}
> >
<LogsExplorer /> <TimezoneProvider>
<LogsExplorer />
</TimezoneProvider>
</VirtuosoMockContext.Provider> </VirtuosoMockContext.Provider>
</QueryBuilderProvider> </QueryBuilderProvider>
</MockQueryClientProvider> </MockQueryClientProvider>
@ -225,7 +230,9 @@ describe('Logs Explorer Tests', () => {
<VirtuosoMockContext.Provider <VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 100 }} value={{ viewportHeight: 300, itemHeight: 100 }}
> >
<LogsExplorer /> <TimezoneProvider>
<LogsExplorer />
</TimezoneProvider>
</VirtuosoMockContext.Provider> </VirtuosoMockContext.Provider>
</QueryBuilderContext.Provider> </QueryBuilderContext.Provider>
</MockQueryClientProvider> </MockQueryClientProvider>
@ -253,7 +260,9 @@ describe('Logs Explorer Tests', () => {
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<MockQueryClientProvider> <MockQueryClientProvider>
<QueryBuilderProvider> <QueryBuilderProvider>
<LogsExplorer />, <TimezoneProvider>
<LogsExplorer />
</TimezoneProvider>
</QueryBuilderProvider> </QueryBuilderProvider>
</MockQueryClientProvider> </MockQueryClientProvider>
</I18nextProvider> </I18nextProvider>

View File

@ -31,6 +31,7 @@ import {
Trash2, Trash2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@ -207,6 +208,8 @@ function SaveView(): JSX.Element {
} }
}; };
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: TableProps<ViewProps>['columns'] = [ const columns: TableProps<ViewProps>['columns'] = [
{ {
title: 'Save View', title: 'Save View',
@ -218,31 +221,10 @@ function SaveView(): JSX.Element {
bgColor = extraData.color; bgColor = extraData.color;
} }
const timeOptions: Intl.DateTimeFormatOptions = { const formattedDateAndTime = formatTimezoneAdjustedTimestamp(
hour: '2-digit', view.createdAt,
minute: '2-digit', 'HH:mm:ss ⎯ MMM D, YYYY (UTC Z)',
second: '2-digit',
hour12: false,
};
const formattedTime = new Date(view.createdAt).toLocaleTimeString(
'en-US',
timeOptions,
); );
const dateOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
const formattedDate = new Date(view.createdAt).toLocaleDateString(
'en-US',
dateOptions,
);
// Combine time and date
const formattedDateAndTime = `${formattedTime}${formattedDate}`;
const isEditDeleteSupported = allowedRoles.includes(role as string); const isEditDeleteSupported = allowedRoles.includes(role as string);
return ( return (

View File

@ -0,0 +1,115 @@
import {
getBrowserTimezone,
getTimezoneObjectByTimezoneString,
Timezone,
UTC_TIMEZONE,
} from 'components/CustomTimePicker/timezoneUtils';
import { LOCALSTORAGE } from 'constants/localStorage';
import useTimezoneFormatter, {
TimestampInput,
} from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import React, {
createContext,
Dispatch,
SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
interface TimezoneContextType {
timezone: Timezone;
browserTimezone: Timezone;
updateTimezone: (timezone: Timezone) => void;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
isAdaptationEnabled: boolean;
setIsAdaptationEnabled: Dispatch<SetStateAction<boolean>>;
}
const TimezoneContext = createContext<TimezoneContextType | undefined>(
undefined,
);
function TimezoneProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const getStoredTimezoneValue = (): Timezone | null => {
try {
const timezoneValue = localStorage.getItem(LOCALSTORAGE.PREFERRED_TIMEZONE);
if (timezoneValue) {
return getTimezoneObjectByTimezoneString(timezoneValue);
}
} catch (error) {
console.error('Error reading timezone from localStorage:', error);
}
return null;
};
const setStoredTimezoneValue = (value: string): void => {
try {
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, value);
} catch (error) {
console.error('Error saving timezone to localStorage:', error);
}
};
const browserTimezone = useMemo(() => getBrowserTimezone(), []);
const [timezone, setTimezone] = useState<Timezone>(
getStoredTimezoneValue() ?? browserTimezone,
);
const [isAdaptationEnabled, setIsAdaptationEnabled] = useState(true);
const updateTimezone = useCallback((timezone: Timezone): void => {
if (!timezone.value) return;
// TODO(shaheer): replace this with user preferences API
setStoredTimezoneValue(timezone.value);
setTimezone(timezone);
// Enable adaptation when a new timezone is set
setIsAdaptationEnabled(true);
}, []);
const { formatTimezoneAdjustedTimestamp } = useTimezoneFormatter({
userTimezone: timezone,
});
const value = React.useMemo(
() => ({
timezone: isAdaptationEnabled ? timezone : UTC_TIMEZONE,
browserTimezone,
updateTimezone,
formatTimezoneAdjustedTimestamp,
isAdaptationEnabled,
setIsAdaptationEnabled,
}),
[
timezone,
browserTimezone,
updateTimezone,
formatTimezoneAdjustedTimestamp,
isAdaptationEnabled,
],
);
return (
<TimezoneContext.Provider value={value}>{children}</TimezoneContext.Provider>
);
}
export const useTimezone = (): TimezoneContextType => {
const context = useContext(TimezoneContext);
if (context === undefined) {
throw new Error('useTimezone must be used within a TimezoneProvider');
}
return context;
};
export default TimezoneProvider;

View File

@ -1,6 +1,7 @@
import { render, RenderOptions, RenderResult } from '@testing-library/react'; import { render, RenderOptions, RenderResult } from '@testing-library/react';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { ResourceProvider } from 'hooks/useResourceAttribute'; import { ResourceProvider } from 'hooks/useResourceAttribute';
import TimezoneProvider from 'providers/Timezone';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
@ -89,7 +90,9 @@ function AllTheProviders({
<Provider store={mockStored(role)}> <Provider store={mockStored(role)}>
{' '} {' '}
{/* Use the mock store with the provided role */} {/* Use the mock store with the provided role */}
<BrowserRouter>{children}</BrowserRouter> <BrowserRouter>
<TimezoneProvider>{children}</TimezoneProvider>
</BrowserRouter>
</Provider> </Provider>
</QueryClientProvider> </QueryClientProvider>
</ResourceProvider> </ResourceProvider>