Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
145 changes: 145 additions & 0 deletions osprey_ui/src/utils/QueryStoreUtils.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
41 changes: 30 additions & 11 deletions osprey_ui/src/utils/QueryStoreUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { saveQueryToHistory } from '../actions/QueryActions';
import {
BaseQuery,
DefaultIntervals,
IntervalOptions,
QueryState,
CustomSummaryFeatures,
ScanQueryOrder,
MomentRangeValues,
TopNTable,
Chart,
} from '../types/QueryTypes';
import { getQueryDateRange, isEmptyDateRange } from './QueryUtils';
import { CUSTOM_RANGE_OPTION, getQueryDateRange, isEmptyDateRange } from './QueryUtils';

import { Routes } from '../Constants';

Expand All @@ -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.
Expand All @@ -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);
}

Expand Down
Loading