diff --git a/.changeset/nine-dragons-clap.md b/.changeset/nine-dragons-clap.md
new file mode 100644
index 000000000..62de4ffed
--- /dev/null
+++ b/.changeset/nine-dragons-clap.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+Adds "Relative Time" switch to TimePicker component (if relative time is supported by parent). When enabled, searches will work similar to Live Tail but be relative to the option selected.
diff --git a/.prettierignore b/.prettierignore
index 6b540313c..ff2b07e26 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,5 +1,4 @@
# Ignore artifacts:
dist
coverage
-tests
.volumes
diff --git a/packages/app/.stylelintignore b/packages/app/.stylelintignore
index f9a6ffa62..1ef1a71a8 100644
--- a/packages/app/.stylelintignore
+++ b/packages/app/.stylelintignore
@@ -1,4 +1,5 @@
.next
.storybook
node_modules
-coverage
\ No newline at end of file
+coverage
+playwright-report
\ No newline at end of file
diff --git a/packages/app/package.json b/packages/app/package.json
index 39768c200..66bc55791 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -63,6 +63,7 @@
"ky": "^0.30.0",
"ky-universal": "^0.10.1",
"lodash": "^4.17.21",
+ "ms": "^2.1.3",
"next": "^14.2.32",
"next-query-params": "^4.1.0",
"next-runtime-env": "1",
@@ -125,6 +126,7 @@
"@types/intercom-web": "^2.8.18",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.14.186",
+ "@types/ms": "^0.7.31",
"@types/object-hash": "^2.2.1",
"@types/pluralize": "^0.0.29",
"@types/react": "18.3.1",
diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts
index 3f1602ee2..498fb6fb2 100644
--- a/packages/app/playwright.config.ts
+++ b/packages/app/playwright.config.ts
@@ -24,7 +24,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
- baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080',
+ baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8081',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Take screenshot on failure */
@@ -49,8 +49,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command:
- 'NEXT_PUBLIC_IS_LOCAL_MODE=true NEXT_TELEMETRY_DISABLED=1 yarn run dev',
- port: 8080,
+ 'NEXT_PUBLIC_IS_LOCAL_MODE=true NEXT_TELEMETRY_DISABLED=1 PORT=8081 yarn run dev',
+ port: 8081,
reuseExistingServer: !process.env.CI,
timeout: 180 * 1000,
stdout: 'pipe',
diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx
index 96bd604c6..834668b8b 100644
--- a/packages/app/src/DBSearchPage.tsx
+++ b/packages/app/src/DBSearchPage.tsx
@@ -12,6 +12,7 @@ import Link from 'next/link';
import router from 'next/router';
import {
parseAsBoolean,
+ parseAsInteger,
parseAsJson,
parseAsString,
parseAsStringEnum,
@@ -43,7 +44,6 @@ import {
Flex,
Grid,
Group,
- Input,
Menu,
Modal,
Paper,
@@ -91,7 +91,11 @@ import {
useSource,
useSources,
} from '@/source';
-import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery';
+import {
+ parseRelativeTimeQuery,
+ parseTimeQuery,
+ useNewTimeQuery,
+} from '@/timeQuery';
import { QUERY_LOCAL_STORAGE, useLocalStorage, usePrevious } from '@/utils';
import { SQLPreview } from './components/ChartSQLPreview';
@@ -99,6 +103,10 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
import PatternTable from './components/PatternTable';
import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart';
import SourceSchemaPreview from './components/SourceSchemaPreview';
+import {
+ getRelativeTimeOptionLabel,
+ LIVE_TAIL_DURATION_MS,
+} from './components/TimePicker/utils';
import { useTableMetadata } from './hooks/useMetadata';
import { useSqlSuggestions } from './hooks/useSqlSuggestions';
import {
@@ -417,7 +425,7 @@ function useLiveUpdate({
onTimeRangeSelect: (
start: Date,
end: Date,
- displayedTimeInputValue?: string | undefined,
+ displayedTimeInputValue?: string | null,
) => void;
pause: boolean;
}) {
@@ -426,7 +434,7 @@ function useLiveUpdate({
const [refreshOnVisible, setRefreshOnVisible] = useState(false);
const refresh = useCallback(() => {
- onTimeRangeSelect(new Date(Date.now() - interval), new Date(), 'Live Tail');
+ onTimeRangeSelect(new Date(Date.now() - interval), new Date(), null);
}, [onTimeRangeSelect, interval]);
// When the user comes back to the app after switching tabs, we immediately refresh the list.
@@ -664,8 +672,10 @@ function DBSearchPage() {
parseAsString,
);
- const [_isLive, setIsLive] = useQueryState('isLive', parseAsBoolean);
- const isLive = _isLive ?? true;
+ const [isLive, setIsLive] = useQueryState(
+ 'isLive',
+ parseAsBoolean.withDefault(true),
+ );
useEffect(() => {
if (analysisMode === 'delta' || analysisMode === 'pattern') {
@@ -727,7 +737,7 @@ function DBSearchPage() {
const [displayedTimeInputValue, setDisplayedTimeInputValue] =
useState('Live Tail');
- const { from, to, isReady, searchedTimeRange, onSearch, onTimeRangeSelect } =
+ const { isReady, searchedTimeRange, onSearch, onTimeRangeSelect } =
useNewTimeQuery({
initialDisplayValue: 'Live Tail',
initialTimeRange: defaultTimeRange,
@@ -736,18 +746,6 @@ function DBSearchPage() {
updateInput: !isLive,
});
- // If live tail is null, but time range exists, don't live tail
- // If live tail is null, and time range is null, let's live tail
- useEffect(() => {
- if (_isLive == null && isReady) {
- if (from == null && to == null) {
- setIsLive(true);
- } else {
- setIsLive(false);
- }
- }
- }, [_isLive, setIsLive, from, to, isReady]);
-
// Sync url state back with form state
// (ex. for history navigation)
// TODO: Check if there are any bad edge cases here
@@ -1014,9 +1012,29 @@ function DBSearchPage() {
// State for collapsing all expanded rows when resuming live tail
const [collapseAllRows, setCollapseAllRows] = useState(false);
+ const [interval, setInterval] = useQueryState(
+ 'liveInterval',
+ parseAsInteger.withDefault(LIVE_TAIL_DURATION_MS),
+ );
+
+ const updateRelativeTimeInputValue = useCallback((interval: number) => {
+ const label = getRelativeTimeOptionLabel(interval);
+ if (label) {
+ setDisplayedTimeInputValue(label);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isReady && isLive) {
+ updateRelativeTimeInputValue(interval);
+ }
+ // we only want this to run on initial mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [updateRelativeTimeInputValue, isReady]);
+
useLiveUpdate({
isLive,
- interval: 1000 * 60 * 15,
+ interval,
refreshFrequency: 4000,
onTimeRangeSelect,
pause: isAnyQueryFetching || !queryReady || !isTabVisible,
@@ -1043,13 +1061,12 @@ function DBSearchPage() {
const handleResumeLiveTail = useCallback(() => {
setIsLive(true);
- setDisplayedTimeInputValue('Live Tail');
+ updateRelativeTimeInputValue(interval);
// Trigger collapsing all expanded rows
setCollapseAllRows(true);
// Reset the collapse trigger after a short delay
setTimeout(() => setCollapseAllRows(false), 100);
- onSearch('Live Tail');
- }, [onSearch, setIsLive]);
+ }, [interval, updateRelativeTimeInputValue, setIsLive]);
const dbSqlRowTableConfig = useMemo(() => {
if (chartConfig == null) {
@@ -1508,14 +1525,21 @@ function DBSearchPage() {
inputValue={displayedTimeInputValue}
setInputValue={setDisplayedTimeInputValue}
onSearch={range => {
- if (range === 'Live Tail') {
- setIsLive(true);
- } else {
- setIsLive(false);
- }
+ setIsLive(false);
onSearch(range);
}}
+ onRelativeSearch={rangeMs => {
+ const _range = parseRelativeTimeQuery(rangeMs);
+ setIsLive(true);
+ setInterval(rangeMs);
+ onTimeRangeSelect(_range[0], _range[1], null);
+ }}
showLive={analysisMode === 'results'}
+ isLiveMode={isLive}
+ // Default to relative time mode if the user has made changes to interval and reloaded.
+ defaultRelativeTimeMode={
+ isLive && interval !== LIVE_TAIL_DURATION_MS
+ }
/>
-
-
@@ -282,8 +325,19 @@ export const TimePicker = ({
) : (
handleSearch(item[0])}
+ key={item[0]}
+ disabled={
+ isRelative &&
+ !item[2] &&
+ item[0] !== LIVE_TAIL_TIME_QUERY
+ }
+ onClick={() => {
+ if (isRelative || item[0] === LIVE_TAIL_TIME_QUERY) {
+ handleRelativeSearch?.(item[0], item[1]);
+ } else {
+ handleSearch(item[0]);
+ }
+ }}
w="100%"
size="compact-xs"
color="gray"
@@ -307,6 +361,7 @@ export const TimePicker = ({
mb="xs"
data={[TimePickerMode.Range, TimePickerMode.Around]}
value={mode}
+ disabled={isRelative}
onChange={newMode => {
const value = newMode as TimePickerMode;
setMode(value);
@@ -351,6 +406,7 @@ export const TimePicker = ({
<>
Start time
>
@@ -368,6 +425,7 @@ export const TimePicker = ({
<>
Time
@@ -401,7 +460,7 @@ export const TimePicker = ({
data-testid="time-picker-apply"
size="compact-sm"
variant="light"
- disabled={!form.isValid()}
+ disabled={!form.isValid() || isRelative}
onClick={handleApply}
>
Apply
diff --git a/packages/app/src/components/TimePicker/utils.ts b/packages/app/src/components/TimePicker/utils.ts
index be5ad1583..35b7d09a4 100644
--- a/packages/app/src/components/TimePicker/utils.ts
+++ b/packages/app/src/components/TimePicker/utils.ts
@@ -1,4 +1,5 @@
import * as chrono from 'chrono-node';
+import ms from 'ms';
function normalizeParsedDate(parsed?: chrono.ParsedComponents): Date | null {
if (!parsed) {
@@ -60,30 +61,44 @@ export function parseTimeRangeInput(
}
}
-export const LIVE_TAIL_TIME_QUERY = 'Live Tail';
+export const LIVE_TAIL_TIME_QUERY = 'Live Tail' as const;
+export const LIVE_TAIL_DURATION_MS = ms('15m');
-export const RELATIVE_TIME_OPTIONS: ([string, string] | 'divider')[] = [
+export const RELATIVE_TIME_OPTIONS: (
+ | [label: string, duration: number, relativeSupport?: boolean]
+ | 'divider'
+)[] = [
// ['Last 15 seconds', '15s'],
// ['Last 30 seconds', '30s'],
// 'divider',
- ['Last 1 minute', '1m'],
- ['Last 5 minutes', '5m'],
- ['Last 15 minutes', '15m'],
- ['Last 30 minutes', '30m'],
- ['Last 45 minutes', '45m'],
+ ['Last 1 minute', ms('1m'), true],
+ ['Last 5 minutes', ms('5m'), true],
+ ['Last 15 minutes', ms('15m'), true],
+ ['Last 30 minutes', ms('30m'), true],
+ ['Last 45 minutes', ms('45m'), true],
'divider',
- ['Last 1 hour', '1h'],
- ['Last 3 hours', '3h'],
- ['Last 6 hours', '6h'],
- ['Last 12 hours', '12h'],
+ ['Last 1 hour', ms('1h'), true],
+ ['Last 3 hours', ms('3h')],
+ ['Last 6 hours', ms('6h')],
+ ['Last 12 hours', ms('12h')],
'divider',
- ['Last 1 days', '1d'],
- ['Last 2 days', '2d'],
- ['Last 7 days', '7d'],
- ['Last 14 days', '14d'],
- ['Last 30 days', '30d'],
+ ['Last 1 days', ms('1d')],
+ ['Last 2 days', ms('2d')],
+ ['Last 7 days', ms('7d')],
+ ['Last 14 days', ms('14d')],
+ ['Last 30 days', ms('30d')],
];
+export function getRelativeTimeOptionLabel(value: number) {
+ if (value === LIVE_TAIL_DURATION_MS) {
+ return LIVE_TAIL_TIME_QUERY;
+ }
+ const option = RELATIVE_TIME_OPTIONS.find(
+ option => option !== 'divider' && option[1] === value,
+ ) as [string, number, boolean] | undefined;
+ return option ? option[0] : undefined;
+}
+
export const DURATION_OPTIONS = [
'30s',
'1m',
diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts
index a903838a1..166710828 100644
--- a/packages/app/src/timeQuery.ts
+++ b/packages/app/src/timeQuery.ts
@@ -9,14 +9,13 @@ import {
} from 'react';
import { useRouter } from 'next/router';
import {
- format,
formatDuration,
intervalToDuration,
isValid,
startOfSecond,
sub,
+ subMilliseconds,
} from 'date-fns';
-import { formatInTimeZone } from 'date-fns-tz';
import { parseAsFloat, useQueryStates } from 'nuqs';
import {
NumberParam,
@@ -51,17 +50,15 @@ function isInputTimeQueryLive(inputTimeQuery: string) {
return inputTimeQuery === '' || inputTimeQuery.includes(LIVE_TAIL_TIME_QUERY);
}
+export function parseRelativeTimeQuery(interval: number) {
+ const end = startOfSecond(new Date());
+ return [subMilliseconds(end, interval), end];
+}
+
export function parseTimeQuery(
timeQuery: string,
isUTC: boolean,
): [Date | null, Date | null] {
- // If it's a live tail, return the last 15 minutes from now
- // Round to the nearest second as when we stop live tail, we'll query up to the nearest second
- // Without rounding, we'll end up needing to do a refetch for the ms differences
- if (timeQuery.includes(LIVE_TAIL_TIME_QUERY)) {
- const end = startOfSecond(new Date());
- return [sub(end, { minutes: 15 }), end];
- }
return parseTimeRangeInput(timeQuery, isUTC);
}
@@ -349,11 +346,6 @@ export function useTimeQuery({
searchedTimeRange,
onSearch: useCallback(
(timeQuery: string) => {
- if (timeQuery.includes(LIVE_TAIL_TIME_QUERY)) {
- setIsLive(true);
- return;
- }
-
const [start, end] = parseTimeQuery(timeQuery, isUTC);
// TODO: Add validation UI
if (start != null && end != null) {
@@ -367,13 +359,7 @@ export function useTimeQuery({
}
}
},
- [
- isUTC,
- setTimeRangeQuery,
- setDisplayedTimeInputValue,
- setIsLive,
- setInputTimeQuery,
- ],
+ [isUTC, setTimeRangeQuery, setDisplayedTimeInputValue, setInputTimeQuery],
),
onTimeRangeSelect: useCallback(
(start: Date, end: Date) => {
@@ -411,7 +397,7 @@ export type UseTimeQueryReturnType = {
onTimeRangeSelect: (
start: Date,
end: Date,
- displayedTimeInputValue?: string,
+ displayedTimeInputValue?: string | null,
) => void;
from: number | null;
to: number | null;
@@ -517,12 +503,13 @@ export function useNewTimeQuery({
searchedTimeRange,
onSearch,
onTimeRangeSelect: useCallback(
- (start: Date, end: Date, displayedTimeInputValue?: string) => {
+ (start: Date, end: Date, displayedTimeInputValue?: string | null) => {
setTimeRangeQuery({ from: start.getTime(), to: end.getTime() });
setSearchedTimeRange([start, end]);
const dateRangeStr = dateRangeToString([start, end], isUTC);
-
- _setDisplayedTimeInputValue(displayedTimeInputValue ?? dateRangeStr);
+ if (displayedTimeInputValue !== null) {
+ _setDisplayedTimeInputValue(displayedTimeInputValue ?? dateRangeStr);
+ }
},
[setTimeRangeQuery, isUTC, _setDisplayedTimeInputValue],
),
diff --git a/packages/app/tests/e2e/features/search/relative-time.spec.ts b/packages/app/tests/e2e/features/search/relative-time.spec.ts
new file mode 100644
index 000000000..95467d9e2
--- /dev/null
+++ b/packages/app/tests/e2e/features/search/relative-time.spec.ts
@@ -0,0 +1,403 @@
+import { Page } from '@playwright/test';
+
+import { expect, test } from '../../utils/base-test';
+
+const getRelativeTimeSwitch = (page: Page) =>
+ page.getByTestId('time-picker-relative-switch');
+
+const clickRelativeTimeSwitch = async (page: Page) => {
+ const switchInput = getRelativeTimeSwitch(page);
+ await switchInput.locator('..').click();
+};
+
+const openTimePickerModal = async (page: Page) => {
+ await page.click('[data-testid="time-picker-input"]');
+ await page.waitForSelector('[data-testid="time-picker-popover"]', {
+ state: 'visible',
+ });
+};
+
+test.describe('Relative Time Picker', { tag: '@relative-time' }, () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/search');
+ // Wait for the page to be ready
+ await expect(page.locator('[data-testid="search-form"]')).toBeVisible();
+ await openTimePickerModal(page);
+ });
+
+ test.describe('Basic Functionality', () => {
+ test('should display relative time toggle switch', async ({ page }) => {
+ await test.step('Verify switch is interactive', async () => {
+ const switchInput = getRelativeTimeSwitch(page);
+ // Check initial state (should be checked if in live mode)
+ const isChecked = await switchInput.isChecked();
+ expect(typeof isChecked).toBe('boolean');
+ });
+ });
+
+ test('should toggle relative time mode on/off', async ({ page }) => {
+ const switchInput = getRelativeTimeSwitch(page);
+
+ await test.step('Toggle relative time off', async () => {
+ const initialState = await switchInput.isChecked();
+ await clickRelativeTimeSwitch(page);
+
+ const newState = await switchInput.isChecked();
+ expect(newState).toBe(!initialState);
+ });
+
+ await test.step('Toggle relative time back on', async () => {
+ const currentState = await switchInput.isChecked();
+ await clickRelativeTimeSwitch(page);
+
+ const newState = await switchInput.isChecked();
+ expect(newState).toBe(!currentState);
+ });
+ });
+
+ test('should show Live Tail option in relative time mode', async ({
+ page,
+ }) => {
+ const liveTailButton = page.locator('text=Live Tail').locator('..');
+ await expect(liveTailButton).toBeVisible();
+ });
+ });
+
+ test.describe('Relative Time Options', () => {
+ test('should select different relative time intervals', async ({
+ page,
+ }) => {
+ const intervals = [
+ { label: 'Last 1 minute', ms: 60000 },
+ { label: 'Last 5 minutes', ms: 300000 },
+ { label: 'Last 15 minutes', ms: 900000 },
+ { label: 'Last 30 minutes', ms: 1800000 },
+ { label: 'Last 45 minutes', ms: 2700000 },
+ { label: 'Last 1 hour', ms: 3600000 },
+ ];
+
+ for (const interval of intervals) {
+ await test.step(`Select ${interval.label}`, async () => {
+ // Ensure relative time mode is enabled
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (!isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+
+ // Click the interval option
+ const intervalButton = page.locator(`text=${interval.label}`);
+ await expect(intervalButton).toBeVisible();
+ await intervalButton.click();
+
+ // Wait for URL to update
+ await page.waitForURL(`**/search**liveInterval=${interval.ms}**`);
+
+ // Verify URL contains the correct liveInterval parameter
+ const url = page.url();
+ expect(url).toContain('liveInterval=');
+ expect(url).toContain(`liveInterval=${interval.ms}`);
+
+ // Verify isLive is true
+ expect(url).toContain('isLive=true');
+
+ // Verify the time picker input displays the selected interval
+ const timePickerInput = page.locator(
+ '[data-testid="time-picker-input"]',
+ );
+ const inputValue = await timePickerInput.inputValue();
+ expect(inputValue).toBe(interval.label);
+
+ await openTimePickerModal(page);
+ });
+ }
+ });
+
+ test('should select Live Tail (15m default)', async ({ page }) => {
+ await test.step('Select Live Tail', async () => {
+ const liveTailButton = page.locator('text=Live Tail').locator('..');
+ await liveTailButton.click();
+ await page.waitForURL('**/search**liveInterval=900000**');
+ });
+
+ await test.step('Verify URL parameters', async () => {
+ const url = page.url();
+ expect(url).toContain('isLive=true');
+ // Live Tail defaults to 15 minutes (900000ms)
+ expect(url).toContain('liveInterval=900000');
+ });
+
+ await test.step('Verify time picker input shows Live Tail', async () => {
+ const timePickerInput = page.locator(
+ '[data-testid="time-picker-input"]',
+ );
+ const inputValue = await timePickerInput.inputValue();
+ expect(inputValue).toBe('Live Tail');
+ });
+ });
+
+ test('should disable non-relative options when relative mode is off', async ({
+ page,
+ }) => {
+ await test.step('Turn off relative time mode', async () => {
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+ });
+
+ await test.step('Verify time options without relative support are not disabled', async () => {
+ // Options like "Last 3 hours", "Last 6 hours" etc. should work in absolute mode
+ const last3HoursButton = page.locator('text=Last 3 hours');
+ await expect(last3HoursButton).toBeVisible();
+ const isDisabled = await last3HoursButton.isDisabled();
+ expect(isDisabled).toBe(false);
+ });
+
+ await test.step('Verify clicking an option in absolute mode works', async () => {
+ const last1HourButton = page.locator('text=Last 1 hour');
+ await last1HourButton.click();
+
+ // Wait for URL to update with absolute time range
+ await page.waitForURL('**/search**from=**to=**');
+
+ // In absolute mode, should set time range but not live mode
+ const url = page.url();
+ expect(url).toContain('from=');
+ expect(url).toContain('to=');
+ });
+ });
+ });
+
+ test.describe('Live Mode Integration', () => {
+ test('should start in live mode by default', async ({ page }) => {
+ // Fresh page load should default to live mode
+ await page.goto('/search');
+ await page.waitForLoadState('networkidle');
+
+ const timePickerInput = page.locator('[data-testid="time-picker-input"]');
+ const inputValue = await timePickerInput.inputValue();
+ expect(inputValue).toBe('Live Tail');
+ });
+
+ test('should exit live mode when selecting absolute time range', async ({
+ page,
+ }) => {
+ await test.step('Open time picker and turn off relative mode', async () => {
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+ });
+
+ await test.step('Select an absolute time range', async () => {
+ const last1HourButton = page.locator('text=Last 1 hour');
+ await last1HourButton.click();
+ await page.waitForURL('**/search**isLive=false**');
+ });
+
+ await test.step('Verify exited live mode', async () => {
+ const url = page.url();
+ // Should have absolute time range
+ expect(url).toContain('from=');
+ expect(url).toContain('to=');
+ // Should NOT be in live mode
+ expect(url).toContain('isLive=false');
+ });
+ });
+
+ test('should resume live tail with selected interval', async ({ page }) => {
+ await test.step('Select a specific relative interval', async () => {
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (!isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+
+ const last5MinButton = page.locator('text=Last 5 minutes');
+ await last5MinButton.click();
+ await page.waitForURL('**/search**liveInterval=300000**');
+ });
+
+ await test.step('Pause live tail by selecting absolute time', async () => {
+ await page.click('[data-testid="time-picker-input"]');
+ await page.waitForSelector('[data-testid="time-picker-popover"]', {
+ state: 'visible',
+ });
+
+ await clickRelativeTimeSwitch(page);
+
+ const last1HourButton = page.locator('text=Last 1 hour');
+ await last1HourButton.click();
+ await page.waitForURL('**/search**isLive=false**');
+ });
+
+ await test.step('Resume live tail', async () => {
+ // Look for a resume/play button or similar control
+ // This might be in the UI - adjust selector as needed
+ const resumeButton = page.locator('text=/Resume|Play/i').first();
+ const isVisible = await resumeButton.isVisible().catch(() => false);
+
+ if (isVisible) {
+ await resumeButton.click();
+ await page.waitForURL('**/search**isLive=true**');
+
+ // Verify back in live mode
+ const url = page.url();
+ expect(url).toContain('isLive=true');
+ // Should retain the previously selected interval (5 minutes = 300000ms)
+ expect(url).toContain('liveInterval=300000');
+ }
+ });
+ });
+ });
+
+ test.describe('URL State Management', () => {
+ test('should persist relative time settings in URL', async ({ page }) => {
+ await test.step('Select relative time interval', async () => {
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (!isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+
+ const last30MinButton = page.locator('text=Last 30 minutes');
+ await last30MinButton.click();
+ await page.waitForURL('**/search**liveInterval=1800000**');
+ });
+
+ await test.step('Copy URL and navigate away', async () => {
+ const urlWithRelativeTime = page.url();
+
+ // Navigate to a different page
+ await page.goto('/search');
+ await page.waitForLoadState('networkidle');
+
+ // Navigate back using the saved URL
+ await page.goto(urlWithRelativeTime);
+ await page.waitForLoadState('networkidle');
+ });
+
+ await test.step('Verify relative time settings are restored', async () => {
+ const url = page.url();
+ expect(url).toContain('isLive=true');
+ expect(url).toContain('liveInterval=1800000'); // 30 minutes
+
+ const timePickerInput = page.locator(
+ '[data-testid="time-picker-input"]',
+ );
+ const inputValue = await timePickerInput.inputValue();
+ expect(inputValue).toBe('Last 30 minutes');
+ });
+ });
+
+ test('should restore relative time toggle state from URL', async ({
+ page,
+ }) => {
+ await test.step('Set up relative time mode', async () => {
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (!isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+
+ const last30MinButton = page.locator('text=Last 30 minutes');
+ await last30MinButton.click();
+ await page.waitForURL('**/search**liveInterval=1800000**');
+ });
+
+ await test.step('Reload page', async () => {
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+ });
+
+ await test.step('Open time picker and verify relative toggle is on', async () => {
+ await page.click('[data-testid="time-picker-input"]');
+ await page.waitForSelector('[data-testid="time-picker-popover"]', {
+ state: 'visible',
+ });
+
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ expect(isChecked).toBe(true);
+ });
+ });
+ });
+
+ test.describe('Search Integration', () => {
+ test('should perform search with relative time range', async ({ page }) => {
+ await test.step('Select relative time interval', async () => {
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (!isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+
+ const last5MinButton = page.locator('text=Last 5 minutes');
+ await last5MinButton.click();
+ await page.waitForURL('**/search**liveInterval=300000**');
+ });
+
+ await test.step('Perform search', async () => {
+ const searchSubmitButton = page.locator(
+ '[data-testid="search-submit-button"]',
+ );
+ await searchSubmitButton.click();
+ await page.waitForLoadState('networkidle');
+ });
+
+ await test.step('Verify search results or empty state', async () => {
+ // Results may or may not exist depending on data
+ const searchResultsTable = page.locator(
+ '[data-testid="search-results-table"]',
+ );
+ const tableVisible = await searchResultsTable
+ .isVisible({ timeout: 2000 })
+ .catch(() => false);
+
+ expect(typeof tableVisible).toBe('boolean');
+ });
+
+ await test.step('Verify URL maintains relative time params', async () => {
+ const url = page.url();
+ expect(url).toContain('isLive=true');
+ expect(url).toContain('liveInterval=300000'); // 5 minutes
+ });
+ });
+
+ test('should update search results when switching between intervals', async ({
+ page,
+ }) => {
+ const intervals = [
+ { label: 'Last 5 minutes', ms: 300000 },
+ { label: 'Last 15 minutes', ms: 900000 },
+ { label: 'Last 1 hour', ms: 3600000 },
+ ];
+ const switchInput = getRelativeTimeSwitch(page);
+ const isChecked = await switchInput.isChecked();
+ if (!isChecked) {
+ await clickRelativeTimeSwitch(page);
+ }
+
+ for (const interval of intervals) {
+ await test.step(`Search with ${interval.label}`, async () => {
+ await page.click('[data-testid="time-picker-input"]');
+ await page.waitForSelector('[data-testid="time-picker-popover"]', {
+ state: 'visible',
+ });
+
+ const intervalButton = page.locator(`text=${interval.label}`);
+ await intervalButton.click();
+ await page.waitForURL(`**/search**liveInterval=${interval.ms}**`);
+
+ await page.waitForLoadState('networkidle');
+
+ const url = page.url();
+ expect(url).toContain(`liveInterval=${interval.ms}`);
+ });
+ }
+ });
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 09e005c9c..8ff182b20 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4422,6 +4422,7 @@ __metadata:
"@types/intercom-web": "npm:^2.8.18"
"@types/jest": "npm:^29.5.14"
"@types/lodash": "npm:^4.14.186"
+ "@types/ms": "npm:^0.7.31"
"@types/object-hash": "npm:^2.2.1"
"@types/pluralize": "npm:^0.0.29"
"@types/react": "npm:18.3.1"
@@ -4454,6 +4455,7 @@ __metadata:
ky: "npm:^0.30.0"
ky-universal: "npm:^0.10.1"
lodash: "npm:^4.17.21"
+ ms: "npm:^2.1.3"
msw: "npm:^2.3.0"
msw-storybook-addon: "npm:^2.0.2"
next: "npm:^14.2.32"