diff --git a/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx b/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx
index 30cbe797561..d090ad0447f 100644
--- a/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx
+++ b/public/app/features/explore-map/components/ExploreMapFloatingToolbar.tsx
@@ -1,9 +1,9 @@
import { css } from '@emotion/css';
-import { useCallback } from 'react';
+import { useCallback, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
-import { Trans } from '@grafana/i18n';
-import { Button, useStyles2 } from '@grafana/ui';
+import { Trans, t } from '@grafana/i18n';
+import { Button, ButtonGroup, Dropdown, Menu, MenuItem, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types/store';
import { addPanel } from '../state/crdtSlice';
@@ -11,6 +11,7 @@ import { addPanel } from '../state/crdtSlice';
export function ExploreMapFloatingToolbar() {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
+ const [isOpen, setIsOpen] = useState(false);
const handleAddPanel = useCallback(() => {
dispatch(
@@ -21,13 +22,51 @@ export function ExploreMapFloatingToolbar() {
},
})
);
+ setIsOpen(false);
}, [dispatch]);
+ const handleAddTracesDrilldownPanel = useCallback(() => {
+ dispatch(
+ addPanel({
+ viewportSize: {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ },
+ kind: 'traces-drilldown',
+ })
+ );
+ setIsOpen(false);
+ }, [dispatch]);
+
+ const MenuActions = () => (
+
+ );
+
return (
-
+
+
+
+
+
+
);
}
diff --git a/public/app/features/explore-map/components/ExploreMapPanelContent.tsx b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx
index f3953aedf79..3d667033645 100644
--- a/public/app/features/explore-map/components/ExploreMapPanelContent.tsx
+++ b/public/app/features/explore-map/components/ExploreMapPanelContent.tsx
@@ -14,6 +14,8 @@ import { usePanelStateSync } from '../hooks/usePanelStateSync';
// import { useExploreStateSync } from '../hooks/useExploreStateSync';
import { selectPanels } from '../state/selectors';
+import { ExploreMapTracesDrilldownPanel } from './ExploreMapTracesDrilldownPanel';
+
interface ExploreMapPanelContentProps {
panelId: string;
exploreId: string;
@@ -118,8 +120,14 @@ export function ExploreMapPanelContent({ panelId, exploreId, width, height }: Ex
return panels[panelId];
});
- // Initialize Explore pane on mount
+ // Initialize Explore pane on mount (only for standard Explore panels)
useEffect(() => {
+ if (panel?.mode === 'traces-drilldown') {
+ // Traces drilldown panels don't need Explore initialization
+ setIsInitialized(true);
+ return;
+ }
+
const initializePane = async () => {
// Use saved state if available, otherwise defaults
const savedState = panel?.exploreState;
@@ -133,7 +141,7 @@ export function ExploreMapPanelContent({ panelId, exploreId, width, height }: Ex
queries: savedState?.queries || [],
range: savedState?.range || DEFAULT_RANGE,
eventBridge: eventBus,
- compact: savedState?.compact || false,
+ compact: savedState?.compact ?? false,
})
);
setIsInitialized(true);
@@ -146,7 +154,11 @@ export function ExploreMapPanelContent({ panelId, exploreId, width, height }: Ex
eventBus.removeAllListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [dispatch, exploreId, eventBus]);
+ }, [dispatch, exploreId, eventBus, panel?.exploreState, panel?.mode]);
+
+ if (panel?.mode === 'traces-drilldown') {
+ return ;
+ }
// Wait for Redux state to be initialized
if (!isInitialized) {
diff --git a/public/app/features/explore-map/components/ExploreMapTracesDrilldownPanel.tsx b/public/app/features/explore-map/components/ExploreMapTracesDrilldownPanel.tsx
new file mode 100644
index 00000000000..f6a34c00343
--- /dev/null
+++ b/public/app/features/explore-map/components/ExploreMapTracesDrilldownPanel.tsx
@@ -0,0 +1,150 @@
+import { css } from '@emotion/css';
+import { useEffect, useRef, useState } from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Trans, t } from '@grafana/i18n';
+import { config } from '@grafana/runtime';
+import { useStyles2 } from '@grafana/ui';
+import { useDispatch, useSelector } from 'app/types/store';
+
+import { updatePanelIframeUrl } from '../state/crdtSlice';
+import { selectPanels } from '../state/selectors';
+
+interface ExploreMapTracesDrilldownPanelProps {
+ exploreId: string;
+ width: number;
+ height: number;
+}
+
+export function ExploreMapTracesDrilldownPanel({ exploreId, width, height }: ExploreMapTracesDrilldownPanelProps) {
+ const styles = useStyles2(getStyles);
+ const dispatch = useDispatch();
+ const [isInitialized, setIsInitialized] = useState(false);
+ const iframeRef = useRef(null);
+
+ // Find the panel with this exploreId to get saved state
+ const panel = useSelector((state) => {
+ const panels = selectPanels(state.exploreMapCRDT);
+ return Object.values(panels).find((p) => p.exploreId === exploreId);
+ });
+
+ // Build iframe URL for traces drilldown - use saved URL if available, otherwise default
+ // IMPORTANT: Only compute this once on mount to prevent iframe reloads
+ const [tracesDrilldownUrl] = useState(() => {
+ // If we have a saved iframe URL from previous session, use it
+ if (panel?.mode === 'traces-drilldown' && panel?.iframeUrl) {
+ return panel.iframeUrl;
+ }
+ // Otherwise, construct the default URL
+ const origin = window.location.origin;
+ const subUrl = config.appSubUrl || '';
+ const appPath = '/a/grafana-exploretraces-app';
+ return `${origin}${subUrl}${appPath}`;
+ });
+
+ // Initialize immediately for traces drilldown panels
+ useEffect(() => {
+ setIsInitialized(true);
+ }, []);
+
+ // Auto-save iframe URL changes for traces-drilldown panels
+ useEffect(() => {
+ if (!isInitialized) {
+ return;
+ }
+
+ const panelId = panel?.id;
+ if (!panelId) {
+ return;
+ }
+
+ let lastSavedUrl = panel?.iframeUrl || '';
+
+ const checkAndSaveUrl = () => {
+ const iframe = iframeRef.current;
+ if (!iframe) {
+ return;
+ }
+
+ try {
+ const currentUrl = iframe.contentWindow?.location.href;
+ if (currentUrl && currentUrl !== lastSavedUrl) {
+ lastSavedUrl = currentUrl;
+ dispatch(
+ updatePanelIframeUrl({
+ panelId,
+ iframeUrl: currentUrl,
+ })
+ );
+ } else if (!currentUrl) {
+ console.log('No URL detected');
+ }
+ } catch (e) {
+ console.error('Cannot read iframe URL:', e);
+ }
+ };
+
+ // Start checking periodically (every 2 seconds)
+ const intervalId = setInterval(checkAndSaveUrl, 2000);
+
+ // Also check immediately
+ setTimeout(checkAndSaveUrl, 100);
+
+ return () => {
+ clearInterval(intervalId);
+ };
+ // Intentionally not including panel.iframeUrl to avoid re-creating interval on every save
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isInitialized, panel?.id, dispatch]);
+
+ if (!isInitialized) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ container: css({
+ width: '100%',
+ height: '100%',
+ overflow: 'hidden',
+ position: 'relative',
+ }),
+ 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/crdt/state.ts b/public/app/features/explore-map/crdt/state.ts
index b7d91b7ff21..8f475b1d0d0 100644
--- a/public/app/features/explore-map/crdt/state.ts
+++ b/public/app/features/explore-map/crdt/state.ts
@@ -6,6 +6,9 @@
*/
import { v4 as uuidv4 } from 'uuid';
+
+import { SerializedExploreState } from '../state/types';
+
import { HybridLogicalClock } from './hlc';
import { LWWRegister, createLWWRegister } from './lwwregister';
import { ORSet } from './orset';
@@ -20,11 +23,11 @@ import {
UpdatePanelSizeOperation,
UpdatePanelZIndexOperation,
UpdatePanelExploreStateOperation,
+ UpdatePanelIframeUrlOperation,
UpdateTitleOperation,
OperationResult,
CRDTExploreMapStateJSON,
} from './types';
-import { SerializedExploreState } from '../state/types';
export class CRDTStateManager {
private state: CRDTExploreMapState;
@@ -111,6 +114,8 @@ export class CRDTStateManager {
zIndex: data.zIndex.get(),
},
exploreState: data.exploreState.get(),
+ mode: data.mode.get(),
+ iframeUrl: data.iframeUrl.get(),
remoteVersion: data.remoteVersion,
};
}
@@ -135,7 +140,8 @@ export class CRDTStateManager {
createAddPanelOperation(
panelId: string,
exploreId: string,
- position: { x: number; y: number; width: number; height: number }
+ position: { x: number; y: number; width: number; height: number },
+ mode: 'explore' | 'traces-drilldown' = 'explore'
): AddPanelOperation {
const timestamp = this.clock.tick();
return {
@@ -148,6 +154,7 @@ export class CRDTStateManager {
panelId,
exploreId,
position,
+ mode,
},
};
}
@@ -279,6 +286,31 @@ export class CRDTStateManager {
};
}
+ /**
+ * Create an update panel iframe URL operation
+ */
+ createUpdatePanelIframeUrlOperation(
+ panelId: string,
+ iframeUrl: string | undefined
+ ): UpdatePanelIframeUrlOperation | null {
+ if (!this.state.panels.contains(panelId)) {
+ return null;
+ }
+
+ const timestamp = this.clock.tick();
+ return {
+ type: 'update-panel-iframe-url',
+ mapUid: this.mapUid,
+ operationId: uuidv4(),
+ timestamp,
+ nodeId: this.nodeId,
+ payload: {
+ panelId,
+ iframeUrl,
+ },
+ };
+ }
+
/**
* Create an update title operation
*/
@@ -317,6 +349,8 @@ export class CRDTStateManager {
return this.applyUpdatePanelZIndex(operation);
case 'update-panel-explore-state':
return this.applyUpdatePanelExploreState(operation);
+ case 'update-panel-iframe-url':
+ return this.applyUpdatePanelIframeUrl(operation);
case 'update-title':
return this.applyUpdateTitle(operation);
case 'batch':
@@ -338,7 +372,8 @@ export class CRDTStateManager {
}
private applyAddPanel(operation: AddPanelOperation): OperationResult {
- const { panelId, exploreId, position } = operation.payload;
+ const { panelId, exploreId, position, mode } = operation.payload;
+ const panelMode = mode || 'explore';
// Add to OR-Set with operation ID as tag
this.state.panels.add(panelId, operation.operationId);
@@ -356,6 +391,8 @@ export class CRDTStateManager {
height: new LWWRegister(position.height, operation.timestamp),
zIndex: new LWWRegister(zIndex, operation.timestamp),
exploreState: new LWWRegister(undefined, operation.timestamp),
+ mode: new LWWRegister(panelMode, operation.timestamp),
+ iframeUrl: new LWWRegister(undefined, operation.timestamp),
remoteVersion: 0,
});
}
@@ -446,6 +483,22 @@ export class CRDTStateManager {
};
}
+ private applyUpdatePanelIframeUrl(operation: UpdatePanelIframeUrlOperation): OperationResult {
+ const { panelId, iframeUrl } = operation.payload;
+ const panelData = this.state.panelData.get(panelId);
+
+ if (!panelData) {
+ return { success: true, applied: false, error: 'Panel not found' };
+ }
+
+ const updated = panelData.iframeUrl.set(iframeUrl, operation.timestamp);
+
+ return {
+ success: true,
+ applied: updated,
+ };
+ }
+
private applyUpdateTitle(operation: UpdateTitleOperation): OperationResult {
const { title } = operation.payload;
const updated = this.state.title.set(title, operation.timestamp);
@@ -502,6 +555,8 @@ export class CRDTStateManager {
height: otherPanelData.height.clone(),
zIndex: otherPanelData.zIndex.clone(),
exploreState: otherPanelData.exploreState.clone(),
+ mode: otherPanelData.mode.clone(),
+ iframeUrl: otherPanelData.iframeUrl.clone(),
remoteVersion: otherPanelData.remoteVersion,
});
} else {
@@ -512,6 +567,8 @@ export class CRDTStateManager {
myPanelData.height.merge(otherPanelData.height);
myPanelData.zIndex.merge(otherPanelData.zIndex);
myPanelData.exploreState.merge(otherPanelData.exploreState);
+ myPanelData.mode.merge(otherPanelData.mode);
+ myPanelData.iframeUrl.merge(otherPanelData.iframeUrl);
}
}
@@ -535,6 +592,8 @@ export class CRDTStateManager {
height: data.height.toJSON(),
zIndex: data.zIndex.toJSON(),
exploreState: data.exploreState.toJSON(),
+ mode: data.mode.toJSON(),
+ iframeUrl: data.iframeUrl.toJSON(),
remoteVersion: data.remoteVersion,
};
}
@@ -561,6 +620,8 @@ export class CRDTStateManager {
// Load panel data
for (const [panelId, data] of Object.entries(json.panelData)) {
+ // Get the first timestamp from existing registers for defaults
+ const defaultTimestamp = data.positionX?.timestamp || { nodeId: manager.nodeId, counter: 0, wallClock: Date.now() };
manager.state.panelData.set(panelId, {
id: data.id,
exploreId: data.exploreId,
@@ -570,6 +631,8 @@ export class CRDTStateManager {
height: LWWRegister.fromJSON(data.height),
zIndex: LWWRegister.fromJSON(data.zIndex),
exploreState: LWWRegister.fromJSON(data.exploreState),
+ mode: data.mode ? LWWRegister.fromJSON(data.mode) : new LWWRegister('explore', defaultTimestamp),
+ iframeUrl: data.iframeUrl ? LWWRegister.fromJSON(data.iframeUrl) : new LWWRegister(undefined, defaultTimestamp),
remoteVersion: data.remoteVersion || 0,
});
}
diff --git a/public/app/features/explore-map/crdt/types.ts b/public/app/features/explore-map/crdt/types.ts
index ffd93df1216..4ea3a037868 100644
--- a/public/app/features/explore-map/crdt/types.ts
+++ b/public/app/features/explore-map/crdt/types.ts
@@ -5,11 +5,12 @@
* feature, enabling conflict-free collaborative editing.
*/
+import { SerializedExploreState } from '../state/types';
+
+import { HLCTimestamp } from './hlc';
import { LWWRegister } from './lwwregister';
import { ORSet } from './orset';
import { PNCounter } from './pncounter';
-import { HLCTimestamp } from './hlc';
-import { SerializedExploreState } from '../state/types';
/**
* CRDT state for a single panel
@@ -29,6 +30,12 @@ export interface CRDTPanelData {
// CRDT-replicated explore state
exploreState: LWWRegister;
+ // Panel mode (explore or traces-drilldown)
+ mode: LWWRegister<'explore' | 'traces-drilldown'>;
+
+ // Iframe URL for traces-drilldown panels
+ iframeUrl: LWWRegister;
+
// Local counter incremented only for remote explore state updates
remoteVersion: number;
}
@@ -91,6 +98,8 @@ export interface CRDTExploreMapStateJSON {
height: { value: number; timestamp: HLCTimestamp };
zIndex: { value: number; timestamp: HLCTimestamp };
exploreState: { value: SerializedExploreState | undefined; timestamp: HLCTimestamp };
+ mode: { value: 'explore' | 'traces-drilldown'; timestamp: HLCTimestamp };
+ iframeUrl: { value: string | undefined; timestamp: HLCTimestamp };
remoteVersion?: number;
}>;
zIndexCounter: {
@@ -109,6 +118,7 @@ export type CRDTOperationType =
| 'update-panel-size'
| 'update-panel-zindex'
| 'update-panel-explore-state'
+ | 'update-panel-iframe-url'
| 'update-title'
| 'batch'; // For batching multiple operations
@@ -137,6 +147,7 @@ export interface AddPanelOperation extends CRDTOperationBase {
width: number;
height: number;
};
+ mode?: 'explore' | 'traces-drilldown';
};
}
@@ -197,6 +208,17 @@ export interface UpdatePanelExploreStateOperation extends CRDTOperationBase {
};
}
+/**
+ * Update panel iframe URL operation
+ */
+export interface UpdatePanelIframeUrlOperation extends CRDTOperationBase {
+ type: 'update-panel-iframe-url';
+ payload: {
+ panelId: string;
+ iframeUrl: string | undefined;
+ };
+}
+
/**
* Update map title operation
*/
@@ -227,6 +249,7 @@ export type CRDTOperation =
| UpdatePanelSizeOperation
| UpdatePanelZIndexOperation
| UpdatePanelExploreStateOperation
+ | UpdatePanelIframeUrlOperation
| UpdateTitleOperation
| BatchOperation;
diff --git a/public/app/features/explore-map/state/crdtSlice.ts b/public/app/features/explore-map/state/crdtSlice.ts
index c9bd3753733..96541b37daf 100644
--- a/public/app/features/explore-map/state/crdtSlice.ts
+++ b/public/app/features/explore-map/state/crdtSlice.ts
@@ -202,6 +202,7 @@ const crdtSlice = createSlice({
addPanel: (state, action: PayloadAction<{
viewportSize?: { width: number; height: number };
position?: { x: number; y: number; width: number; height: number };
+ kind?: 'explore' | 'traces-drilldown';
}>) => {
const manager = getCRDTManager(state);
@@ -225,8 +226,9 @@ const crdtSlice = createSlice({
// Create operation
const panelId = uuidv4();
const exploreId = generateExploreId();
+ const mode = action.payload.kind || 'explore';
- const operation = manager.createAddPanelOperation(panelId, exploreId, position);
+ const operation = manager.createAddPanelOperation(panelId, exploreId, position, mode);
// Apply locally
manager.applyOperation(operation);
@@ -398,6 +400,29 @@ const crdtSlice = createSlice({
state.pendingOperations.push(operation);
},
+ /**
+ * Update panel iframe URL
+ */
+ updatePanelIframeUrl: (
+ state,
+ action: PayloadAction<{ panelId: string; iframeUrl: string | undefined }>
+ ) => {
+ const manager = getCRDTManager(state);
+
+ const operation = manager.createUpdatePanelIframeUrlOperation(
+ action.payload.panelId,
+ action.payload.iframeUrl
+ );
+
+ if (!operation) {
+ return;
+ }
+
+ manager.applyOperation(operation);
+ saveCRDTManager(state, manager);
+ state.pendingOperations.push(operation);
+ },
+
/**
* Update map title
*/
@@ -435,7 +460,8 @@ const crdtSlice = createSlice({
y: sourcePanel.position.y + offset,
width: sourcePanel.position.width,
height: sourcePanel.position.height,
- }
+ },
+ sourcePanel.mode || 'explore'
);
manager.applyOperation(addOperation);
@@ -563,6 +589,7 @@ export const {
updatePanelSize,
bringPanelToFront,
savePanelExploreState,
+ updatePanelIframeUrl,
updateMapTitle,
duplicatePanel,
clearPendingOperations,
diff --git a/public/app/features/explore-map/state/exploreMapSlice.ts b/public/app/features/explore-map/state/exploreMapSlice.ts
index c80a5ca51c7..ca061de6ba1 100644
--- a/public/app/features/explore-map/state/exploreMapSlice.ts
+++ b/public/app/features/explore-map/state/exploreMapSlice.ts
@@ -15,6 +15,11 @@ import {
interface AddPanelPayload {
position?: Partial;
viewportSize?: { width: number; height: number };
+ /**
+ * Optional panel mode. Defaults to 'explore' for backward compatibility.
+ * Use 'traces-drilldown' to embed the Explore Traces drilldown app.
+ */
+ mode?: 'explore' | 'traces-drilldown';
}
const exploreMapSlice = createSlice({
@@ -24,6 +29,7 @@ const exploreMapSlice = createSlice({
addPanel: (state, action: PayloadAction) => {
const panelId = uuidv4();
const exploreId = generateExploreId();
+ const mode = action.payload.mode ?? 'explore';
// Calculate center of current viewport in canvas coordinates
const viewportSize = action.payload.viewportSize || { width: 1920, height: 1080 };
@@ -45,6 +51,7 @@ const exploreMapSlice = createSlice({
state.panels[panelId] = {
id: panelId,
exploreId: exploreId,
+ mode,
position: { ...defaultPosition, ...action.payload.position },
};
state.nextZIndex++;
@@ -58,11 +65,18 @@ const exploreMapSlice = createSlice({
updatePanelPosition: (
state,
- action: PayloadAction<{ panelId: string; position: Partial }>
+ action: PayloadAction<{ panelId: string; position: Partial }>
) => {
const panel = state.panels[action.payload.panelId];
if (panel) {
- panel.position = { ...panel.position, ...action.payload.position };
+ // Handle position updates
+ const { iframeUrl, ...positionUpdates } = action.payload.position;
+ panel.position = { ...panel.position, ...positionUpdates };
+
+ // Handle iframe URL updates separately
+ if (iframeUrl !== undefined) {
+ panel.iframeUrl = iframeUrl;
+ }
}
},
@@ -176,6 +190,7 @@ const exploreMapSlice = createSlice({
state.panels[newPanelId] = {
id: newPanelId,
exploreId: newExploreId,
+ mode: sourcePanel.mode ?? 'explore',
position: {
...sourcePanel.position,
x: sourcePanel.position.x + 30,
@@ -207,7 +222,16 @@ const exploreMapSlice = createSlice({
return {
...initialExploreMapState,
...loadedState,
- panels: loadedState.panels || {},
+ panels: Object.fromEntries(
+ Object.entries(loadedState.panels || {}).map(([panelId, panel]) => [
+ panelId,
+ {
+ ...panel,
+ // Default to 'explore' for canvases saved before panel modes existed
+ mode: panel.mode ?? 'explore',
+ },
+ ])
+ ),
selectedPanelIds: [],
cursors: {},
};
diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts
index 4a5652a988c..58a4cc211cb 100644
--- a/public/app/features/explore-map/state/types.ts
+++ b/public/app/features/explore-map/state/types.ts
@@ -23,6 +23,17 @@ export interface ExploreMapPanel {
position: PanelPosition;
exploreState?: SerializedExploreState;
remoteVersion?: number; // Increments only on remote explore state updates
+ /**
+ * Panel mode determines what kind of content is rendered inside the panel.
+ * - 'explore': standard Explore pane (current behavior)
+ * - 'traces-drilldown': Traces Drilldown app
+ */
+ mode: 'explore' | 'traces-drilldown';
+ /**
+ * For iframe-based panels (like traces-drilldown), store the complete URL
+ * including query parameters to restore state on reload
+ */
+ iframeUrl?: string;
}
export interface CanvasViewport {