diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx index fc726bd8717..5a21eb88441 100644 --- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx +++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx @@ -238,9 +238,11 @@ export function ExploreMapCanvas() { role="button" tabIndex={0} > - {Object.values(panels).map((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 ; + })} {Object.values(cursors).map((cursor) => ( ))} diff --git a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx index 11cf1782573..5b3c00a8394 100644 --- a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx +++ b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx @@ -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); diff --git a/public/app/features/explore-map/components/ExploreMapPanelContent.tsx b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx index 3c2260c6a52..f3953aedf79 100644 --- a/public/app/features/explore-map/components/ExploreMapPanelContent.tsx +++ b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx @@ -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({ diff --git a/public/app/features/explore-map/crdt/state.ts b/public/app/features/explore-map/crdt/state.ts index 0a1dc3759a0..b7d91b7ff21 100644 --- a/public/app/features/explore-map/crdt/state.ts +++ b/public/app/features/explore-map/crdt/state.ts @@ -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, }); } diff --git a/public/app/features/explore-map/crdt/types.ts b/public/app/features/explore-map/crdt/types.ts index c63fce14bb6..ffd93df1216 100644 --- a/public/app/features/explore-map/crdt/types.ts +++ b/public/app/features/explore-map/crdt/types.ts @@ -28,6 +28,9 @@ export interface CRDTPanelData { // CRDT-replicated explore state exploreState: LWWRegister; + + // 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; diff --git a/public/app/features/explore-map/hooks/useCanvasPersistence.ts b/public/app/features/explore-map/hooks/useCanvasPersistence.ts index f4504f470d9..e93733a99f4 100644 --- a/public/app/features/explore-map/hooks/useCanvasPersistence.ts +++ b/public/app/features/explore-map/hooks/useCanvasPersistence.ts @@ -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(null); const saveTimeoutRef = useRef(null); const initialLoadDone = useRef(false); + const lastSavedCRDTStateRef = useRef(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) { diff --git a/public/app/features/explore-map/hooks/usePanelStateSync.ts b/public/app/features/explore-map/hooks/usePanelStateSync.ts new file mode 100644 index 00000000000..5fe597f2200 --- /dev/null +++ b/public/app/features/explore-map/hooks/usePanelStateSync.ts @@ -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(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]); +} diff --git a/public/app/features/explore-map/realtime/useRealtimeSync.ts b/public/app/features/explore-map/realtime/useRealtimeSync.ts index 424e46ccfb3..491d237fe0f 100644 --- a/public/app/features/explore-map/realtime/useRealtimeSync.ts +++ b/public/app/features/explore-map/realtime/useRealtimeSync.ts @@ -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); diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts index 2348d18f772..c9bd3753733 100644 --- a/public/app/features/explore-map/state/crdtSlice.ts +++ b/public/app/features/explore-map/state/crdtSlice.ts @@ -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; diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts index dc29c31c079..4a5652a988c 100644 --- a/public/app/features/explore-map/state/types.ts +++ b/public/app/features/explore-map/state/types.ts @@ -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; + crdtState?: any; // Raw CRDT state JSON for proper sync } export const initialExploreMapState: ExploreMapState = {