From 270415c5bd750e88c3d1daf5e420117e807f6c3f Mon Sep 17 00:00:00 2001 From: Aleksandar Petrov <8142643+aleks-p@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:13:14 -0400 Subject: [PATCH] Add global time range selector --- .../components/ExploreMapToolbar.tsx | 127 +++++++++++++---- public/app/features/explore-map/crdt/state.ts | 20 +-- public/app/features/explore-map/crdt/types.ts | 2 + .../explore-map/operations/creators.ts | 1 + .../features/explore-map/state/crdtSlice.ts | 132 ++++++++++++++++-- .../features/explore-map/state/selectors.ts | 5 + 6 files changed, 239 insertions(+), 48 deletions(-) diff --git a/public/app/features/explore-map/components/ExploreMapToolbar.tsx b/public/app/features/explore-map/components/ExploreMapToolbar.tsx index 5494dcf3fea..36013b84f25 100644 --- a/public/app/features/explore-map/components/ExploreMapToolbar.tsx +++ b/public/app/features/explore-map/components/ExploreMapToolbar.tsx @@ -1,14 +1,14 @@ import { css } from '@emotion/css'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { Button, ButtonGroup, ConfirmModal, Input, ToolbarButton, UsersIndicator, useStyles2 } from '@grafana/ui'; +import { Button, ButtonGroup, ConfirmModal, Dropdown, Input, Menu, ToolbarButton, UsersIndicator, useStyles2, TimeRangePicker } from '@grafana/ui'; import { useDispatch, useSelector } from 'app/types/store'; import { useTransformContext } from '../context/TransformContext'; import { useCanvasPersistence } from '../hooks/useCanvasPersistence'; -import { updateMapTitle } from '../state/crdtSlice'; +import { updateMapTitle, updateGlobalTimeRange, updateAllPanelsTimeRange } from '../state/crdtSlice'; import { selectPanelCount, selectViewport, selectMapTitle, selectActiveUsers } from '../state/selectors'; interface ExploreMapToolbarProps { @@ -25,10 +25,14 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) { const [titleValue, setTitleValue] = useState(''); const titleInputRef = useRef(null); + // Time range state - read from Redux + const [timeZone] = useState('browser'); + const panelCount = useSelector((state) => selectPanelCount(state.exploreMapCRDT)); const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT)); const mapTitle = useSelector((state) => selectMapTitle(state.exploreMapCRDT)); const activeUsers = useSelector((state) => selectActiveUsers(state.exploreMapCRDT)); + const timeRange = useSelector((state) => state.exploreMapCRDT.local.globalTimeRange); useEffect(() => { if (mapTitle) { @@ -52,7 +56,7 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) { // dispatch(resetCanvas()); console.warn('Reset canvas not yet implemented for CRDT state'); setShowResetConfirm(false); - }, [dispatch]); + }, []); const handleZoomIn = useCallback(() => { if (transformRef?.current) { @@ -115,17 +119,25 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) { [handleTitleBlur, mapTitle] ); + const handleTimeRangeChange = useCallback((newTimeRange: TimeRange) => { + dispatch(updateGlobalTimeRange({ timeRange: newTimeRange })); + }, [dispatch]); + + const handleApplyTimeRangeToAll = useCallback(() => { + dispatch(updateAllPanelsTimeRange({ timeRange })); + }, [dispatch, timeRange]); + const getSaveStatus = () => { if (!uid) { return null; // No status in localStorage mode } if (saving) { - return Saving...; + return {t('explore-map.toolbar.saving', 'Saving...')}; } if (lastSaved) { const secondsAgo = Math.floor((Date.now() - lastSaved.getTime()) / 1000); if (secondsAgo < 5) { - return Saved; + return {t('explore-map.toolbar.saved', 'Saved')}; } } return null; @@ -141,7 +153,7 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) { variant="secondary" size="sm" onClick={() => (window.location.href = '/atlas')} - tooltip="Back to maps list" + tooltip={t('explore-map.toolbar.back', 'Back to maps list')} fill="text" /> )} @@ -156,8 +168,19 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) { className={styles.titleInput} /> ) : ( -
-

{mapTitle || 'Untitled Map'}

+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleTitleClick(); + } + }} + role="button" + tabIndex={0} + > +

