Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
05cc4ee
feat(search): Make default lookback configurable via search.defaultLo…
yurishkuro May 27, 2026
0e7d915
feat(search): Reconstruct lookback from URL timestamps when absent
yurishkuro May 27, 2026
95b501c
fix: Validate defaultLookback config and guard inverted timestamps
yurishkuro May 27, 2026
e5043e2
refactor(search): Rename search to searchConfig in SearchFormImpl
yurishkuro May 27, 2026
6caf083
refactor(search): Inline searchConfig.defaultLookback
yurishkuro May 27, 2026
2558234
refactor(config): Move defaultLookback above maxLookback in search co…
yurishkuro May 27, 2026
dfece54
refactor: Move time-range-options from constants/ to utils/
yurishkuro May 27, 2026
5d7f4c2
refactor: Replace isValidLookback with asValidLookback
yurishkuro May 27, 2026
6583a43
refactor(search): Remove redundant defaultLookback in SearchFormImpl
yurishkuro May 27, 2026
6167ae3
docs(url): Clarify lookback reconstruction uses round-up semantics
yurishkuro May 27, 2026
320760f
fix(search): Reconstruct lookback from timestamps in mapStateToProps
yurishkuro May 27, 2026
1643eca
fix(search): Validate URL lookback param with asValidLookback
yurishkuro May 27, 2026
d390c38
refactor: Use if statement in asValidLookback for readability
yurishkuro May 27, 2026
7f21c3a
refactor(search): Extract lookbackFromUrl, eliminate duplicated logic
yurishkuro May 27, 2026
d5f7c60
refactor(url): Avoid double-parsing in searchQueryFromUrl
yurishkuro May 27, 2026
48632f3
fix(url): Validate URL lookback param in lookbackFromUrlState
yurishkuro May 27, 2026
104c1bc
fix: Disallow 'custom' as search.defaultLookback config value
yurishkuro May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import '@testing-library/jest-dom';
import { MonitorATMServicesViewImpl as MonitorATMServicesView, mapStateToProps, mapDispatchToProps } from '.';
import { getLoopbackInterval, timeFrameOptions, yAxisTickFormat } from './timeFrameUtils';
import { useServices } from '../../../hooks/useTraceDiscovery';
import { ONE_HOUR_MS, TIME_RANGE_OPTIONS } from '../../../constants/time-range-options';
import { ONE_HOUR_MS, TIME_RANGE_OPTIONS } from '../../../utils/time-range-options';
import {
originInitialState,
serviceMetrics,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

import { ONE_HOUR_MS, TIME_RANGE_OPTIONS } from '../../../constants/time-range-options';
import { ONE_HOUR_MS, TIME_RANGE_OPTIONS } from '../../../utils/time-range-options';
import { convertToTimeUnit } from '../../../utils/date';

export { ONE_HOUR_MS };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ vi.mock('../../hooks/useTraceDiscovery', () => ({
vi.mock('./useUploadedTraces', () => ({
useClearUploadedTraces: jest.fn(() => jest.fn()),
}));
vi.mock('../../utils/config/get-config', () => ({
default: jest.fn(() => ({ search: {} })),
}));

import React from 'react';
import { MemoryRouter } from 'react-router-dom';
Expand Down Expand Up @@ -75,6 +78,7 @@ import * as markers from './SearchForm.markers';
import { CHANGE_SERVICE_ACTION_TYPE } from '../../constants/search-form';
import { useServices, useSpanNames } from '../../hooks/useTraceDiscovery';
import { AppQueryClientProvider } from '../../query/app-query-client';
import getConfig from '../../utils/config/get-config';

function makeDateParams(dateOffset = 0) {
const date = new Date();
Expand Down Expand Up @@ -1005,6 +1009,18 @@ describe('mapStateToProps()', () => {
expect(msDiff(dateParams.dateStr, '00:00', startDate, startDateTime)).toBeLessThan(60 * 1000);
expect(msDiff(dateParams.dateStr, dateParams.dateTimeStr, endDate, endDateTime)).toBeLessThan(60 * 1000);
});

it('uses search.defaultLookback from config when no lookback is in the URL', () => {
vi.mocked(getConfig).mockReturnValue({ search: { defaultLookback: '15m' } });
const { lookback } = callMapStateToProps('').initialValues;
expect(lookback).toBe('15m');
});

it('URL lookback takes precedence over search.defaultLookback', () => {
vi.mocked(getConfig).mockReturnValue({ search: { defaultLookback: '15m' } });
const { lookback } = callMapStateToProps('lookback=2h').initialValues;
expect(lookback).toBe('2h');
});
});

describe('submitForm() adjustEndTime toggle', () => {
Expand Down
22 changes: 12 additions & 10 deletions packages/jaeger-ui/src/components/SearchTracePage/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import queryString from 'query-string';
import { IoHelp } from 'react-icons/io5';
import { connect } from 'react-redux';
import { useLocation, useNavigate } from 'react-router-dom';
import { getUrl as getSearchUrl } from './url';
import { getUrl as getSearchUrl, lookbackFromUrl } from './url';
import type { Dispatch } from 'redux';
import { useIsSearchFetching } from '../../hooks/useTraceDiscovery';
import { useClearUploadedTraces } from './useUploadedTraces';
import store from '../../utils/storage';
import getConfig from '../../utils/config/get-config';

import * as markers from './SearchForm.markers';
import { trackFormInput } from './SearchForm.track';
Expand All @@ -29,7 +30,7 @@ import { useConfig } from '../../hooks/useConfig';
import { useServices, useSpanNames } from '../../hooks/useTraceDiscovery';
import { ReduxState } from '../../types';
import { SearchQuery } from '../../types/search';
import { TIME_RANGE_OPTIONS } from '../../constants/time-range-options';
import { TIME_RANGE_OPTIONS, asValidConfigLookback } from '../../utils/time-range-options';

Comment thread
yurishkuro marked this conversation as resolved.
const FormItem = Form.Item;
const Option = Select.Option;
Expand Down Expand Up @@ -312,9 +313,9 @@ export const SearchFormImpl: React.FC<ISearchFormImplProps> = ({
const submitting = useIsSearchFetching();
const navigate = useNavigate();
const clearUploadedTraces = useClearUploadedTraces();
const { useOpenTelemetryTerms: useOtelTerms, search } = useConfig();
const searchMaxLookback: ILookbackOption | undefined = search?.maxLookback;
const searchAdjustEndTime: string | undefined = search?.adjustEndTime;
const { useOpenTelemetryTerms: useOtelTerms, search: searchConfig } = useConfig();
const searchMaxLookback: ILookbackOption | undefined = searchConfig?.maxLookback;
const searchAdjustEndTime: string | undefined = searchConfig?.adjustEndTime;
const [formData, setFormData] = useState<Partial<ISearchFormFields>>(() => ({
service: initialValues?.service,
Comment thread
yurishkuro marked this conversation as resolved.
operation: initialValues?.operation,
Expand Down Expand Up @@ -549,7 +550,6 @@ export const SearchFormImpl: React.FC<ISearchFormImplProps> = ({
data-testid="lookback"
value={formData.lookback}
Comment thread
yurishkuro marked this conversation as resolved.
disabled={submitting}
defaultValue={DEFAULT_LOOKBACK}
onChange={(value: string) => handleChange({ lookback: value })}
>
{searchMaxLookback && optionsWithinMaxLookback(searchMaxLookback)}
Expand Down Expand Up @@ -669,7 +669,7 @@ export const SearchFormImpl: React.FC<ISearchFormImplProps> = ({
placeholder="Limit Results"
type="number"
min={1}
max={search?.maxLimit}
max={searchConfig?.maxLimit}
onChange={e => handleChange({ resultsLimit: e.target.value })}
/>
</FormItem>
Expand All @@ -686,7 +686,7 @@ export const SearchFormImpl: React.FC<ISearchFormImplProps> = ({
);
};

export function mapStateToProps(state: ReduxState, ownProps: { search?: string }) {
export function mapStateToProps(_state: ReduxState, ownProps: { search?: string }) {
const {
service,
limit,
Expand All @@ -697,7 +697,6 @@ export function mapStateToProps(state: ReduxState, ownProps: { search?: string }
tags: logfmtTags,
maxDuration,
minDuration,
lookback,
traceID: traceIDParams,
} = queryString.parse(ownProps.search || '');

Expand Down Expand Up @@ -782,7 +781,10 @@ export function mapStateToProps(state: ReduxState, ownProps: { search?: string }
initialValues: {
service: (service as string | undefined) || lastSearchService || '-',
resultsLimit: (limit as string | undefined) || String(DEFAULT_LIMIT),
lookback: (lookback as string | undefined) || DEFAULT_LOOKBACK,
lookback:
lookbackFromUrl(ownProps.search || '') ||
asValidConfigLookback(getConfig().search?.defaultLookback) ||
DEFAULT_LOOKBACK,
startDate: queryStartDate || today,
Comment thread
yurishkuro marked this conversation as resolved.
startDateTime: queryStartDateTime || '00:00',
Comment thread
yurishkuro marked this conversation as resolved.
endDate: queryEndDate || today,
Expand Down
41 changes: 40 additions & 1 deletion packages/jaeger-ui/src/components/SearchTracePage/url.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import * as ReactRouterDom from 'react-router-dom';

import { MAX_LENGTH } from '../DeepDependencies/Graph/DdgNodeContent/constants';
import { ROUTE_PATH, getUrl, getUrlState, isSameQuery, matches } from './url';
import { ROUTE_PATH, getUrl, getUrlState, isSameQuery, matches, searchQueryFromUrl } from './url';

vi.mock('react-router-dom', () => ({
matchPath: vi.fn(),
Expand Down Expand Up @@ -242,4 +242,43 @@ describe('SearchTracePage/url', () => {
expect(isSameQuery(baseQuery, { ...copy, [otherKey]: 'changed' })).toBe(true);
});
});

describe('searchQueryFromUrl', () => {
it('returns null when no service, start, or end is present', () => {
expect(searchQueryFromUrl('?limit=20')).toBeNull();
});

it('uses explicit lookback from URL when present', () => {
const result = searchQueryFromUrl('?service=svc&start=1000000000&end=4600000000&lookback=1h');
expect(result?.lookback).toBe('1h');
});

it('reconstructs lookback from duration when lookback is absent', () => {
// 6-hour window in microseconds
const startUs = 1_000_000_000_000;
const endUs = startUs + 6 * 60 * 60 * 1_000_000;
const result = searchQueryFromUrl(`?service=svc&start=${startUs}&end=${endUs}`);
expect(result?.lookback).toBe('6h');
});

it('snaps up to the next bucket when duration falls between options', () => {
// 70-minute window → should snap up to 2h
const startUs = 1_000_000_000_000;
const endUs = startUs + 70 * 60 * 1_000_000;
const result = searchQueryFromUrl(`?service=svc&start=${startUs}&end=${endUs}`);
expect(result?.lookback).toBe('2h');
});

it('returns "custom" when duration exceeds the largest option', () => {
const startUs = 1_000_000_000_000;
const endUs = startUs + 100 * 24 * 60 * 60 * 1_000_000; // 100 days
const result = searchQueryFromUrl(`?service=svc&start=${startUs}&end=${endUs}`);
expect(result?.lookback).toBe('custom');
});

it('returns empty lookback when service is present but no timestamps', () => {
const result = searchQueryFromUrl('?service=svc');
expect(result?.lookback).toBe('');
});
});
});
37 changes: 34 additions & 3 deletions packages/jaeger-ui/src/components/SearchTracePage/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MAX_LENGTH } from '../DeepDependencies/Graph/DdgNodeContent/constants';

import { SearchQuery } from '../../types/search';
import parseQuery from '../../utils/parseQuery';
import { asValidLookback, lookbackFromDuration } from '../../utils/time-range-options';

export { isSameQuery, isQueryEmpty } from '../../utils/search-query';

Expand Down Expand Up @@ -100,6 +101,25 @@ export function searchQueryToUrlState(q: SearchQuery): TUrlState {
return state;
}

/**
* Derive the lookback from an already-parsed URL state.
* Returns the explicit lookback param when present, reconstructs it from
* start/end timestamps when absent, or returns '' when neither is available.
*/
function lookbackFromUrlState(q: TUrlState): string {
const lookback = asValidLookback(firstOf(q.lookback));
if (lookback) return lookback;
const startUs = Number(firstOf(q.start));
const endUs = Number(firstOf(q.end));
if (startUs > 0 && endUs > startUs) return lookbackFromDuration((endUs - startUs) / 1000);
Comment thread
yurishkuro marked this conversation as resolved.
return '';
}

/** Derives the lookback from a URL search string; see lookbackFromUrlState for semantics. */
export function lookbackFromUrl(search: string): string {
return lookbackFromUrlState(getUrlState(search));
}

/**
* Parse the URL search string into a typed SearchQuery, or null when no search
* params are present (homepage / no query submitted yet).
Expand All @@ -108,20 +128,31 @@ export function searchQueryToUrlState(q: SearchQuery): TUrlState {
* from the lookback selector) so that shared links reproduce the same time window.
* lookback is kept in the URL so SearchForm can restore the selector label on the
* next visit.
*
* When lookback is absent or invalid, the lookback value is reconstructed from the
* time-range duration by rounding up to the smallest standard option that covers the
* full range, or 'custom' when the range exceeds all standard options. This keeps
* the form's dropdown in sync with the actual time range that was searched.
*/
export function searchQueryFromUrl(search: string): SearchQuery | null {
const q = getUrlState(search);
if (!q.service && !q.start && !q.end) return null;

const startStr = firstOf(q.start) ?? '';
const endStr = firstOf(q.end) ?? '';

const lookback = lookbackFromUrlState(q);

return {
service: firstOf(q?.service),
operation: firstOf(q.operation),
start: firstOf(q.start) ?? '',
end: firstOf(q.end) ?? '',
start: startStr,
end: endStr,
limit: (() => {
const n = Number(firstOf(q.limit));
return Number.isFinite(n) && n > 0 ? n : 20;
})(),
lookback: firstOf(q.lookback) ?? '1h',
lookback,
minDuration: typeof q.minDuration === 'string' ? q.minDuration : undefined,
maxDuration: typeof q.maxDuration === 'string' ? q.maxDuration : undefined,
tags: typeof q.tags === 'string' ? q.tags : undefined,
Expand Down
30 changes: 0 additions & 30 deletions packages/jaeger-ui/src/constants/time-range-options.test.ts

This file was deleted.

7 changes: 7 additions & 0 deletions packages/jaeger-ui/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ export type Config = {

// search section controls some aspects of the Search panel.
search?: {
// defaultLookback sets the pre-selected value in the Lookback dropdown when
// no lookback is present in the URL. Must be one of the values in the dropdown
// (e.g. "5m", "15m", "30m", "1h", "2h", "3h", "6h", "12h", "24h", "2d",
// "3d", "5d", "7d", "2w", "3w", "4w"). Defaults to "1h" when not set or
// when the configured value is not one of the recognized options.
Comment thread
yurishkuro marked this conversation as resolved.
defaultLookback?: string;

// maxLookback controls how far back in time the search may apply.
// By default the Lookback dropdown contains values from "Last 5 minutes"
// to "Last 2 days". Setting maxLookback to a shorter time range,
Expand Down
55 changes: 55 additions & 0 deletions packages/jaeger-ui/src/utils/time-range-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2026 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

import { ONE_HOUR_MS, TIME_RANGE_OPTIONS, lookbackFromDuration } from './time-range-options';

describe('TIME_RANGE_OPTIONS', () => {
it('keeps lookback values and millisecond values aligned', () => {
const unitMs = {
m: 60_000,
h: ONE_HOUR_MS,
d: 24 * ONE_HOUR_MS,
w: 7 * 24 * ONE_HOUR_MS,
};

TIME_RANGE_OPTIONS.forEach(({ lookback, valueMs }) => {
const match = lookback.match(/^(\d+)([mhdw])$/);
expect(match).not.toBeNull();

const [, amount, unit] = match!;
expect(valueMs).toBe(Number(amount) * unitMs[unit as keyof typeof unitMs]);
});
});

it('lists options from shortest to longest', () => {
const values = TIME_RANGE_OPTIONS.map(({ valueMs }) => valueMs);
const sortedValues = [...values].sort((a, b) => a - b);

expect(values).toEqual(sortedValues);
});
});

describe('lookbackFromDuration', () => {
it('returns the exact matching option when duration equals a bucket', () => {
expect(lookbackFromDuration(ONE_HOUR_MS)).toBe('1h');
expect(lookbackFromDuration(15 * 60_000)).toBe('15m');
expect(lookbackFromDuration(2 * 24 * ONE_HOUR_MS)).toBe('2d');
});

it('snaps up to the next bucket when duration falls between two options', () => {
// 70 minutes is between 1h and 2h → should snap up to 2h
expect(lookbackFromDuration(70 * 60_000)).toBe('2h');
// 1 minute is less than 5m → should return 5m (smallest bucket)
expect(lookbackFromDuration(60_000)).toBe('5m');
});

it('returns "custom" when duration exceeds the largest option', () => {
const largestMs = TIME_RANGE_OPTIONS[TIME_RANGE_OPTIONS.length - 1].valueMs;
expect(lookbackFromDuration(largestMs + 1)).toBe('custom');
expect(lookbackFromDuration(365 * 24 * ONE_HOUR_MS)).toBe('custom');
});

it('returns the smallest option for duration of 0', () => {
expect(lookbackFromDuration(0)).toBe(TIME_RANGE_OPTIONS[0].lookback);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,36 @@ export const TIME_RANGE_OPTIONS: readonly ITimeRangeOption[] = Object.freeze([
valueMs: 4 * ONE_WEEK_MS,
},
]);

/**
* Returns value when it is a valid lookback for the Lookback dropdown:
* one of the TIME_RANGE_OPTIONS lookback strings, or 'custom'.
* Use for URL params where 'custom' is a legitimate user selection.
* For config validation use asValidConfigLookback instead.
*/
export function asValidLookback(value: string | undefined): string | undefined {
if (value === 'custom' || TIME_RANGE_OPTIONS.some(o => o.lookback === value)) return value;
return undefined;
}

/**
* Returns value when it is a recognized TIME_RANGE_OPTIONS lookback string.
* Stricter than asValidLookback: rejects 'custom' since a config default
* of 'custom' would leave the form in an ambiguous state on load.
*/
export function asValidConfigLookback(value: string | undefined): string | undefined {
if (TIME_RANGE_OPTIONS.some(o => o.lookback === value)) return value;
return undefined;
}

/**
* Given a duration in milliseconds, return the lookback string of the smallest
* TIME_RANGE_OPTIONS entry whose window is >= durationMs.
* Returns 'custom' if durationMs exceeds the largest option.
*/
export function lookbackFromDuration(durationMs: number): string {
for (const option of TIME_RANGE_OPTIONS) {
if (option.valueMs >= durationMs) return option.lookback;
}
return 'custom';
}