diff --git a/packages/api/src/routers/external-api/v2/utils/search.ts b/packages/api/src/routers/external-api/v2/utils/search.ts index 2fc750dd3d..a4c3832d87 100644 --- a/packages/api/src/routers/external-api/v2/utils/search.ts +++ b/packages/api/src/routers/external-api/v2/utils/search.ts @@ -142,7 +142,6 @@ export async function runSearchConfig({ dateRange: [startDate, endDate], }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const chartConfig: ChartConfigWithDateRange = { ...searchBase, connection: source.connection.toString(), diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 92fe91474c..7cc9fb4aaf 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -832,6 +832,26 @@ export function DBSearchPage() { id: searchedConfig.source, kinds: [SourceKind.Log, SourceKind.Trace], }); + + const { data: searchedSourceColumns } = useColumns( + { + databaseName: searchedSource?.from?.databaseName ?? '', + tableName: searchedSource?.from?.tableName ?? '', + connectionId: searchedSource?.connection ?? '', + }, + { enabled: !!searchedSource }, + ); + const showMs = useMemo(() => { + if (!searchedSource || !searchedSourceColumns) return true; + const tsParts = splitAndTrimWithBracket( + searchedSource.timestampValueExpression, + ); + return tsParts.some(part => { + const colMeta = searchedSourceColumns.find(c => c.name === part); + return colMeta?.type?.startsWith('DateTime64') ?? false; + }); + }, [searchedSource, searchedSourceColumns]); + const directTraceSource = directTraceId != null && searchedSource?.kind === SourceKind.Trace ? searchedSource @@ -946,6 +966,7 @@ export function DBSearchPage() { showRelativeInterval: isLive ?? true, setDisplayedTimeInputValue, updateInput: !isLive, + showMs, }); // Sync url state back with form state @@ -1996,6 +2017,7 @@ export function DBSearchPage() { onRelativeSearch={onTimePickerRelativeSearch} showLive={analysisMode === 'results'} isLiveMode={isLive} + showMs={showMs} // Default to relative time mode if the user has made changes to interval and reloaded. defaultRelativeTimeMode={ isLive && interval !== LIVE_TAIL_DURATION_MS diff --git a/packages/app/src/components/TimePicker/TimePicker.tsx b/packages/app/src/components/TimePicker/TimePicker.tsx index d61862088f..36012ff8e5 100644 --- a/packages/app/src/components/TimePicker/TimePicker.tsx +++ b/packages/app/src/components/TimePicker/TimePicker.tsx @@ -43,8 +43,10 @@ const modeAtom = atomWithStorage( TimePickerMode.Range, ); -const DATE_INPUT_PLACEHOLDER = 'YYYY-MM-DD HH:mm:ss'; -const DATE_INPUT_FORMAT = 'YYYY-MM-DD HH:mm:ss'; +const DATE_INPUT_PLACEHOLDER_MS = 'YYYY-MM-DD HH:mm:ss.SSS'; +const DATE_INPUT_FORMAT_MS = 'YYYY-MM-DD HH:mm:ss.SSS'; +const DATE_INPUT_PLACEHOLDER_SEC = 'YYYY-MM-DD HH:mm:ss'; +const DATE_INPUT_FORMAT_SEC = 'YYYY-MM-DD HH:mm:ss'; /** Ensure a value is a Date object (Mantine v9 DateInput returns strings). */ const toDate = (v: Date | string | null): Date | null => @@ -75,8 +77,8 @@ const DateInputCmp = ({ size="xs" highlightToday withTime - placeholder={DATE_INPUT_PLACEHOLDER} - valueFormat={DATE_INPUT_FORMAT} + placeholder={DATE_INPUT_PLACEHOLDER_MS} + valueFormat={DATE_INPUT_FORMAT_MS} variant="filled" dateParser={dateParser} onKeyDown={e => { @@ -105,6 +107,7 @@ const TimePickerComponent = ({ showLive = false, isLiveMode = false, defaultRelativeTimeMode = false, + showMs = true, width = 350, }: { inputValue: string; @@ -115,12 +118,18 @@ const TimePickerComponent = ({ showLive?: boolean; isLiveMode?: boolean; defaultRelativeTimeMode?: boolean; + showMs?: boolean; width?: number | string; }) => { const { userPreferences: { timeFormat }, } = useUserPreferences(); + const dateInputPlaceholder = showMs + ? DATE_INPUT_PLACEHOLDER_MS + : DATE_INPUT_PLACEHOLDER_SEC; + const dateInputFormat = showMs ? DATE_INPUT_FORMAT_MS : DATE_INPUT_FORMAT_SEC; + const [opened, { close, toggle }] = useDisclosure(false); useHotkeys('d', () => toggle(), { preventDefault: true }, [toggle]); @@ -187,8 +196,13 @@ const TimePickerComponent = ({ if (!from || !to) { return; } - const formatStr = - timeFormat === '24h' ? 'MMM d HH:mm:ss' : 'MMM d h:mm:ss a'; + const formatStr = showMs + ? timeFormat === '24h' + ? 'MMM d HH:mm:ss.SSS' + : 'MMM d h:mm:ss.SSS a' + : timeFormat === '24h' + ? 'MMM d HH:mm:ss' + : 'MMM d h:mm:ss a'; const rangeStr = [from, to] .map(d => d && format(d, formatStr)) .join(' - '); @@ -433,6 +447,8 @@ const TimePickerComponent = ({ popoverProps={dateComponentPopoverProps} maxDate={today} mb="xs" + placeholder={dateInputPlaceholder} + valueFormat={dateInputFormat} {...form.getInputProps('startDate')} /> End time @@ -441,6 +457,8 @@ const TimePickerComponent = ({ maxDate={today} minDate={form.values.startDate ?? undefined} disabled={isRelative} + placeholder={dateInputPlaceholder} + valueFormat={dateInputFormat} {...form.getInputProps('endDate')} /> @@ -452,6 +470,8 @@ const TimePickerComponent = ({ popoverProps={dateComponentPopoverProps} maxDate={today} mb="xs" + placeholder={dateInputPlaceholder} + valueFormat={dateInputFormat} {...form.getInputProps('startDate')} /> Duration ± diff --git a/packages/app/src/components/TimePicker/__tests__/utils.test.ts b/packages/app/src/components/TimePicker/__tests__/utils.test.ts index 6aad671882..3aaa054635 100644 --- a/packages/app/src/components/TimePicker/__tests__/utils.test.ts +++ b/packages/app/src/components/TimePicker/__tests__/utils.test.ts @@ -99,6 +99,18 @@ describe('dateParser', () => { const result = dateParser('Jan 16 22:00:01'); expect(result).toEqual(new Date('2024-01-16T22:00:01')); }); + + it('parses date with milliseconds (YYYY-MM-DD HH:mm:ss.SSS)', () => { + const result = dateParser('2024-01-15 14:37:42.123'); + expect(result).toEqual(new Date('2024-01-15T14:37:42.123')); + expect(result?.getMilliseconds()).toBe(123); + }); + + it('parses date with milliseconds (MMM d HH:mm:ss.SSS)', () => { + const result = dateParser('Jan 10 14:37:42.456'); + expect(result).toEqual(new Date('2025-01-10T14:37:42.456')); + expect(result?.getMilliseconds()).toBe(456); + }); }); describe('parseTimeRangeInput', () => { @@ -153,4 +165,22 @@ describe('parseTimeRangeInput', () => { new Date('2024-01-16T12:00:00'), ]); }); + + it('parses a range with milliseconds correctly', () => { + const [start, end] = parseTimeRangeInput( + 'Jan 10 14:37:42.123 - Jan 10 15:37:42.456', + ); + expect(start).toEqual(new Date('2025-01-10T14:37:42.123')); + expect(start?.getMilliseconds()).toBe(123); + expect(end).toEqual(new Date('2025-01-10T15:37:42.456')); + expect(end?.getMilliseconds()).toBe(456); + }); + + it('parses a range with YYYY-MM-DD and milliseconds', () => { + const [start, end] = parseTimeRangeInput( + '2024-01-15 14:37:42.100 - 2024-01-15 15:37:42.999', + ); + expect(start?.getMilliseconds()).toBe(100); + expect(end?.getMilliseconds()).toBe(999); + }); }); diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts index 7c9c2a499f..5de89502a6 100644 --- a/packages/app/src/timeQuery.ts +++ b/packages/app/src/timeQuery.ts @@ -32,14 +32,19 @@ import { usePrevious } from './utils'; const LIVE_TAIL_TIME_QUERY = 'Live Tail'; const LIVE_TAIL_REFRESH_INTERVAL_MS = 1000; -export const dateRangeToString = (range: [Date, Date], isUTC: boolean) => { +export const dateRangeToString = ( + range: [Date, Date], + isUTC: boolean, + showMs = true, +) => { + const fmt = showMs ? 'withMs' : 'normal'; return `${formatDate(range[0], { isUTC, - format: 'normal', + format: fmt, clock: '24h', })} - ${formatDate(range[1], { isUTC, - format: 'normal', + format: fmt, clock: '24h', })}`; }; @@ -385,6 +390,8 @@ export type UseTimeQueryInputType = { showRelativeInterval?: boolean; setDisplayedTimeInputValue?: (value: string) => void; updateInput?: boolean; + /** When true, format displayed time strings with millisecond precision. */ + showMs?: boolean; }; export type UseTimeQueryReturnType = { @@ -420,6 +427,7 @@ export function useNewTimeQuery({ showRelativeInterval, setDisplayedTimeInputValue, updateInput, + showMs = true, }: UseTimeQueryInputType): UseTimeQueryReturnType { const router = useRouter(); // We need to return true in SSR to prevent mismatch issues @@ -433,7 +441,9 @@ export function useNewTimeQuery({ deprecatedDisplayedTimeInputValue, deprecatedSetDisplayedTimeInputValue, ] = useState(() => { - return initialDisplayValue ?? dateRangeToString(initialTimeRange, isUTC); + return ( + initialDisplayValue ?? dateRangeToString(initialTimeRange, isUTC, showMs) + ); }); const _setDisplayedTimeInputValue = @@ -472,14 +482,14 @@ export function useNewTimeQuery({ const relativeInterval = showRelativeInterval && getRelativeInterval(start, end); const dateRangeStr = - relativeInterval || dateRangeToString([start, end], isUTC); + relativeInterval || dateRangeToString([start, end], isUTC, showMs); if (updateInput !== false) { _setDisplayedTimeInputValue(dateRangeStr); } } } else if (from == null && to == null && isReady) { setSearchedTimeRange(initialTimeRange); - const dateRangeStr = dateRangeToString(initialTimeRange, isUTC); + const dateRangeStr = dateRangeToString(initialTimeRange, isUTC, showMs); if (updateInput !== false) { if (!showRelativeInterval) { _setDisplayedTimeInputValue(dateRangeStr); @@ -498,6 +508,7 @@ export function useNewTimeQuery({ showRelativeInterval, _setDisplayedTimeInputValue, updateInput, + showMs, ]); return { @@ -512,12 +523,12 @@ export function useNewTimeQuery({ (start: Date, end: Date, displayedTimeInputValue?: string | null) => { setTimeRangeQuery({ from: start.getTime(), to: end.getTime() }); setSearchedTimeRange([start, end]); - const dateRangeStr = dateRangeToString([start, end], isUTC); + const dateRangeStr = dateRangeToString([start, end], isUTC, showMs); if (displayedTimeInputValue !== null) { _setDisplayedTimeInputValue(displayedTimeInputValue ?? dateRangeStr); } }, - [setTimeRangeQuery, isUTC, _setDisplayedTimeInputValue], + [setTimeRangeQuery, isUTC, _setDisplayedTimeInputValue, showMs], ), }; } diff --git a/packages/app/tests/e2e/components/TimePickerComponent.ts b/packages/app/tests/e2e/components/TimePickerComponent.ts index 4a2a9fd341..f9d1e345ee 100644 --- a/packages/app/tests/e2e/components/TimePickerComponent.ts +++ b/packages/app/tests/e2e/components/TimePickerComponent.ts @@ -183,14 +183,14 @@ export class TimePickerComponent { * the single DateInput is also the first (and only). */ get startDateInput() { - return this.pickerPopover.getByPlaceholder('YYYY-MM-DD HH:mm:ss').nth(0); + return this.pickerPopover.getByPlaceholder(/^YYYY-MM-DD HH:mm:ss/).nth(0); } /** * Locator for the End time DateInput. Only present in Range mode. */ get endDateInput() { - return this.pickerPopover.getByPlaceholder('YYYY-MM-DD HH:mm:ss').nth(1); + return this.pickerPopover.getByPlaceholder(/^YYYY-MM-DD HH:mm:ss/).nth(1); } /** diff --git a/packages/app/tests/e2e/features/search/custom-time-range.spec.ts b/packages/app/tests/e2e/features/search/custom-time-range.spec.ts index 4d14276734..b259e06868 100644 --- a/packages/app/tests/e2e/features/search/custom-time-range.spec.ts +++ b/packages/app/tests/e2e/features/search/custom-time-range.spec.ts @@ -38,8 +38,12 @@ test.describe('Custom Time Range', { tag: '@custom-time-range' }, () => { }); await test.step('Inputs retain the typed values before applying', async () => { - await expect(searchPage.timePicker.startDateInput).toHaveValue(start); - await expect(searchPage.timePicker.endDateInput).toHaveValue(end); + await expect(searchPage.timePicker.startDateInput).toHaveValue( + `${start}.000`, + ); + await expect(searchPage.timePicker.endDateInput).toHaveValue( + `${end}.000`, + ); }); await test.step('Apply and confirm times propagate to the picker input', async () => { @@ -73,8 +77,12 @@ test.describe('Custom Time Range', { tag: '@custom-time-range' }, () => { await test.step('Reopen picker and verify inputs still show typed times', async () => { await searchPage.timePicker.open(); - await expect(searchPage.timePicker.startDateInput).toHaveValue(start); - await expect(searchPage.timePicker.endDateInput).toHaveValue(end); + await expect(searchPage.timePicker.startDateInput).toHaveValue( + `${start}.000`, + ); + await expect(searchPage.timePicker.endDateInput).toHaveValue( + `${end}.000`, + ); }); }); diff --git a/packages/common-utils/src/core/eventDeltas.ts b/packages/common-utils/src/core/eventDeltas.ts index 060b40d3bc..1576799454 100644 --- a/packages/common-utils/src/core/eventDeltas.ts +++ b/packages/common-utils/src/core/eventDeltas.ts @@ -22,7 +22,7 @@ */ export function flattenData(data: Record): Record { const result: Record = {}; - // eslint-disable-next-line security/detect-object-injection -- prop is built from known object keys via recursion, not user input + function recurse(cur: Record, prop: string) { if (Object(cur) !== cur) { result[prop] = cur; // eslint-disable-line security/detect-object-injection