WIP: GraCoCa - Vibe coding session #1
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<ReactZoomPanPinchRef>(null);
|
||||
|
||||
// Initialize canvas persistence
|
||||
useCanvasPersistence();
|
||||
|
||||
useEffect(() => {
|
||||
chrome.update({
|
||||
sectionNav: navModel,
|
||||
});
|
||||
}, [chrome, navModel]);
|
||||
|
||||
return (
|
||||
<ErrorBoundaryAlert>
|
||||
<TransformProvider value={{ transformRef }}>
|
||||
<div className={styles.pageWrapper}>
|
||||
<h1 className="sr-only">
|
||||
<Trans i18nKey="nav.explore-map.title">Explore Map</Trans>
|
||||
</h1>
|
||||
<ExploreMapToolbar />
|
||||
<ExploreMapCanvas />
|
||||
</div>
|
||||
</TransformProvider>
|
||||
</ErrorBoundaryAlert>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
pageWrapper: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className={styles.canvasWrapper}>
|
||||
<TransformWrapper
|
||||
ref={contextTransformRef as any}
|
||||
initialScale={viewport.zoom}
|
||||
initialPositionX={viewport.panX}
|
||||
initialPositionY={viewport.panY}
|
||||
minScale={0.1}
|
||||
maxScale={4}
|
||||
limitToBounds={false}
|
||||
centerOnInit={false}
|
||||
panning={{
|
||||
disabled: false,
|
||||
excluded: ['panel-drag-handle', 'react-rnd'],
|
||||
}}
|
||||
onTransformed={handleTransformChange}
|
||||
doubleClick={{ disabled: true }}
|
||||
wheel={{ step: 0.1 }}
|
||||
>
|
||||
<TransformComponent wrapperClass={styles.transformWrapper} contentClass={styles.transformContent}>
|
||||
<div ref={canvasRef} className={styles.canvas} onClick={handleCanvasClick}>
|
||||
{Object.values(panels).map((panel) => (
|
||||
<ExploreMapPanelContainer key={panel.id} panel={panel} />
|
||||
))}
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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<Rnd>(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 (
|
||||
<Rnd
|
||||
ref={rndRef}
|
||||
position={{ x: panel.position.x, y: panel.position.y }}
|
||||
size={{ width: panel.position.width, height: panel.position.height }}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleResizeStop}
|
||||
onMouseDown={handleMouseDown}
|
||||
bounds="parent"
|
||||
dragHandleClassName="panel-drag-handle"
|
||||
className={cx(styles.panelContainer, { [styles.selectedPanel]: isSelected })}
|
||||
style={{ zIndex: panel.position.zIndex }}
|
||||
minWidth={300}
|
||||
minHeight={200}
|
||||
>
|
||||
<div className={styles.panel}>
|
||||
<div className={cx(styles.panelHeader, 'panel-drag-handle')}>
|
||||
<div className={styles.panelTitle}>Explore Panel {panel.id.slice(0, 8)}</div>
|
||||
<div className={styles.panelActions}>
|
||||
<Button
|
||||
icon="copy"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fill="text"
|
||||
onClick={handleDuplicate}
|
||||
tooltip="Duplicate panel"
|
||||
/>
|
||||
<Button icon="times" variant="secondary" size="sm" fill="text" onClick={handleRemove} tooltip="Remove" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.panelContent}>
|
||||
<ExploreMapPanelContent
|
||||
exploreId={panel.exploreId}
|
||||
width={panel.position.width}
|
||||
height={panel.position.height - 36}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Rnd>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.loading}>Initializing Explore...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ExplorePaneContainer exploreId={exploreId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarSection}>
|
||||
<Button icon="plus" onClick={handleAddPanel} variant="primary">
|
||||
Add Panel
|
||||
</Button>
|
||||
<span className={styles.panelCount}>{panelCount} panels</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbarSection}>
|
||||
<ButtonGroup>
|
||||
<ToolbarButton icon="search-minus" onClick={handleZoomOut} tooltip="Zoom out" />
|
||||
<ToolbarButton onClick={handleResetZoom} tooltip="Reset zoom">
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</ToolbarButton>
|
||||
<ToolbarButton icon="search-plus" onClick={handleZoomIn} tooltip="Zoom in" />
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbarSection}>
|
||||
<ButtonGroup>
|
||||
<ToolbarButton icon="save" onClick={handleExport} tooltip="Export canvas" />
|
||||
<ToolbarButton icon="upload" onClick={handleImport} tooltip="Import canvas" />
|
||||
<ToolbarButton icon="trash-alt" onClick={handleResetCanvas} tooltip="Clear all panels" variant="destructive" />
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
|
||||
|
||||
interface TransformContextType {
|
||||
transformRef: React.RefObject<ReactZoomPanPinchRef> | null;
|
||||
}
|
||||
|
||||
const TransformContext = createContext<TransformContextType>({ transformRef: null });
|
||||
|
||||
export const useTransformContext = () => {
|
||||
return useContext(TransformContext);
|
||||
};
|
||||
|
||||
export const TransformProvider = TransformContext.Provider;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<PanelPosition> }>) => {
|
||||
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<PanelPosition> }>
|
||||
) => {
|
||||
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<Partial<CanvasViewport>>) => {
|
||||
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<ExploreMapState>) => {
|
||||
return action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addPanel,
|
||||
removePanel,
|
||||
updatePanelPosition,
|
||||
bringPanelToFront,
|
||||
selectPanel,
|
||||
updateViewport,
|
||||
resetCanvas,
|
||||
duplicatePanel,
|
||||
loadCanvas,
|
||||
} = exploreMapSlice.actions;
|
||||
|
||||
export const exploreMapReducer = exploreMapSlice.reducer;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { exploreMapReducer } from './exploreMapSlice';
|
||||
|
||||
export default {
|
||||
exploreMap: exploreMapReducer,
|
||||
};
|
||||
@@ -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<string, ExploreMapPanel>;
|
||||
selectedPanelId?: string;
|
||||
nextZIndex: number;
|
||||
}
|
||||
|
||||
export const initialExploreMapState: ExploreMapState = {
|
||||
viewport: {
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
},
|
||||
panels: {},
|
||||
selectedPanelId: undefined,
|
||||
nextZIndex: 1,
|
||||
};
|
||||
@@ -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: () => <NavLandingPage navId="drilldown" />,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user