Add an option to copy/paste panels between maps

This commit is contained in:
Aleksandar Petrov
2025-12-04 15:14:41 -04:00
parent efc5e97a74
commit 12c04d55f6
5 changed files with 239 additions and 15 deletions
@@ -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}
/>
<MenuItem
label={t('explore-map.toolbar.paste-panel', 'Paste panel')}
icon="document-info"
onClick={handlePaste}
disabled={!clipboard && !hasSystemClipboard}
/>
<MenuItem
label={t('explore-map.toolbar.add-sticky', 'Add Sticky note')}
icon="file-alt"
@@ -338,8 +406,8 @@ CRITICAL:
<button
className={`${styles.cursorModeButton} ${cursorMode === 'pointer' ? styles.cursorModeButtonActive : ''}`}
onClick={handleSetPointerMode}
title="Pointer mode (V)"
aria-label="Pointer mode"
title={t('explore-map.toolbar.pointer-mode', 'Pointer mode (V)')}
aria-label={t('explore-map.toolbar.pointer-mode-aria', 'Pointer mode')}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className={styles.cursorIcon}>
<path
@@ -351,8 +419,8 @@ CRITICAL:
<button
className={`${styles.cursorModeButton} ${cursorMode === 'hand' ? styles.cursorModeButtonActive : ''}`}
onClick={handleSetHandMode}
title="Hand mode (H)"
aria-label="Hand mode"
title={t('explore-map.toolbar.hand-mode', 'Hand mode (H)')}
aria-label={t('explore-map.toolbar.hand-mode-aria', 'Hand mode')}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className={styles.cursorIcon}>
<path
@@ -442,7 +510,9 @@ const getStyles = (theme: GrafanaTheme2) => {
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,
@@ -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()}
/>
<Menu.Item
label={t('explore-map.panel.duplicate', 'Duplicate panel')}
label={t('explore-map.panel.copy', 'Copy panel')}
icon="copy"
onClick={handleCopy}
/>
<Menu.Item
label={t('explore-map.panel.duplicate', 'Duplicate panel')}
icon="apps"
onClick={handleDuplicate}
/>
<Menu.Divider />
@@ -29,7 +29,6 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const maxWaitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const initialLoadDone = useRef(false);
const lastSavedCRDTStateRef = useRef<string | null | undefined>(null);
const firstChangeTimeRef = useRef<number | null>(null);
@@ -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;
@@ -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
*/