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 = {