Persist explore panel state

This commit is contained in:
Aleksandar Petrov
2025-11-28 13:21:09 -04:00
parent 1e687c9d6c
commit 81738addac
5 changed files with 136 additions and 23 deletions
@@ -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 (
<div className={styles.container}>
<div className={styles.loading}>Initializing Explore...</div>
<div className={styles.loading}>
<Trans i18nKey="explore-map.panel.initializing">Initializing Explore...</Trans>
</div>
</div>
);
}
@@ -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) {
@@ -16,11 +16,13 @@ interface MockUser {
pauseUntil: number;
}
const MOCK_USERS: Array<Omit<MockUser, 'targetX' | 'targetY' | 'currentX' | 'currentY' | 'pauseUntil'>> = [
const ENABLE_MOCK_USERS = false;
let MOCK_USERS: Array<Omit<MockUser, 'targetX' | 'targetY' | 'currentX' | 'currentY' | 'pauseUntil'>> = 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 = {
@@ -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<ExploreMapState>) => {
return action.payload;
},
@@ -119,6 +137,7 @@ export const {
updateViewport,
resetCanvas,
duplicatePanel,
savePanelExploreState,
loadCanvas,
updateCursor,
removeCursor,
@@ -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 {