diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx index 046f87f8c66..1b2e6677467 100644 --- a/public/app/features/explore-map/components/ExploreMapCanvas.tsx +++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; import { GrafanaTheme2 } from '@grafana/data'; @@ -38,11 +38,17 @@ export function ExploreMapCanvas() { const mapUid = useSelector((state) => selectMapUid(state.exploreMapCRDT)); // Initialize cursor sync - const { updatePosition } = useCursorSync({ + const { updatePosition, updatePositionImmediate } = useCursorSync({ mapUid: mapUid || '', enabled: !!mapUid, }); + // Send selection update immediately when selection changes + // Use position (0, 0) since we only care about the selection, not cursor position + useEffect(() => { + updatePositionImmediate(0, 0, selectedPanelIds); + }, [selectedPanelIds, updatePositionImmediate]); + const handleCanvasClick = useCallback( (e: React.MouseEvent) => { // Don't deselect if we just finished a selection drag @@ -124,7 +130,8 @@ export function ExploreMapCanvas() { const canvasY = Math.max(0, Math.min(10000, screenY / scale)); // Update cursor position for all sessions (using actual canvas coordinates, clamped to bounds) - updatePosition(canvasX, canvasY); + // Include currently selected panel IDs for collaborative selection visualization + updatePosition(canvasX, canvasY, selectedPanelIds); // Handle selection rectangle if dragging (using canvas coordinates) if (isSelecting && selectionRect) { @@ -135,7 +142,7 @@ export function ExploreMapCanvas() { }); } }, - [isSelecting, selectionRect, updatePosition, contextTransformRef, viewport] + [isSelecting, selectionRect, updatePosition, contextTransformRef, viewport, selectedPanelIds] ); const handleCanvasMouseUp = useCallback( diff --git a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx index 54fca72aa5b..0aff4f621ad 100644 --- a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx +++ b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx @@ -4,7 +4,7 @@ import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Button, Tooltip, useStyles2 } from '@grafana/ui'; +import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui'; import { useDispatch, useSelector } from 'app/types/store'; import { splitClose } from '../../explore/state/main'; @@ -19,7 +19,7 @@ import { updatePanelPosition, updatePanelSize, } from '../state/crdtSlice'; -import { selectSelectedPanelIds, selectViewport } from '../state/selectors'; +import { selectSelectedPanelIds, selectViewport, selectCursors } from '../state/selectors'; import { ExploreMapPanel } from '../state/types'; import { ExploreMapPanelContent } from './ExploreMapPanelContent'; @@ -36,8 +36,14 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT)); const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT)); + const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT)); const isSelected = selectedPanelIds.includes(panel.id); + // Find all users who have this panel selected (excluding current user) + const remoteSelectingUsers = Object.values(cursors).filter( + (cursor) => cursor.selectedPanelIds?.includes(panel.id) + ); + // Get the active drag info from a parent context (if any panel is being dragged) const activeDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeDrag); @@ -217,40 +223,70 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr minWidth={300} minHeight={200} > -
-
-
- {t('explore-map.panel.title', 'Explore Panel {{id}}', { id: panel.id.slice(0, 8) })} +
+ {/* Render remote user selection outlines */} + {remoteSelectingUsers.map((user, index) => ( +
+ ))} +
+
+
+ {t('explore-map.panel.title', 'Explore Panel {{id}}', { id: panel.id.slice(0, 8) })} +
+
+ {remoteSelectingUsers.length > 0 && ( +
+ {remoteSelectingUsers.map((user) => ( + + {user.userName} + + ))} +
+ )} +
+ + + + + + + } + placement="bottom-end" + > +
+
-
- -
-
+
); } @@ -291,6 +328,18 @@ const getStyles = (theme: GrafanaTheme2) => { panelContainer: css({ cursor: 'default', }), + panelWrapper: css({ + position: 'relative', + width: '100%', + height: '100%', + }), + remoteSelection: css({ + position: 'absolute', + pointerEvents: 'none', + border: '2px solid', + borderRadius: theme.shape.radius.default, + zIndex: 1, + }), panel: css({ width: '100%', height: '100%', @@ -301,9 +350,11 @@ const getStyles = (theme: GrafanaTheme2) => { borderRadius: theme.shape.radius.default, overflow: 'hidden', boxShadow: theme.shadows.z2, + position: 'relative', + zIndex: 2, }), selectedPanel: css({ - '& > div': { + '& > div > div': { border: `2px solid ${theme.colors.primary.border}`, }, }), @@ -321,6 +372,31 @@ const getStyles = (theme: GrafanaTheme2) => { fontSize: theme.typography.body.fontSize, fontWeight: theme.typography.fontWeightMedium, userSelect: 'none', + flex: 1, + minWidth: 0, // Allow text truncation + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }), + panelHeaderRight: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + flexShrink: 0, // Prevent shrinking + }), + remoteUsers: css({ + display: 'flex', + gap: theme.spacing(0.5), + alignItems: 'center', + }), + remoteUserBadge: css({ + fontSize: theme.typography.bodySmall.fontSize, + padding: theme.spacing(0.25, 0.75), + borderRadius: theme.shape.radius.default, + color: 'white', + fontWeight: theme.typography.fontWeightMedium, + userSelect: 'none', + whiteSpace: 'nowrap', }), panelActions: css({ display: 'flex', diff --git a/public/app/features/explore-map/hooks/useCursorSync.ts b/public/app/features/explore-map/hooks/useCursorSync.ts index 172f6271436..39e6008aa6e 100644 --- a/public/app/features/explore-map/hooks/useCursorSync.ts +++ b/public/app/features/explore-map/hooks/useCursorSync.ts @@ -5,9 +5,9 @@ * for collaborative cursor sharing across all active sessions. */ +import { throttle } from 'lodash'; import { useCallback, useEffect, useRef } from 'react'; import { Unsubscribable } from 'rxjs'; -import { throttle } from 'lodash'; import { LiveChannelAddress, LiveChannelScope, isLiveChannelMessageEvent } from '@grafana/data'; import { getGrafanaLiveSrv } from '@grafana/runtime'; @@ -32,6 +32,7 @@ interface CursorUpdateMessage { x: number; y: number; color: string; + selectedPanelIds: string[]; }; timestamp: number; } @@ -59,6 +60,30 @@ export function useCursorSync(options: CursorSyncOptions) { const channelAddressRef = useRef(null); const cursorColorRef = useRef(generateRandomColor()); + // Send cursor leave message + const sendCursorLeave = useCallback(() => { + if (!channelAddressRef.current) { + return; + } + + const liveService = getGrafanaLiveSrv(); + if (!liveService) { + return; + } + + const message: CursorLeaveMessage = { + type: 'cursor_leave', + sessionId, + userId: '', // Will be enriched by backend + userName: '', // Will be enriched by backend + timestamp: Date.now(), + }; + + liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => { + // Failed to send cursor leave - ignore silently + }); + }, [sessionId]); + // Initialize channel connection useEffect(() => { if (!enabled || !mapUid) { @@ -93,7 +118,7 @@ export function useCursorSync(options: CursorSyncOptions) { try { // Handle cursor message events if (isLiveChannelMessageEvent(event)) { - const message = event.message as CursorMessage; + const message: CursorMessage = event.message; // Skip our own messages (backend should filter, but double-check) if (message.sessionId === sessionId) { @@ -109,6 +134,7 @@ export function useCursorSync(options: CursorSyncOptions) { x: message.data.x, y: message.data.y, lastUpdated: message.timestamp, + selectedPanelIds: message.data.selectedPanelIds || [], }; dispatch(updateCursor(cursor)); } else if (message.type === 'cursor_leave') { @@ -146,35 +172,11 @@ export function useCursorSync(options: CursorSyncOptions) { } channelAddressRef.current = null; }; - }, [mapUid, enabled, sessionId, dispatch]); - - // Send cursor leave message - const sendCursorLeave = useCallback(() => { - if (!channelAddressRef.current) { - return; - } - - const liveService = getGrafanaLiveSrv(); - if (!liveService) { - return; - } - - const message: CursorLeaveMessage = { - type: 'cursor_leave', - sessionId, - userId: '', // Will be enriched by backend - userName: '', // Will be enriched by backend - timestamp: Date.now(), - }; - - liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => { - // Failed to send cursor leave - ignore silently - }); - }, [sessionId]); + }, [mapUid, enabled, sessionId, dispatch, sendCursorLeave]); // Throttled cursor update function const sendCursorUpdate = useRef( - throttle((x: number, y: number) => { + throttle((x: number, y: number, selectedPanelIds: string[]) => { if (!channelAddressRef.current) { return; } @@ -193,6 +195,7 @@ export function useCursorSync(options: CursorSyncOptions) { x, y, color: cursorColorRef.current, + selectedPanelIds, }, timestamp: Date.now(), }; @@ -203,16 +206,50 @@ export function useCursorSync(options: CursorSyncOptions) { }, throttleMs) ).current; - // Update cursor position + // Update cursor position (throttled) const updatePosition = useCallback( - (x: number, y: number) => { - sendCursorUpdate(x, y); + (x: number, y: number, selectedPanelIds: string[]) => { + sendCursorUpdate(x, y, selectedPanelIds); }, [sendCursorUpdate] ); + // Force send cursor update immediately (not throttled) - used for selection changes + const updatePositionImmediate = useCallback( + (x: number, y: number, selectedPanelIds: string[]) => { + if (!channelAddressRef.current) { + return; + } + + const liveService = getGrafanaLiveSrv(); + if (!liveService) { + return; + } + + const message: CursorUpdateMessage = { + type: 'cursor_update', + sessionId, + userId: '', // Will be enriched by backend + userName: '', // Will be enriched by backend + data: { + x, + y, + color: cursorColorRef.current, + selectedPanelIds, + }, + timestamp: Date.now(), + }; + + liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => { + // Failed to send cursor update - ignore silently + }); + }, + [sessionId] + ); + return { updatePosition, + updatePositionImmediate, color: cursorColorRef.current, }; } diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts index 97a7e0fe1c8..cc86ca43786 100644 --- a/public/app/features/explore-map/state/types.ts +++ b/public/app/features/explore-map/state/types.ts @@ -57,6 +57,7 @@ export interface UserCursor { x: number; y: number; lastUpdated: number; + selectedPanelIds: string[]; } export interface ExploreMapState {