refactor: Update usage of date formatting (#439)

This commit is contained in:
Ernest Iliiasov 2024-06-25 11:08:01 -04:00 committed by GitHub
parent 72c094e494
commit 1751b2e16d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 226 additions and 129 deletions

View file

@ -0,0 +1,5 @@
---
'@hyperdx/app': major
---
Propogate isUTC and clock settings (12h/24h) across the app

View file

@ -23,7 +23,7 @@ import { withAppNav } from './layout';
import { Tags } from './Tags';
import type { Alert, AlertHistory, LogView } from './types';
import { AlertState } from './types';
import { formatHumanReadableDate } from './utils';
import { FormatTime } from './useFormatTime';
import styles from '../styles/AlertsPage.module.scss';
@ -165,7 +165,7 @@ function AckAlert({ alert }: { alert: Alert }) {
</>
) : null}{' '}
on <br />
{formatHumanReadableDate(new Date(alert.silenced?.at))}
<FormatTime value={alert.silenced?.at} />
.<br />
</Menu.Label>
@ -174,8 +174,7 @@ function AckAlert({ alert }: { alert: Alert }) {
'Alert resumed.'
) : (
<>
Resumes{' '}
{formatHumanReadableDate(new Date(alert.silenced.until))}
Resumes <FormatTime value={alert.silenced.until} />.
</>
)}
</Menu.Label>

View file

