diff --git a/package.json b/package.json index 2e6fcba94f2..8f359fc6f2a 100644 --- a/package.json +++ b/package.json @@ -404,6 +404,7 @@ "react-redux": "9.2.0", "react-resizable": "3.0.5", "react-responsive-carousel": "^3.2.23", + "react-rnd": "10.4.13", "react-router": "5.3.4", "react-router-dom": "5.3.4", "react-router-dom-v5-compat": "^6.26.1", @@ -416,6 +417,7 @@ "react-virtualized-auto-sizer": "1.0.26", "react-window": "1.8.11", "react-window-infinite-loader": "1.0.10", + "react-zoom-pan-pinch": "3.6.1", "reduce-reducers": "^1.0.4", "redux": "5.0.1", "redux-thunk": "3.1.0", diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 6f6ddd06e4c..2aecada91ac 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -41,6 +41,7 @@ const ( NavIDRoot = "root" NavIDDashboards = "dashboards/browse" NavIDExplore = "explore" + NavIDExploreMap = "explore-map" NavIDDrilldown = "drilldown" NavIDAdaptiveTelemetry = "adaptive-telemetry" NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 5b9eb985bd8..4439966dc22 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -134,6 +134,17 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere }) } + if s.cfg.ExploreEnabled && hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) { + treeRoot.AddSection(&navtree.NavLink{ + Text: "Explore Map", + Id: navtree.NavIDExploreMap, + SubTitle: "Explore your data on an open canvas", + Icon: "apps", + SortWeight: navtree.WeightExplore + 1, + Url: s.cfg.AppSubURL + "/explore-map", + }) + } + if hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) { treeRoot.AddSection(&navtree.NavLink{ Text: "Drilldown", diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index d86b93c916e..262e1d8cb0b 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -15,6 +15,7 @@ import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/s import dashboardReducers from 'app/features/dashboard/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import exploreReducers from 'app/features/explore/state/main'; +import exploreMapReducers from 'app/features/explore-map/state/reducers'; import invitesReducers from 'app/features/invites/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; import organizationReducers from 'app/features/org/state/reducers'; @@ -36,6 +37,7 @@ const rootReducers = { ...teamsReducers, ...dashboardReducers, ...exploreReducers, + ...exploreMapReducers, ...dataSourcesReducers, ...usersReducers, ...serviceAccountsReducer, diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 0ac5619e893..11738d8fc69 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -60,6 +60,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.scenes.title', 'Scenes'); case 'explore': return t('nav.explore.title', 'Explore'); + case 'explore-map': + return t('nav.explore-map.title', 'Explore Map'); case 'drilldown': return t('nav.drilldown.title', 'Drilldown'); case 'alerting': diff --git a/public/app/features/explore-map/ExploreMapPage.tsx b/public/app/features/explore-map/ExploreMapPage.tsx new file mode 100644 index 00000000000..0ec6a03eaa8 --- /dev/null +++ b/public/app/features/explore-map/ExploreMapPage.tsx @@ -0,0 +1,58 @@ +import { css } from '@emotion/css'; +import { useEffect, useRef } from 'react'; +import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui'; +import { useGrafana } from 'app/core/context/GrafanaContext'; +import { useNavModel } from 'app/core/hooks/useNavModel'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { ExploreMapCanvas } from './components/ExploreMapCanvas'; +import { ExploreMapToolbar } from './components/ExploreMapToolbar'; +import { TransformProvider } from './context/TransformContext'; +import { useCanvasPersistence } from './hooks/useCanvasPersistence'; + +export default function ExploreMapPage(props: GrafanaRouteComponentProps) { + const styles = useStyles2(getStyles); + const { chrome } = useGrafana(); + const navModel = useNavModel('explore-map'); + const transformRef = useRef(null); + + // Initialize canvas persistence + useCanvasPersistence(); + + useEffect(() => { + chrome.update({ + sectionNav: navModel, + }); + }, [chrome, navModel]); + + return ( + + +
+

+ Explore Map +

+ + +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + pageWrapper: css({ + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + backgroundColor: theme.colors.background.primary, + }), + }; +}; diff --git a/public/app/features/explore-map/components/ExploreMapCanvas.tsx b/public/app/features/explore-map/components/ExploreMapCanvas.tsx new file mode 100644 index 00000000000..4927c8dd691 --- /dev/null +++ b/public/app/features/explore-map/components/ExploreMapCanvas.tsx @@ -0,0 +1,109 @@ +import { css } from '@emotion/css'; +import { useCallback, useRef } from 'react'; +import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { useDispatch, useSelector } from 'app/types/store'; + +import { useTransformContext } from '../context/TransformContext'; +import { selectPanel, updateViewport } from '../state/exploreMapSlice'; + +import { ExploreMapPanelContainer } from './ExploreMapPanelContainer'; + +export function ExploreMapCanvas() { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const canvasRef = useRef(null); + const { transformRef: contextTransformRef } = useTransformContext(); + + const panels = useSelector((state) => state.exploreMap.panels); + const viewport = useSelector((state) => state.exploreMap.viewport); + + const handleCanvasClick = useCallback( + (e: React.MouseEvent) => { + // Only deselect if clicking directly on canvas (not on panels) + if (e.target === e.currentTarget) { + dispatch(selectPanel({ panelId: undefined })); + } + }, + [dispatch] + ); + + const handleTransformChange = useCallback( + (ref: any) => { + dispatch( + updateViewport({ + zoom: ref.state.scale, + panX: ref.state.positionX, + panY: ref.state.positionY, + }) + ); + }, + [dispatch] + ); + + return ( +
+ + +
+ {Object.values(panels).map((panel) => ( + + ))} +
+
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + canvasWrapper: css({ + position: 'relative', + flex: 1, + overflow: 'hidden', + backgroundColor: theme.colors.background.canvas, + }), + transformWrapper: css({ + width: '100%', + height: '100%', + cursor: 'grab', + '&:active': { + cursor: 'grabbing', + }, + }), + transformContent: css({ + width: '100%', + height: '100%', + }), + canvas: css({ + position: 'relative', + width: '5000px', + height: '5000px', + backgroundImage: ` + linear-gradient(${theme.colors.border.weak} 1px, transparent 1px), + linear-gradient(90deg, ${theme.colors.border.weak} 1px, transparent 1px) + `, + backgroundSize: '20px 20px', + backgroundPosition: '-1px -1px', + }), + }; +}; diff --git a/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx new file mode 100644 index 00000000000..01778d18858 --- /dev/null +++ b/public/app/features/explore-map/components/ExploreMapPanelContainer.tsx @@ -0,0 +1,180 @@ +import { css, cx } from '@emotion/css'; +import { useCallback, useRef } from 'react'; +import { Rnd } from 'react-rnd'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, useStyles2 } from '@grafana/ui'; +import { useDispatch, useSelector } from 'app/types/store'; + +import { splitClose } from '../../explore/state/main'; +import { + bringPanelToFront, + duplicatePanel, + removePanel, + selectPanel, + updatePanelPosition, +} from '../state/exploreMapSlice'; +import { ExploreMapPanel } from '../state/types'; + +import { ExploreMapPanelContent } from './ExploreMapPanelContent'; + +interface ExploreMapPanelContainerProps { + panel: ExploreMapPanel; +} + +export function ExploreMapPanelContainer({ panel }: ExploreMapPanelContainerProps) { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const rndRef = useRef(null); + + const selectedPanelId = useSelector((state) => state.exploreMap.selectedPanelId); + const isSelected = selectedPanelId === panel.id; + + const handleDragStop = useCallback( + (e: any, data: any) => { + dispatch( + updatePanelPosition({ + panelId: panel.id, + position: { x: data.x, y: data.y }, + }) + ); + }, + [dispatch, panel.id] + ); + + const handleResizeStop = useCallback( + (e: any, direction: any, ref: any, delta: any, position: any) => { + const newWidth = ref.offsetWidth; + const newHeight = ref.offsetHeight; + + dispatch( + updatePanelPosition({ + panelId: panel.id, + position: { + x: position.x, + y: position.y, + width: newWidth, + height: newHeight, + }, + }) + ); + + // Trigger resize event for Explore components + window.dispatchEvent(new Event('resize')); + }, + [dispatch, panel.id] + ); + + const handleMouseDown = useCallback(() => { + dispatch(selectPanel({ panelId: panel.id })); + dispatch(bringPanelToFront({ panelId: panel.id })); + }, [dispatch, panel.id]); + + const handleRemove = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + // Clean up Explore state first + dispatch(splitClose(panel.exploreId)); + // Then remove panel + dispatch(removePanel({ panelId: panel.id })); + }, + [dispatch, panel.id, panel.exploreId] + ); + + const handleDuplicate = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(duplicatePanel({ panelId: panel.id })); + }, + [dispatch, panel.id] + ); + + return ( + +
+
+
Explore Panel {panel.id.slice(0, 8)}
+
+
+
+
+ +
+
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + panelContainer: css({ + cursor: 'default', + }), + panel: css({ + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + backgroundColor: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + overflow: 'hidden', + boxShadow: theme.shadows.z2, + }), + selectedPanel: css({ + '& > div': { + border: `2px solid ${theme.colors.primary.border}`, + }, + }), + panelHeader: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1, 1.5), + backgroundColor: theme.colors.background.secondary, + borderBottom: `1px solid ${theme.colors.border.weak}`, + cursor: 'move', + minHeight: '36px', + }), + panelTitle: css({ + fontSize: theme.typography.body.fontSize, + fontWeight: theme.typography.fontWeightMedium, + userSelect: 'none', + }), + panelActions: css({ + display: 'flex', + gap: theme.spacing(0.5), + }), + panelContent: css({ + flex: 1, + overflow: 'hidden', + position: 'relative', + }), + }; +}; diff --git a/public/app/features/explore-map/components/ExploreMapPanelContent.tsx b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx new file mode 100644 index 00000000000..5ae9427f26d --- /dev/null +++ b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx @@ -0,0 +1,92 @@ +import { css } from '@emotion/css'; +import { useEffect, useMemo, useState } from 'react'; + +import { EventBusSrv, GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { useDispatch, useSelector } from 'app/types/store'; + +import { ExplorePaneContainer } from '../../explore/ExplorePaneContainer'; +import { DEFAULT_RANGE } from '../../explore/state/constants'; +import { initializeExplore } from '../../explore/state/explorePane'; + +interface ExploreMapPanelContentProps { + exploreId: string; + width: number; + height: number; +} + +export function ExploreMapPanelContent({ exploreId }: ExploreMapPanelContentProps) { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const [isInitialized, setIsInitialized] = useState(false); + + // Create scoped event bus for this panel + const eventBus = useMemo(() => new EventBusSrv(), []); + + // Check if the explore pane exists in Redux + const explorePane = useSelector((state) => state.explore?.panes?.[exploreId]); + + // Initialize Explore pane on mount + useEffect(() => { + const initializePane = async () => { + await dispatch( + initializeExplore({ + exploreId, + datasource: undefined, + queries: [], + range: DEFAULT_RANGE, + eventBridge: eventBus, + compact: false, + }) + ); + setIsInitialized(true); + }; + + initializePane(); + + // Cleanup on unmount + return () => { + eventBus.removeAllListeners(); + }; + }, [dispatch, exploreId, eventBus]); + + // Wait for Redux state to be initialized + if (!isInitialized || !explorePane) { + return ( +
+
Initializing Explore...
+
+ ); + } + + return ( +
+ +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + width: '100%', + height: '100%', + overflow: 'hidden', + position: 'relative', + + // Override Explore styles to fit in panel + '& .explore-container': { + padding: 0, + height: '100%', + }, + }), + loading: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: theme.colors.text.secondary, + fontSize: theme.typography.body.fontSize, + }), + }; +}; diff --git a/public/app/features/explore-map/components/ExploreMapToolbar.tsx b/public/app/features/explore-map/components/ExploreMapToolbar.tsx new file mode 100644 index 00000000000..a0993579ad2 --- /dev/null +++ b/public/app/features/explore-map/components/ExploreMapToolbar.tsx @@ -0,0 +1,110 @@ +import { css } from '@emotion/css'; +import { useCallback } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, ButtonGroup, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { useDispatch, useSelector } from 'app/types/store'; + +import { useTransformContext } from '../context/TransformContext'; +import { useCanvasPersistence } from '../hooks/useCanvasPersistence'; +import { addPanel, resetCanvas } from '../state/exploreMapSlice'; + +export function ExploreMapToolbar() { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const { exportCanvas, importCanvas } = useCanvasPersistence(); + const { transformRef } = useTransformContext(); + + const panelCount = useSelector((state) => Object.keys(state.exploreMap.panels).length); + const viewport = useSelector((state) => state.exploreMap.viewport); + + const handleAddPanel = useCallback(() => { + dispatch(addPanel({})); + }, [dispatch]); + + const handleResetCanvas = useCallback(() => { + if (confirm('Are you sure you want to clear all panels? This cannot be undone.')) { + dispatch(resetCanvas()); + } + }, [dispatch]); + + const handleZoomIn = useCallback(() => { + if (transformRef?.current) { + transformRef.current.zoomIn(0.2); + } + }, [transformRef]); + + const handleZoomOut = useCallback(() => { + if (transformRef?.current) { + transformRef.current.zoomOut(0.2); + } + }, [transformRef]); + + const handleResetZoom = useCallback(() => { + if (transformRef?.current) { + transformRef.current.resetTransform(); + } + }, [transformRef]); + + const handleExport = useCallback(() => { + exportCanvas(); + }, [exportCanvas]); + + const handleImport = useCallback(() => { + importCanvas(); + }, [importCanvas]); + + return ( +
+
+ + {panelCount} panels +
+ +
+ + + + {Math.round(viewport.zoom * 100)}% + + + +
+ +
+ + + + + +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + toolbar: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1, 2), + backgroundColor: theme.colors.background.secondary, + borderBottom: `1px solid ${theme.colors.border.weak}`, + minHeight: '48px', + gap: theme.spacing(2), + }), + toolbarSection: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }), + panelCount: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + marginLeft: theme.spacing(1), + }), + }; +}; diff --git a/public/app/features/explore-map/context/TransformContext.tsx b/public/app/features/explore-map/context/TransformContext.tsx new file mode 100644 index 00000000000..6d3553c5ead --- /dev/null +++ b/public/app/features/explore-map/context/TransformContext.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react'; +import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; + +interface TransformContextType { + transformRef: React.RefObject | null; +} + +const TransformContext = createContext({ transformRef: null }); + +export const useTransformContext = () => { + return useContext(TransformContext); +}; + +export const TransformProvider = TransformContext.Provider; diff --git a/public/app/features/explore-map/hooks/useCanvasPersistence.ts b/public/app/features/explore-map/hooks/useCanvasPersistence.ts new file mode 100644 index 00000000000..d061a257079 --- /dev/null +++ b/public/app/features/explore-map/hooks/useCanvasPersistence.ts @@ -0,0 +1,93 @@ +import { useEffect } from 'react'; + +import { useDispatch, useSelector } from 'app/types/store'; + +import { loadCanvas } from '../state/exploreMapSlice'; +import { ExploreMapState } from '../state/types'; + +const STORAGE_KEY = 'grafana.exploreMap.state'; + +export function useCanvasPersistence() { + const dispatch = useDispatch(); + const exploreMapState = useSelector((state) => state.exploreMap); + + // Load state from localStorage on mount + // Note: Explore state is not persisted here - each panel will re-initialize + // its Explore instance when ExploreMapPanelContent mounts + useEffect(() => { + try { + const savedState = localStorage.getItem(STORAGE_KEY); + if (savedState) { + const parsed: ExploreMapState = JSON.parse(savedState); + dispatch(loadCanvas(parsed)); + } + } catch (error) { + console.error('Failed to load canvas state from localStorage:', error); + } + }, [dispatch]); + + // Save state to localStorage whenever it changes + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(exploreMapState)); + } catch (error) { + console.error('Failed to save canvas state to localStorage:', error); + } + }, [exploreMapState]); + + const exportCanvas = () => { + try { + const dataStr = JSON.stringify(exploreMapState, null, 2); + const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); + + const exportFileDefaultName = `explore-map-${new Date().toISOString()}.json`; + + const linkElement = document.createElement('a'); + linkElement.setAttribute('href', dataUri); + linkElement.setAttribute('download', exportFileDefaultName); + linkElement.click(); + } catch (error) { + console.error('Failed to export canvas:', error); + alert('Failed to export canvas. Check console for details.'); + } + }; + + const importCanvas = () => { + try { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + + input.onchange = (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const content = event.target?.result as string; + const parsed: ExploreMapState = JSON.parse(content); + dispatch(loadCanvas(parsed)); + alert('Canvas imported successfully!'); + } catch (error) { + console.error('Failed to parse imported canvas:', error); + alert('Failed to import canvas. Invalid file format.'); + } + }; + reader.readAsText(file); + }; + + input.click(); + } catch (error) { + console.error('Failed to import canvas:', error); + alert('Failed to import canvas. Check console for details.'); + } + }; + + return { + exportCanvas, + importCanvas, + }; +} diff --git a/public/app/features/explore-map/state/exploreMapSlice.ts b/public/app/features/explore-map/state/exploreMapSlice.ts new file mode 100644 index 00000000000..b1398a7e610 --- /dev/null +++ b/public/app/features/explore-map/state/exploreMapSlice.ts @@ -0,0 +1,117 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { v4 as uuidv4 } from 'uuid'; + +import { generateExploreId } from 'app/core/utils/explore'; + +import { CanvasViewport, ExploreMapState, initialExploreMapState, PanelPosition } from './types'; + +const exploreMapSlice = createSlice({ + name: 'exploreMap', + initialState: initialExploreMapState, + reducers: { + addPanel: (state, action: PayloadAction<{ position?: Partial }>) => { + const panelId = uuidv4(); + const exploreId = generateExploreId(); + const defaultPosition: PanelPosition = { + x: 100 + Object.keys(state.panels).length * 50, + y: 100 + Object.keys(state.panels).length * 50, + width: 600, + height: 400, + zIndex: state.nextZIndex, + }; + + state.panels[panelId] = { + id: panelId, + exploreId: exploreId, + position: { ...defaultPosition, ...action.payload.position }, + }; + state.nextZIndex++; + state.selectedPanelId = panelId; + }, + + removePanel: (state, action: PayloadAction<{ panelId: string }>) => { + delete state.panels[action.payload.panelId]; + if (state.selectedPanelId === action.payload.panelId) { + state.selectedPanelId = undefined; + } + }, + + updatePanelPosition: ( + state, + action: PayloadAction<{ panelId: string; position: Partial }> + ) => { + const panel = state.panels[action.payload.panelId]; + if (panel) { + panel.position = { ...panel.position, ...action.payload.position }; + } + }, + + bringPanelToFront: (state, action: PayloadAction<{ panelId: string }>) => { + const panel = state.panels[action.payload.panelId]; + if (panel) { + panel.position.zIndex = state.nextZIndex; + state.nextZIndex++; + } + }, + + selectPanel: (state, action: PayloadAction<{ panelId?: string }>) => { + state.selectedPanelId = action.payload.panelId; + if (action.payload.panelId) { + const panel = state.panels[action.payload.panelId]; + if (panel) { + panel.position.zIndex = state.nextZIndex; + state.nextZIndex++; + } + } + }, + + updateViewport: (state, action: PayloadAction>) => { + state.viewport = { ...state.viewport, ...action.payload }; + }, + + resetCanvas: (state) => { + state.panels = {}; + state.selectedPanelId = undefined; + state.nextZIndex = 1; + state.viewport = initialExploreMapState.viewport; + }, + + duplicatePanel: (state, action: PayloadAction<{ panelId: string }>) => { + const sourcePanel = state.panels[action.payload.panelId]; + if (sourcePanel) { + const newPanelId = uuidv4(); + const newExploreId = generateExploreId(); + state.panels[newPanelId] = { + id: newPanelId, + exploreId: newExploreId, + position: { + ...sourcePanel.position, + x: sourcePanel.position.x + 30, + y: sourcePanel.position.y + 30, + zIndex: state.nextZIndex, + }, + }; + state.nextZIndex++; + state.selectedPanelId = newPanelId; + } + }, + + loadCanvas: (state, action: PayloadAction) => { + return action.payload; + }, + }, +}); + +export const { + addPanel, + removePanel, + updatePanelPosition, + bringPanelToFront, + selectPanel, + updateViewport, + resetCanvas, + duplicatePanel, + loadCanvas, +} = exploreMapSlice.actions; + +export const exploreMapReducer = exploreMapSlice.reducer; diff --git a/public/app/features/explore-map/state/reducers.ts b/public/app/features/explore-map/state/reducers.ts new file mode 100644 index 00000000000..47f24454254 --- /dev/null +++ b/public/app/features/explore-map/state/reducers.ts @@ -0,0 +1,5 @@ +import { exploreMapReducer } from './exploreMapSlice'; + +export default { + exploreMap: exploreMapReducer, +}; diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts new file mode 100644 index 00000000000..e468eb8f3f4 --- /dev/null +++ b/public/app/features/explore-map/state/types.ts @@ -0,0 +1,37 @@ +export interface PanelPosition { + x: number; + y: number; + width: number; + height: number; + zIndex: number; +} + +export interface ExploreMapPanel { + id: string; + exploreId: string; + position: PanelPosition; +} + +export interface CanvasViewport { + zoom: number; + panX: number; + panY: number; +} + +export interface ExploreMapState { + viewport: CanvasViewport; + panels: Record; + selectedPanelId?: string; + nextZIndex: number; +} + +export const initialExploreMapState: ExploreMapState = { + viewport: { + zoom: 1, + panX: 0, + panY: 0, + }, + panels: {}, + selectedPanelId: undefined, + nextZIndex: 1, +}; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 78cf632a1b1..68bafce175a 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -170,6 +170,16 @@ export function getAppRoutes(): RouteDescriptor[] { : import(/* webpackChunkName: "explore-feature-toggle-page" */ 'app/features/explore/FeatureTogglePage') ), }, + { + path: '/explore-map', + pageClass: 'page-explore-map', + roles: () => contextSrv.evaluatePermission([AccessControlAction.DataSourcesExplore]), + component: SafeDynamicImport(() => + config.exploreEnabled + ? import(/* webpackChunkName: "explore-map" */ 'app/features/explore-map/ExploreMapPage') + : import(/* webpackChunkName: "explore-feature-toggle-page" */ 'app/features/explore/FeatureTogglePage') + ), + }, { path: '/drilldown', component: () => , diff --git a/yarn.lock b/yarn.lock index abe69dda8b7..ed800cc8be0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14005,6 +14005,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^1.1.1": + version: 1.2.1 + resolution: "clsx@npm:1.2.1" + checksum: 10/5ded6f61f15f1fa0350e691ccec43a28b12fb8e64c8e94715f2a937bc3722d4c3ed41d6e945c971fc4dcc2a7213a43323beaf2e1c28654af63ba70c9968a8643 + languageName: node + linkType: hard + "clsx@npm:^2.0.0, clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" @@ -19489,6 +19496,7 @@ __metadata: react-refresh: "npm:0.14.0" react-resizable: "npm:3.0.5" react-responsive-carousel: "npm:^3.2.23" + react-rnd: "npm:10.4.13" react-router: "npm:5.3.4" react-router-dom: "npm:5.3.4" react-router-dom-v5-compat: "npm:^6.26.1" @@ -19502,6 +19510,7 @@ __metadata: react-virtualized-auto-sizer: "npm:1.0.26" react-window: "npm:1.8.11" react-window-infinite-loader: "npm:1.0.10" + react-zoom-pan-pinch: "npm:3.6.1" reduce-reducers: "npm:^1.0.4" redux: "npm:5.0.1" redux-mock-store: "npm:1.5.5" @@ -28122,6 +28131,16 @@ __metadata: languageName: node linkType: hard +"re-resizable@npm:6.10.0": + version: 6.10.0 + resolution: "re-resizable@npm:6.10.0" + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 + checksum: 10/303e582feffdfd3e491e2b51dc75c8641c726882e2d8e3ec249b2bc1d23bfffa73bd4a982ed5456bcab9ec25f52b5430e8c632f32647295ed773679691a961a2 + languageName: node + linkType: hard + "re-resizable@npm:6.11.2": version: 6.11.2 resolution: "re-resizable@npm:6.11.2" @@ -28307,6 +28326,19 @@ __metadata: languageName: node linkType: hard +"react-draggable@npm:4.4.6": + version: 4.4.6 + resolution: "react-draggable@npm:4.4.6" + dependencies: + clsx: "npm:^1.1.1" + prop-types: "npm:^15.8.1" + peerDependencies: + react: ">= 16.3.0" + react-dom: ">= 16.3.0" + checksum: 10/51b9ac7f913797fc1cebc30ae383f346883033c45eb91e9b0b92e9ebd224bb1545b4ae2391825b649b798cc711a38351a5f41be24d949c64c6703ebc24eba661 + languageName: node + linkType: hard + "react-draggable@npm:4.5.0, react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5": version: 4.5.0 resolution: "react-draggable@npm:4.5.0" @@ -28671,6 +28703,20 @@ __metadata: languageName: node linkType: hard +"react-rnd@npm:10.4.13": + version: 10.4.13 + resolution: "react-rnd@npm:10.4.13" + dependencies: + re-resizable: "npm:6.10.0" + react-draggable: "npm:4.4.6" + tslib: "npm:2.6.2" + peerDependencies: + react: ">=16.3.0" + react-dom: ">=16.3.0" + checksum: 10/3a343d71117c37e105286eeee38e244d9dc62e0e4b2efa929128995056408cf7fda15f5100f66b8548370ae63f886b94c7851e862fd90dd0b543ec382f43c4f1 + languageName: node + linkType: hard + "react-router-dom-v5-compat@npm:^6.26.1": version: 6.26.1 resolution: "react-router-dom-v5-compat@npm:6.26.1" @@ -28987,6 +29033,16 @@ __metadata: languageName: node linkType: hard +"react-zoom-pan-pinch@npm:3.6.1": + version: 3.6.1 + resolution: "react-zoom-pan-pinch@npm:3.6.1" + peerDependencies: + react: "*" + react-dom: "*" + checksum: 10/9146aa5c427dd6d0c8a4ebe3db0c720718eef6262d1b4b36033ee433bc76a9c84e30ca91311211ab95446305d3e2813d9abc576d093efbf5562be984431896cb + languageName: node + linkType: hard + "react@npm:18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -32660,6 +32716,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.6.2": + version: 2.6.2 + resolution: "tslib@npm:2.6.2" + checksum: 10/bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca + languageName: node + linkType: hard + "tslib@npm:2.6.3": version: 2.6.3 resolution: "tslib@npm:2.6.3"