Add global time range selector
This commit is contained in:
@@ -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
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user