From 12c04d55f669030c8baa8dcc90522ae1f0855b75 Mon Sep 17 00:00:00 2001 From: Aleksandar Petrov <8142643+aleks-p@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:14:41 -0400 Subject: [PATCH] Add an option to copy/paste panels between maps --- .../components/ExploreMapFloatingToolbar.tsx | 84 +++++++++++-- .../components/ExploreMapPanelContainer.tsx | 44 +++++-- .../explore-map/hooks/useCanvasPersistence.ts | 1 - .../features/explore-map/state/crdtSlice.ts | 118 ++++++++++++++++++ .../features/explore-map/state/selectors.ts | 7 ++ 5 files changed, 239 insertions(+), 15 deletions(-) diff --git a/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx b/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx index c6372ac02dd..4846ab6ba0a 100644 --- a/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx +++ b/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAssistant, createAssistantContextItem } from '@grafana/assistant'; import { GrafanaTheme2 } from '@grafana/data'; @@ -13,13 +13,14 @@ import prometheusLogoSvg from 'app/plugins/datasource/prometheus/img/prometheus_ import tempoLogoSvg from 'app/plugins/datasource/tempo/img/tempo_logo.svg'; import { useDispatch, useSelector } from 'app/types/store'; -import { addPanel, addFrame, addPostItNote, setCursorMode } from '../state/crdtSlice'; +import { addPanel, addFrame, addPostItNote, pastePanel, setCursorMode } from '../state/crdtSlice'; import { selectPanels, selectMapUid, selectViewport, selectSelectedPanelIds, selectCursorMode, + selectClipboard, } from '../state/selectors'; import { AddPanelAction } from './AssistantComponents'; @@ -28,6 +29,7 @@ export function ExploreMapFloatingToolbar() { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const [isOpen, setIsOpen] = useState(false); + const [hasSystemClipboard, setHasSystemClipboard] = useState(false); const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown'; // Get assistant functionality @@ -39,6 +41,30 @@ export function ExploreMapFloatingToolbar() { const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT)); const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT)); const cursorMode = useSelector((state) => selectCursorMode(state.exploreMapCRDT)); + const clipboard = useSelector((state) => selectClipboard(state.exploreMapCRDT)); + + // Check system clipboard for panel data when dropdown opens + useEffect(() => { + if (!isOpen) { + return; + } + + const checkClipboard = async () => { + try { + const text = await navigator.clipboard.readText(); + const data = JSON.parse(text); + if (data && typeof data === 'object' && 'mode' in data && 'width' in data && 'height' in data) { + setHasSystemClipboard(true); + } else { + setHasSystemClipboard(false); + } + } catch { + setHasSystemClipboard(false); + } + }; + + checkClipboard(); + }, [isOpen]); const handleAddPanel = useCallback(() => { dispatch( @@ -175,6 +201,42 @@ export function ExploreMapFloatingToolbar() { setIsOpen(false); }, [dispatch, currentUsername]); + const handlePaste = useCallback(async () => { + try { + // Try to read from system clipboard first for cross-canvas support + const text = await navigator.clipboard.readText(); + const clipboardData = JSON.parse(text); + + // Validate clipboard data structure + if (clipboardData && typeof clipboardData === 'object' && 'mode' in clipboardData && 'width' in clipboardData && 'height' in clipboardData) { + dispatch( + pastePanel({ + viewportSize: { + width: window.innerWidth, + height: window.innerHeight, + }, + createdBy: currentUsername, + clipboardData, + }) + ); + return; + } + } catch (err) { + // Fall through to use local clipboard + } + + // Fallback to local clipboard + dispatch( + pastePanel({ + viewportSize: { + width: window.innerWidth, + height: window.innerHeight, + }, + createdBy: currentUsername, + }) + ); + }, [dispatch, currentUsername]); + const handleSetPointerMode = useCallback(() => { dispatch(setCursorMode({ mode: 'pointer' })); }, [dispatch]); @@ -324,6 +386,12 @@ CRITICAL: logoAlt="Pyroscope" onClick={handleAddProfilesDrilldownPanel} /> + { backgroundColor: 'transparent', color: theme.colors.text.secondary, cursor: 'pointer', - transition: 'all 0.2s ease', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'all 0.2s ease', + }, '&:hover': { backgroundColor: theme.colors.action.hover, color: theme.colors.text.primary, diff --git a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx index cb3bf27ce4b..36f7a7eff18 100644 --- a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx +++ b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx @@ -5,6 +5,8 @@ import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui'; +import { notifyApp } from 'app/core/actions'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; import { useDispatch, useSelector } from 'app/types/store'; import { splitClose } from '../../explore/state/main'; @@ -12,6 +14,7 @@ import { associatePanelWithFrame, bringPanelToFront, clearActiveDrag, + copyPanel, disassociatePanelFromFrame, duplicatePanel, removePanel, @@ -267,8 +270,7 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr ); const handleRemove = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); + () => { // Clean up Explore state first dispatch(splitClose(panel.exploreId)); // Then remove panel @@ -278,15 +280,38 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr ); const handleDuplicate = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); + () => { dispatch(duplicatePanel({ panelId: panel.id })); }, [dispatch, panel.id] ); - const handleInfoClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); + const handleCopy = useCallback( + async () => { + dispatch(copyPanel({ panelId: panel.id })); + + // Also copy to system clipboard for cross-canvas support + try { + const panelData = { + mode: panel.mode, + width: panel.position.width, + height: panel.position.height, + exploreState: panel.exploreState, + iframeUrl: panel.iframeUrl, + createdBy: panel.createdBy, + }; + await navigator.clipboard.writeText(JSON.stringify(panelData)); + dispatch(notifyApp(createSuccessNotification('Panel copied', 'You can paste it on any canvas from the toolbar'))); + } catch (err) { + // Fallback if clipboard API fails + dispatch(notifyApp(createSuccessNotification('Panel copied', 'You can paste it from the toolbar on this canvas'))); + } + }, + [dispatch, panel.id, panel.mode, panel.position, panel.exploreState, panel.iframeUrl, panel.createdBy] + ); + + const handleInfoClick = useCallback(() => { + // Info click doesn't do anything, just shows the description }, []); // Build tooltip content for panel info @@ -354,8 +379,13 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr description={getInfoTooltipContent()} /> + diff --git a/public/app/features/explore-map/hooks/useCanvasPersistence.ts b/public/app/features/explore-map/hooks/useCanvasPersistence.ts index 33521631f33..171bcbe1351 100644 --- a/public/app/features/explore-map/hooks/useCanvasPersistence.ts +++ b/public/app/features/explore-map/hooks/useCanvasPersistence.ts @@ -29,7 +29,6 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) { const [saving, setSaving] = useState(false); const [lastSaved, setLastSaved] = useState(null); const saveTimeoutRef = useRef(null); - const maxWaitTimeoutRef = useRef(null); const initialLoadDone = useRef(false); const lastSavedCRDTStateRef = useRef(null); const firstChangeTimeRef = useRef(null); diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts index 66336356fa4..b6542c65f6b 100644 --- a/public/app/features/explore-map/state/crdtSlice.ts +++ b/public/app/features/explore-map/state/crdtSlice.ts @@ -54,6 +54,16 @@ export interface ExploreMapCRDTState { deltaX: number; deltaY: number; }; + clipboard?: { + panelData: { + mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'; + width: number; + height: number; + exploreState?: SerializedExploreState; + iframeUrl?: string; + createdBy?: string; + }; + }; }; } @@ -86,6 +96,7 @@ export function createInitialCRDTState(mapUid?: string): ExploreMapCRDTState { isSyncing: false, activeDrag: undefined, activeFrameDrag: undefined, + clipboard: undefined, }, }; } @@ -1144,6 +1155,111 @@ const crdtSlice = createSlice({ state.local.activeFrameDrag = undefined; }, + /** + * Copy a panel to clipboard + */ + copyPanel: (state, action: PayloadAction<{ panelId: string }>) => { + const manager = getCRDTManager(state); + const panel = manager.getPanelForUI(action.payload.panelId); + + if (!panel) { + return; + } + + // Store panel data in clipboard + state.local.clipboard = { + panelData: { + mode: panel.mode || 'explore', + width: panel.position.width, + height: panel.position.height, + exploreState: panel.exploreState, + iframeUrl: panel.iframeUrl, + createdBy: panel.createdBy, + }, + }; + }, + + /** + * Paste a panel from clipboard + * This action can be called with clipboardData for pasting from system clipboard + */ + pastePanel: (state, action: PayloadAction<{ + viewportSize?: { width: number; height: number }; + createdBy?: string; + clipboardData?: { + mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'; + width: number; + height: number; + exploreState?: SerializedExploreState; + iframeUrl?: string; + createdBy?: string; + }; + }>) => { + // Use provided clipboard data or fall back to local clipboard + const clipboardData = action.payload.clipboardData || state.local.clipboard?.panelData; + + if (!clipboardData) { + return; + } + + const manager = getCRDTManager(state); + + // Calculate position at viewport center + const viewportSize = action.payload.viewportSize || { width: 1920, height: 1080 }; + const canvasCenterX = (-state.local.viewport.panX + viewportSize.width / 2) / state.local.viewport.zoom; + const canvasCenterY = (-state.local.viewport.panY + viewportSize.height / 2) / state.local.viewport.zoom; + + const position = { + x: canvasCenterX - clipboardData.width / 2, + y: canvasCenterY - clipboardData.height / 2, + width: clipboardData.width, + height: clipboardData.height, + }; + + // Create new panel + const newPanelId = uuidv4(); + const newExploreId = generateExploreId(); + + const addOperation = manager.createAddPanelOperation( + newPanelId, + newExploreId, + position, + clipboardData.mode || 'explore', + action.payload.createdBy || clipboardData.createdBy + ); + + manager.applyOperation(addOperation); + state.pendingOperations.push(addOperation); + + // Copy explore state if exists + if (clipboardData.exploreState) { + const stateOperation = manager.createUpdatePanelExploreStateOperation( + newPanelId, + clipboardData.exploreState + ); + + if (stateOperation) { + manager.applyOperation(stateOperation); + state.pendingOperations.push(stateOperation); + } + } + + // Copy iframe URL if exists + if (clipboardData.iframeUrl) { + const urlOperation = manager.createUpdatePanelIframeUrlOperation( + newPanelId, + clipboardData.iframeUrl + ); + + if (urlOperation) { + manager.applyOperation(urlOperation); + state.pendingOperations.push(urlOperation); + } + } + + saveCRDTManager(state, manager); + }, + /** * Clear all state (reset) */ @@ -1201,6 +1317,8 @@ export const { clearActiveDrag, setActiveFrameDrag, clearActiveFrameDrag, + copyPanel, + pastePanel, clearMap, } = crdtSlice.actions; diff --git a/public/app/features/explore-map/state/selectors.ts b/public/app/features/explore-map/state/selectors.ts index e8b0578037f..ad9fba2b0f0 100644 --- a/public/app/features/explore-map/state/selectors.ts +++ b/public/app/features/explore-map/state/selectors.ts @@ -345,6 +345,13 @@ export const selectSessionId = (state: ExploreMapCRDTState): string => { return state.sessionId; }; +/** + * Select clipboard state + */ +export const selectClipboard = (state: ExploreMapCRDTState) => { + return state.local.clipboard; +}; + /** * Select panel count */