diff --git a/osprey_ui/src/utils/QueryStoreUtils.test.tsx b/osprey_ui/src/utils/QueryStoreUtils.test.tsx new file mode 100644 index 00000000..1f26922c --- /dev/null +++ b/osprey_ui/src/utils/QueryStoreUtils.test.tsx @@ -0,0 +1,145 @@ +import { Location } from 'history'; +import dayjs from 'dayjs'; + +import '../utils/DayjsSetup'; + +import { saveQueryToHistory } from '../actions/QueryActions'; +import { extractQueryStateFromSearchParams } from './QueryStoreUtils'; + +jest.mock('../actions/QueryActions', () => { + return { + saveQueryToHistory: jest.fn(), + }; +}); + +const mockedSaveQueryToHistory = saveQueryToHistory as jest.Mock; + +const makeLocation = (search: string): Location => { + return { + pathname: '/', + search, + hash: '', + state: undefined, + key: 'test', + } as Location; +}; + +const ONE_MINUTE_MS = 60 * 1000; + +const expectIsApproximatelyNow = (timestamp: string, toleranceMs: number = ONE_MINUTE_MS): void => { + const diff = Math.abs(dayjs.utc(timestamp).diff(dayjs.utc())); + expect(diff).toBeLessThanOrEqual(toleranceMs); +}; + +const expectIsApproximatelyNowMinus = ( + timestamp: string, + amount: number, + unit: dayjs.ManipulateType, + toleranceMs: number = ONE_MINUTE_MS +): void => { + const expected = dayjs.utc().subtract(amount, unit); + const diff = Math.abs(dayjs.utc(timestamp).diff(expected)); + expect(diff).toBeLessThanOrEqual(toleranceMs); +}; + +describe('extractQueryStateFromSearchParams', () => { + describe('home route with no URL params (fresh page load)', () => { + it('populates start and end from the default 1-day interval relative to now', () => { + const state = extractQueryStateFromSearchParams(makeLocation('')); + + expect(state.executedQuery.interval).toBe('day'); + expect(state.executedQuery.start).not.toBe(''); + expect(state.executedQuery.end).not.toBe(''); + expectIsApproximatelyNowMinus(state.executedQuery.start, 1, 'day'); + expectIsApproximatelyNow(state.executedQuery.end); + }); + }); + + describe('home route with relative interval and stale timestamps', () => { + it('recomputes start and end from the interval relative to now (ignores stale URL timestamps)', () => { + const staleStart = '2025-01-01T00:00:00Z'; + const staleEnd = '2025-01-02T00:00:00Z'; + const state = extractQueryStateFromSearchParams( + makeLocation(`?interval=day&start=${encodeURIComponent(staleStart)}&end=${encodeURIComponent(staleEnd)}`) + ); + + expect(state.executedQuery.interval).toBe('day'); + // The URL timestamps were from 2025-01-01, but interval=day means "last + // 24h relative to now" — the returned range must be fresh. + expect(state.executedQuery.start).not.toBe(staleStart); + expect(state.executedQuery.end).not.toBe(staleEnd); + expectIsApproximatelyNowMinus(state.executedQuery.start, 1, 'day'); + expectIsApproximatelyNow(state.executedQuery.end); + }); + + it('recomputes start and end for a different relative interval (twoHours)', () => { + const state = extractQueryStateFromSearchParams( + makeLocation(`?interval=twoHours&start=2025-01-01T00:00:00Z&end=2025-01-01T02:00:00Z`) + ); + + expect(state.executedQuery.interval).toBe('twoHours'); + expectIsApproximatelyNowMinus(state.executedQuery.start, 2, 'hour'); + expectIsApproximatelyNow(state.executedQuery.end); + }); + }); + + describe('home route with custom interval and explicit timestamps', () => { + it('preserves the explicit start and end timestamps from the URL', () => { + const explicitStart = '2025-01-01T00:00:00Z'; + const explicitEnd = '2025-01-02T00:00:00Z'; + const state = extractQueryStateFromSearchParams( + makeLocation( + `?interval=custom&start=${encodeURIComponent(explicitStart)}&end=${encodeURIComponent(explicitEnd)}` + ) + ); + + expect(state.executedQuery.interval).toBe('custom'); + expect(state.executedQuery.start).toBe(explicitStart); + expect(state.executedQuery.end).toBe(explicitEnd); + }); + }); + + describe('home route with relative interval but no timestamps', () => { + it('derives fresh timestamps from the interval', () => { + const state = extractQueryStateFromSearchParams(makeLocation('?interval=hour')); + + expect(state.executedQuery.interval).toBe('hour'); + expectIsApproximatelyNowMinus(state.executedQuery.start, 1, 'hour'); + expectIsApproximatelyNow(state.executedQuery.end); + }); + }); + + describe('home route with an unknown interval value', () => { + it('falls back to the URL timestamps (or empty) and does not throw on an unrecognized interval', () => { + const state = extractQueryStateFromSearchParams( + makeLocation('?interval=garbage&start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z') + ); + + // Unknown interval is preserved as-is from the URL (existing cast + // behavior); the start/end fall through to the URL values without + // attempting to recompute against a missing IntervalOptions entry. + expect(state.executedQuery.start).toBe('2025-01-01T00:00:00Z'); + expect(state.executedQuery.end).toBe('2025-01-02T00:00:00Z'); + }); + }); + + describe('saveQueryToHistory side effect', () => { + beforeEach(() => { + mockedSaveQueryToHistory.mockClear(); + }); + + it('does not save to history when the URL had no explicit timestamps (fresh page load)', () => { + extractQueryStateFromSearchParams(makeLocation('')); + + expect(mockedSaveQueryToHistory).not.toHaveBeenCalled(); + }); + + it('saves to history when the URL has explicit start and end timestamps', () => { + extractQueryStateFromSearchParams( + makeLocation('?interval=custom&start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z') + ); + + expect(mockedSaveQueryToHistory).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/osprey_ui/src/utils/QueryStoreUtils.tsx b/osprey_ui/src/utils/QueryStoreUtils.tsx index e2ff39a7..cd791309 100644 --- a/osprey_ui/src/utils/QueryStoreUtils.tsx +++ b/osprey_ui/src/utils/QueryStoreUtils.tsx @@ -5,6 +5,7 @@ import { saveQueryToHistory } from '../actions/QueryActions'; import { BaseQuery, DefaultIntervals, + IntervalOptions, QueryState, CustomSummaryFeatures, ScanQueryOrder, @@ -12,7 +13,7 @@ import { TopNTable, Chart, } from '../types/QueryTypes'; -import { getQueryDateRange, isEmptyDateRange } from './QueryUtils'; +import { CUSTOM_RANGE_OPTION, getQueryDateRange, isEmptyDateRange } from './QueryUtils'; import { Routes } from '../Constants'; @@ -35,15 +36,33 @@ export function extractQueryStateFromSearchParams(location: Location): QueryStor const customSummaryFeatures = queryString.getAll('customSummaryFeatures'); + const intervalParam = queryString.get('interval'); + const interval: DefaultIntervals = + intervalParam != null && intervalParam !== '' ? (intervalParam as DefaultIntervals) : 'day'; + + const startParam = queryString.get('start') || ''; + const endParam = queryString.get('end') || ''; + const hasExplicitDateRange = !isEmptyDateRange(startParam, endParam); + + // For relative intervals ("Last Hour", "Last Day", etc.), always derive + // start/end from "now" so the query reflects the user-intended relative + // window — not whatever stale timestamps may be on the URL from a + // previous session. Custom intervals preserve the explicit timestamps + // the user picked from the date-range picker. + let start = startParam; + let end = endParam; + if (interval != null && interval !== CUSTOM_RANGE_OPTION && interval in IntervalOptions) { + const derived = getQueryDateRange(interval); + start = derived.start; + end = derived.end; + } + const queryState = { executedQuery: { queryFilter: queryString.get('queryFilter') || '', - start: queryString.get('start') || '', - end: queryString.get('end') || '', - interval: - queryString.get('interval') != null && queryString.get('interval') !== '' - ? (queryString.get('interval') as DefaultIntervals) - : 'day', + start, + end, + interval, }, // Parse a `ScanQueryOrder` from the query string, defaulting to `descending` if the order isn't understood or // not provided. @@ -55,10 +74,10 @@ export function extractQueryStateFromSearchParams(location: Location): QueryStor customSummaryFeatures: customSummaryFeatures.length === 0 ? null : customSummaryFeatures, }; - const { start, end } = queryState.executedQuery; - - // Do not save queries from the entity view yet - if (!isEntityView && !isEmptyDateRange(start, end)) { + // Only save to query history when the URL provided explicit timestamps — + // a derived default range from loading the page without explicit params + // is not a query the user ran. + if (!isEntityView && hasExplicitDateRange) { saveQueryToHistory(queryState); }