{mapTitle || t('explore-map.toolbar.untitled', 'Untitled Map')}

) @@ -191,24 +214,53 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) {
)} - - - - - +
+
+ {}} + timeZone={timeZone} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + hideText={false} + /> +
+ +
+ + + + + + + } + > + + @@ -256,7 +308,9 @@ const getStyles = (theme: GrafanaTheme2) => { padding: theme.spacing(0.5, 1), cursor: 'pointer', borderRadius: theme.shape.radius.default, - transition: 'background-color 0.2s', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'background-color 0.2s', + }, '&:hover': { backgroundColor: theme.colors.background.primary, '& .fa-pencil': { @@ -287,5 +341,22 @@ const getStyles = (theme: GrafanaTheme2) => { alignItems: 'center', marginRight: theme.spacing(2), }), + timePickerContainer: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), + timePickerWrapper: css({ + // Hide the backward, forward, and zoom buttons + '& > div > button:first-child': { + display: 'none', // Hide backward button + }, + '& > div > button:nth-last-child(2)': { + display: 'none', // Hide forward button + }, + '& > div > button:last-child': { + display: 'none', // Hide zoom button + }, + }), }; }; diff --git a/public/app/features/explore-map/crdt/state.ts b/public/app/features/explore-map/crdt/state.ts index e2581e6f19e..b9dd3385b16 100644 --- a/public/app/features/explore-map/crdt/state.ts +++ b/public/app/features/explore-map/crdt/state.ts @@ -389,7 +389,8 @@ export class CRDTStateManager { */ createUpdatePanelExploreStateOperation( panelId: string, - exploreState: SerializedExploreState | undefined + exploreState: SerializedExploreState | undefined, + forceReload?: boolean, ): UpdatePanelExploreStateOperation | null { if (!this.state.panels.contains(panelId)) { return null; @@ -405,6 +406,7 @@ export class CRDTStateManager { payload: { panelId, exploreState, + forceReload, }, }; } @@ -414,7 +416,8 @@ export class CRDTStateManager { */ createUpdatePanelIframeUrlOperation( panelId: string, - iframeUrl: string | undefined + iframeUrl: string | undefined, + forceReload?: boolean ): UpdatePanelIframeUrlOperation | null { if (!this.state.panels.contains(panelId)) { return null; @@ -430,6 +433,7 @@ export class CRDTStateManager { payload: { panelId, iframeUrl, + forceReload, }, }; } @@ -1207,7 +1211,7 @@ export class CRDTStateManager { } private applyUpdatePanelExploreState(operation: UpdatePanelExploreStateOperation): OperationResult { - const { panelId, exploreState } = operation.payload; + const { panelId, exploreState, forceReload } = operation.payload; const panelData = this.state.panelData.get(panelId); if (!panelData) { @@ -1216,8 +1220,8 @@ export class CRDTStateManager { const updated = panelData.exploreState.set(exploreState, operation.timestamp); - // Increment remoteVersion only for remote operations - if (updated && operation.nodeId !== this.nodeId) { + // Increment remoteVersion for remote operations or when forceReload is true + if (updated && (operation.nodeId !== this.nodeId || forceReload)) { panelData.remoteVersion++; } @@ -1228,7 +1232,7 @@ export class CRDTStateManager { } private applyUpdatePanelIframeUrl(operation: UpdatePanelIframeUrlOperation): OperationResult { - const { panelId, iframeUrl } = operation.payload; + const { panelId, iframeUrl, forceReload } = operation.payload; const panelData = this.state.panelData.get(panelId); if (!panelData) { @@ -1237,8 +1241,8 @@ export class CRDTStateManager { const updated = panelData.iframeUrl.set(iframeUrl, operation.timestamp); - // Increment remoteVersion only for remote operations - if (updated && operation.nodeId !== this.nodeId) { + // Increment remoteVersion for remote operations or when forceReload is true + if (updated && (operation.nodeId !== this.nodeId || forceReload)) { panelData.remoteVersion++; } diff --git a/public/app/features/explore-map/crdt/types.ts b/public/app/features/explore-map/crdt/types.ts index 7ac4efcfecb..b378414a363 100644 --- a/public/app/features/explore-map/crdt/types.ts +++ b/public/app/features/explore-map/crdt/types.ts @@ -354,6 +354,7 @@ export interface UpdatePanelExploreStateOperation extends CRDTOperationBase { payload: { panelId: string; exploreState: SerializedExploreState | undefined; + forceReload?: boolean; // Force panel reload even for local operations }; } @@ -365,6 +366,7 @@ export interface UpdatePanelIframeUrlOperation extends CRDTOperationBase { payload: { panelId: string; iframeUrl: string | undefined; + forceReload?: boolean; // Force panel reload even for local operations }; } diff --git a/public/app/features/explore-map/operations/creators.ts b/public/app/features/explore-map/operations/creators.ts index 9648723122f..b86a133bc8b 100644 --- a/public/app/features/explore-map/operations/creators.ts +++ b/public/app/features/explore-map/operations/creators.ts @@ -152,6 +152,7 @@ export function createUpdatePanelExploreStateOperation( payload: { panelId: string; exploreState: SerializedExploreState | undefined; + forceReload?: boolean, } ): UpdatePanelExploreStateOperation { return { diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts index bde91a1058c..624d8c3b103 100644 --- a/public/app/features/explore-map/state/crdtSlice.ts +++ b/public/app/features/explore-map/state/crdtSlice.ts @@ -8,7 +8,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { v4 as uuidv4 } from 'uuid'; -import { TimeRange } from '@grafana/data'; +import { TimeRange, dateTime } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { generateExploreId } from 'app/core/utils/explore'; @@ -44,6 +44,7 @@ export interface ExploreMapCRDTState { cursorMode: 'pointer' | 'hand'; isOnline: boolean; isSyncing: boolean; + globalTimeRange: TimeRange; activeDrag?: { draggedPanelId: string; deltaX: number; @@ -94,6 +95,11 @@ export function createInitialCRDTState(mapUid?: string): ExploreMapCRDTState { cursorMode: 'pointer', isOnline: false, isSyncing: false, + globalTimeRange: { + from: dateTime().subtract(1, 'hour'), + to: dateTime(), + raw: { from: 'now-1h', to: 'now' }, + }, activeDrag: undefined, activeFrameDrag: undefined, clipboard: undefined, @@ -277,8 +283,8 @@ const crdtSlice = createSlice({ const panelId = uuidv4(); const exploreId = generateExploreId(); - // Build initial explore state with default time range - // Always set a default time range (last 1 hour) to ensure panels work correctly + // Build initial explore state with the global time range + // Use the global time range from state so new panels inherit the current time range // Note: We store strings instead of DateTime objects because they serialize properly through CRDT. // The receiver (useExploreStateReceiver) will extract the raw values and pass them to updateTime. @@ -294,15 +300,9 @@ const crdtSlice = createSlice({ ] as unknown as DataQuery[]) : []; - // Create a time range that will serialize properly through CRDT. - // We use strings for from/to instead of DateTime objects, which the receiver handles correctly. - // Type assertion is necessary here because we're intentionally using strings for serialization. - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const timeRange = { - from: 'now-1h', - to: 'now', - raw: { from: 'now-1h', to: 'now' }, - } as unknown as TimeRange; + // Use the global time range from state + // We pass the entire timeRange object to preserve both the DateTime objects and raw values + const timeRange = state.local.globalTimeRange; const initialExploreState: SerializedExploreState = { queries, @@ -715,6 +715,112 @@ const crdtSlice = createSlice({ state.pendingOperations.push(operation); }, + /** + * Update the global time range + */ + updateGlobalTimeRange: (state, action: PayloadAction<{ timeRange: TimeRange }>) => { + state.local.globalTimeRange = action.payload.timeRange; + }, + + /** + * Update time range for all panels + */ + updateAllPanelsTimeRange: (state, action: PayloadAction<{ timeRange: TimeRange }>) => { + // Update the global time range + state.local.globalTimeRange = action.payload.timeRange; + + const manager = getCRDTManager(state); + const panelIds = manager.getPanelIds(); + const operations: CRDTOperation[] = []; + + // Helper function to update time range in URL parameters + const updateUrlTimeRange = (url: string, timeRange: TimeRange): string => { + try { + const urlObj = new URL(url); + + // Convert time range to appropriate format + // If raw values are strings (relative time like "now-1h"), use them directly + // If raw values are Moment objects (absolute time), convert to Unix timestamps in milliseconds + let fromValue: string; + let toValue: string; + + if (typeof timeRange.raw.from === 'string') { + fromValue = timeRange.raw.from; + } else { + // Absolute time - convert to Unix timestamp in milliseconds + fromValue = timeRange.from.valueOf().toString(); + } + + if (typeof timeRange.raw.to === 'string') { + toValue = timeRange.raw.to; + } else { + // Absolute time - convert to Unix timestamp in milliseconds + toValue = timeRange.to.valueOf().toString(); + } + + // Update all from/to parameters (including from-2, from-3, to-2, to-3, etc.) + urlObj.searchParams.forEach((value, key) => { + if (key.startsWith('from')) { + urlObj.searchParams.set(key, fromValue); + } else if (key.startsWith('to')) { + urlObj.searchParams.set(key, toValue); + } + }); + + return urlObj.toString(); + } catch (e) { + console.warn('[updateAllPanelsTimeRange] Failed to parse URL:', url, e); + return url; + } + }; + + for (const panelId of panelIds) { + const panel = manager.getPanelForUI(panelId); + + if (panel) { + // For explore panels, update the explore state + if (panel.mode === 'explore' && panel.exploreState) { + // Pass the timeRange object directly to preserve relative times + const updatedExploreState = { + ...panel.exploreState, + range: action.payload.timeRange, + }; + + // Pass forceReload: true to ensure panels reload even for local operations + const operation = manager.createUpdatePanelExploreStateOperation(panelId, updatedExploreState, true); + if (operation) { + operations.push(operation); + } + } + // For drilldown panels (iframe-based), update the iframe URL + else if (panel.iframeUrl && ( + panel.mode === 'traces-drilldown' || + panel.mode === 'metrics-drilldown' || + panel.mode === 'profiles-drilldown' || + panel.mode === 'logs-drilldown' + )) { + const updatedUrl = updateUrlTimeRange(panel.iframeUrl, action.payload.timeRange); + + // Pass forceReload: true to ensure iframe panels reload even when URL is the same + const operation = manager.createUpdatePanelIframeUrlOperation(panelId, updatedUrl, true); + if (operation) { + operations.push(operation); + } + } + } + } + + // Apply all operations + for (const operation of operations) { + manager.applyOperation(operation); + } + + saveCRDTManager(state, manager); + + // Push all operations for broadcast + state.pendingOperations.push(...operations); + }, + /** * Associate post-it note with frame */ @@ -1365,6 +1471,8 @@ export const { updatePostItNoteColor, associatePostItWithFrame, disassociatePostItFromFrame, + updateGlobalTimeRange, + updateAllPanelsTimeRange, duplicatePanel, addFrame, removeFrame, diff --git a/public/app/features/explore-map/state/selectors.ts b/public/app/features/explore-map/state/selectors.ts index 21638ebadef..266fda68733 100644 --- a/public/app/features/explore-map/state/selectors.ts +++ b/public/app/features/explore-map/state/selectors.ts @@ -7,6 +7,8 @@ import { createSelector } from '@reduxjs/toolkit'; +import { dateTime } from '@grafana/data'; + import { CRDTStateManager } from '../crdt/state'; import { ExploreMapCRDTState } from './crdtSlice'; @@ -69,6 +71,7 @@ export const selectPanels = createSelector( cursorMode: 'pointer', isOnline: false, isSyncing: false, + globalTimeRange: { from: dateTime().subtract(1, 'hour'), to: dateTime(), raw: { from: 'now-1h', to: 'now' } }, }, }; const manager = getCRDTManager(state); @@ -161,6 +164,7 @@ export const selectFrames = createSelector( cursorMode: 'pointer', isOnline: false, isSyncing: false, + globalTimeRange: { from: dateTime().subtract(1, 'hour'), to: dateTime(), raw: { from: 'now-1h', to: 'now' } }, }, }; const manager = getCRDTManager(state); @@ -290,6 +294,7 @@ export const selectPostItNotes = createSelector( cursorMode: 'pointer', isOnline: false, isSyncing: false, + globalTimeRange: { from: dateTime().subtract(1, 'hour'), to: dateTime(), raw: { from: 'now-1h', to: 'now' } }, }, }; const manager = getCRDTManager(state);