Add Metrics Drilldown

This commit is contained in:
Joey
2025-12-02 09:40:36 +00:00
parent 3111cc9283
commit b1b0af06e7
8 changed files with 191 additions and 16 deletions
@@ -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 ExploreMapMetricsDrilldownPanelProps {
exploreId: string;
width: number;
height: number;
}
export function ExploreMapMetricsDrilldownPanel({ exploreId, width, height }: ExploreMapMetricsDrilldownPanelProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [isInitialized, setIsInitialized] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(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 metrics drilldown - use saved URL if available, otherwise default
// IMPORTANT: Only compute this once on mount to prevent iframe reloads
const [metricsDrilldownUrl] = useState(() => {
// If we have a saved iframe URL from previous session, use it
if (panel?.mode === 'metrics-drilldown' && panel?.iframeUrl) {
return panel.iframeUrl;
}
// Otherwise, construct the default URL
const origin = window.location.origin;
const subUrl = config.appSubUrl || '';
const appPath = '/a/grafana-metricsdrilldown-app';
return `${origin}${subUrl}${appPath}`;
});
// Initialize immediately for metrics drilldown panels
useEffect(() => {
setIsInitialized(true);
}, []);
// Auto-save iframe URL changes for metrics-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 (
<div className={styles.container}>
<div className={styles.loading}>
<Trans i18nKey="explore-map.panel.initializing">Initializing...</Trans>
</div>
</div>
);
}
return (
<div
className={styles.container}
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
<iframe
ref={iframeRef}
src={metricsDrilldownUrl}
title={t('explore-map.panel.metrics-drilldown', 'Metrics Drilldown')}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
/>
</div>
);
}
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,
}),
};
};
@@ -7,8 +7,8 @@ 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';
import { updatePanelIframeUrl } from '../../state/crdtSlice';
import { selectPanels } from '../../state/selectors';
interface ExploreMapTracesDrilldownPanelProps {
exploreId: string;
@@ -38,6 +38,19 @@ export function ExploreMapFloatingToolbar() {
setIsOpen(false);
}, [dispatch]);
const handleAddMetricsDrilldownPanel = useCallback(() => {
dispatch(
addPanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
kind: 'metrics-drilldown',
})
);
setIsOpen(false);
}, [dispatch]);
const MenuActions = () => (
<Menu>
<MenuItem
@@ -50,6 +63,11 @@ export function ExploreMapFloatingToolbar() {
icon="drilldown"
onClick={handleAddTracesDrilldownPanel}
/>
<MenuItem
label={t('explore-map.toolbar.add-metrics-drilldown-panel', 'Add Metrics Drilldown panel')}
icon="chart-line"
onClick={handleAddMetricsDrilldownPanel}
/>
</Menu>
);
@@ -14,7 +14,8 @@ import { usePanelStateSync } from '../hooks/usePanelStateSync';
// import { useExploreStateSync } from '../hooks/useExploreStateSync';
import { selectPanels } from '../state/selectors';
import { ExploreMapTracesDrilldownPanel } from './ExploreMapTracesDrilldownPanel';
import { ExploreMapMetricsDrilldownPanel } from './Drilldown/ExploreMapMetricsDrilldownPanel';
import { ExploreMapTracesDrilldownPanel } from './Drilldown/ExploreMapTracesDrilldownPanel';
interface ExploreMapPanelContentProps {
panelId: string;
@@ -122,8 +123,8 @@ export function ExploreMapPanelContent({ panelId, exploreId, width, height }: Ex
// Initialize Explore pane on mount (only for standard Explore panels)
useEffect(() => {
if (panel?.mode === 'traces-drilldown') {
// Traces drilldown panels don't need Explore initialization
if (panel?.mode === 'traces-drilldown' || panel?.mode === 'metrics-drilldown') {
// Drilldown panels don't need Explore initialization
setIsInitialized(true);
return;
}
@@ -160,6 +161,10 @@ export function ExploreMapPanelContent({ panelId, exploreId, width, height }: Ex
return <ExploreMapTracesDrilldownPanel exploreId={exploreId} width={width} height={height} />;
}
if (panel?.mode === 'metrics-drilldown') {
return <ExploreMapMetricsDrilldownPanel exploreId={exploreId} width={width} height={height} />;
}
// Wait for Redux state to be initialized
if (!isInitialized) {
return (
@@ -141,7 +141,7 @@ export class CRDTStateManager {
panelId: string,
exploreId: string,
position: { x: number; y: number; width: number; height: number },
mode: 'explore' | 'traces-drilldown' = 'explore'
mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown' = 'explore'
): AddPanelOperation {
const timestamp = this.clock.tick();
return {
@@ -30,8 +30,8 @@ export interface CRDTPanelData {
// CRDT-replicated explore state
exploreState: LWWRegister<SerializedExploreState | undefined>;
// Panel mode (explore or traces-drilldown)
mode: LWWRegister<'explore' | 'traces-drilldown'>;
// Panel mode (explore, traces-drilldown, or metrics-drilldown)
mode: LWWRegister<'explore' | 'traces-drilldown' | 'metrics-drilldown'>;
// Iframe URL for traces-drilldown panels
iframeUrl: LWWRegister<string | undefined>;
@@ -98,7 +98,7 @@ 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 };
mode: { value: 'explore' | 'traces-drilldown' | 'metrics-drilldown'; timestamp: HLCTimestamp };
iframeUrl: { value: string | undefined; timestamp: HLCTimestamp };
remoteVersion?: number;
}>;
@@ -147,7 +147,7 @@ export interface AddPanelOperation extends CRDTOperationBase {
width: number;
height: number;
};
mode?: 'explore' | 'traces-drilldown';
mode?: 'explore' | 'traces-drilldown' | 'metrics-drilldown';
};
}
@@ -202,7 +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';
kind?: 'explore' | 'traces-drilldown' | 'metrics-drilldown';
}>) => {
const manager = getCRDTManager(state);
@@ -214,9 +214,10 @@ const crdtSlice = createSlice({
const mode = action.payload.kind || 'explore';
// Set default panel size based on panel type
// Traces drilldown panels are larger to accommodate the iframe content
const defaultWidth = mode === 'traces-drilldown' ? 1000 : 600;
const defaultHeight = mode === 'traces-drilldown' ? 550 : 400;
// Drilldown panels (traces and metrics) are larger to accommodate the iframe content
const isDrilldownPanel = mode === 'traces-drilldown' || mode === 'metrics-drilldown';
const defaultWidth = isDrilldownPanel ? 1000 : 600;
const defaultHeight = isDrilldownPanel ? 550 : 400;
const panelWidth = action.payload.position?.width || defaultWidth;
const panelHeight = action.payload.position?.height || defaultHeight;
@@ -467,7 +468,7 @@ const crdtSlice = createSlice({
width: sourcePanel.position.width,
height: sourcePanel.position.height,
},
sourcePanel.mode || 'explore'
(sourcePanel.mode || 'explore') as 'explore' | 'traces-drilldown' | 'metrics-drilldown'
);
manager.applyOperation(addOperation);
@@ -27,8 +27,9 @@ export interface ExploreMapPanel {
* Panel mode determines what kind of content is rendered inside the panel.
* - 'explore': standard Explore pane (current behavior)
* - 'traces-drilldown': Traces Drilldown app
* - 'metrics-drilldown': Metrics Drilldown app
*/
mode: 'explore' | 'traces-drilldown';
mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown';
/**
* For iframe-based panels (like traces-drilldown), store the complete URL
* including query parameters to restore state on reload