@ -14,8 +14,10 @@ import {
import HyperDX from '@hyperdx/browser';
import {
ActionIcon,
Badge,
CloseButton,
Collapse,
Group,
Input,
Loader,
ScrollArea,
@ -23,6 +25,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { version } from '../package.json';
import { useUserPreferences } from '../src/useUserPreferences';
import api from './api';
import {
@ -911,6 +914,10 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
{ close: closeUserPreferences, open: openUserPreferences },
] = useDisclosure(false);
const {
userPreferences: { isUTC },
} = useUserPreferences();
return (
<>
<AuthLoadingBlocker />
@ -931,7 +938,21 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
<Icon size={22} />
</div>
) : (
<Logo />
<Group gap="xs" align="center">
<Logo />
{isUTC && (
<Badge
size="xs"
color="gray"
bg="gray.8"
variant="light"
fw="normal"
title="Showing time in UTC"
>
UTC
</Badge>
)}
</Group>
)}
</Link>
<Button

View file

@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import cx from 'classnames';
import { add, format } from 'date-fns';
import { add } from 'date-fns';
import { withErrorBoundary } from 'react-error-boundary';
import {
Bar,
@ -28,9 +28,9 @@ import {
seriesToUrlSearchQueryParam,
} from './ChartUtils';
import type { ChartSeries, NumberFormat } from './types';
import { useUserPreferences } from './useUserPreferences';
import { FormatTime, useFormatTime } from './useFormatTime';
import { formatNumber } from './utils';
import { semanticKeyedColor, TIME_TOKENS, truncateMiddle } from './utils';
import { semanticKeyedColor, truncateMiddle } from './utils';
import styles from '../styles/HDXLineChart.module.scss';
@ -38,16 +38,12 @@ const MAX_LEGEND_ITEMS = 4;
const HDXLineChartTooltip = withErrorBoundary(
memo((props: any) => {
const {
userPreferences: { timeFormat },
} = useUserPreferences();
const tsFormat = TIME_TOKENS[timeFormat];
const { active, payload, label, numberFormat } = props;
if (active && payload && payload.length) {
return (
<div className={styles.chartTooltip}>
<div className={styles.chartTooltipHeader}>
{format(new Date(label * 1000), tsFormat)}
<FormatTime value={label * 1000} />
</div>
<div className={styles.chartTooltipContent}>
{payload
@ -221,11 +217,14 @@ const MemoChart = memo(function MemoChart({
}, [groupKeys, graphResults, displayType, lineNames, lineColors]);
const sizeRef = useRef<[number, number]>([0, 0]);
const {
userPreferences: { timeFormat },
} = useUserPreferences();
const tsFormat = TIME_TOKENS[timeFormat];
// Gets the preffered time format from User Preferences, then converts it to a formattable token
const formatTime = useFormatTime();
const xTickFormatter = useCallback(
(value: number) => {
return formatTime(value * 1000);
},
[formatTime],
);
const tickFormatter = useCallback(
(value: number) =>
@ -290,7 +289,7 @@ const MemoChart = memo(function MemoChart({
interval="preserveStartEnd"
scale="time"
type="number"
tickFormatter={tick => format(new Date(tick * 1000), tsFormat)}
tickFormatter={xTickFormatter}
minTickGap={50}
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
/>

View file

@ -63,6 +63,7 @@ import TimelineChart from './TimelineChart';
import { dateRangeToString } from './timeQuery';
import type { StacktraceBreadcrumb, StacktraceFrame } from './types';
import { Dictionary } from './types';
import { FormatTime } from './useFormatTime';
import {
formatDistanceToNowStrictShort,
useFirstNonNullValue,
@ -664,10 +665,7 @@ function TraceSubpanel({
</>
)}
<span className="text-muted">at</span>{' '}
{format(
new Date(selectedLogData.timestamp),
'MMM d HH:mm:ss.SSS',
)}
<FormatTime value={selectedLogData.timestamp} format="withMs" />
</div>
{isNetworkRequestSpan({ logData: selectedLogData }) && (
<ErrorBoundary
@ -1549,10 +1547,7 @@ function PropertySubpanel({
{isException && (
<span className="text-danger me-2">Exception</span>
)}
{format(
new Date(event.timestamp / 1000),
'MMM d HH:mm:ss.SSS',
)}
<FormatTime value={event.timestamp / 1000} format="withMs" />
</div>
{isException ? (
<ExceptionEvent
@ -2059,7 +2054,7 @@ function SidePanelHeader({
) : null}
<span className="me-2">
<span className="text-muted">at</span>{' '}
{format(new Date(logData.timestamp), 'MMM d HH:mm:ss.SSS')}{' '}
<FormatTime value={logData.timestamp} format="withMs" />{' '}
<span className="text-muted">
&middot;{' '}
{formatDistanceToNowStrictShort(new Date(logData.timestamp))} ago

View file

@ -8,6 +8,7 @@ import HyperJson from './components/HyperJson';
import { TableCellButton } from './components/Table';
import { UNDEFINED_WIDTH } from './tableUtils';
import type { StacktraceBreadcrumb, StacktraceFrame } from './types';
import { FormatTime } from './useFormatTime';
import { useLocalStorage } from './utils';
import styles from '../styles/LogSidePanel.module.scss';
@ -286,7 +287,7 @@ export const breadcrumbColumns: ColumnDef<StacktraceBreadcrumb>[] = [
size: 220,
cell: ({ row }) => (
<span className="text-slate-500">
{format(new Date(row.original.timestamp * 1000), 'MMM d HH:mm:ss.SSS')}
<FormatTime value={row.original.timestamp * 1000} format="withMs" />
</span>
),
},

View file

@ -1,7 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import { format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import curry from 'lodash/curry';
import { Button, Modal } from 'react-bootstrap';
import { CSVLink } from 'react-csv';
@ -28,9 +26,9 @@ import InstallInstructionsModal from './InstallInstructionsModal';
import LogLevel from './LogLevel';
import { useSearchEventStream } from './search';
import { UNDEFINED_WIDTH } from './tableUtils';
import { FormatTime } from './useFormatTime';
import { useUserPreferences } from './useUserPreferences';
import { useLocalStorage, usePrevious, useWindowSize } from './utils';
import { TIME_TOKENS } from './utils';
import styles from '../styles/LogTable.module.scss';
type Row = Record<string, any> & { duration: number };
@ -273,15 +271,13 @@ export const RawLogTable = memo(
const { width } = useWindowSize();
const isSmallScreen = (width ?? 1000) < 900;
const {
userPreferences: { timeFormat, isUTC },
userPreferences: { isUTC },
} = useUserPreferences();
const tsFormat = TIME_TOKENS[timeFormat];
const [columnSizeStorage, setColumnSizeStorage] = useLocalStorage<
Record<string, number>
>(`${tableId}-column-sizes`, {});
const tsShortFormat = 'HH:mm:ss';
//once the user has scrolled within 500px of the bottom of the table, fetch more data if there is any
const FETCH_NEXT_PAGE_PX = 500;
@ -337,13 +333,10 @@ export const RawLogTable = memo(
const date = new Date(info.getValue<string>());
return (
<span className="text-muted">
{isUTC
? formatInTimeZone(
date,
'Etc/UTC',
isSmallScreen ? tsShortFormat : tsFormat,
)
: format(date, isSmallScreen ? tsShortFormat : tsFormat)}
<FormatTime
value={date}
format={isSmallScreen ? 'short' : 'withMs'}
/>
</span>
);
},
@ -436,7 +429,6 @@ export const RawLogTable = memo(
columnSizeStorage,
showServiceColumn,
columnNameMap,
tsFormat,
],
);

View file

@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { format } from 'date-fns';
import uniqBy from 'lodash/uniqBy';
import Button from 'react-bootstrap/Button';
import { Group } from '@mantine/core';
@ -8,34 +7,13 @@ import Checkbox from './Checkbox';
import type { PlaybarMarker } from './PlaybarSlider';
import { PlaybarSlider } from './PlaybarSlider';
import { useSessionEvents } from './sessionUtils';
import { FormatTime } from './useFormatTime';
import { getShortUrl, useLocalStorage } from './utils';
import 'react-bootstrap-range-slider/dist/react-bootstrap-range-slider.css';
function formatTs({
ts,
minTs,
showRelativeTime,
}: {
ts: number | null | undefined;
minTs: number;
showRelativeTime: boolean;
}) {
if (ts == null) {
return '--:--';
} else if (showRelativeTime) {
const value = Math.max(ts - minTs, 0);
const minutes = Math.floor(value / 1000 / 60);
const seconds = Math.floor((value / 1000) % 60);
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
} else {
try {
return format(new Date(ts), 'hh:mm:ss a');
} catch (err) {
console.error(err, ts);
return '--:--';
}
}
function formatRelativeTime(seconds: number) {
const minutes = Math.floor(Math.max(seconds, 0) / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
}
export default function Playbar({
@ -185,7 +163,11 @@ export default function Playbar({
setShowRelativeTime(!showRelativeTime);
}}
>
{formatTs({ ts: focus?.ts, minTs, showRelativeTime })}
{showRelativeTime ? (
formatRelativeTime((focus?.ts || 0) - minTs / 1000)
) : (
<FormatTime value={focus?.ts} format="short" />
)}
</div>
<div className="w-100 d-flex align-self-stretch align-items-center me-3">
<PlaybarSlider

View file

@ -2,6 +2,7 @@ import * as React from 'react';
import { format } from 'date-fns';
import { Slider, Tooltip } from '@mantine/core';
import { useFormatTime } from './useFormatTime';
import { truncateText } from './utils';
import styles from '../styles/PlaybarSlider.module.scss';
@ -32,16 +33,18 @@ export const PlaybarSlider = ({
onChange,
setPlayerState,
}: PlaybarSliderProps) => {
const formatTime = useFormatTime();
const valueLabelFormat = React.useCallback(
(ts: number) => {
const value = Math.max(ts - min, 0);
const minutes = Math.floor(value / 1000 / 60);
const seconds = Math.floor((value / 1000) % 60);
const timestamp = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
const time = format(new Date(ts), 'hh:mm:ss a');
const time = formatTime(ts, { format: 'short' });
return `${timestamp} at ${time}`;
},
[min],
[formatTime, min],
);
const markersContent = React.useMemo(

View file

@ -12,8 +12,7 @@ import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import cx from 'classnames';
import { clamp, format, sub } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { clamp, sub } from 'date-fns';
import { Button } from 'react-bootstrap';
import { ErrorBoundary } from 'react-error-boundary';
import { useHotkeys } from 'react-hotkeys-hook';
@ -48,32 +47,18 @@ import SearchTimeRangePicker from './SearchTimeRangePicker';
import { Tags } from './Tags';
import { useTimeQuery } from './timeQuery';
import { useDisplayedColumns } from './useDisplayedColumns';
import { useUserPreferences } from './useUserPreferences';
import { FormatTime, useFormatTime } from './useFormatTime';
import 'react-modern-drawer/dist/index.css';
import styles from '../styles/SearchPage.module.scss';
const formatDate = (
date: Date,
isUTC: boolean,
strFormat = 'MMM d HH:mm:ss',
) => {
return isUTC
? formatInTimeZone(date, 'Etc/UTC', strFormat)
: format(date, strFormat);
};
const dateRangeToString = (range: [Date, Date], isUTC: boolean) => {
return `${formatDate(range[0], isUTC)} - ${formatDate(range[1], isUTC)}`;
};
const HistogramBarChartTooltip = (props: any) => {
const tsFormat = 'MMM d HH:mm:ss.SSS';
const { active, payload, label } = props;
if (active && payload && payload.length) {
return (
<div className="bg-grey px-3 py-2 rounded fs-8">
<div className="mb-2">
{formatDate(new Date(label * 1000), props.isUTC, tsFormat)}
<FormatTime value={label * 1000} format="withMs" />
</div>
{payload.map((p: any) => (
<div key={p.name} style={{ color: p.color }}>
@ -99,10 +84,6 @@ const HDXHistogram = memo(
onTimeRangeSelect: (start: Date, end: Date) => void;
isLive: boolean;
}) => {
const {
userPreferences: { isUTC },
} = useUserPreferences();
const { data: histogramResults, isLoading: isHistogramResultsLoading } =
api.useLogHistogram(
where,
@ -133,6 +114,8 @@ const HDXHistogram = memo(
const [highlightStart, setHighlightStart] = useState<string | undefined>();
const [highlightEnd, setHighlightEnd] = useState<string | undefined>();
const formatTime = useFormatTime();
return isHistogramResultsLoading ? (
<div className="w-100 h-100 d-flex align-items-center justify-content-center">
Loading Graph...
@ -202,9 +185,7 @@ const HDXHistogram = memo(
interval="preserveStartEnd"
scale="time"
type="number"
tickFormatter={tick =>
formatDate(new Date(tick * 1000), isUTC, 'MMM d HH:mm')
}
tickFormatter={tick => formatTime(tick * 1000, { format: 'short' })}
minTickGap={50}
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
/>
@ -459,10 +440,6 @@ function SearchPage() {
[searchInput],
);
const {
userPreferences: { isUTC },
} = useUserPreferences();
const [saveSearchModalMode, setSaveSearchModalMode] = useState<
'update' | 'save' | 'hidden'
>('hidden');
@ -566,6 +543,7 @@ function SearchPage() {
[setDisplayedSearchQuery, doSearch, displayedTimeInputValue],
);
const formatTime = useFormatTime();
const generateSearchUrl = useCallback(
(newQuery?: string, newTimeRange?: [Date, Date], lid?: string) => {
const fromDate = newTimeRange ? newTimeRange[0] : searchedTimeRange[0];
@ -574,14 +552,14 @@ function SearchPage() {
q: newQuery ?? searchedQuery,
from: fromDate.getTime().toString(),
to: toDate.getTime().toString(),
tq: dateRangeToString([fromDate, toDate], isUTC),
tq: `${formatTime(fromDate)} - ${formatTime(toDate)}`,
...(lid ? { lid } : {}),
});
return `/search${
selectedSavedSearch != null ? `/${selectedSavedSearch._id}` : ''
}?${qparams.toString()}`;
},
[searchedQuery, searchedTimeRange, selectedSavedSearch, isUTC],
[searchedQuery, searchedTimeRange, selectedSavedSearch, formatTime],
);
const generateChartUrl = useCallback(

View file

@ -1,5 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { format } from 'date-fns';
import throttle from 'lodash/throttle';
import { parseAsInteger, useQueryState } from 'nuqs';
import ReactDOM from 'react-dom';
@ -11,7 +10,9 @@ import Playbar from './Playbar';
import SearchInput from './SearchInput';
import { useSessionEvents } from './sessionUtils';
import TabBar from './TabBar';
import { FormatTime } from './useFormatTime';
import { getShortUrl, usePrevious } from './utils';
function SessionEventList({
config: { where, dateRange },
onClick,
@ -229,7 +230,7 @@ function SessionEventList({
onClick={() => onTimeClick(row.timestamp)}
>
<i className="bi bi-play-fill me-1" />
{format(new Date(row.timestamp), 'hh:mm:ss a')}
<FormatTime value={row.timestamp} format="short" />
</div>
</div>

View file

@ -19,10 +19,8 @@ import SearchInput from './SearchInput';
import SearchTimeRangePicker from './SearchTimeRangePicker';
import SessionSidePanel from './SessionSidePanel';
import { parseTimeQuery, useTimeQuery } from './timeQuery';
import {
formatDistanceToNowStrictShort,
formatHumanReadableDate,
} from './utils';
import { FormatTime } from './useFormatTime';
import { formatDistanceToNowStrictShort } from './utils';
function SessionCard({
email,
@ -74,7 +72,7 @@ function SessionCard({
<div className="text-end">
<div>Last active {timeAgo} ago</div>
<div className="text-muted fs-8 mt-1">
Started on {formatHumanReadableDate(minTime)}
Started on <FormatTime value={minTime} />
</div>
</div>
</div>

View file

@ -113,10 +113,7 @@ export const UserPreferencesModal = ({
allowDeselect={false}
/>
</SettingContainer>
<SettingContainer
label="Use UTC time in log tables"
description="Currently only applied to log table timestamps. Charts and other areas will be updated soon"
>
<SettingContainer label="Use UTC time">
<Switch
size="md"
onLabel="UTC"

View file

@ -0,0 +1,45 @@
import { formatDate } from '../utils';
describe('utils', () => {
it('12h utc', () => {
const date = new Date('2021-01-01T12:00:00Z');
expect(
formatDate(date, {
clock: '12h',
isUTC: true,
}),
).toEqual('Jan 1 12:00:00 PM');
});
it('24h utc', () => {
const date = new Date('2021-01-01T12:00:00Z');
expect(
formatDate(date, {
clock: '24h',
isUTC: true,
format: 'withMs',
}),
).toEqual('Jan 1 12:00:00.000');
});
it('12h local', () => {
const date = new Date('2021-01-01T12:00:00');
expect(
formatDate(date, {
clock: '12h',
isUTC: false,
}),
).toEqual('Jan 1 12:00:00 PM');
});
it('24h local', () => {
const date = new Date('2021-01-01T12:00:00');
expect(
formatDate(date, {
clock: '24h',
isUTC: false,
format: 'withMs',
}),
).toEqual('Jan 1 12:00:00.000');
});
});

View file

@ -1,10 +1,11 @@
import * as React from 'react';
import Link from 'next/link';
import { format, sub } from 'date-fns';
import { sub } from 'date-fns';
import { Anchor, Badge, Group, Text, Timeline } from '@mantine/core';
import api from '../api';
import { KubePhase } from '../types';
import { FormatTime } from '../useFormatTime';
type KubeEvent = {
id: string;
@ -21,8 +22,6 @@ type AnchorEvent = {
label: React.ReactNode;
};
const FORMAT = 'MMM d hh:mm:ss a';
const renderKubeEvent = (event: KubeEvent) => {
let href = '#';
try {
@ -39,7 +38,7 @@ const renderKubeEvent = (event: KubeEvent) => {
<Timeline.Item key={event.id}>
<Link href={href} passHref legacyBehavior>
<Anchor size="11" c="gray.6" title={event.timestamp}>
{format(new Date(event.timestamp), FORMAT)}
<FormatTime value={event.timestamp} />
</Anchor>
</Link>
<Group gap="xs" my={4}>
@ -152,7 +151,7 @@ export const KubeTimeline = ({
{podEventsAfterAnchor.map(renderKubeEvent)}
<Timeline.Item key={anchorEvent.timestamp} ref={anchorRef}>
<Text size="11" c="gray.6" title={anchorEvent.timestamp}>
{format(new Date(anchorEvent.timestamp), FORMAT)}
<FormatTime value={anchorEvent.timestamp} />
</Text>
<Group gap="xs" my={4}>
<Text size="12" c="white" fw="bold">

View file

@ -34,6 +34,7 @@ const formatDate = (
? formatInTimeZone(date, 'Etc/UTC', strFormat)
: format(date, strFormat);
};
export const dateRangeToString = (range: [Date, Date], isUTC: boolean) => {
return `${formatDate(range[0], isUTC)} - ${formatDate(range[1], isUTC)}`;
};

View file

@ -0,0 +1,54 @@
import React from 'react';
import { useUserPreferences } from './useUserPreferences';
import { formatDate } from './utils';
type DateLike = number | string | Date;
type DateFormat = 'normal' | 'short' | 'withMs';
const parse = (time: DateLike) => {
if (time instanceof Date) {
return time;
}
return new Date(time);
};
export const useFormatTime = () => {
const {
userPreferences: { isUTC, timeFormat },
} = useUserPreferences();
return React.useCallback(
(time: DateLike, { format }: { format?: DateFormat } | undefined = {}) => {
try {
const date = parse(time);
return formatDate(date, {
clock: timeFormat,
isUTC,
format,
});
} catch (err) {
console.error(err, time);
return 'Unknown date';
}
},
[isUTC, timeFormat],
);
};
export const FormatTime = ({
value,
format,
}: {
value?: DateLike;
format?: DateFormat;
}) => {
const formatTime = useFormatTime();
if (!value) {
return null;
}
return <>{formatTime(value, { format })}</>;
};

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/router';
import { format as fnsFormat, formatDistanceToNowStrict } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import numbro from 'numbro';
import type { MutableRefObject } from 'react';
@ -161,11 +162,6 @@ export const useDebounce = <T,>(
return debouncedValue;
};
export const TIME_TOKENS = {
'12h': 'MMM d h:mm:ss a',
'24h': 'MMM d HH:mm:ss.SSS',
};
export function useLocalStorage<T>(key: string, initialValue: T) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
@ -241,10 +237,6 @@ export function formatDistanceToNowStrictShort(date: Date) {
.replace(' seconds', 's');
}
export function formatHumanReadableDate(date: Date) {
return fnsFormat(date, 'MMMM d, h:mmaaa');
}
export const getLogLevelClass = (lvl: string | undefined) => {
const level = lvl?.toLowerCase();
if (level == null) {
@ -463,3 +455,38 @@ export const legacyMetricNameToNameAndDataType = (metricName?: string) => {
dataType: mDataType as MetricsDataType,
};
};
// Date formatting
const TIME_TOKENS = {
normal: {
'12h': 'MMM d h:mm:ss a',
'24h': 'MMM d HH:mm:ss',
},
short: {
'12h': 'MMM d h:mma',
'24h': 'MMM d HH:mm',
},
withMs: {
'12h': 'MMM d h:mm:ss.SSS a',
'24h': 'MMM d HH:mm:ss.SSS',
},
};
export const formatDate = (
date: Date,
{
isUTC = false,
format = 'normal',
clock = '12h',
}: {
isUTC?: boolean;
format?: 'normal' | 'short' | 'withMs';
clock?: '12h' | '24h';
},
) => {
const formatStr = TIME_TOKENS[format][clock];
return isUTC
? formatInTimeZone(date, 'Etc/UTC', formatStr)
: fnsFormat(date, formatStr);
};