Skip to content

Commit fbde116

Browse files
[Streams] Fix timerange lost retention tab (elastic#247544)
Closes elastic#244616 ## Summary This PR fixes time range persistence issues in the Streams app. What started as a simple fix for the Retention tab exposed deeper architectural inconsistencies in time state management that needed to be addressed. ### Problem Time lost on Retention tab refresh: When setting a time range on the Retention tab and refreshing the page, the time would reset to the default 15 minutes. This happened because the Streams app wasn't syncing the global timefilter with the URL. ### Initial Fix and Exposed Issues The initial fix was straightforward: add `syncGlobalQueryStateWithUrl` to sync the timefilter with the `_g` URL parameter (following the pattern used by Discover and other Kibana apps). However, this fix exposed pre-existing architectural inconsistencies that weren't visible before: 1. Time not syncing between tabs: After adding global sync, we expected time to be consistent across all tabs. But navigating from Main page → Data Quality tab would show different times. This happened because: - The Data Quality tab maintained its own time state via a separate `pageState` URL parameter - The router preserved `pageState` across navigations, which contained stale time values - The Data Quality controller prioritized `pageState` over the global timefilter 2. Time lost when clicking stream links: Setting time on the Main page and clicking a stream name would lose the time. This happened because: - Stream name links used `href={router.link(...)}` which captured URL params at render time - When users set a new time, the `_g` was updated in the URL, but the link's `href` was already rendered with the old value - Clicking the link navigated to the stale URL without the updated `_g` These issues existed before but weren't noticed because without global sync, there was no expectation of time consistency across tabs—each part of the app effectively managed time independently ### Revised Approach After review feedback, the solution was changed from using the `_g` global state pattern to using explicit route query params (`rangeFrom/rangeTo`). This approach is more consistent with the Streams app's existing routing architecture and avoids pulling in additional helper functions for global state management ### Changes **Route Configuration** - Added `timeRangeQueryParams` (`rangeFrom`/`rangeTo`) to each child route that needs time state. Management routes have extended `managementQueryParams` that also include `openFlyout`, `selectedFeatures`, and `pageState` for feature-specific navigation - Time params are now recognized by the typed router **New Hooks** - `useTimeRange` - Reads time range from URL params via `useLocation()`, converts relative times (e.g., "now-15m") to absolute timestamps. Uses direct URL reading to work universally across all routes. - `useTimeRangeUpdate` - Updates time range in URL while preserving other query params **DateRangeRedirect Component** - Ensures time params are always present in the URL - If missing, populates them from the global timefilter (session state) or Kibana defaults - Syncs URL time params to the global timefilter so `useTimefilter()` consumers stay in sync **Updated Components** - `StreamsAppSearchBar` - Now uses URL-based time state instead of global timefilter directly - `StreamDetailSignificantEventsView` - Uses new time hooks **Data Quality Controller** - Now reads time from URL params (`rangeFrom`/`rangeTo`) as source of truth - Always overrides `pageState` time with URL time params, ensuring consistency even when stale pageState exists - Uses shared `useTimeRangeUpdate` hook instead of custom time-setting logic ## Limitations & Workarounds ### Not all navigation calls pass time params explicitly **Current state**: Key navigation paths now explicitly pass time params via the typed router. Some secondary navigation calls (e.g., links in schema editor, routing views, discovery pages) still don't pass them explicitly **Workaround**: `DateRangeRedirect` reads from the global timefilter when time params are missing. Since the timefilter retains the last known time within the session, time is effectively preserved even through links that don't pass params explicitly **Why**: Updating all ~20 navigation calls would significantly expand the scope of this PR and touch unrelated parts of the codebase. The current approach works correctly for in-session navigation **Future improvement**: A follow-up could update all navigation calls to pass time params explicitly. See TODO comment in `DateRangeRedirect` ### No refresh interval persistence The `refreshPaused` and `refreshInterval` params are not yet implemented. As Streams is primarily used for configuration and analysis rather than real-time monitoring, I believe we can skip this, at least for now --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent eb23d96 commit fbde116

19 files changed

Lines changed: 502 additions & 101 deletions

File tree

x-pack/platform/plugins/shared/streams_app/public/components/app_root/index.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { streamsAppRouter } from '../../routes/config';
2020
import type { StreamsAppStartDependencies } from '../../types';
2121
import type { StreamsAppServices } from '../../services/types';
2222
import { KbnUrlStateStorageFromRouterProvider } from '../../util/kbn_url_state_context';
23+
import { DateRangeRedirect } from '../date_range_redirect';
2324

2425
const queryClient = new QueryClient();
2526

@@ -53,13 +54,15 @@ export function AppRoot({
5354
<QueryClientProvider client={queryClient}>
5455
{/* @ts-expect-error upgrade typescript v5.4.5 */}
5556
<RouterProvider history={history} router={streamsAppRouter}>
56-
<PerformanceContextProvider>
57-
<KbnUrlStateStorageFromRouterProvider>
58-
<BreadcrumbsContextProvider>
59-
<RouteRenderer />
60-
</BreadcrumbsContextProvider>
61-
</KbnUrlStateStorageFromRouterProvider>
62-
</PerformanceContextProvider>
57+
<DateRangeRedirect>
58+
<PerformanceContextProvider>
59+
<KbnUrlStateStorageFromRouterProvider>
60+
<BreadcrumbsContextProvider>
61+
<RouteRenderer />
62+
</BreadcrumbsContextProvider>
63+
</KbnUrlStateStorageFromRouterProvider>
64+
</PerformanceContextProvider>
65+
</DateRangeRedirect>
6366
</RouterProvider>
6467
</QueryClientProvider>
6568
</StreamsTourProvider>

x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_management/classic.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function ClassicStreamDetailManagement({
118118
'Control how long data stays in this stream. Set a custom duration or apply a shared policy.',
119119
})}
120120
>
121-
<span tabIndex={0}>
121+
<span data-test-subj="retentionTab" tabIndex={0}>
122122
{i18n.translate('xpack.streams.streamDetailView.lifecycleTab', {
123123
defaultMessage: 'Retention',
124124
})}

x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_management/wired.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export function WiredStreamDetailManagement({
113113
'Control how long data stays in this stream. Set a custom duration or apply a shared policy.',
114114
})}
115115
>
116-
<span tabIndex={0}>
116+
<span data-test-subj="retentionTab" tabIndex={0}>
117117
{i18n.translate('xpack.streams.streamDetailView.lifecycleTab', {
118118
defaultMessage: 'Retention',
119119
})}

x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_management/wrapper.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useKibana } from '../../../hooks/use_kibana';
1616
import { useStreamDetail } from '../../../hooks/use_stream_detail';
1717
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
1818
import { useStreamDocCountsFetch } from '../../../hooks/use_streams_doc_counts_fetch';
19+
import { useTimeRange } from '../../../hooks/use_time_range';
1920
import { calculateDataQuality } from '../../../util/calculate_data_quality';
2021
import { FeedbackButton } from '../../feedback_button';
2122
import {
@@ -48,6 +49,7 @@ export function Wrapper({
4849
const { definition } = useStreamDetail();
4950
const { services } = useKibana();
5051
const { getStepPropsByStepId } = useStreamsTour();
52+
const { rangeFrom, rangeTo } = useTimeRange();
5153

5254
const lastTrackedRef = useRef<string | null>(null);
5355

@@ -82,6 +84,7 @@ export function Wrapper({
8284
{
8385
href: router.link('/{key}/management/{tab}', {
8486
path: { key: streamId, tab: tabName },
87+
query: { rangeFrom, rangeTo },
8588
}),
8689
label: currentTab.label,
8790
content: currentTab.content,

x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_routing/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export function StreamDetailRoutingImpl() {
137137
http: core.http,
138138
navigateToUrl: core.application.navigateToUrl,
139139
openConfirm: core.overlays.openConfirm,
140+
shouldPromptOnReplace: false,
140141
});
141142

142143
const availableStreams = streamsListFetch.value?.streams.map((stream) => stream.name) ?? [];
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { useLayoutEffect, useEffect, useCallback } from 'react';
9+
import { useHistory, useLocation } from 'react-router-dom';
10+
import { UI_SETTINGS } from '@kbn/data-plugin/common';
11+
import { useKibana } from '../../hooks/use_kibana';
12+
13+
interface TimePickerTimeDefaults {
14+
from: string;
15+
to: string;
16+
}
17+
18+
/**
19+
* Hook to check if time range params are set and provide a redirect function.
20+
*/
21+
function useDateRangeRedirect() {
22+
const history = useHistory();
23+
const location = useLocation();
24+
const {
25+
core: { uiSettings },
26+
dependencies: {
27+
start: {
28+
data: { query: queryService },
29+
},
30+
},
31+
} = useKibana();
32+
33+
// Parse URL params synchronously during render
34+
const searchParams = new URLSearchParams(location.search);
35+
const isDateRangeSet = searchParams.has('rangeFrom') && searchParams.has('rangeTo');
36+
const rangeFrom = searchParams.get('rangeFrom');
37+
const rangeTo = searchParams.get('rangeTo');
38+
39+
const redirect = useCallback(() => {
40+
const timePickerTimeDefaults = uiSettings.get<TimePickerTimeDefaults>(
41+
UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS
42+
);
43+
const timePickerSharedState = queryService.timefilter.timefilter.getTime();
44+
45+
// Set defaults, preserving any existing params
46+
const nextParams = new URLSearchParams(location.search);
47+
nextParams.set('rangeFrom', timePickerSharedState.from ?? timePickerTimeDefaults.from);
48+
nextParams.set('rangeTo', timePickerSharedState.to ?? timePickerTimeDefaults.to);
49+
50+
history.replace({
51+
...location,
52+
search: nextParams.toString(),
53+
});
54+
}, [history, location, queryService, uiSettings]);
55+
56+
return { isDateRangeSet, rangeFrom, rangeTo, redirect, queryService };
57+
}
58+
59+
/**
60+
* Component that ensures time range params (rangeFrom/rangeTo) are present in the URL.
61+
* If they are missing, it blocks rendering and redirects to add default values.
62+
*
63+
* When adding defaults, it reads from the global timefilter first (which retains the
64+
* last known time within the session), falling back to Kibana's default time settings.
65+
* This allows navigation links that don't explicitly pass rangeFrom/rangeTo to still
66+
* preserve the time range within a session.
67+
*
68+
* Also syncs URL time params to the global timefilter on mount and URL changes.
69+
* This ensures components using useTimefilter() get the correct time from URL.
70+
*
71+
* TODO: Ideally all router.link/push calls should pass time params explicitly.
72+
* See the route definitions in routes/config.tsx for the expected query params.
73+
*/
74+
export function DateRangeRedirect({ children }: { children: React.ReactNode }) {
75+
const { isDateRangeSet, rangeFrom, rangeTo, redirect, queryService } = useDateRangeRedirect();
76+
77+
// Use useLayoutEffect to redirect before paint, avoiding the
78+
// "Cannot update a component while rendering" warning
79+
useLayoutEffect(() => {
80+
if (!isDateRangeSet) {
81+
redirect();
82+
}
83+
}, [isDateRangeSet, redirect]);
84+
85+
useEffect(() => {
86+
if (rangeFrom && rangeTo) {
87+
const currentTime = queryService.timefilter.timefilter.getTime();
88+
if (currentTime.from !== rangeFrom || currentTime.to !== rangeTo) {
89+
queryService.timefilter.timefilter.setTime({
90+
from: rangeFrom,
91+
to: rangeTo,
92+
});
93+
}
94+
}
95+
}, [rangeFrom, rangeTo, queryService]);
96+
97+
// Block rendering until time params are set
98+
if (!isDateRangeSet) {
99+
return null;
100+
}
101+
102+
return <>{children}</>;
103+
}

x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_significant_events_view/index.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { EditSignificantEventFlyout } from './edit_significant_event_flyout';
1717
import { useFetchSignificantEvents } from '../../hooks/use_fetch_significant_events';
1818
import { useSignificantEventsApi } from '../../hooks/use_significant_events_api';
1919
import { useTimefilter } from '../../hooks/use_timefilter';
20+
import { useTimeRange } from '../../hooks/use_time_range';
21+
import { useTimeRangeUpdate } from '../../hooks/use_time_range_update';
2022
import { LoadingPanel } from '../loading_panel';
2123
import type { Flow } from './add_significant_event_flyout/types';
2224
import { SignificantEventsTable } from './significant_events_table';
@@ -34,7 +36,9 @@ interface Props {
3436
}
3537

3638
export function StreamDetailSignificantEventsView({ definition }: Props) {
37-
const { timeState, setTime, refresh } = useTimefilter();
39+
const { rangeFrom, rangeTo, startMs, endMs } = useTimeRange();
40+
const { updateTimeRange } = useTimeRangeUpdate();
41+
const { refresh } = useTimefilter();
3842
const {
3943
dependencies: {
4044
start: { unifiedSearch },
@@ -44,8 +48,8 @@ export function StreamDetailSignificantEventsView({ definition }: Props) {
4448
const aiFeatures = useAIFeatures();
4549

4650
const xFormatter = useMemo(() => {
47-
return niceTimeFormatter([timeState.start, timeState.end]);
48-
}, [timeState.start, timeState.end]);
51+
return niceTimeFormatter([startMs, endMs]);
52+
}, [startMs, endMs]);
4953

5054
const { systems, refreshSystems, systemsLoading } = useStreamSystems(definition.stream.name);
5155
const [query, setQuery] = useState<string>('');
@@ -60,7 +64,7 @@ export function StreamDetailSignificantEventsView({ definition }: Props) {
6064

6165
const [selectedSystems, setSelectedSystems] = useState<System[]>([]);
6266
const [queryToEdit, setQueryToEdit] = useState<StreamQueryKql | undefined>();
63-
const [dateRange, setDateRange] = useState<TimeRange>(timeState.timeRange);
67+
const [dateRange, setDateRange] = useState<TimeRange>({ from: rangeFrom, to: rangeTo });
6468

6569
useEffect(() => {
6670
const urlParams = new URLSearchParams(window.location.search);
@@ -158,8 +162,8 @@ export function StreamDetailSignificantEventsView({ definition }: Props) {
158162

159163
if (isEqual(queryN.dateRange, dateRange)) {
160164
refresh();
161-
} else {
162-
setTime(queryN.dateRange);
165+
} else if (queryN.dateRange) {
166+
updateTimeRange(queryN.dateRange);
163167
setDateRange(queryN.dateRange);
164168
}
165169
}}

x-pack/platform/plugins/shared/streams_app/public/components/stream_detail_systems/stream_systems/stream_existing_systems_table.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,9 @@ import { EuiButtonIcon, EuiScreenReaderOnly } from '@elastic/eui';
2020
import type { Streams, System } from '@kbn/streams-schema';
2121
import { i18n } from '@kbn/i18n';
2222
import type { AIFeatures } from '../../../hooks/use_ai_features';
23-
import {
24-
OPEN_SIGNIFICANT_EVENTS_FLYOUT_URL_PARAM,
25-
SELECTED_SYSTEMS_URL_PARAM,
26-
} from '../../../constants';
2723
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
2824
import { useStreamSystemsApi } from '../../../hooks/use_stream_systems_api';
25+
import { useTimeRange } from '../../../hooks/use_time_range';
2926
import { StreamSystemDetailsFlyout } from './stream_system_details_flyout';
3027
import { TableTitle } from './table_title';
3128
import { useStreamSystemsTable } from './hooks/use_stream_systems_table';
@@ -44,6 +41,7 @@ export function StreamExistingSystemsTable({
4441
aiFeatures: AIFeatures | null;
4542
}) {
4643
const router = useStreamsAppRouter();
44+
const { rangeFrom, rangeTo } = useTimeRange();
4745

4846
const [selectedSystem, setSelectedSystem] = useState<System>();
4947
const [selectedSystems, setSelectedSystems] = useState<System[]>([]);
@@ -59,8 +57,10 @@ export function StreamExistingSystemsTable({
5957
router.push('/{key}/management/{tab}', {
6058
path: { key: definition.name, tab: 'significantEvents' },
6159
query: {
62-
[OPEN_SIGNIFICANT_EVENTS_FLYOUT_URL_PARAM]: 'true',
63-
[SELECTED_SYSTEMS_URL_PARAM]: significantEventsSystems.map((s) => s.name).join(','),
60+
rangeFrom,
61+
rangeTo,
62+
openFlyout: 'true',
63+
selectedSystems: significantEventsSystems.map((s) => s.name).join(','),
6464
},
6565
});
6666
};

x-pack/platform/plugins/shared/streams_app/public/components/stream_list_view/classic_stream_creation_flyout.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { i18n } from '@kbn/i18n';
1717
import { useKibana } from '../../hooks/use_kibana';
1818
import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch';
1919
import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
20+
import { useTimeRange } from '../../hooks/use_time_range';
2021

2122
interface ClassicStreamCreationFlyoutProps {
2223
onClose: () => void;
@@ -37,6 +38,7 @@ export function ClassicStreamCreationFlyout({ onClose }: ClassicStreamCreationFl
3738
} = useKibana();
3839

3940
const router = useStreamsAppRouter();
41+
const { rangeFrom, rangeTo } = useTimeRange();
4042
const isIlmAvailable = !!indexLifecycleManagement?.apiService;
4143

4244
const templatesListFetch = useStreamsAppFetch(async () => {
@@ -108,7 +110,7 @@ export function ClassicStreamCreationFlyout({ onClose }: ClassicStreamCreationFl
108110

109111
router.push('/{key}/management/{tab}', {
110112
path: { key: streamName, tab: 'retention' },
111-
query: {},
113+
query: { rangeFrom, rangeTo },
112114
});
113115

114116
onClose();
@@ -124,7 +126,15 @@ export function ClassicStreamCreationFlyout({ onClose }: ClassicStreamCreationFl
124126
});
125127
}
126128
},
127-
[streamsRepositoryClient, signal, core.notifications.toasts, router, onClose]
129+
[
130+
streamsRepositoryClient,
131+
signal,
132+
core.notifications.toasts,
133+
router,
134+
onClose,
135+
rangeFrom,
136+
rangeTo,
137+
]
128138
);
129139

130140
const handleValidate = useCallback(

x-pack/platform/plugins/shared/streams_app/public/components/stream_list_view/tree_table.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { DataQualityColumn } from './data_quality_column';
4040
import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
4141
import { useStreamDocCountsFetch } from '../../hooks/use_streams_doc_counts_fetch';
4242
import { useTimefilter } from '../../hooks/use_timefilter';
43+
import { useTimeRange } from '../../hooks/use_time_range';
4344
import { RetentionColumn } from './retention_column';
4445
import { calculateDataQuality } from '../../util/calculate_data_quality';
4546
import {
@@ -73,6 +74,7 @@ export function StreamsTreeTable({
7374
loading?: boolean;
7475
}) {
7576
const router = useStreamsAppRouter();
77+
const { rangeFrom, rangeTo } = useTimeRange();
7678
const { euiTheme } = useEuiTheme();
7779
const { timeState } = useTimefilter();
7880
const { getStepPropsByStepId } = useStreamsTour();
@@ -396,7 +398,17 @@ export function StreamsTreeTable({
396398
<EuiFlexItem grow={false}>
397399
<EuiLink
398400
data-test-subj={`streamsNameLink-${item.stream.name}`}
399-
href={router.link('/{key}', { path: { key: item.stream.name } })}
401+
href={router.link('/{key}', {
402+
path: { key: item.stream.name },
403+
query: { rangeFrom, rangeTo },
404+
})}
405+
onClick={(e: React.MouseEvent) => {
406+
e.preventDefault();
407+
router.push('/{key}', {
408+
path: { key: item.stream.name },
409+
query: { rangeFrom, rangeTo },
410+
});
411+
}}
400412
>
401413
<EuiHighlight search={searchQuery?.text ?? ''}>{item.stream.name}</EuiHighlight>
402414
</EuiLink>

0 commit comments

Comments
 (0)