diff --git a/public/app/features/explore-map/components/ExploreMapPanelContent.tsx b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx index 5ae9427f26d..d9134f5504f 100644 --- a/public/app/features/explore-map/components/ExploreMapPanelContent.tsx +++ b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useState } from 'react'; import { EventBusSrv, GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; import { useDispatch, useSelector } from 'app/types/store'; @@ -15,6 +16,7 @@ interface ExploreMapPanelContentProps { height: number; } + export function ExploreMapPanelContent({ exploreId }: ExploreMapPanelContentProps) { const styles = useStyles2(getStyles); const dispatch = useDispatch(); @@ -26,17 +28,25 @@ export function ExploreMapPanelContent({ exploreId }: ExploreMapPanelContentProp // Check if the explore pane exists in Redux const explorePane = useSelector((state) => state.explore?.panes?.[exploreId]); + // Find the panel with this exploreId to get saved state + const panel = useSelector((state) => + Object.values(state.exploreMap.panels).find((p) => p.exploreId === exploreId) + ); + // Initialize Explore pane on mount useEffect(() => { const initializePane = async () => { + // Use saved state if available, otherwise defaults + const savedState = panel?.exploreState; + await dispatch( initializeExplore({ exploreId, - datasource: undefined, - queries: [], - range: DEFAULT_RANGE, + datasource: savedState?.datasourceUid, + queries: savedState?.queries || [], + range: savedState?.range || DEFAULT_RANGE, eventBridge: eventBus, - compact: false, + compact: savedState?.compact || false, }) ); setIsInitialized(true); @@ -48,13 +58,15 @@ export function ExploreMapPanelContent({ exploreId }: ExploreMapPanelContentProp return () => { eventBus.removeAllListeners(); }; - }, [dispatch, exploreId, eventBus]); + }, [dispatch, exploreId, eventBus, panel?.exploreState]); // Wait for Redux state to be initialized if (!isInitialized || !explorePane) { return (
-
Initializing Explore...
+
+ Initializing Explore... +
); } diff --git a/public/app/features/explore-map/hooks/useCanvasPersistence.ts b/public/app/features/explore-map/hooks/useCanvasPersistence.ts index d061a257079..8330d7ee2a9 100644 --- a/public/app/features/explore-map/hooks/useCanvasPersistence.ts +++ b/public/app/features/explore-map/hooks/useCanvasPersistence.ts @@ -1,43 +1,105 @@ import { useEffect } from 'react'; +import { store } from '@grafana/data'; import { useDispatch, useSelector } from 'app/types/store'; import { loadCanvas } from '../state/exploreMapSlice'; -import { ExploreMapState } from '../state/types'; +import { ExploreMapState, SerializedExploreState } from '../state/types'; const STORAGE_KEY = 'grafana.exploreMap.state'; export function useCanvasPersistence() { const dispatch = useDispatch(); const exploreMapState = useSelector((state) => state.exploreMap); + const exploreState = useSelector((state) => state.explore); - // Load state from localStorage on mount - // Note: Explore state is not persisted here - each panel will re-initialize - // its Explore instance when ExploreMapPanelContent mounts + // Load state from storage on mount useEffect(() => { try { - const savedState = localStorage.getItem(STORAGE_KEY); + const savedState = store.get(STORAGE_KEY); if (savedState) { const parsed: ExploreMapState = JSON.parse(savedState); dispatch(loadCanvas(parsed)); } } catch (error) { - console.error('Failed to load canvas state from localStorage:', error); + console.error('Failed to load canvas state from storage:', error); } }, [dispatch]); - // Save state to localStorage whenever it changes + // Save state to storage whenever it changes, including Explore state useEffect(() => { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(exploreMapState)); + // Enrich exploreMapState with current Explore state for each panel + const enrichedState: ExploreMapState = { + ...exploreMapState, + cursors: {}, // Don't persist cursor state - it's ephemeral + panels: Object.fromEntries( + Object.entries(exploreMapState.panels).map(([panelId, panel]) => { + const explorePane = exploreState.panes?.[panel.exploreId]; + + let exploreStateToSave: SerializedExploreState | undefined = undefined; + if (explorePane) { + exploreStateToSave = { + queries: explorePane.queries, + datasourceUid: explorePane.datasourceInstance?.uid, + range: explorePane.range, + refreshInterval: explorePane.refreshInterval, + panelsState: explorePane.panelsState, + compact: explorePane.compact, + }; + } + + return [ + panelId, + { + ...panel, + exploreState: exploreStateToSave, + }, + ]; + }) + ), + }; + + store.set(STORAGE_KEY, JSON.stringify(enrichedState)); } catch (error) { - console.error('Failed to save canvas state to localStorage:', error); + console.error('Failed to save canvas state to storage:', error); } - }, [exploreMapState]); + }, [exploreMapState, exploreState]); const exportCanvas = () => { try { - const dataStr = JSON.stringify(exploreMapState, null, 2); + // Enrich with Explore state before exporting + const enrichedState: ExploreMapState = { + ...exploreMapState, + cursors: {}, // Don't export cursor state - it's ephemeral + panels: Object.fromEntries( + Object.entries(exploreMapState.panels).map(([panelId, panel]) => { + const explorePane = exploreState.panes?.[panel.exploreId]; + + let exploreStateToSave: SerializedExploreState | undefined = undefined; + if (explorePane) { + exploreStateToSave = { + queries: explorePane.queries, + datasourceUid: explorePane.datasourceInstance?.uid, + range: explorePane.range, + refreshInterval: explorePane.refreshInterval, + panelsState: explorePane.panelsState, + compact: explorePane.compact, + }; + } + + return [ + panelId, + { + ...panel, + exploreState: exploreStateToSave, + }, + ]; + }) + ), + }; + + const dataStr = JSON.stringify(enrichedState, null, 2); const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); const exportFileDefaultName = `explore-map-${new Date().toISOString()}.json`; @@ -59,7 +121,10 @@ export function useCanvasPersistence() { input.accept = 'application/json'; input.onchange = (e: Event) => { - const file = (e.target as HTMLInputElement).files?.[0]; + if (!(e.target instanceof HTMLInputElement)) { + return; + } + const file = e.target.files?.[0]; if (!file) { return; } @@ -67,8 +132,11 @@ export function useCanvasPersistence() { const reader = new FileReader(); reader.onload = (event) => { try { - const content = event.target?.result as string; - const parsed: ExploreMapState = JSON.parse(content); + const result = event.target?.result; + if (typeof result !== 'string') { + throw new Error('Invalid file content'); + } + const parsed: ExploreMapState = JSON.parse(result); dispatch(loadCanvas(parsed)); alert('Canvas imported successfully!'); } catch (error) { diff --git a/public/app/features/explore-map/hooks/useMockCursors.ts b/public/app/features/explore-map/hooks/useMockCursors.ts index 9569e261dd0..eee33f4437c 100644 --- a/public/app/features/explore-map/hooks/useMockCursors.ts +++ b/public/app/features/explore-map/hooks/useMockCursors.ts @@ -16,11 +16,13 @@ interface MockUser { pauseUntil: number; } -const MOCK_USERS: Array> = [ +const ENABLE_MOCK_USERS = false; + +let MOCK_USERS: Array> = ENABLE_MOCK_USERS ? [ { userId: 'mock-1', userName: 'Christian', color: '#FF6B6B', speed: 8 }, { userId: 'mock-2', userName: 'Ryan', color: '#4ECDC4', speed: 6 }, { userId: 'mock-3', userName: 'Marc', color: '#45B7D1', speed: 10 }, -]; +] : []; // Constrain cursors to upper left area of canvas const MOVEMENT_AREA = { diff --git a/public/app/features/explore-map/state/exploreMapSlice.ts b/public/app/features/explore-map/state/exploreMapSlice.ts index 52619213d08..4707427f64e 100644 --- a/public/app/features/explore-map/state/exploreMapSlice.ts +++ b/public/app/features/explore-map/state/exploreMapSlice.ts @@ -3,7 +3,14 @@ import { v4 as uuidv4 } from 'uuid'; import { generateExploreId } from 'app/core/utils/explore'; -import { CanvasViewport, ExploreMapState, initialExploreMapState, PanelPosition, UserCursor } from './types'; +import { + CanvasViewport, + ExploreMapState, + initialExploreMapState, + PanelPosition, + SerializedExploreState, + UserCursor, +} from './types'; const exploreMapSlice = createSlice({ name: 'exploreMap', @@ -90,12 +97,23 @@ const exploreMapSlice = createSlice({ y: sourcePanel.position.y + 30, zIndex: state.nextZIndex, }, + exploreState: sourcePanel.exploreState, }; state.nextZIndex++; state.selectedPanelId = newPanelId; } }, + savePanelExploreState: ( + state, + action: PayloadAction<{ panelId: string; exploreState: SerializedExploreState }> + ) => { + const panel = state.panels[action.payload.panelId]; + if (panel) { + panel.exploreState = action.payload.exploreState; + } + }, + loadCanvas: (state, action: PayloadAction) => { return action.payload; }, @@ -119,6 +137,7 @@ export const { updateViewport, resetCanvas, duplicatePanel, + savePanelExploreState, loadCanvas, updateCursor, removeCursor, diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts index d677ec1ce00..5809afabf1f 100644 --- a/public/app/features/explore-map/state/types.ts +++ b/public/app/features/explore-map/state/types.ts @@ -1,3 +1,5 @@ +import { DataQuery, TimeRange, ExplorePanelsState } from '@grafana/data'; + export interface PanelPosition { x: number; y: number; @@ -6,10 +8,20 @@ export interface PanelPosition { zIndex: number; } +export interface SerializedExploreState { + queries: DataQuery[]; + datasourceUid?: string; + range: TimeRange; + refreshInterval?: string; + panelsState?: ExplorePanelsState; + compact?: boolean; +} + export interface ExploreMapPanel { id: string; exploreId: string; position: PanelPosition; + exploreState?: SerializedExploreState; } export interface CanvasViewport {