Add global time range selector

This commit is contained in:
Aleksandar Petrov
2025-12-04 17:13:14 -04:00
parent b8e86bb46f
commit 270415c5bd
6 changed files with 239 additions and 48 deletions
@@ -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<HTMLInputElement>(null);
// Time range state - read from Redux
const [timeZone] = useState<TimeZone>('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 <span className={styles.saveStatus}>Saving...</span>;
return <span className={styles.saveStatus}>{t('explore-map.toolbar.saving', 'Saving...')}</span>;
}
if (lastSaved) {
const secondsAgo = Math.floor((Date.now() - lastSaved.getTime()) / 1000);
if (secondsAgo < 5) {
return <span className={styles.saveStatus}>Saved</span>;
return <span className={styles.saveStatus}>{t('explore-map.toolbar.saved', 'Saved')}</span>;
}
}
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}
/>
) : (
<div className={styles.titleDisplay} onClick={handleTitleClick}>
<h2 className={styles.title}>{mapTitle || 'Untitled Map'}</h2>
<div
className={styles.titleDisplay}
onClick={handleTitleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleTitleClick();
}
}}
role="button"
tabIndex={0}
>
<h2 className={styles.title}>{mapTitle || t('explore-map.toolbar.untitled', 'Untitled Map')}</h2>
<span className="fa fa-pencil" />
</div>
)
@@ -191,24 +214,53 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) {
<UsersIndicator users={activeUsers} limit={5} />
</div>
)}
<ButtonGroup>
<ToolbarButton
icon="save"
onClick={handleExport}
tooltip={t('explore-map.toolbar.export', 'Export canvas')}
/>
<ToolbarButton
icon="upload"
onClick={handleImport}
tooltip={t('explore-map.toolbar.import', 'Import canvas')}
/>
<ToolbarButton
icon="trash-alt"
onClick={handleResetCanvas}
tooltip={t('explore-map.toolbar.clear', 'Clear all panels')}
variant="destructive"
/>
</ButtonGroup>
<div className={styles.timePickerContainer}>
<div className={styles.timePickerWrapper}>
<TimeRangePicker
value={timeRange}
onChange={handleTimeRangeChange}
onChangeTimeZone={() => {}}
timeZone={timeZone}
onMoveBackward={() => {}}
onMoveForward={() => {}}
onZoom={() => {}}
hideText={false}
/>
</div>
<Button
size="sm"
variant="secondary"
onClick={handleApplyTimeRangeToAll}
tooltip={t('explore-map.toolbar.apply-time-to-all', 'Apply this time range to all panels')}
>
{t('explore-map.toolbar.apply-to-all', 'Apply')}
</Button>
</div>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('explore-map.toolbar.export', 'Export canvas')}
icon="save"
onClick={handleExport}
/>
<Menu.Item
label={t('explore-map.toolbar.import', 'Import canvas')}
icon="upload"
onClick={handleImport}
/>
<Menu.Divider />
<Menu.Item
label={t('explore-map.toolbar.clear', 'Clear all panels')}
icon="trash-alt"
onClick={handleResetCanvas}
destructive
/>
</Menu>
}
>
<ToolbarButton icon="ellipsis-v" tooltip={t('explore-map.toolbar.more-actions', 'More actions')} />
</Dropdown>
</div>
</div>
@@ -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
},
}),
};
};
+12 -8
View File
@@ -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++;
}
@@ -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
};
}
@@ -152,6 +152,7 @@ export function createUpdatePanelExploreStateOperation(
payload: {
panelId: string;
exploreState: SerializedExploreState | undefined;
forceReload?: boolean,
}
): UpdatePanelExploreStateOperation {
return {
@@ -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,
@@ -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);