Sync panel internal state across sessions
This commit is contained in:
@@ -238,9 +238,11 @@ export function ExploreMapCanvas() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{Object.values(panels).map((panel) => (
|
||||
<ExploreMapPanelContainer key={panel.id} panel={panel} />
|
||||
))}
|
||||
{Object.values(panels).map((panel) => {
|
||||
// Use remoteVersion in the key to force re-render when remote state changes
|
||||
// remoteVersion only increments on remote updates, so local edits won't trigger re-render
|
||||
return <ExploreMapPanelContainer key={`${panel.id}-v${panel.remoteVersion || 0}`} panel={panel} />;
|
||||
})}
|
||||
{Object.values(cursors).map((cursor) => (
|
||||
<UserCursor key={cursor.userId} cursor={cursor} />
|
||||
))}
|
||||
|
||||
@@ -73,33 +73,38 @@ export function ExploreMapPanelContainer({ panel }: ExploreMapPanelContainerProp
|
||||
const deltaX = data.x - dragStartPos.x;
|
||||
const deltaY = data.y - dragStartPos.y;
|
||||
|
||||
// Final position update for all panels
|
||||
if (isSelected && selectedPanelIds.length > 1) {
|
||||
// Update other panels with the remaining delta
|
||||
dispatch(
|
||||
updateMultiplePanelPositions({
|
||||
panelId: panel.id,
|
||||
deltaX,
|
||||
deltaY,
|
||||
})
|
||||
);
|
||||
// Update the dragged panel's position in Redux
|
||||
dispatch(
|
||||
updatePanelPosition({
|
||||
panelId: panel.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Single panel drag
|
||||
dispatch(
|
||||
updatePanelPosition({
|
||||
panelId: panel.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
})
|
||||
);
|
||||
// Only update position if there was actual movement
|
||||
const hasMoved = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
|
||||
|
||||
if (hasMoved) {
|
||||
// Final position update for all panels
|
||||
if (isSelected && selectedPanelIds.length > 1) {
|
||||
// Update other panels with the remaining delta
|
||||
dispatch(
|
||||
updateMultiplePanelPositions({
|
||||
panelId: panel.id,
|
||||
deltaX,
|
||||
deltaY,
|
||||
})
|
||||
);
|
||||
// Update the dragged panel's position in Redux
|
||||
dispatch(
|
||||
updatePanelPosition({
|
||||
panelId: panel.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Single panel drag
|
||||
dispatch(
|
||||
updatePanelPosition({
|
||||
panelId: panel.id,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
setDragStartPos(null);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDispatch, useSelector } from 'app/types/store';
|
||||
import { ExplorePaneContainer } from '../../explore/ExplorePaneContainer';
|
||||
import { DEFAULT_RANGE } from '../../explore/state/constants';
|
||||
import { initializeExplore } from '../../explore/state/explorePane';
|
||||
import { usePanelStateSync } from '../hooks/usePanelStateSync';
|
||||
// import { useExploreStateReceiver } from '../hooks/useExploreStateReceiver';
|
||||
// import { useExploreStateSync } from '../hooks/useExploreStateSync';
|
||||
import { selectPanels } from '../state/selectors';
|
||||
@@ -85,6 +86,12 @@ export function ExploreMapPanelContent({ panelId, exploreId, width, height }: Ex
|
||||
// Create scoped event bus for this panel
|
||||
const eventBus = useMemo(() => new EventBusSrv(), []);
|
||||
|
||||
// Sync panel state when deselected (outgoing)
|
||||
usePanelStateSync({
|
||||
panelId,
|
||||
exploreId,
|
||||
});
|
||||
|
||||
// TODO: Re-enable once we fix the re-rendering issue
|
||||
// Sync Explore state changes to CRDT (outgoing)
|
||||
// useExploreStateSync({
|
||||
|
||||
@@ -111,6 +111,7 @@ export class CRDTStateManager {
|
||||
zIndex: data.zIndex.get(),
|
||||
},
|
||||
exploreState: data.exploreState.get(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -355,6 +356,7 @@ export class CRDTStateManager {
|
||||
height: new LWWRegister(position.height, operation.timestamp),
|
||||
zIndex: new LWWRegister(zIndex, operation.timestamp),
|
||||
exploreState: new LWWRegister(undefined, operation.timestamp),
|
||||
remoteVersion: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,6 +435,11 @@ export class CRDTStateManager {
|
||||
|
||||
const updated = panelData.exploreState.set(exploreState, operation.timestamp);
|
||||
|
||||
// Increment remoteVersion only for remote operations
|
||||
if (updated && operation.nodeId !== this.nodeId) {
|
||||
panelData.remoteVersion++;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
applied: updated,
|
||||
@@ -495,6 +502,7 @@ export class CRDTStateManager {
|
||||
height: otherPanelData.height.clone(),
|
||||
zIndex: otherPanelData.zIndex.clone(),
|
||||
exploreState: otherPanelData.exploreState.clone(),
|
||||
remoteVersion: otherPanelData.remoteVersion,
|
||||
});
|
||||
} else {
|
||||
// Merge each LWW register
|
||||
@@ -527,6 +535,7 @@ export class CRDTStateManager {
|
||||
height: data.height.toJSON(),
|
||||
zIndex: data.zIndex.toJSON(),
|
||||
exploreState: data.exploreState.toJSON(),
|
||||
remoteVersion: data.remoteVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -561,6 +570,7 @@ export class CRDTStateManager {
|
||||
height: LWWRegister.fromJSON(data.height),
|
||||
zIndex: LWWRegister.fromJSON(data.zIndex),
|
||||
exploreState: LWWRegister.fromJSON(data.exploreState),
|
||||
remoteVersion: data.remoteVersion || 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface CRDTPanelData {
|
||||
|
||||
// CRDT-replicated explore state
|
||||
exploreState: LWWRegister<SerializedExploreState | undefined>;
|
||||
|
||||
// Local counter incremented only for remote explore state updates
|
||||
remoteVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +91,7 @@ export interface CRDTExploreMapStateJSON {
|
||||
height: { value: number; timestamp: HLCTimestamp };
|
||||
zIndex: { value: number; timestamp: HLCTimestamp };
|
||||
exploreState: { value: SerializedExploreState | undefined; timestamp: HLCTimestamp };
|
||||
remoteVersion?: number;
|
||||
}>;
|
||||
zIndexCounter: {
|
||||
increments: Record<string, number>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createErrorNotification, createSuccessNotification } from 'app/core/cop
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { exploreMapApi } from '../api/exploreMapApi';
|
||||
import { initializeFromLegacyState } from '../state/crdtSlice';
|
||||
import { initializeFromLegacyState, loadState as loadCRDTState } from '../state/crdtSlice';
|
||||
import { loadCanvas } from '../state/exploreMapSlice';
|
||||
import { selectPanels, selectMapTitle, selectViewport } from '../state/selectors';
|
||||
import { ExploreMapState, initialExploreMapState, SerializedExploreState } from '../state/types';
|
||||
@@ -29,6 +29,7 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const initialLoadDone = useRef(false);
|
||||
const lastSavedCRDTStateRef = useRef<string | null | undefined>(null);
|
||||
|
||||
// Helper to enrich state with Explore pane data
|
||||
const enrichStateWithExploreData = useCallback(
|
||||
@@ -101,12 +102,19 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
dispatch(loadCanvas(parsed));
|
||||
|
||||
// Initialize CRDT state from loaded data
|
||||
dispatch(initializeFromLegacyState({
|
||||
uid: parsed.uid,
|
||||
title: parsed.title,
|
||||
panels: parsed.panels || {},
|
||||
viewport: parsed.viewport || initialExploreMapState.viewport,
|
||||
}));
|
||||
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
|
||||
if (parsed.crdtState) {
|
||||
// Load the saved CRDT state which includes proper OR-Set metadata
|
||||
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
|
||||
} else {
|
||||
// Fallback to legacy initialization for backward compatibility
|
||||
dispatch(initializeFromLegacyState({
|
||||
uid: parsed.uid,
|
||||
title: parsed.title,
|
||||
panels: parsed.panels || {},
|
||||
viewport: parsed.viewport || initialExploreMapState.viewport,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load map from API:', error);
|
||||
dispatch(
|
||||
@@ -132,12 +140,19 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
dispatch(loadCanvas(parsed));
|
||||
|
||||
// Initialize CRDT state from loaded data
|
||||
dispatch(initializeFromLegacyState({
|
||||
uid: parsed.uid,
|
||||
title: parsed.title,
|
||||
panels: parsed.panels,
|
||||
viewport: parsed.viewport,
|
||||
}));
|
||||
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
|
||||
if (parsed.crdtState) {
|
||||
// Load the saved CRDT state which includes proper OR-Set metadata
|
||||
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
|
||||
} else {
|
||||
// Fallback to legacy initialization for backward compatibility
|
||||
dispatch(initializeFromLegacyState({
|
||||
uid: parsed.uid,
|
||||
title: parsed.title,
|
||||
panels: parsed.panels,
|
||||
viewport: parsed.viewport,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load canvas state from storage:', error);
|
||||
@@ -166,6 +181,13 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if CRDT state has actually changed (ignore local UI state like selection)
|
||||
const currentCRDTStateStr = crdtState.crdtStateJSON;
|
||||
if (currentCRDTStateStr === lastSavedCRDTStateRef.current) {
|
||||
// No changes to persist
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
@@ -174,8 +196,9 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
const saveState = async () => {
|
||||
// CRDT panels already contain exploreState from savePanelExploreState actions
|
||||
// We don't need to enrich them with live Explore pane data
|
||||
console.log('[Persistence] Saving panels:', panels);
|
||||
// console.log('[Persistence] Saving panels:', panels);
|
||||
|
||||
// Save both legacy format (for backward compat) and CRDT state
|
||||
const enrichedState: ExploreMapState = {
|
||||
uid,
|
||||
title: mapTitle,
|
||||
@@ -184,6 +207,8 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
selectedPanelIds: [],
|
||||
nextZIndex: 1,
|
||||
cursors: {},
|
||||
// Store the raw CRDT state for proper sync across sessions
|
||||
crdtState: crdtState.crdtStateJSON ? JSON.parse(crdtState.crdtStateJSON) : undefined,
|
||||
};
|
||||
|
||||
if (uid) {
|
||||
@@ -197,6 +222,8 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
data: enrichedState,
|
||||
});
|
||||
setLastSaved(new Date());
|
||||
// Update last saved state ref to prevent duplicate saves
|
||||
lastSavedCRDTStateRef.current = currentCRDTStateStr;
|
||||
} catch (error) {
|
||||
console.error('Failed to save map to API:', error);
|
||||
dispatch(notifyApp(createErrorNotification('Failed to save explore map', 'Changes may not be persisted')));
|
||||
@@ -208,6 +235,8 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
// Save to localStorage immediately (legacy mode)
|
||||
try {
|
||||
store.set(STORAGE_KEY, JSON.stringify(enrichedState));
|
||||
// Update last saved state ref to prevent duplicate saves
|
||||
lastSavedCRDTStateRef.current = currentCRDTStateStr;
|
||||
} catch (error) {
|
||||
console.error('Failed to save canvas state to storage:', error);
|
||||
}
|
||||
@@ -276,12 +305,19 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
|
||||
dispatch(loadCanvas(parsed));
|
||||
|
||||
// Initialize CRDT state from imported data
|
||||
dispatch(initializeFromLegacyState({
|
||||
uid: parsed.uid,
|
||||
title: parsed.title,
|
||||
panels: parsed.panels,
|
||||
viewport: parsed.viewport,
|
||||
}));
|
||||
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
|
||||
if (parsed.crdtState) {
|
||||
// Load the saved CRDT state which includes proper OR-Set metadata
|
||||
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
|
||||
} else {
|
||||
// Fallback to legacy initialization for backward compatibility
|
||||
dispatch(initializeFromLegacyState({
|
||||
uid: parsed.uid,
|
||||
title: parsed.title,
|
||||
panels: parsed.panels,
|
||||
viewport: parsed.viewport,
|
||||
}));
|
||||
}
|
||||
|
||||
dispatch(notifyApp(createSuccessNotification('Canvas imported successfully')));
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Hook to sync panel explore state when panel is deselected
|
||||
*
|
||||
* This hook monitors panel selection changes and syncs the explore state
|
||||
* to CRDT when a panel is deselected, but only if the state has changed.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { savePanelExploreState } from '../state/crdtSlice';
|
||||
import { selectSelectedPanelIds, selectPanels } from '../state/selectors';
|
||||
import { SerializedExploreState } from '../state/types';
|
||||
|
||||
export interface UsePanelStateSyncOptions {
|
||||
panelId: string;
|
||||
exploreId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to sync panel explore state when it's deselected
|
||||
*/
|
||||
export function usePanelStateSync({ panelId, exploreId }: UsePanelStateSyncOptions) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Track if this panel was previously selected
|
||||
const wasSelectedRef = useRef(false);
|
||||
|
||||
// Track the last synced state
|
||||
const lastSyncedStateRef = useRef<string | null>(null);
|
||||
|
||||
// Get current selection state
|
||||
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
|
||||
const isSelected = selectedPanelIds.includes(panelId);
|
||||
|
||||
// Get current explore state
|
||||
const explorePane = useSelector((state) => state.explore?.panes?.[exploreId]);
|
||||
|
||||
// Get the panel's saved state
|
||||
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
|
||||
const panel = panels[panelId];
|
||||
|
||||
useEffect((): void | (() => void) => {
|
||||
// Update tracking ref
|
||||
const previouslySelected = wasSelectedRef.current;
|
||||
wasSelectedRef.current = isSelected;
|
||||
|
||||
// Detect deselection (was selected, now not selected)
|
||||
if (previouslySelected && !isSelected) {
|
||||
console.log('[PanelStateSync] Panel deselected:', panelId);
|
||||
|
||||
// Add a small delay to ensure explore state updates have been committed to Redux
|
||||
const syncTimer = setTimeout(() => {
|
||||
// Get current explore state (after delay to ensure it's updated)
|
||||
if (!explorePane) {
|
||||
console.log('[PanelStateSync] No explore pane found for', exploreId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the key fields we care about
|
||||
const currentState: SerializedExploreState = {
|
||||
queries: explorePane.queries || [],
|
||||
datasourceUid: explorePane.datasourceInstance?.uid,
|
||||
range: explorePane.range,
|
||||
refreshInterval: explorePane.refreshInterval,
|
||||
panelsState: explorePane.panelsState,
|
||||
compact: explorePane.compact,
|
||||
};
|
||||
|
||||
// Log detailed info about queries and datasource
|
||||
console.log('[PanelStateSync] Current state:', {
|
||||
datasourceUid: currentState.datasourceUid,
|
||||
queriesCount: currentState.queries.length,
|
||||
queries: currentState.queries,
|
||||
});
|
||||
|
||||
// Create a stable string representation focusing on the important fields
|
||||
const currentStateStr = JSON.stringify({
|
||||
datasourceUid: currentState.datasourceUid,
|
||||
queries: currentState.queries,
|
||||
range: currentState.range,
|
||||
});
|
||||
|
||||
console.log('[PanelStateSync] Last synced state:', lastSyncedStateRef.current?.substring(0, 100));
|
||||
console.log('[PanelStateSync] Current state str:', currentStateStr.substring(0, 100));
|
||||
|
||||
// Check if state has changed since last sync
|
||||
const hasChanged = lastSyncedStateRef.current !== currentStateStr;
|
||||
|
||||
if (hasChanged) {
|
||||
console.log('[PanelStateSync] State changed, syncing full state');
|
||||
|
||||
// Dispatch sync action with full state
|
||||
dispatch(savePanelExploreState({
|
||||
panelId,
|
||||
exploreState: currentState,
|
||||
}));
|
||||
|
||||
// Update last synced state
|
||||
lastSyncedStateRef.current = currentStateStr;
|
||||
} else {
|
||||
console.log('[PanelStateSync] State unchanged, skipping sync');
|
||||
}
|
||||
}, 100); // Small delay to ensure state is updated
|
||||
|
||||
// Return cleanup function
|
||||
return () => clearTimeout(syncTimer);
|
||||
}
|
||||
}, [isSelected, panelId, exploreId, explorePane, dispatch]);
|
||||
|
||||
// Initialize last synced state from panel's saved state
|
||||
useEffect(() => {
|
||||
if (panel?.exploreState && lastSyncedStateRef.current === null) {
|
||||
// Use the same format as in the main effect for consistency
|
||||
const initStateStr = JSON.stringify({
|
||||
datasourceUid: panel.exploreState.datasourceUid,
|
||||
queries: panel.exploreState.queries || [],
|
||||
range: panel.exploreState.range,
|
||||
});
|
||||
lastSyncedStateRef.current = initStateStr;
|
||||
console.log('[PanelStateSync] Initialized with saved state:', initStateStr.substring(0, 100));
|
||||
}
|
||||
}, [panel?.exploreState]);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { Unsubscribable } from 'rxjs';
|
||||
|
||||
import { LiveChannelAddress, LiveChannelScope, isLiveChannelMessageEvent } from '@grafana/data';
|
||||
import { getGrafanaLiveSrv } from '@grafana/runtime';
|
||||
|
||||
import { StoreState, useDispatch, useSelector } from 'app/types/store';
|
||||
|
||||
import { CRDTOperation } from '../crdt/types';
|
||||
@@ -193,12 +192,16 @@ export function useRealtimeSync(options: RealtimeSyncOptions): RealtimeSyncStatu
|
||||
|
||||
const channelAddress = channelAddressRef.current;
|
||||
|
||||
console.log('[CRDT] Broadcasting', pendingOperations.length, 'pending operations');
|
||||
|
||||
// Broadcast each pending operation
|
||||
for (const operation of pendingOperations) {
|
||||
try {
|
||||
// Mark as applied locally
|
||||
appliedOpsRef.current.add(operation.operationId);
|
||||
|
||||
console.log('[CRDT] Broadcasting operation:', operation.type, operation.operationId);
|
||||
|
||||
// Publish to channel
|
||||
liveService.publish(channelAddress, operation).catch((error) => {
|
||||
console.error('[CRDT] Failed to broadcast operation:', error);
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { generateExploreId } from 'app/core/utils/explore';
|
||||
|
||||
import { CRDTStateManager } from '../crdt/state';
|
||||
import { CRDTOperation } from '../crdt/types';
|
||||
|
||||
import { CanvasViewport, SerializedExploreState, UserCursor } from './types';
|
||||
|
||||
/**
|
||||
@@ -348,6 +350,21 @@ const crdtSlice = createSlice({
|
||||
bringPanelToFront: (state, action: PayloadAction<{ panelId: string }>) => {
|
||||
const manager = getCRDTManager(state);
|
||||
|
||||
// Check if panel is already at the front
|
||||
const currentPanel = manager.getPanelForUI(action.payload.panelId);
|
||||
if (!currentPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all panels and find the max z-index
|
||||
const allPanels = manager.getAllPanelsForUI();
|
||||
const maxZIndex = Math.max(...Object.values(allPanels).map((p) => p.position.zIndex));
|
||||
|
||||
// Only update if this panel isn't already at the front
|
||||
if (currentPanel.position.zIndex >= maxZIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operation = manager.createUpdatePanelZIndexOperation(action.payload.panelId);
|
||||
if (!operation) {
|
||||
return;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ExploreMapPanel {
|
||||
exploreId: string;
|
||||
position: PanelPosition;
|
||||
exploreState?: SerializedExploreState;
|
||||
remoteVersion?: number; // Increments only on remote explore state updates
|
||||
}
|
||||
|
||||
export interface CanvasViewport {
|
||||
@@ -47,6 +48,7 @@ export interface ExploreMapState {
|
||||
selectedPanelIds: string[];
|
||||
nextZIndex: number;
|
||||
cursors: Record<string, UserCursor>;
|
||||
crdtState?: any; // Raw CRDT state JSON for proper sync
|
||||
}
|
||||
|
||||
export const initialExploreMapState: ExploreMapState = {
|
||||
|
||||
Reference in New Issue
Block a user