Sync panel internal state across sessions

This commit is contained in:
Aleksandar Petrov
2025-12-01 18:42:55 -04:00
parent 71526b2ab9
commit 19a6441467
10 changed files with 262 additions and 51 deletions
@@ -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 = {