Persist explore panel state
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user