Add frames

This commit is contained in:
Aleksandar Petrov
2025-12-03 18:45:56 -04:00
parent 0b5a7b91fc
commit 66ed7e526a
13 changed files with 1826 additions and 29 deletions
@@ -11,20 +11,15 @@ export const DebugAssistantContext: React.FC = () => {
const pageContext = usePageContext();
useEffect(() => {
// eslint-disable-next-line no-console
console.group('🔍 Assistant Debug Info');
// eslint-disable-next-line no-console
console.log('Current URL:', window.location.pathname);
// eslint-disable-next-line no-console
console.log('Registered Components:', Object.keys(pageComponents));
// eslint-disable-next-line no-console
console.log('Component Details:', pageComponents);
// eslint-disable-next-line no-console
console.log('Page Context Items:', pageContext.length);
// eslint-disable-next-line no-console
console.log('Page Context:', pageContext);
// eslint-disable-next-line no-console
console.groupEnd();
// Debug logging disabled to reduce console noise
// Uncomment if needed for assistant debugging
// console.group('🔍 Assistant Debug Info');
// console.log('Current URL:', window.location.pathname);
// console.log('Registered Components:', Object.keys(pageComponents));
// console.log('Component Details:', pageComponents);
// console.log('Page Context Items:', pageContext.length);
// console.log('Page Context:', pageContext);
// console.groupEnd();
}, [pageComponents, pageContext]);
return null;
@@ -0,0 +1,71 @@
import { useCallback, useState } from 'react';
import { Checkbox, ConfirmModal } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { splitClose } from '../../explore/state/main';
import { removeFrame } from '../state/crdtSlice';
import { selectPanelsInFrame, selectPanels } from '../state/selectors';
interface ConfirmDeleteFrameDialogProps {
frameId: string;
frameTitle: string;
panelCount: number;
onClose: () => void;
}
export function ConfirmDeleteFrameDialog({ frameId, frameTitle, panelCount, onClose }: ConfirmDeleteFrameDialogProps) {
const dispatch = useDispatch();
const panelsInFrame = useSelector((state) => selectPanelsInFrame(state.exploreMapCRDT, frameId));
const allPanels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const [deletePanels, setDeletePanels] = useState(false);
const handleConfirm = useCallback(() => {
// If deleting panels, clean up Explore state first
if (deletePanels) {
for (const panelId of panelsInFrame) {
const panel = allPanels[panelId];
if (panel) {
dispatch(splitClose(panel.exploreId));
}
}
}
dispatch(
removeFrame({
frameId,
deletePanels,
})
);
onClose();
}, [dispatch, frameId, deletePanels, onClose, panelsInFrame, allPanels]);
return (
<ConfirmModal
isOpen={true}
title="Delete frame"
body={
<div>
<p>
Are you sure you want to delete the frame &quot;{frameTitle}&quot;?
</p>
{panelCount > 0 && (
<>
<p>
This frame contains {panelCount} panel{panelCount > 1 ? 's' : ''}.
</p>
<Checkbox
label="Also delete all panels in this frame"
value={deletePanels}
onChange={(e) => setDeletePanels(e.currentTarget.checked)}
/>
</>
)}
</div>
}
confirmText="Delete"
onConfirm={handleConfirm}
onDismiss={onClose}
/>
);
}
@@ -10,10 +10,11 @@ import { useTransformContext } from '../context/TransformContext';
import { useCursorSync } from '../hooks/useCursorSync';
import { useCursorViewportTracking } from '../hooks/useCursorViewportTracking';
import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice';
import { selectPanels, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors';
import { selectPanels, selectFrames, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid } from '../state/selectors';
import { EdgeCursorIndicator } from './EdgeCursorIndicator';
import { ExploreMapComment } from './ExploreMapComment';
import { ExploreMapFrame } from './ExploreMapFrame';
import { ExploreMapPanelContainer } from './ExploreMapPanelContainer';
import { UserCursor } from './UserCursor';
@@ -36,6 +37,7 @@ export function ExploreMapCanvas() {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT));
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
@@ -322,6 +324,11 @@ export function ExploreMapCanvas() {
role="button"
tabIndex={0}
>
{/* Render frames first (lower z-index) */}
{Object.values(frames).map((frame) => (
<ExploreMapFrame key={frame.id} frame={frame} />
))}
{/* Render panels on top */}
{Object.values(panels).map((panel) => {
return <ExploreMapPanelContainer key={panel.id} panel={panel} />;
})}
@@ -13,8 +13,8 @@ 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 } from '../state/crdtSlice';
import { selectPanels, selectMapUid } from '../state/selectors';
import { addPanel, addFrame } from '../state/crdtSlice';
import { selectPanels, selectMapUid, selectViewport, selectSelectedPanelIds } from '../state/selectors';
import { AddPanelAction } from './AssistantComponents';
export function ExploreMapFloatingToolbar() {
@@ -29,6 +29,8 @@ export function ExploreMapFloatingToolbar() {
// Get canvas state for assistant context
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const mapUid = useSelector((state) => selectMapUid(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
const handleAddPanel = useCallback(() => {
dispatch(
@@ -99,6 +101,61 @@ export function ExploreMapFloatingToolbar() {
setIsOpen(false);
}, [dispatch, currentUsername]);
const handleAddFrame = useCallback(() => {
// Get selected panels that are not already in a frame
const selectedUnframedPanels = selectedPanelIds
.map((id) => panels[id])
.filter((panel) => panel && !panel.frameId);
let position: { x: number; y: number; width: number; height: number };
if (selectedUnframedPanels.length > 0) {
// Calculate bounds around selected unframed panels
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const panel of selectedUnframedPanels) {
minX = Math.min(minX, panel.position.x);
minY = Math.min(minY, panel.position.y);
maxX = Math.max(maxX, panel.position.x + panel.position.width);
maxY = Math.max(maxY, panel.position.y + panel.position.height);
}
// Add padding around the panels
const padding = 50;
position = {
x: minX - padding,
y: minY - padding,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
};
} else {
// No selected panels, position at viewport center
const viewportSize = { width: window.innerWidth, height: window.innerHeight };
const canvasCenterX = (-viewport.panX + viewportSize.width / 2) / viewport.zoom;
const canvasCenterY = (-viewport.panY + viewportSize.height / 2) / viewport.zoom;
const frameWidth = 800;
const frameHeight = 600;
position = {
x: canvasCenterX - frameWidth / 2,
y: canvasCenterY - frameHeight / 2,
width: frameWidth,
height: frameHeight,
};
}
dispatch(
addFrame({
position,
createdBy: currentUsername,
})
);
}, [dispatch, currentUsername, selectedPanelIds, panels, viewport]);
// Build context for assistant
const canvasContext = useMemo(() => {
const panelsArray = Object.values(panels);
@@ -245,6 +302,9 @@ CRITICAL:
/>
</Dropdown>
</ButtonGroup>
<Button icon="folder-plus" onClick={handleAddFrame} variant="secondary">
<Trans i18nKey="explore-map.toolbar.add-frame">Add frame</Trans>
</Button>
{isAssistantAvailable && Object.keys(panels).length > 0 && (
<Button icon="ai-sparkle" onClick={handleOpenAssistant} variant="secondary">
<Trans i18nKey="explore-map.toolbar.ask-assistant">Ask Assistant</Trans>
@@ -0,0 +1,503 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import {
updateFramePosition,
updateFrameSize,
updateFrameTitle,
setActiveFrameDrag,
clearActiveFrameDrag,
associatePanelWithFrame,
disassociatePanelFromFrame,
removeFrame,
} from '../state/crdtSlice';
import { selectViewport, selectPanelsInFrame, selectPanels, selectFrames } from '../state/selectors';
import { ExploreMapFrame as Frame } from '../state/types';
import { ConfirmDeleteFrameDialog } from './ConfirmDeleteFrameDialog';
interface ExploreMapFrameProps {
frame: Frame;
}
function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const panelsInFrame = useSelector((state) => selectPanelsInFrame(state.exploreMapCRDT, frame.id));
const allPanels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const allFrames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState(frame.title);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Track current size during resize for visual feedback
const [currentSize, setCurrentSize] = useState({
width: frame.position.width,
height: frame.position.height,
});
// Sync currentSize when frame position changes (from remote updates or after resize completes)
React.useEffect(() => {
setCurrentSize({
width: frame.position.width,
height: frame.position.height,
});
}, [frame.position.width, frame.position.height]);
// Check if two rectangles overlap
const checkRectOverlap = useCallback(
(
x1: number,
y1: number,
w1: number,
h1: number,
x2: number,
y2: number,
w2: number,
h2: number
): boolean => {
return !(x1 + w1 <= x2 || x2 + w2 <= x1 || y1 + h1 <= y2 || y2 + h2 <= y1);
},
[]
);
// Check if a proposed position would cause overlap with other frames
const wouldOverlapOtherFrames = useCallback(
(x: number, y: number, width: number, height: number): boolean => {
for (const [frameId, otherFrame] of Object.entries(allFrames)) {
// Skip the current frame
if (frameId === frame.id) {
continue;
}
if (
checkRectOverlap(
x,
y,
width,
height,
otherFrame.position.x,
otherFrame.position.y,
otherFrame.position.width,
otherFrame.position.height
)
) {
return true;
}
}
return false;
},
[allFrames, frame.id, checkRectOverlap]
);
// Constrain position to avoid overlaps with other frames
const constrainPosition = useCallback(
(proposedX: number, proposedY: number, width: number, height: number): { x: number; y: number } => {
// If no overlap, use the proposed position
if (!wouldOverlapOtherFrames(proposedX, proposedY, width, height)) {
return { x: proposedX, y: proposedY };
}
// Try to find the nearest valid position by checking nearby positions
// We'll use the last valid position if we can't find one
return {
x: frame.position.x,
y: frame.position.y,
};
},
[frame.position.x, frame.position.y, wouldOverlapOtherFrames]
);
// Check if a panel is >50% inside the frame bounds
const isPanelInFrame = useCallback(
(
panelX: number,
panelY: number,
panelWidth: number,
panelHeight: number,
frameX: number,
frameY: number,
frameWidth: number,
frameHeight: number
): boolean => {
const panelLeft = panelX;
const panelRight = panelX + panelWidth;
const panelTop = panelY;
const panelBottom = panelY + panelHeight;
const frameLeft = frameX;
const frameRight = frameX + frameWidth;
const frameTop = frameY;
const frameBottom = frameY + frameHeight;
// Calculate intersection area
const intersectLeft = Math.max(panelLeft, frameLeft);
const intersectRight = Math.min(panelRight, frameRight);
const intersectTop = Math.max(panelTop, frameTop);
const intersectBottom = Math.min(panelBottom, frameBottom);
if (intersectRight > intersectLeft && intersectBottom > intersectTop) {
const intersectArea = (intersectRight - intersectLeft) * (intersectBottom - intersectTop);
const panelArea = panelWidth * panelHeight;
// If >50% of panel is inside frame, consider it contained
return intersectArea / panelArea > 0.5;
}
return false;
},
[]
);
// Update panel-frame associations based on current frame bounds
// This should only handle NEW panels entering the frame, not existing associations
const updatePanelAssociations = useCallback(
(frameX: number, frameY: number, frameWidth: number, frameHeight: number, skipExisting: boolean = false) => {
for (const [panelId, panel] of Object.entries(allPanels)) {
// Skip panels that are already associated with this frame if skipExisting is true
// This prevents recalculating offsets for panels that moved with the frame
if (skipExisting && panel.frameId === frame.id) {
continue;
}
const isInside = isPanelInFrame(
panel.position.x,
panel.position.y,
panel.position.width,
panel.position.height,
frameX,
frameY,
frameWidth,
frameHeight
);
if (isInside && panel.frameId !== frame.id) {
// Panel moved into this frame
const offsetX = panel.position.x - frameX;
const offsetY = panel.position.y - frameY;
dispatch(
associatePanelWithFrame({
panelId,
frameId: frame.id,
offsetX,
offsetY,
})
);
} else if (!isInside && panel.frameId === frame.id) {
// Panel moved out of this frame
dispatch(
disassociatePanelFromFrame({
panelId,
})
);
}
}
},
[allPanels, frame.id, isPanelInFrame, dispatch]
);
const handleDragStart: RndDragCallback = useCallback(
(_e, data) => {
setDragStartPos({ x: data.x, y: data.y });
},
[]
);
const handleDrag: RndDragCallback = useCallback(
(_e, data) => {
if (!dragStartPos) {
return;
}
// Update local state to show frame and child panels moving in real-time
const deltaX = data.x - dragStartPos.x;
const deltaY = data.y - dragStartPos.y;
dispatch(setActiveFrameDrag({
draggedFrameId: frame.id,
deltaX,
deltaY,
}));
},
[dispatch, frame.id, dragStartPos]
);
const handleDragStop: RndDragCallback = useCallback(
(_e, data) => {
// Clear the active frame drag state
dispatch(clearActiveFrameDrag());
// Apply collision detection to constrain the position
const constrainedPos = constrainPosition(data.x, data.y, frame.position.width, frame.position.height);
// Update the frame position (this will move existing child panels)
dispatch(
updateFramePosition({
frameId: frame.id,
x: constrainedPos.x,
y: constrainedPos.y,
})
);
// Check for capturing NEW panels (don't disassociate existing ones)
// Skip existing panels to avoid recalculating their offsets after they've moved with the frame
setTimeout(() => {
updatePanelAssociations(constrainedPos.x, constrainedPos.y, frame.position.width, frame.position.height, true);
}, 0);
setDragStartPos(null);
},
[dispatch, frame.id, frame.position.width, frame.position.height, constrainPosition, updatePanelAssociations]
);
const handleResize: RndResizeCallback = useCallback(
(_e, _direction, ref) => {
// Update current size during resize for visual feedback
setCurrentSize({
width: ref.offsetWidth,
height: ref.offsetHeight,
});
},
[]
);
const handleResizeStop: RndResizeCallback = useCallback(
(_e, _direction, ref, _delta, position) => {
const newWidth = ref.offsetWidth;
const newHeight = ref.offsetHeight;
// Check if the new size would cause overlap
if (wouldOverlapOtherFrames(position.x, position.y, newWidth, newHeight)) {
// Revert to the original size if overlap detected
setCurrentSize({
width: frame.position.width,
height: frame.position.height,
});
return;
}
// Update local size state
setCurrentSize({
width: newWidth,
height: newHeight,
});
dispatch(
updateFramePosition({
frameId: frame.id,
x: position.x,
y: position.y,
})
);
dispatch(
updateFrameSize({
frameId: frame.id,
width: newWidth,
height: newHeight,
})
);
// Check for panel associations AFTER updating frame
// This ensures we check against the new frame size
setTimeout(() => {
updatePanelAssociations(position.x, position.y, newWidth, newHeight);
}, 0);
},
[dispatch, frame.id, frame.position.width, frame.position.height, wouldOverlapOtherFrames, updatePanelAssociations]
);
const handleTitleDoubleClick = useCallback(() => {
setIsEditingTitle(true);
setTitleValue(frame.title);
}, [frame.title]);
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitleValue(e.target.value);
}, []);
const handleTitleBlur = useCallback(() => {
if (titleValue.trim() && titleValue !== frame.title) {
dispatch(
updateFrameTitle({
frameId: frame.id,
title: titleValue.trim(),
})
);
}
setIsEditingTitle(false);
}, [dispatch, frame.id, titleValue, frame.title]);
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleTitleBlur();
} else if (e.key === 'Escape') {
setIsEditingTitle(false);
setTitleValue(frame.title);
}
},
[handleTitleBlur, frame.title]
);
const handleDeleteClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// If the frame has panels, show the confirmation dialog
// Otherwise, delete the frame immediately
if (panelsInFrame.length > 0) {
setShowDeleteDialog(true);
} else {
dispatch(removeFrame({ frameId: frame.id }));
}
},
[panelsInFrame.length, dispatch, frame.id]
);
const handleCloseDeleteDialog = useCallback(() => {
setShowDeleteDialog(false);
}, []);
return (
<Rnd
position={{ x: frame.position.x, y: frame.position.y }}
size={{ width: frame.position.width, height: frame.position.height }}
scale={viewport.zoom}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragStop={handleDragStop}
onResize={handleResize}
onResizeStop={handleResizeStop}
bounds="parent"
dragHandleClassName="frame-drag-handle"
className={styles.frameContainer}
style={{ zIndex: frame.position.zIndex }}
minWidth={400}
minHeight={300}
>
<div className={styles.frame}>
{/* Border overlay that scales inversely with zoom to maintain constant visual width */}
<div
className={styles.frameBorder}
style={{
width: `${currentSize.width * viewport.zoom}px`,
height: `${currentSize.height * viewport.zoom}px`,
transform: `scale(${1 / viewport.zoom})`,
transformOrigin: 'top left',
}}
/>
<div
className={styles.frameHeader + ' frame-drag-handle'}
style={{
top: `${-30 / viewport.zoom}px`,
transform: `scale(${1 / viewport.zoom})`,
transformOrigin: 'top left',
}}
>
{isEditingTitle ? (
<input
className={styles.frameTitleInput}
value={titleValue}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={styles.frameTitle} onDoubleClick={handleTitleDoubleClick}>
{frame.title}
</div>
)}
<IconButton
name="trash-alt"
size="sm"
variant="secondary"
onClick={handleDeleteClick}
tooltip="Delete frame"
className={styles.deleteButton}
/>
</div>
</div>
{showDeleteDialog && (
<ConfirmDeleteFrameDialog
frameId={frame.id}
frameTitle={frame.title}
panelCount={panelsInFrame.length}
onClose={handleCloseDeleteDialog}
/>
)}
</Rnd>
);
}
export const ExploreMapFrame = React.memo(ExploreMapFrameComponent);
const getStyles = (theme: GrafanaTheme2) => ({
frameContainer: css({
cursor: 'default',
// Container needs pointer events for resize handles to work
// But we'll make the interior non-interactive
'& .react-resizable-handle': {
pointerEvents: 'auto',
zIndex: 10, // Ensure handles are above everything
},
}),
frame: css({
width: '100%',
height: '100%',
position: 'relative',
backgroundColor: 'transparent',
pointerEvents: 'none', // Frame interior doesn't intercept clicks - lets them pass through to panels
}),
frameBorder: css({
position: 'absolute',
top: 0,
left: 0,
border: `2px solid ${theme.colors.border.strong}`,
borderRadius: theme.shape.radius.default,
pointerEvents: 'none',
}),
frameHeader: css({
position: 'absolute',
top: '-30px',
left: '0',
padding: theme.spacing(0.5, 1),
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.strong}`,
borderRadius: theme.shape.radius.default,
cursor: 'move',
pointerEvents: 'auto', // Header is interactive
userSelect: 'none',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
deleteButton: css({
cursor: 'pointer',
}),
frameTitle: css({
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
minWidth: '100px',
}),
frameTitleInput: css({
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
padding: 0,
border: 'none',
outline: `2px solid ${theme.colors.primary.border}`,
backgroundColor: theme.colors.background.primary,
borderRadius: theme.shape.radius.default,
minWidth: '100px',
}),
});
@@ -9,8 +9,10 @@ import { useDispatch, useSelector } from 'app/types/store';
import { splitClose } from '../../explore/state/main';
import {
associatePanelWithFrame,
bringPanelToFront,
clearActiveDrag,
disassociatePanelFromFrame,
duplicatePanel,
removePanel,
selectPanel,
@@ -19,7 +21,7 @@ import {
updatePanelPosition,
updatePanelSize,
} from '../state/crdtSlice';
import { selectSelectedPanelIds, selectViewport, selectCursors } from '../state/selectors';
import { selectSelectedPanelIds, selectViewport, selectCursors, selectFrames } from '../state/selectors';
import { ExploreMapPanel } from '../state/types';
import { ExploreMapPanelContent } from './ExploreMapPanelContent';
@@ -37,6 +39,7 @@ 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 frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const isSelected = selectedPanelIds.includes(panel.id);
// Find all users who have this panel selected (excluding current user)
@@ -47,6 +50,47 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
// Get the active drag info from a parent context (if any panel is being dragged)
const activeDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeDrag);
// Get active frame drag info
const activeFrameDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeFrameDrag);
// Check if panel intersects with any frame (>50% overlap)
const checkFrameIntersection = useCallback((
panelX: number,
panelY: number,
panelWidth: number,
panelHeight: number
): string | null => {
for (const frame of Object.values(frames)) {
const panelLeft = panelX;
const panelRight = panelX + panelWidth;
const panelTop = panelY;
const panelBottom = panelY + panelHeight;
const frameLeft = frame.position.x;
const frameRight = frame.position.x + frame.position.width;
const frameTop = frame.position.y;
const frameBottom = frame.position.y + frame.position.height;
// Calculate intersection area
const intersectLeft = Math.max(panelLeft, frameLeft);
const intersectRight = Math.min(panelRight, frameRight);
const intersectTop = Math.max(panelTop, frameTop);
const intersectBottom = Math.min(panelBottom, frameBottom);
if (intersectRight > intersectLeft && intersectBottom > intersectTop) {
const intersectArea = (intersectRight - intersectLeft) * (intersectBottom - intersectTop);
const panelArea = panelWidth * panelHeight;
// If >50% of panel is inside frame, consider it contained
if (intersectArea / panelArea > 0.5) {
return frame.id;
}
}
}
return null;
}, [frames]);
// Calculate effective position considering active drag
let effectiveX = panel.position.x;
let effectiveY = panel.position.y;
@@ -57,6 +101,12 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
effectiveY += activeDragInfo.deltaY;
}
// If this panel's frame is being dragged, apply the frame drag offset
if (activeFrameDragInfo && panel.frameId === activeFrameDragInfo.draggedFrameId) {
effectiveX += activeFrameDragInfo.deltaX;
effectiveY += activeFrameDragInfo.deltaY;
}
const handleDragStart: RndDragCallback = useCallback(
(_e, data) => {
setDragStartPos({ x: data.x, y: data.y });
@@ -116,11 +166,76 @@ function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerPr
y: data.y,
})
);
// Check if panel should be associated with a frame
const intersectingFrameId = checkFrameIntersection(
data.x,
data.y,
panel.position.width,
panel.position.height
);
// eslint-disable-next-line no-console
console.log('[Frame Association] Panel drag ended:', {
panelId: panel.id,
currentFrameId: panel.frameId,
intersectingFrameId,
panelPos: { x: data.x, y: data.y },
willAssociate: intersectingFrameId && intersectingFrameId !== panel.frameId,
willDisassociate: !intersectingFrameId && panel.frameId,
});
if (intersectingFrameId && intersectingFrameId !== panel.frameId) {
// Panel moved into a frame
const frame = frames[intersectingFrameId];
const offsetX = data.x - frame.position.x;
const offsetY = data.y - frame.position.y;
// eslint-disable-next-line no-console
console.log('[Frame Association] Associating panel with frame:', {
panelId: panel.id,
frameId: intersectingFrameId,
offset: { offsetX, offsetY },
});
dispatch(
associatePanelWithFrame({
panelId: panel.id,
frameId: intersectingFrameId,
offsetX,
offsetY,
})
);
} else if (!intersectingFrameId && panel.frameId) {
// Panel moved out of frame
// eslint-disable-next-line no-console
console.log('[Frame Association] Disassociating panel from frame:', {
panelId: panel.id,
frameId: panel.frameId,
});
dispatch(
disassociatePanelFromFrame({
panelId: panel.id,
})
);
}
}
}
setDragStartPos(null);
},
[dispatch, panel.id, dragStartPos, isSelected, selectedPanelIds.length]
[
dispatch,
panel.id,
panel.position.width,
panel.position.height,
panel.frameId,
dragStartPos,
isSelected,
selectedPanelIds.length,
checkFrameIntersection,
frames,
]
);
const handleResizeStop: RndResizeCallback = useCallback(
@@ -57,7 +57,7 @@ const getStyles = (theme: GrafanaTheme2) => {
// Using linear for more predictive movement
// Also transition transform for smooth scaling when zoom changes
// Add opacity transition for smooth fade in/out when entering/leaving view
transition: 'left 0.1s linear, top 0.1s linear, transform 0.2s ease-out, opacity 0.2s ease-out',
transition: 'left 0.1s linear, top 0.1s linear, opacity 0.2s ease-out',
// Will-change hint for better performance
willChange: 'left, top, transform, opacity',
// Fade in animation
+462 -1
View File
@@ -7,7 +7,7 @@
import { v4 as uuidv4 } from 'uuid';
import { ExploreMapPanel, SerializedExploreState } from '../state/types';
import { ExploreMapPanel, ExploreMapFrame, SerializedExploreState } from '../state/types';
import { HybridLogicalClock } from './hlc';
import { LWWRegister, createLWWRegister } from './lwwregister';
@@ -16,6 +16,7 @@ import { PNCounter } from './pncounter';
import {
CRDTExploreMapState,
CRDTPanelData,
CRDTFrameData,
CRDTOperation,
AddPanelOperation,
RemovePanelOperation,
@@ -27,6 +28,13 @@ import {
UpdateTitleOperation,
AddCommentOperation,
RemoveCommentOperation,
AddFrameOperation,
RemoveFrameOperation,
UpdateFramePositionOperation,
UpdateFrameSizeOperation,
UpdateFrameTitleOperation,
AssociatePanelWithFrameOperation,
DisassociatePanelFromFrameOperation,
OperationResult,
CRDTExploreMapStateJSON,
CommentData,
@@ -56,6 +64,8 @@ export class CRDTStateManager {
commentData: new Map(),
panels: new ORSet<string>(),
panelData: new Map(),
frames: new ORSet<string>(),
frameData: new Map(),
zIndexCounter: new PNCounter(),
local: {
viewport: {
@@ -123,6 +133,9 @@ export class CRDTStateManager {
mode: data.mode.get(),
iframeUrl: data.iframeUrl.get(),
createdBy: data.createdBy.get(),
frameId: data.frameId.get(),
frameOffsetX: data.frameOffsetX.get(),
frameOffsetY: data.frameOffsetY.get(),
remoteVersion: data.remoteVersion,
};
}
@@ -141,6 +154,75 @@ export class CRDTStateManager {
return panels;
}
/**
* Get all frame IDs currently in the set
*/
getFrameIds(): string[] {
return this.state.frames.values();
}
/**
* Get frame data by ID
*/
getFrameData(frameId: string): CRDTFrameData | undefined {
if (!this.state.frames.contains(frameId)) {
return undefined;
}
return this.state.frameData.get(frameId);
}
/**
* Get a plain object representation of a frame for UI rendering
*/
getFrameForUI(frameId: string) {
const data = this.getFrameData(frameId);
if (!data) {
return undefined;
}
return {
id: data.id,
title: data.title.get(),
position: {
x: data.positionX.get(),
y: data.positionY.get(),
width: data.width.get(),
height: data.height.get(),
zIndex: data.zIndex.get(),
},
createdBy: data.createdBy.get(),
remoteVersion: data.remoteVersion,
};
}
/**
* Get all frames for UI rendering
*/
getAllFramesForUI(): Record<string, ExploreMapFrame> {
const frames: Record<string, ExploreMapFrame> = {};
for (const frameId of this.getFrameIds()) {
const frame = this.getFrameForUI(frameId);
if (frame) {
frames[frameId] = frame;
}
}
return frames;
}
/**
* Helper to get all panels in a frame
*/
getPanelsInFrame(frameId: string): string[] {
const panelIds: string[] = [];
for (const panelId of this.getPanelIds()) {
const panel = this.getPanelData(panelId);
if (panel && panel.frameId.get() === frameId) {
panelIds.push(panelId);
}
}
return panelIds;
}
/**
* Create an add panel operation
*/
@@ -376,6 +458,162 @@ export class CRDTStateManager {
};
}
/**
* Create an add frame operation
*/
createAddFrameOperation(
frameId: string,
title: string,
position: { x: number; y: number; width: number; height: number },
createdBy?: string
): AddFrameOperation {
const timestamp = this.clock.tick();
return {
type: 'add-frame',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: { frameId, title, position, createdBy },
};
}
/**
* Create a remove frame operation
*/
createRemoveFrameOperation(frameId: string): RemoveFrameOperation | null {
if (!this.state.frames.contains(frameId)) {
return null;
}
const timestamp = this.clock.tick();
const observedTags = this.state.frames.getTags(frameId);
return {
type: 'remove-frame',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: { frameId, observedTags },
};
}
/**
* Create an update frame position operation
*/
createUpdateFramePositionOperation(
frameId: string,
x: number,
y: number,
deltaX: number,
deltaY: number
): UpdateFramePositionOperation | null {
if (!this.state.frames.contains(frameId)) {
return null;
}
const timestamp = this.clock.tick();
return {
type: 'update-frame-position',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: { frameId, x, y, deltaX, deltaY },
};
}
/**
* Create an update frame size operation
*/
createUpdateFrameSizeOperation(
frameId: string,
width: number,
height: number
): UpdateFrameSizeOperation | null {
if (!this.state.frames.contains(frameId)) {
return null;
}
const timestamp = this.clock.tick();
return {
type: 'update-frame-size',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: { frameId, width, height },
};
}
/**
* Create an update frame title operation
*/
createUpdateFrameTitleOperation(
frameId: string,
title: string
): UpdateFrameTitleOperation | null {
if (!this.state.frames.contains(frameId)) {
return null;
}
const timestamp = this.clock.tick();
return {
type: 'update-frame-title',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: { frameId, title },
};
}
/**
* Create an associate panel with frame operation
*/
createAssociatePanelWithFrameOperation(
panelId: string,
frameId: string,
offsetX: number,
offsetY: number
): AssociatePanelWithFrameOperation | null {
if (!this.state.panels.contains(panelId)) {
return null;
}
const timestamp = this.clock.tick();
return {
type: 'associate-panel-with-frame',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: { panelId, frameId, offsetX, offsetY },
};
}
/**
* Create a disassociate panel from frame operation
*/
createDisassociatePanelFromFrameOperation(
panelId: string
): DisassociatePanelFromFrameOperation | null {
if (!this.state.panels.contains(panelId)) {
return null;
}
const timestamp = this.clock.tick();
return {
type: 'disassociate-panel-from-frame',
mapUid: this.mapUid,
operationId: uuidv4(),
timestamp,
nodeId: this.nodeId,
payload: { panelId },
};
}
/**
* Get all comment IDs
*/
@@ -438,6 +676,20 @@ export class CRDTStateManager {
return this.applyAddComment(operation);
case 'remove-comment':
return this.applyRemoveComment(operation);
case 'add-frame':
return this.applyAddFrame(operation);
case 'remove-frame':
return this.applyRemoveFrame(operation);
case 'update-frame-position':
return this.applyUpdateFramePosition(operation);
case 'update-frame-size':
return this.applyUpdateFrameSize(operation);
case 'update-frame-title':
return this.applyUpdateFrameTitle(operation);
case 'associate-panel-with-frame':
return this.applyAssociatePanelWithFrame(operation);
case 'disassociate-panel-from-frame':
return this.applyDisassociatePanelFromFrame(operation);
case 'batch':
return this.applyBatchOperation(operation);
default:
@@ -479,6 +731,9 @@ export class CRDTStateManager {
mode: new LWWRegister(panelMode, operation.timestamp),
iframeUrl: new LWWRegister(undefined, operation.timestamp),
createdBy: new LWWRegister(createdBy, operation.timestamp),
frameId: new LWWRegister(undefined, operation.timestamp),
frameOffsetX: new LWWRegister(undefined, operation.timestamp),
frameOffsetY: new LWWRegister(undefined, operation.timestamp),
remoteVersion: 0,
});
}
@@ -667,6 +922,130 @@ export class CRDTStateManager {
};
}
private applyAddFrame(operation: AddFrameOperation): OperationResult {
const { frameId, title, position, createdBy } = operation.payload;
// Add to OR-Set with operation ID as tag
this.state.frames.add(frameId, operation.operationId);
// Initialize frame data if it doesn't exist
if (!this.state.frameData.has(frameId)) {
// Frames always use z-index 0 to stay below panels
// Panels start from z-index 1 and increment from there
const zIndex = 0;
this.state.frameData.set(frameId, {
id: frameId,
title: new LWWRegister(title, operation.timestamp),
positionX: new LWWRegister(position.x, operation.timestamp),
positionY: new LWWRegister(position.y, operation.timestamp),
width: new LWWRegister(position.width, operation.timestamp),
height: new LWWRegister(position.height, operation.timestamp),
zIndex: new LWWRegister(zIndex, operation.timestamp),
createdBy: new LWWRegister(createdBy, operation.timestamp),
remoteVersion: 0,
});
}
return { success: true, applied: true };
}
private applyRemoveFrame(operation: RemoveFrameOperation): OperationResult {
const { frameId, observedTags } = operation.payload;
// Remove from OR-Set
this.state.frames.remove(frameId, observedTags);
// Disassociate all panels from this frame
for (const panelId of this.getPanelIds()) {
const panel = this.state.panelData.get(panelId);
if (panel && panel.frameId.get() === frameId) {
panel.frameId.set(undefined, operation.timestamp);
panel.frameOffsetX.set(undefined, operation.timestamp);
panel.frameOffsetY.set(undefined, operation.timestamp);
}
}
// Keep frame data as tombstone for CRDT correctness
return { success: true, applied: true };
}
private applyUpdateFramePosition(operation: UpdateFramePositionOperation): OperationResult {
const { frameId, x, y } = operation.payload;
const frameData = this.state.frameData.get(frameId);
if (!frameData) {
return { success: true, applied: false, error: 'Frame not found' };
}
const xUpdated = frameData.positionX.set(x, operation.timestamp);
const yUpdated = frameData.positionY.set(y, operation.timestamp);
return { success: true, applied: xUpdated || yUpdated };
}
private applyUpdateFrameSize(operation: UpdateFrameSizeOperation): OperationResult {
const { frameId, width, height } = operation.payload;
const frameData = this.state.frameData.get(frameId);
if (!frameData) {
return { success: true, applied: false, error: 'Frame not found' };
}
const widthUpdated = frameData.width.set(width, operation.timestamp);
const heightUpdated = frameData.height.set(height, operation.timestamp);
return { success: true, applied: widthUpdated || heightUpdated };
}
private applyUpdateFrameTitle(operation: UpdateFrameTitleOperation): OperationResult {
const { frameId, title } = operation.payload;
const frameData = this.state.frameData.get(frameId);
if (!frameData) {
return { success: true, applied: false, error: 'Frame not found' };
}
const updated = frameData.title.set(title, operation.timestamp);
// Increment remoteVersion only for remote operations
if (updated && operation.nodeId !== this.nodeId) {
frameData.remoteVersion++;
}
return { success: true, applied: updated };
}
private applyAssociatePanelWithFrame(operation: AssociatePanelWithFrameOperation): OperationResult {
const { panelId, frameId, offsetX, offsetY } = operation.payload;
const panelData = this.state.panelData.get(panelId);
if (!panelData) {
return { success: true, applied: false, error: 'Panel not found' };
}
const frameUpdated = panelData.frameId.set(frameId, operation.timestamp);
const offsetXUpdated = panelData.frameOffsetX.set(offsetX, operation.timestamp);
const offsetYUpdated = panelData.frameOffsetY.set(offsetY, operation.timestamp);
return { success: true, applied: frameUpdated || offsetXUpdated || offsetYUpdated };
}
private applyDisassociatePanelFromFrame(operation: DisassociatePanelFromFrameOperation): OperationResult {
const { panelId } = operation.payload;
const panelData = this.state.panelData.get(panelId);
if (!panelData) {
return { success: true, applied: false, error: 'Panel not found' };
}
const frameUpdated = panelData.frameId.set(undefined, operation.timestamp);
const offsetXUpdated = panelData.frameOffsetX.set(undefined, operation.timestamp);
const offsetYUpdated = panelData.frameOffsetY.set(undefined, operation.timestamp);
return { success: true, applied: frameUpdated || offsetXUpdated || offsetYUpdated };
}
/**
* Merge another CRDT state into this one
*/
@@ -705,6 +1084,9 @@ export class CRDTStateManager {
mode: otherPanelData.mode.clone(),
iframeUrl: otherPanelData.iframeUrl.clone(),
createdBy: otherPanelData.createdBy.clone(),
frameId: otherPanelData.frameId.clone(),
frameOffsetX: otherPanelData.frameOffsetX.clone(),
frameOffsetY: otherPanelData.frameOffsetY.clone(),
remoteVersion: otherPanelData.remoteVersion,
});
} else {
@@ -718,6 +1100,41 @@ export class CRDTStateManager {
myPanelData.mode.merge(otherPanelData.mode);
myPanelData.iframeUrl.merge(otherPanelData.iframeUrl);
myPanelData.createdBy.merge(otherPanelData.createdBy);
myPanelData.frameId.merge(otherPanelData.frameId);
myPanelData.frameOffsetX.merge(otherPanelData.frameOffsetX);
myPanelData.frameOffsetY.merge(otherPanelData.frameOffsetY);
}
}
// Merge frame OR-Set
this.state.frames.merge(other.frames);
// Merge frame data
for (const [frameId, otherFrameData] of other.frameData.entries()) {
const myFrameData = this.state.frameData.get(frameId);
if (!myFrameData) {
// Frame doesn't exist locally - copy it
this.state.frameData.set(frameId, {
id: otherFrameData.id,
title: otherFrameData.title.clone(),
positionX: otherFrameData.positionX.clone(),
positionY: otherFrameData.positionY.clone(),
width: otherFrameData.width.clone(),
height: otherFrameData.height.clone(),
zIndex: otherFrameData.zIndex.clone(),
createdBy: otherFrameData.createdBy.clone(),
remoteVersion: otherFrameData.remoteVersion,
});
} else {
// Merge each LWW register
myFrameData.title.merge(otherFrameData.title);
myFrameData.positionX.merge(otherFrameData.positionX);
myFrameData.positionY.merge(otherFrameData.positionY);
myFrameData.width.merge(otherFrameData.width);
myFrameData.height.merge(otherFrameData.height);
myFrameData.zIndex.merge(otherFrameData.zIndex);
myFrameData.createdBy.merge(otherFrameData.createdBy);
}
}
@@ -744,6 +1161,9 @@ export class CRDTStateManager {
mode: data.mode.toJSON(),
iframeUrl: data.iframeUrl.toJSON(),
createdBy: data.createdBy.toJSON(),
frameId: data.frameId.toJSON(),
frameOffsetX: data.frameOffsetX.toJSON(),
frameOffsetY: data.frameOffsetY.toJSON(),
remoteVersion: data.remoteVersion,
};
}
@@ -755,6 +1175,23 @@ export class CRDTStateManager {
}
}
const frameData: Record<string, any> = {};
for (const [frameId, data] of this.state.frameData.entries()) {
if (this.state.frames.contains(frameId)) {
frameData[frameId] = {
id: data.id,
title: data.title.toJSON(),
positionX: data.positionX.toJSON(),
positionY: data.positionY.toJSON(),
width: data.width.toJSON(),
height: data.height.toJSON(),
zIndex: data.zIndex.toJSON(),
createdBy: data.createdBy.toJSON(),
remoteVersion: data.remoteVersion,
};
}
}
return {
uid: this.state.uid,
title: this.state.title.toJSON(),
@@ -762,6 +1199,8 @@ export class CRDTStateManager {
commentData,
panels: this.state.panels.toJSON(),
panelData,
frames: this.state.frames.toJSON(),
frameData,
zIndexCounter: this.state.zIndexCounter.toJSON(),
};
}
@@ -782,6 +1221,7 @@ export class CRDTStateManager {
}
}
manager.state.panels = ORSet.fromJSON(json.panels);
manager.state.frames = json.frames ? ORSet.fromJSON(json.frames) : new ORSet<string>();
manager.state.zIndexCounter = PNCounter.fromJSON(json.zIndexCounter);
// Load panel data
@@ -800,10 +1240,31 @@ export class CRDTStateManager {
mode: data.mode ? LWWRegister.fromJSON(data.mode) : new LWWRegister('explore', defaultTimestamp),
iframeUrl: data.iframeUrl ? LWWRegister.fromJSON(data.iframeUrl) : new LWWRegister(undefined, defaultTimestamp),
createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp),
frameId: data.frameId ? LWWRegister.fromJSON(data.frameId) : new LWWRegister(undefined, defaultTimestamp),
frameOffsetX: data.frameOffsetX ? LWWRegister.fromJSON(data.frameOffsetX) : new LWWRegister(undefined, defaultTimestamp),
frameOffsetY: data.frameOffsetY ? LWWRegister.fromJSON(data.frameOffsetY) : new LWWRegister(undefined, defaultTimestamp),
remoteVersion: data.remoteVersion || 0,
});
}
// Load frame data
if (json.frameData) {
for (const [frameId, data] of Object.entries(json.frameData)) {
const defaultTimestamp = data.positionX?.timestamp || { nodeId: manager.nodeId, counter: 0, wallClock: Date.now() };
manager.state.frameData.set(frameId, {
id: data.id,
title: LWWRegister.fromJSON(data.title),
positionX: LWWRegister.fromJSON(data.positionX),
positionY: LWWRegister.fromJSON(data.positionY),
width: LWWRegister.fromJSON(data.width),
height: LWWRegister.fromJSON(data.height),
zIndex: LWWRegister.fromJSON(data.zIndex),
createdBy: data.createdBy ? LWWRegister.fromJSON(data.createdBy) : new LWWRegister(undefined, defaultTimestamp),
remoteVersion: data.remoteVersion || 0,
});
}
}
return manager;
}
}
@@ -39,10 +39,37 @@ export interface CRDTPanelData {
// Creator metadata (username of who created the panel)
createdBy: LWWRegister<string | undefined>;
// Frame association properties
frameId: LWWRegister<string | undefined>; // Parent frame ID
frameOffsetX: LWWRegister<number | undefined>; // Offset from frame origin
frameOffsetY: LWWRegister<number | undefined>; // Offset from frame origin
// Local counter incremented only for remote explore state updates
remoteVersion: number;
}
/**
* CRDT state for a single frame
*/
export interface CRDTFrameData {
// Stable identifier
id: string;
// CRDT-replicated properties
title: LWWRegister<string>;
positionX: LWWRegister<number>;
positionY: LWWRegister<number>;
width: LWWRegister<number>;
height: LWWRegister<number>;
zIndex: LWWRegister<number>;
// Creator metadata (username of who created the frame)
createdBy: LWWRegister<string | undefined>;
// Local counter incremented only for remote title updates
remoteVersion: number;
}
/**
* Complete CRDT-based Explore Map state
*/
@@ -69,6 +96,12 @@ export interface CRDTExploreMapState {
// Panel data (position, size, content)
panelData: Map<string, CRDTPanelData>;
// Frame collection (OR-Set for add/remove operations)
frames: ORSet<string>; // Set of frame IDs
// Frame data (position, size, title)
frameData: Map<string, CRDTFrameData>;
// Counter for allocating z-indices
zIndexCounter: PNCounter;
@@ -121,6 +154,24 @@ export interface CRDTExploreMapStateJSON {
mode: { value: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'; timestamp: HLCTimestamp };
iframeUrl: { value: string | undefined; timestamp: HLCTimestamp };
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
frameId?: { value: string | undefined; timestamp: HLCTimestamp };
frameOffsetX?: { value: number | undefined; timestamp: HLCTimestamp };
frameOffsetY?: { value: number | undefined; timestamp: HLCTimestamp };
remoteVersion?: number;
}>;
frames?: {
adds: Record<string, string[]>;
removes: string[];
};
frameData?: Record<string, {
id: string;
title: { value: string; timestamp: HLCTimestamp };
positionX: { value: number; timestamp: HLCTimestamp };
positionY: { value: number; timestamp: HLCTimestamp };
width: { value: number; timestamp: HLCTimestamp };
height: { value: number; timestamp: HLCTimestamp };
zIndex: { value: number; timestamp: HLCTimestamp };
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
remoteVersion?: number;
}>;
zIndexCounter: {
@@ -143,6 +194,13 @@ export type CRDTOperationType =
| 'update-title'
| 'add-comment'
| 'remove-comment'
| 'add-frame'
| 'remove-frame'
| 'update-frame-position'
| 'update-frame-size'
| 'update-frame-title'
| 'associate-panel-with-frame'
| 'disassociate-panel-from-frame'
| 'batch'; // For batching multiple operations
/**
@@ -276,6 +334,95 @@ export interface RemoveCommentOperation extends CRDTOperationBase {
};
}
/**
* Add frame operation
*/
export interface AddFrameOperation extends CRDTOperationBase {
type: 'add-frame';
payload: {
frameId: string;
title: string;
position: {
x: number;
y: number;
width: number;
height: number;
};
createdBy?: string;
};
}
/**
* Remove frame operation
*/
export interface RemoveFrameOperation extends CRDTOperationBase {
type: 'remove-frame';
payload: {
frameId: string;
observedTags: string[]; // Tags from OR-Set
};
}
/**
* Update frame position operation
*/
export interface UpdateFramePositionOperation extends CRDTOperationBase {
type: 'update-frame-position';
payload: {
frameId: string;
x: number;
y: number;
deltaX: number; // For batch-updating child panels
deltaY: number;
};
}
/**
* Update frame size operation
*/
export interface UpdateFrameSizeOperation extends CRDTOperationBase {
type: 'update-frame-size';
payload: {
frameId: string;
width: number;
height: number;
};
}
/**
* Update frame title operation
*/
export interface UpdateFrameTitleOperation extends CRDTOperationBase {
type: 'update-frame-title';
payload: {
frameId: string;
title: string;
};
}
/**
* Associate panel with frame operation
*/
export interface AssociatePanelWithFrameOperation extends CRDTOperationBase {
type: 'associate-panel-with-frame';
payload: {
panelId: string;
frameId: string;
offsetX: number; // Relative to frame's top-left
offsetY: number;
};
}
/**
* Disassociate panel from frame operation
*/
export interface DisassociatePanelFromFrameOperation extends CRDTOperationBase {
type: 'disassociate-panel-from-frame';
payload: {
panelId: string;
};
}
/**
* Batch operation (multiple operations in one)
*/
@@ -300,6 +447,13 @@ export type CRDTOperation =
| UpdateTitleOperation
| AddCommentOperation
| RemoveCommentOperation
| AddFrameOperation
| RemoveFrameOperation
| UpdateFramePositionOperation
| UpdateFrameSizeOperation
| UpdateFrameTitleOperation
| AssociatePanelWithFrameOperation
| DisassociatePanelFromFrameOperation
| BatchOperation;
/**
@@ -8,7 +8,7 @@ import { useDispatch, useSelector } from 'app/types/store';
import { exploreMapApi } from '../api/exploreMapApi';
import { initializeFromLegacyState, loadState as loadCRDTState } from '../state/crdtSlice';
import { loadCanvas } from '../state/exploreMapSlice';
import { selectPanels, selectMapTitle, selectViewport } from '../state/selectors';
import { selectPanels, selectFrames, selectMapTitle, selectViewport } from '../state/selectors';
import { ExploreMapState, initialExploreMapState, SerializedExploreState } from '../state/types';
const STORAGE_KEY = 'grafana.exploreMap.state';
@@ -80,11 +80,15 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
// Load from API
try {
setLoading(true);
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Loading from API:', { uid });
const mapData = await exploreMapApi.getExploreMap(uid);
// Handle empty or missing data (new maps)
let parsed: ExploreMapState;
if (!mapData.data || mapData.data.trim() === '') {
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Empty map data, initializing with defaults');
// Initialize with default empty state for new maps
parsed = {
...initialExploreMapState,
@@ -96,6 +100,18 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
// Use title from DB column, not from JSON data
parsed.uid = mapData.uid;
parsed.title = mapData.title;
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Loaded map data:', {
uid: parsed.uid,
title: parsed.title,
panelCount: Object.keys(parsed.panels || {}).length,
frameCount: Object.keys(parsed.frames || {}).length,
frameIds: Object.keys(parsed.frames || {}),
frames: parsed.frames,
hasCrdtState: !!parsed.crdtState,
crdtFrames: parsed.crdtState?.frames,
crdtFrameData: parsed.crdtState?.frameData,
});
}
// Load into legacy state (for backward compatibility)
@@ -104,9 +120,13 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
// Initialize CRDT state from loaded data
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
if (parsed.crdtState) {
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Loading CRDT state');
// Load the saved CRDT state which includes proper OR-Set metadata
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
} else {
// eslint-disable-next-line no-console
console.log('[Frame Persistence] No CRDT state found, initializing from legacy state');
// Fallback to legacy initialization for backward compatibility
dispatch(initializeFromLegacyState({
uid: parsed.uid,
@@ -172,18 +192,32 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
// Get current CRDT state as panels
const panels = selectPanels(crdtState);
const frames = selectFrames(crdtState);
const mapTitle = selectMapTitle(crdtState);
const viewport = selectViewport(crdtState);
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Current state:', {
panelCount: Object.keys(panels || {}).length,
frameCount: Object.keys(frames || {}).length,
frameIds: Object.keys(frames || {}),
frames: frames,
});
// Don't persist an empty canvas; this avoids removing a previously saved
// non-empty canvas when the in-memory state is still at its initial value.
if (Object.keys(panels || {}).length === 0) {
// Allow saving if there are either panels or frames
if (Object.keys(panels || {}).length === 0 && Object.keys(frames || {}).length === 0) {
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Skipping save - empty canvas');
return;
}
// Check if CRDT state has actually changed (ignore local UI state like selection)
const currentCRDTStateStr = crdtState.crdtStateJSON;
if (currentCRDTStateStr === lastSavedCRDTStateRef.current) {
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Skipping save - no CRDT state changes');
// No changes to persist
return;
}
@@ -196,7 +230,6 @@ 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);
// Save both legacy format (for backward compat) and CRDT state
const enrichedState: ExploreMapState = {
@@ -204,6 +237,7 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
title: mapTitle,
viewport,
panels: panels, // Already contains exploreState from CRDT
frames: frames, // Frames from CRDT state
selectedPanelIds: [],
nextZIndex: 1,
cursors: {},
@@ -211,21 +245,32 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
crdtState: crdtState.crdtStateJSON ? JSON.parse(crdtState.crdtStateJSON) : undefined,
};
// eslint-disable-next-line no-console
console.log('[Frame Persistence] Preparing to save:', {
uid,
panelCount: Object.keys(panels || {}).length,
frameCount: Object.keys(frames || {}).length,
frameIds: Object.keys(frames || {}),
hasCrdtState: !!enrichedState.crdtState,
crdtFramesInState: enrichedState.crdtState?.frames,
crdtFrameDataInState: enrichedState.crdtState?.frameData,
});
if (uid) {
// Save to API with debounce
saveTimeoutRef.current = setTimeout(async () => {
try {
setSaving(true);
const titleToSave = mapTitle || 'Untitled Map';
const dataToSave = enrichedState;
await exploreMapApi.updateExploreMap(uid, {
title: titleToSave,
data: enrichedState,
data: dataToSave,
});
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')));
} finally {
setSaving(false);
@@ -238,7 +283,6 @@ export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) {
// Update last saved state ref to prevent duplicate saves
lastSavedCRDTStateRef.current = currentCRDTStateStr;
} catch (error) {
console.error('Failed to save canvas state to storage:', error);
}
}
};
@@ -48,6 +48,11 @@ export interface ExploreMapCRDTState {
deltaX: number;
deltaY: number;
};
activeFrameDrag?: {
draggedFrameId: string;
deltaX: number;
deltaY: number;
};
};
}
@@ -78,6 +83,7 @@ export function createInitialCRDTState(mapUid?: string): ExploreMapCRDTState {
isOnline: false,
isSyncing: false,
activeDrag: undefined,
activeFrameDrag: undefined,
},
};
}
@@ -120,11 +126,20 @@ const crdtSlice = createSlice({
* Load CRDT state from server
*/
loadState: (state, action: PayloadAction<{ crdtState: CRDTExploreMapStateJSON }>) => {
// eslint-disable-next-line no-console
console.log('[Frame CRDT] Loading state from JSON:', {
hasFrames: !!action.payload.crdtState.frames,
hasFrameData: !!action.payload.crdtState.frameData,
frames: action.payload.crdtState.frames,
frameData: action.payload.crdtState.frameData,
});
const manager = CRDTStateManager.fromJSON(action.payload.crdtState, state.nodeId);
saveCRDTManager(state, manager);
// Restore uid from the loaded CRDT state
const crdtState = manager.getState();
state.uid = crdtState.uid;
// eslint-disable-next-line no-console
console.log('[Frame CRDT] State loaded, frame IDs:', manager.getFrameIds());
},
/**
@@ -575,6 +590,226 @@ const crdtSlice = createSlice({
state.local.selectedPanelIds = [newPanelId];
},
/**
* Add a frame
*/
addFrame: (state, action: PayloadAction<{
position?: { x: number; y: number; width: number; height: number };
title?: string;
createdBy?: string;
}>) => {
const manager = getCRDTManager(state);
const frameId = uuidv4();
const title = action.payload.title || `Frame ${frameId.slice(0, 8)}`;
const position = action.payload.position || {
x: 200, y: 200, width: 800, height: 600
};
const operation = manager.createAddFrameOperation(
frameId,
title,
position,
action.payload.createdBy
);
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
state.local.selectedPanelIds = []; // Clear panel selection
},
/**
* Remove a frame
*/
removeFrame: (state, action: PayloadAction<{ frameId: string; deletePanels?: boolean }>) => {
const manager = getCRDTManager(state);
// If deletePanels is true, first remove all panels in the frame
if (action.payload.deletePanels) {
const panelIds = manager.getPanelsInFrame(action.payload.frameId);
for (const panelId of panelIds) {
const panelOp = manager.createRemovePanelOperation(panelId);
if (panelOp) {
manager.applyOperation(panelOp);
state.pendingOperations.push(panelOp);
}
}
}
const operation = manager.createRemoveFrameOperation(action.payload.frameId);
if (!operation) {
return;
}
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
},
/**
* Update frame position
*/
updateFramePosition: (state, action: PayloadAction<{
frameId: string;
x: number;
y: number;
}>) => {
const manager = getCRDTManager(state);
// Calculate delta for batch-updating child panels
const frame = manager.getFrameData(action.payload.frameId);
if (!frame) {
return;
}
const deltaX = action.payload.x - frame.positionX.get();
const deltaY = action.payload.y - frame.positionY.get();
// Create frame position operation
const frameOp = manager.createUpdateFramePositionOperation(
action.payload.frameId,
action.payload.x,
action.payload.y,
deltaX,
deltaY
);
if (!frameOp) {
return;
}
// Create operations for all child panels
const panelOps: any[] = [];
const childPanelIds = manager.getPanelsInFrame(action.payload.frameId);
for (const panelId of childPanelIds) {
const panel = manager.getPanelData(panelId);
if (panel) {
const newX = panel.positionX.get() + deltaX;
const newY = panel.positionY.get() + deltaY;
const panelOp = manager.createUpdatePanelPositionOperation(panelId, newX, newY);
if (panelOp) {
panelOps.push(panelOp);
}
}
}
// Apply all operations
manager.applyOperation(frameOp);
for (const op of panelOps) {
manager.applyOperation(op);
}
saveCRDTManager(state, manager);
// Push all operations for broadcast
state.pendingOperations.push(frameOp, ...panelOps);
},
/**
* Update frame size
*/
updateFrameSize: (state, action: PayloadAction<{
frameId: string;
width: number;
height: number;
}>) => {
const manager = getCRDTManager(state);
const operation = manager.createUpdateFrameSizeOperation(
action.payload.frameId,
action.payload.width,
action.payload.height
);
if (!operation) {
return;
}
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
},
/**
* Update frame title
*/
updateFrameTitle: (state, action: PayloadAction<{
frameId: string;
title: string;
}>) => {
const manager = getCRDTManager(state);
const operation = manager.createUpdateFrameTitleOperation(
action.payload.frameId,
action.payload.title
);
if (!operation) {
return;
}
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
},
/**
* Associate panel with frame
*/
associatePanelWithFrame: (state, action: PayloadAction<{
panelId: string;
frameId: string;
offsetX: number;
offsetY: number;
}>) => {
const manager = getCRDTManager(state);
const operation = manager.createAssociatePanelWithFrameOperation(
action.payload.panelId,
action.payload.frameId,
action.payload.offsetX,
action.payload.offsetY
);
if (!operation) {
return;
}
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
},
/**
* Disassociate panel from frame
*/
disassociatePanelFromFrame: (state, action: PayloadAction<{
panelId: string;
}>) => {
const manager = getCRDTManager(state);
const operation = manager.createDisassociatePanelFromFrameOperation(
action.payload.panelId
);
if (!operation) {
return;
}
manager.applyOperation(operation);
saveCRDTManager(state, manager);
state.pendingOperations.push(operation);
},
/**
* Clear pending operations (after broadcast)
*/
@@ -671,6 +906,20 @@ const crdtSlice = createSlice({
state.local.activeDrag = undefined;
},
/**
* Set active frame drag info (for frame drag visual feedback)
*/
setActiveFrameDrag: (state, action: PayloadAction<{ draggedFrameId: string; deltaX: number; deltaY: number }>) => {
state.local.activeFrameDrag = action.payload;
},
/**
* Clear active frame drag info
*/
clearActiveFrameDrag: (state) => {
state.local.activeFrameDrag = undefined;
},
/**
* Clear all state (reset)
*/
@@ -699,6 +948,13 @@ export const {
addComment,
removeComment,
duplicatePanel,
addFrame,
removeFrame,
updateFramePosition,
updateFrameSize,
updateFrameTitle,
associatePanelWithFrame,
disassociatePanelFromFrame,
clearPendingOperations,
updateViewport,
selectPanel,
@@ -709,6 +965,8 @@ export const {
setSyncingStatus,
setActiveDrag,
clearActiveDrag,
setActiveFrameDrag,
clearActiveFrameDrag,
clearMap,
} = crdtSlice.actions;
@@ -10,7 +10,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { CRDTStateManager } from '../crdt/state';
import { ExploreMapCRDTState } from './crdtSlice';
import { ExploreMapPanel } from './types';
import { ExploreMapFrame, ExploreMapPanel } from './types';
/**
* Get CRDT manager from state
@@ -30,6 +30,12 @@ function getCRDTManager(state: ExploreMapCRDTState): CRDTStateManager {
*/
const panelCache = new Map<string, { panel: ExploreMapPanel; data: string }>();
/**
* Cache for individual frame objects
* Key: frameId, Value: { frame object, serialized frame data for comparison }
*/
const frameCache = new Map<string, { frame: ExploreMapFrame; data: string }>();
/**
* Select all panels as a Record (for compatibility with existing UI)
*
@@ -121,6 +127,111 @@ export const selectPanelIds = createSelector(
}
);
/**
* Select all frames as a Record (for compatibility with existing UI)
*
* OPTIMIZATION: This selector caches individual frame objects and only creates
* new frame objects when the underlying CRDT data for that specific frame changes.
* This prevents unnecessary re-renders of unchanged frames when another frame is modified.
*/
export const selectFrames = createSelector(
[
(state: ExploreMapCRDTState) => state.crdtStateJSON,
(state: ExploreMapCRDTState) => state.nodeId,
(state: ExploreMapCRDTState) => state.uid,
],
(crdtStateJSON, nodeId, uid): Record<string, ExploreMapFrame> => {
const state: ExploreMapCRDTState = {
uid,
crdtStateJSON,
nodeId,
sessionId: '', // Not needed for frame selection
pendingOperations: [],
local: {
viewport: { zoom: 1, panX: 0, panY: 0 },
selectedPanelIds: [],
cursors: {},
isOnline: false,
isSyncing: false,
},
};
const manager = getCRDTManager(state);
const frames: Record<string, ExploreMapFrame> = {};
const currentFrameIds = new Set(manager.getFrameIds());
// Clean up cache for removed frames
for (const cachedFrameId of frameCache.keys()) {
if (!currentFrameIds.has(cachedFrameId)) {
frameCache.delete(cachedFrameId);
}
}
// Build frames object, reusing cached objects when data hasn't changed
for (const frameId of currentFrameIds) {
const frameData = manager.getFrameForUI(frameId);
if (!frameData) {
continue;
}
// Serialize the frame data to detect changes
const serializedData = JSON.stringify(frameData);
const cached = frameCache.get(frameId);
// Reuse cached frame if data hasn't changed
if (cached && cached.data === serializedData) {
frames[frameId] = cached.frame;
} else {
// Frame is new or changed, create new object and cache it
const frame = frameData as ExploreMapFrame;
frames[frameId] = frame;
frameCache.set(frameId, { frame, data: serializedData });
}
}
return frames;
}
);
/**
* Select a single frame by ID
*/
export const selectFrame = createSelector(
[
(state: ExploreMapCRDTState) => state,
(_state: ExploreMapCRDTState, frameId: string) => frameId,
],
(state, frameId): ExploreMapFrame | undefined => {
const manager = getCRDTManager(state);
const frameData = manager.getFrameForUI(frameId);
return frameData ? (frameData as ExploreMapFrame) : undefined;
}
);
/**
* Select frame IDs
*/
export const selectFrameIds = createSelector(
[(state: ExploreMapCRDTState) => state],
(state): string[] => {
const manager = getCRDTManager(state);
return manager.getFrameIds();
}
);
/**
* Select all panels in a specific frame
*/
export const selectPanelsInFrame = createSelector(
[
(state: ExploreMapCRDTState) => state,
(_state: ExploreMapCRDTState, frameId: string) => frameId,
],
(state, frameId): string[] => {
const manager = getCRDTManager(state);
return manager.getPanelsInFrame(frameId);
}
);
/**
* Select map title
*/
@@ -347,6 +458,7 @@ export const selectActiveUsers = createSelector(
export const selectLegacyState = createSelector(
[
selectPanels,
selectFrames,
selectMapTitle,
selectViewport,
selectSelectedPanelIds,
@@ -354,7 +466,7 @@ export const selectLegacyState = createSelector(
selectMapUid,
(state: ExploreMapCRDTState) => state,
],
(panels, title, viewport, selectedPanelIds, cursors, uid, state) => {
(panels, frames, title, viewport, selectedPanelIds, cursors, uid, state) => {
const manager = getCRDTManager(state);
const crdtState = manager.getState();
@@ -363,6 +475,7 @@ export const selectLegacyState = createSelector(
title,
viewport,
panels,
frames,
selectedPanelIds,
nextZIndex: crdtState.zIndexCounter.value() + 1,
cursors,
@@ -41,6 +41,20 @@ export interface ExploreMapPanel {
* Username of the user who created this panel
*/
createdBy?: string;
/**
* Frame association properties
*/
frameId?: string; // Parent frame ID
frameOffsetX?: number; // Offset from frame origin
frameOffsetY?: number; // Offset from frame origin
}
export interface ExploreMapFrame {
id: string;
title: string;
position: PanelPosition; // Reuse position type
createdBy?: string;
remoteVersion?: number;
}
export interface CanvasViewport {
@@ -65,6 +79,7 @@ export interface ExploreMapState {
title?: string;
viewport: CanvasViewport;
panels: Record<string, ExploreMapPanel>;
frames: Record<string, ExploreMapFrame>;
selectedPanelIds: string[];
nextZIndex: number;
cursors: Record<string, UserCursor>;
@@ -84,6 +99,7 @@ export const initialExploreMapState: ExploreMapState = {
panY: -4460, // -(5000 - 1080/2) = -4460
},
panels: {},
frames: {},
selectedPanelIds: [],
nextZIndex: 1,
cursors: {},