Compare commits

...

6 Commits

Author SHA1 Message Date
David Kaltschmidt
edf18e9c5f Enhancement: Add performance metrics display for dashboard panels
This commit introduces a new section in the troubleshooting documentation, detailing how to enable and utilize real-time performance metrics for dashboard panels. Users can now toggle performance metrics to view query, transform, and render times, helping to identify performance bottlenecks more effectively. Additionally, the keyboard shortcut for toggling these metrics has been documented.
2025-12-05 18:24:07 +01:00
David Kaltschmidt
2f7ef05eda Enhancement: Update DashboardAnalyticsAggregator and PanelPerformanceMetrics for improved performance metrics handling
This commit enhances the DashboardAnalyticsAggregator by ensuring a fresh Subject is created for each dashboard initialization, improving subscription management. Additionally, it updates the PanelPerformanceMetrics component to utilize internationalization for metric labels, enhancing user experience and accessibility.
2025-12-05 17:50:34 +01:00
David Kaltschmidt
c4ee6a6425 Fix: Enhance panel ID validation in PanelPerformanceMetrics
This commit updates the panel ID validation logic in the PanelPerformanceMetrics component to check for both null and NaN values, ensuring more robust handling of invalid panel IDs.
2025-12-05 17:32:12 +01:00
David Kaltschmidt
b5320defd1 Refactor: Defer state updates and simplify query time handling in PanelPerformanceMetrics
This commit refines the PanelPerformanceMetrics component by deferring state updates to avoid React warnings during rendering. It also simplifies the handling of query time by removing the fake timer logic, directly using the last query time for display. Additionally, the polling for profiling state changes is adjusted to run once, improving performance and reducing unnecessary re-renders.
2025-12-05 17:27:23 +01:00
David Kaltschmidt
7bea99a54b Enhancement: Implement panel profiling toggle and update performance metrics display
This commit introduces a global toggle for panel profiling, allowing users to enable or disable performance metrics via keyboard shortcuts. The PanelPerformanceMetrics component now conditionally renders based on the profiling state, ensuring metrics are displayed appropriately. Additionally, the logic for enabling profiling has been refined to check both global settings and specific dashboard configurations.
2025-12-05 16:43:31 +01:00
David Kaltschmidt
6291c18ba1 Feature: Add panel performance metrics to dashboard
This commit adds panel performance metrics to the dashboard.
2025-12-05 16:43:30 +01:00
15 changed files with 778 additions and 6 deletions

View File

@@ -31,6 +31,22 @@ Use the following strategies to help you troubleshoot common dashboard problems.
- Are you querying many time series or a long time range? Both of these conditions can cause Grafana or your data source to pull in a lot of data, which may slow the dashboard down. Try reducing one or both of these.
- There could be high load on your network infrastructure. If the slowness isn't consistent, this may be the problem.
## Debug dashboard performance with metrics
You can view real-time performance metrics for each panel to identify performance bottlenecks.
To enable performance metrics:
1. Press `d+p` on your keyboard to toggle performance metrics display.
2. Performance metrics appear in each panel header, showing:
- **Q** (Query time): Time spent executing data source queries
- **T** (Transform time): Time spent applying data transformations (only shown if transformations exist)
- **R** (Render time): Time spent rendering the panel visualization
Hover over the metrics icon in a panel header to see detailed timing information in a tooltip, including the total time for all operations.
Use these metrics to identify which panels are slow and whether the bottleneck is in query execution, data transformation, or rendering. Press `d+p` again to hide the metrics.
## Dashboard refresh rate issues
By default, Grafana queries your data source every 30 seconds. However, setting a low refresh rate on your dashboards puts unnecessary stress on the backend. In many cases, querying this frequently isn't necessary because the data source isn't sending data often enough for there to be changes every 30 seconds.

View File

@@ -131,6 +131,7 @@ Grafana has a number of keyboard shortcuts available. Press `?` on your keyboard
- `d+k`: Toggle kiosk mode (hides the menu).
- `d+e`: Expand all rows.
- `d+s`: Dashboard settings.
- `d+p`: Toggle panel performance metrics in panel headers to show query, transform, and render times.
- `Ctrl+K`: Opens the command palette.
- `Esc`: Exits panel when in full screen view or edit mode. Also returns you to the dashboard from dashboard settings.

View File

@@ -4677,4 +4677,4 @@
"count": 1
}
}
}
}

View File

@@ -202,6 +202,10 @@ export const useShortcuts = () => {
keys: ['d', 'x'],
description: t('help-modal.shortcuts-description.toggle-exemplars', 'Toggle exemplars in all panel'),
},
{
keys: ['d', 'p'],
description: t('help-modal.shortcuts-description.toggle-performance-metrics', 'Toggle performance metrics'),
},
],
},
{

View File

@@ -13,6 +13,7 @@ import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { panelLinksBehavior } from './PanelMenuBehavior';
import { PanelNotices } from './PanelNotices';
import { PanelPerformanceMetrics } from './PanelPerformanceMetrics';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { PanelTimeRange } from './panel-timerange/PanelTimeRange';
@@ -64,6 +65,7 @@ export class LibraryPanelBehavior extends SceneObjectBase<LibraryPanelBehaviorSt
})
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
let title;
if (config.featureToggles.preferLibraryPanelTitle) {

View File

@@ -0,0 +1,448 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { PanelAnalyticsMetrics } from '../../dashboard/services/DashboardAnalyticsAggregator';
import * as DashboardProfiler from '../../dashboard/services/DashboardProfiler';
import { activateFullSceneTree } from '../utils/test-utils';
import { PanelPerformanceMetrics } from './PanelPerformanceMetrics';
// Set up plugin import utils (required for VizPanel activation)
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(() => ({
extensions: [],
})),
getDataSourceSrv: () => {
return {
get: jest.fn().mockResolvedValue({
getRef: () => ({ uid: 'ds1' }),
}),
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }),
};
},
}));
// Mock the DashboardProfiler module
jest.mock('../../dashboard/services/DashboardProfiler', () => ({
isPanelProfilingEnabled: jest.fn(),
}));
// Mock the DashboardAnalyticsAggregator
let storedCallback: ((metrics: PanelAnalyticsMetrics) => void) | null = null;
jest.mock('../../dashboard/services/DashboardAnalyticsAggregator', () => ({
getDashboardAnalyticsAggregator: jest.fn(() => ({
subscribeToPanelMetrics: jest.fn((panelId: string, callback: (metrics: PanelAnalyticsMetrics) => void) => {
// Store callback for later use
storedCallback = callback;
return {
unsubscribe: jest.fn(),
};
}),
})),
}));
describe('PanelPerformanceMetrics', () => {
let mockIsPanelProfilingEnabled: jest.MockedFunction<typeof DashboardProfiler.isPanelProfilingEnabled>;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
storedCallback = null;
mockIsPanelProfilingEnabled = DashboardProfiler.isPanelProfilingEnabled as jest.MockedFunction<
typeof DashboardProfiler.isPanelProfilingEnabled
>;
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('should render metrics when profiling is enabled', async () => {
// Arrange: Enable profiling
mockIsPanelProfilingEnabled.mockReturnValue(true);
// Create a VizPanel with PanelPerformanceMetrics
const panelMetrics = new PanelPerformanceMetrics();
const vizPanel = new VizPanel({
key: 'panel-1',
title: 'Test Panel',
pluginId: 'table',
titleItems: [panelMetrics],
});
activateFullSceneTree(vizPanel);
// Provide mock metrics
const mockMetrics: PanelAnalyticsMetrics = {
panelId: '1',
panelKey: 'panel-1',
pluginId: 'table',
totalQueryTime: 100,
totalFieldConfigTime: 10,
totalTransformationTime: 20,
totalRenderTime: 50,
pluginLoadTime: 5,
queryOperations: [{ duration: 100, timestamp: Date.now() }],
fieldConfigOperations: [{ duration: 10, timestamp: Date.now() }],
transformationOperations: [],
renderOperations: [{ duration: 50, timestamp: Date.now() }],
};
// Act: Render the component
const { rerender } = render(<panelMetrics.Component model={panelMetrics} />);
// Wait for activation to complete and callback to be stored
await waitFor(() => {
expect(storedCallback).not.toBeNull();
});
// Trigger the callback with metrics
await act(async () => {
if (storedCallback) {
storedCallback(mockMetrics);
}
// Advance timers to process setTimeout
jest.advanceTimersByTime(10);
});
// Force rerender to see updated state
await act(async () => {
rerender(<panelMetrics.Component model={panelMetrics} />);
});
// Assert: Check that metrics are displayed
await waitFor(() => {
expect(screen.getByText(/Q:/)).toBeInTheDocument();
expect(screen.getByText(/R:/)).toBeInTheDocument();
});
});
it('should display correct metric values', async () => {
// Arrange: Enable profiling
mockIsPanelProfilingEnabled.mockReturnValue(true);
const panelMetrics = new PanelPerformanceMetrics();
const vizPanel = new VizPanel({
key: 'panel-1',
title: 'Test Panel',
pluginId: 'table',
titleItems: [panelMetrics],
});
activateFullSceneTree(vizPanel);
// Provide mock metrics with specific values
const mockMetrics: PanelAnalyticsMetrics = {
panelId: '1',
panelKey: 'panel-1',
pluginId: 'table',
totalQueryTime: 100,
totalFieldConfigTime: 10,
totalTransformationTime: 0,
totalRenderTime: 50,
pluginLoadTime: 5,
queryOperations: [{ duration: 100, timestamp: Date.now() }],
fieldConfigOperations: [],
transformationOperations: [],
renderOperations: [{ duration: 50, timestamp: Date.now() }],
};
// Act: Render the component
const { rerender } = render(<panelMetrics.Component model={panelMetrics} />);
// Wait for activation to complete and callback to be stored
await waitFor(() => {
expect(storedCallback).not.toBeNull();
});
// Trigger the callback with metrics
await act(async () => {
if (storedCallback) {
storedCallback(mockMetrics);
}
// Advance timers to process setTimeout
jest.advanceTimersByTime(10);
});
// Force rerender to see updated state
await act(async () => {
rerender(<panelMetrics.Component model={panelMetrics} />);
});
// Assert: Check that correct values are displayed
await waitFor(() => {
expect(screen.getByText(/Q:100ms/)).toBeInTheDocument();
expect(screen.getByText(/R:50ms/)).toBeInTheDocument();
});
});
it('should display correct metric values with transformations', async () => {
// Arrange: Enable profiling
mockIsPanelProfilingEnabled.mockReturnValue(true);
const panelMetrics = new PanelPerformanceMetrics();
const vizPanel = new VizPanel({
key: 'panel-1',
title: 'Test Panel',
pluginId: 'table',
titleItems: [panelMetrics],
});
activateFullSceneTree(vizPanel);
// Provide mock metrics with transformations
const mockMetrics: PanelAnalyticsMetrics = {
panelId: '1',
panelKey: 'panel-1',
pluginId: 'table',
totalQueryTime: 150,
totalFieldConfigTime: 10,
totalTransformationTime: 25,
totalRenderTime: 75,
pluginLoadTime: 5,
queryOperations: [{ duration: 150, timestamp: Date.now() }],
fieldConfigOperations: [],
transformationOperations: [{ duration: 25, timestamp: Date.now() }],
renderOperations: [{ duration: 75, timestamp: Date.now() }],
};
// Act: Render the component
const { rerender } = render(<panelMetrics.Component model={panelMetrics} />);
// Wait for activation to complete and callback to be stored
await waitFor(() => {
expect(storedCallback).not.toBeNull();
});
// Trigger the callback with metrics
await act(async () => {
if (storedCallback) {
storedCallback(mockMetrics);
}
// Advance timers to process setTimeout
jest.advanceTimersByTime(10);
});
// Force rerender to see updated state
await act(async () => {
rerender(<panelMetrics.Component model={panelMetrics} />);
});
// Assert: Check that correct values are displayed including transform
await waitFor(() => {
expect(screen.getByText(/Q:150ms/)).toBeInTheDocument();
expect(screen.getByText(/T:25ms/)).toBeInTheDocument();
expect(screen.getByText(/R:75ms/)).toBeInTheDocument();
});
});
it('should format durations correctly (seconds for >= 1000ms)', async () => {
// Arrange: Enable profiling
mockIsPanelProfilingEnabled.mockReturnValue(true);
const panelMetrics = new PanelPerformanceMetrics();
const vizPanel = new VizPanel({
key: 'panel-1',
title: 'Test Panel',
pluginId: 'table',
titleItems: [panelMetrics],
});
activateFullSceneTree(vizPanel);
// Provide mock metrics with values >= 1000ms
const mockMetrics: PanelAnalyticsMetrics = {
panelId: '1',
panelKey: 'panel-1',
pluginId: 'table',
totalQueryTime: 2500,
totalFieldConfigTime: 10,
totalTransformationTime: 0,
totalRenderTime: 1500,
pluginLoadTime: 5,
queryOperations: [{ duration: 2500, timestamp: Date.now() }],
fieldConfigOperations: [],
transformationOperations: [],
renderOperations: [{ duration: 1500, timestamp: Date.now() }],
};
// Act: Render the component
const { rerender } = render(<panelMetrics.Component model={panelMetrics} />);
// Wait for activation to complete and callback to be stored
await waitFor(() => {
expect(storedCallback).not.toBeNull();
});
// Trigger the callback with metrics
await act(async () => {
if (storedCallback) {
storedCallback(mockMetrics);
}
// Advance timers to process setTimeout
jest.advanceTimersByTime(10);
});
// Force rerender to see updated state
await act(async () => {
rerender(<panelMetrics.Component model={panelMetrics} />);
});
// Assert: Check that values are formatted as seconds
await waitFor(() => {
expect(screen.getByText(/Q:2\.50s/)).toBeInTheDocument();
expect(screen.getByText(/R:1\.50s/)).toBeInTheDocument();
});
});
it('should not render when profiling is disabled', () => {
// Arrange: Disable profiling
mockIsPanelProfilingEnabled.mockReturnValue(false);
// Create a VizPanel with PanelPerformanceMetrics
const panelMetrics = new PanelPerformanceMetrics();
const vizPanel = new VizPanel({
key: 'panel-1',
title: 'Test Panel',
pluginId: 'table',
titleItems: [panelMetrics],
});
activateFullSceneTree(vizPanel);
// Act: Render the component
const { container } = render(<panelMetrics.Component model={panelMetrics} />);
// Assert: Component should not render anything
expect(container.firstChild).toBeNull();
});
it('should show transform metric when transformations exist', async () => {
// Arrange: Enable profiling
mockIsPanelProfilingEnabled.mockReturnValue(true);
const panelMetrics = new PanelPerformanceMetrics();
const vizPanel = new VizPanel({
key: 'panel-1',
title: 'Test Panel',
pluginId: 'table',
titleItems: [panelMetrics],
});
activateFullSceneTree(vizPanel);
// Provide mock metrics with transformations
const mockMetrics: PanelAnalyticsMetrics = {
panelId: '1',
panelKey: 'panel-1',
pluginId: 'table',
totalQueryTime: 100,
totalFieldConfigTime: 10,
totalTransformationTime: 20,
totalRenderTime: 50,
pluginLoadTime: 5,
queryOperations: [{ duration: 100, timestamp: Date.now() }],
fieldConfigOperations: [],
transformationOperations: [{ duration: 20, timestamp: Date.now() }],
renderOperations: [{ duration: 50, timestamp: Date.now() }],
};
// Act: Render the component
const { rerender } = render(<panelMetrics.Component model={panelMetrics} />);
// Wait for activation to complete and callback to be stored
await waitFor(() => {
expect(storedCallback).not.toBeNull();
});
// Trigger the callback with metrics
await act(async () => {
if (storedCallback) {
storedCallback(mockMetrics);
}
// Advance timers to process setTimeout
jest.advanceTimersByTime(10);
});
// Force rerender to see updated state
await act(async () => {
rerender(<panelMetrics.Component model={panelMetrics} />);
});
// Assert: Transform metric should be displayed
await waitFor(() => {
expect(screen.getByText(/Q:/)).toBeInTheDocument();
expect(screen.getByText(/T:/)).toBeInTheDocument();
expect(screen.getByText(/R:/)).toBeInTheDocument();
});
});
it('should not show transform metric when no transformations exist', async () => {
// Arrange: Enable profiling
mockIsPanelProfilingEnabled.mockReturnValue(true);
const panelMetrics = new PanelPerformanceMetrics();
const vizPanel = new VizPanel({
key: 'panel-1',
title: 'Test Panel',
pluginId: 'table',
titleItems: [panelMetrics],
});
activateFullSceneTree(vizPanel);
// Provide mock metrics without transformations
const mockMetrics: PanelAnalyticsMetrics = {
panelId: '1',
panelKey: 'panel-1',
pluginId: 'table',
totalQueryTime: 100,
totalFieldConfigTime: 10,
totalTransformationTime: 0,
totalRenderTime: 50,
pluginLoadTime: 5,
queryOperations: [{ duration: 100, timestamp: Date.now() }],
fieldConfigOperations: [],
transformationOperations: [],
renderOperations: [{ duration: 50, timestamp: Date.now() }],
};
// Act: Render the component
const { rerender } = render(<panelMetrics.Component model={panelMetrics} />);
// Wait for activation to complete and callback to be stored
await waitFor(() => {
expect(storedCallback).not.toBeNull();
});
// Trigger the callback with metrics
await act(async () => {
if (storedCallback) {
storedCallback(mockMetrics);
}
// Advance timers to process setTimeout
jest.advanceTimersByTime(10);
});
// Force rerender to see updated state
await act(async () => {
rerender(<panelMetrics.Component model={panelMetrics} />);
});
// Assert: Transform metric should NOT be displayed
await waitFor(() => {
expect(screen.queryByText(/T:/)).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,197 @@
import { css } from '@emotion/css';
import { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Icon, PanelChrome, Stack, Tooltip, useStyles2 } from '@grafana/ui';
import {
getDashboardAnalyticsAggregator,
PanelAnalyticsMetrics,
} from '../../dashboard/services/DashboardAnalyticsAggregator';
import { isPanelProfilingEnabled } from '../../dashboard/services/DashboardProfiler';
import { getPanelIdForVizPanel } from '../utils/utils';
interface PanelPerformanceMetricsState extends SceneObjectState {
metrics?: PanelAnalyticsMetrics;
}
export class PanelPerformanceMetrics extends SceneObjectBase<PanelPerformanceMetricsState> {
static Component = PanelPerformanceMetricsRenderer;
constructor() {
super({});
this.addActivationHandler(this.onActivate);
}
private onActivate = () => {
const panel = this.parent;
if (!panel || !(panel instanceof VizPanel)) {
throw new Error('PanelPerformanceMetrics can be used only as title items for VizPanel');
}
const panelId = getPanelIdForVizPanel(panel);
if (panelId == null || isNaN(panelId)) {
return;
}
const aggregator = getDashboardAnalyticsAggregator();
const panelIdStr = String(panelId);
// Subscribe to metrics updates - defer initial callback to avoid setState during render
this._subs.add(
aggregator.subscribeToPanelMetrics(panelIdStr, (updatedMetrics) => {
// Defer state update to avoid React warning about updating during render
setTimeout(() => {
this.setState({ metrics: updatedMetrics });
}, 0);
})
);
};
public getPanel() {
const panel = this.parent;
if (panel && panel instanceof VizPanel) {
return panel;
}
return null;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
function PanelPerformanceMetricsRenderer({ model }: SceneComponentProps<PanelPerformanceMetrics>) {
const panel = model.getPanel();
const styles = useStyles2(getStyles);
const { metrics } = model.useState();
const [isProfilingEnabled, setIsProfilingEnabled] = useState(isPanelProfilingEnabled());
const profilingStateRef = useRef(isProfilingEnabled);
// Update ref when state changes
useEffect(() => {
profilingStateRef.current = isProfilingEnabled;
}, [isProfilingEnabled]);
// Watch for profiling state changes (when toggled via hotkey)
useEffect(() => {
// Poll for profiling state changes - this allows the component to react when profiling is toggled
const checkProfilingState = () => {
const currentState = isPanelProfilingEnabled();
// Compare against ref to avoid stale closure
if (currentState !== profilingStateRef.current) {
setIsProfilingEnabled(currentState);
}
};
// Check immediately and then periodically
checkProfilingState();
const interval = setInterval(checkProfilingState, 100);
return () => clearInterval(interval);
}, []); // Empty dependency array - polling should run once and persist
// Get last operation times (most recent operation in each array)
const lastQueryTime =
metrics && metrics.queryOperations.length > 0
? metrics.queryOperations[metrics.queryOperations.length - 1].duration
: 0;
const lastRenderTime =
metrics && metrics.renderOperations.length > 0
? metrics.renderOperations[metrics.renderOperations.length - 1].duration
: 0;
const lastTransformTime =
metrics && metrics.transformationOperations.length > 0
? metrics.transformationOperations[metrics.transformationOperations.length - 1].duration
: 0;
const lastTotalTime = lastQueryTime + lastRenderTime + lastTransformTime;
// Don't render if panel is not available
if (!panel) {
return null;
}
// If profiling is disabled, don't show the component at all
if (!isProfilingEnabled) {
return null;
}
// If profiling is enabled, always show the component (even with 0 values)
const renderMetricRow = (label: string, current: number) => {
return (
<div>
<strong>{label}:</strong> {formatDuration(current)}
</div>
);
};
// Check if there are any transformation operations
const hasTransformations = metrics && metrics.transformationOperations.length > 0;
const tooltipContent = (
<div>
{renderMetricRow(t('dashboard-scene.panel-performance-metrics.query', 'Query'), lastQueryTime)}
{hasTransformations &&
renderMetricRow(t('dashboard-scene.panel-performance-metrics.transform', 'Transform'), lastTransformTime)}
{renderMetricRow(t('dashboard-scene.panel-performance-metrics.render', 'Render'), lastRenderTime)}
<div style={{ marginTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '8px' }}>
<strong>{t('dashboard-scene.panel-performance-metrics.total-time', 'Total time')}:</strong>{' '}
{formatDuration(lastTotalTime)}
</div>
</div>
);
// Show metrics text - if profiling is enabled, show all metrics even if 0
// Transform is only shown if there are transformation operations
// Otherwise, only show non-zero metrics
const metricsText = isProfilingEnabled
? [
`Q:${formatDuration(lastQueryTime)}`,
hasTransformations && `T:${formatDuration(lastTransformTime)}`,
`R:${formatDuration(lastRenderTime)}`,
]
.filter(Boolean)
.join(' ')
: [
lastQueryTime > 0 && `Q:${formatDuration(lastQueryTime)}`,
lastTransformTime > 0 && `T:${formatDuration(lastTransformTime)}`,
lastRenderTime > 0 && `R:${formatDuration(lastRenderTime)}`,
]
.filter(Boolean)
.join(' ');
return (
<Tooltip content={tooltipContent}>
<PanelChrome.TitleItem className={styles.metrics}>
<Stack gap={1} alignItems={'center'}>
<Icon name="tachometer-fast" size="sm" />
<div>{metricsText}</div>
</Stack>
</PanelChrome.TitleItem>
</Tooltip>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
metrics: css({
color: theme.colors.text.link,
gap: theme.spacing(0.5),
whiteSpace: 'nowrap',
fontSize: theme.typography.bodySmall.fontSize,
'&:hover': {
color: theme.colors.emphasize(theme.colors.text.link, 0.03),
},
}),
};
};

View File

@@ -8,6 +8,7 @@ import { InspectTab } from 'app/features/inspector/types';
import { AccessControlAction } from 'app/types/accessControl';
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
import { enablePanelProfilingForDashboard, togglePanelProfiling } from '../../dashboard/services/DashboardProfiler';
import { PanelInspectDrawer } from '../inspect/PanelInspectDrawer';
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
@@ -130,6 +131,18 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
onTrigger: () => sceneGraph.getTimeRange(scene).onRefresh(),
});
// Toggle performance metrics
keybindings.addBinding({
key: 'd p',
onTrigger: () => {
const newState = togglePanelProfiling();
// If toggling on, enable profiling for the current dashboard
if (newState && scene.state.uid) {
enablePanelProfilingForDashboard(scene, scene.state.uid);
}
},
});
if (config.featureToggles.newTimeRangeZoomShortcuts) {
keybindings.addBinding({
key: 't +',

View File

@@ -31,6 +31,7 @@ import { LibraryPanelBehavior } from '../../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../../scene/PanelMenuBehavior';
import { PanelNotices } from '../../scene/PanelNotices';
import { PanelPerformanceMetrics } from '../../scene/PanelPerformanceMetrics';
import { VizPanelHeaderActions } from '../../scene/VizPanelHeaderActions';
import { VizPanelSubHeader } from '../../scene/VizPanelSubHeader';
import { AutoGridItem } from '../../scene/layout-auto-grid/AutoGridItem';
@@ -54,6 +55,7 @@ export function buildVizPanel(panel: PanelKind, id?: number): VizPanel {
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
const queryOptions = panel.spec.data.spec.queryOptions;
const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride;
@@ -110,6 +112,7 @@ export function buildLibraryPanel(panel: LibraryPanelKind, id?: number): VizPane
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
const vizPanelState: VizPanelState = {
key: getVizPanelKeyForPanelId(id ?? panel.spec.id),

View File

@@ -60,6 +60,7 @@ import {
getDashboardSceneProfilerWithMetadata,
enablePanelProfilingForDashboard,
getDashboardComponentInteractionCallback,
isPanelProfilingEnabled,
} from 'app/features/dashboard/services/DashboardProfiler';
import { DashboardMeta } from 'app/types/dashboard';
@@ -184,7 +185,9 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
// Create profiler once and reuse to avoid duplicate metadata setting
const dashboardProfiler = getDashboardSceneProfilerWithMetadata(metadata.name, dashboard.title);
// Check if profiling should be enabled (global toggle or config)
const enableProfiling =
isPanelProfilingEnabled() ||
config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === metadata.name) !== -1;
const queryController = new behaviors.SceneQueryController(
{

View File

@@ -26,6 +26,7 @@ import {
getDashboardSceneProfilerWithMetadata,
enablePanelProfilingForDashboard,
getDashboardComponentInteractionCallback,
isPanelProfilingEnabled,
} from 'app/features/dashboard/services/DashboardProfiler';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
@@ -46,6 +47,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelPerformanceMetrics } from '../scene/PanelPerformanceMetrics';
import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions';
import { VizPanelSubHeader } from '../scene/VizPanelSubHeader';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
@@ -312,7 +314,9 @@ export function createDashboardSceneFromDashboardModel(
// Create profiler once and reuse to avoid duplicate metadata setting
const dashboardProfiler = getDashboardSceneProfilerWithMetadata(oldModel.uid, oldModel.title);
// Check if profiling should be enabled (global toggle or config)
const enableProfiling =
isPanelProfilingEnabled() ||
config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1;
const queryController = new behaviors.SceneQueryController(
{
@@ -430,6 +434,7 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
);
titleItems.push(new PanelNotices());
titleItems.push(new PanelPerformanceMetrics());
const timeOverrideShown = (panel.timeFrom || panel.timeShift) && !panel.hideTimeOverride;

View File

@@ -23,6 +23,8 @@ import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelPerformanceMetrics } from '../scene/PanelPerformanceMetrics';
import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions';
import { VizPanelSubHeader } from '../scene/VizPanelSubHeader';
@@ -274,7 +276,11 @@ export function getDefaultVizPanel(): VizPanel {
title: newPanelTitle,
pluginId: defaultPluginId,
seriesLimit: config.panelSeriesLimit,
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
titleItems: [
new VizPanelLinks({ menu: new VizPanelLinksMenu({}) }),
new PanelNotices(),
new PanelPerformanceMetrics(),
],
hoverHeaderOffset: 0,
$behaviors: [],
subHeader: new VizPanelSubHeader({

View File

@@ -1,3 +1,5 @@
import { Subject, Subscription } from 'rxjs';
import { logMeasurement, reportInteraction } from '@grafana/runtime';
import { performanceUtils } from '@grafana/scenes';
@@ -13,7 +15,7 @@ import {
/**
* Panel metrics structure for analytics
*/
interface PanelAnalyticsMetrics {
export interface PanelAnalyticsMetrics {
panelId: string;
panelKey: string;
pluginId: string;
@@ -54,12 +56,19 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
private panelMetrics = new Map<string, PanelAnalyticsMetrics>();
private dashboardUID = '';
private dashboardTitle = '';
private panelMetricsSubject = new Subject<{ panelId: string; metrics: PanelAnalyticsMetrics }>();
public initialize(uid: string, title: string) {
// Clear previous dashboard data and set new context
this.panelMetrics.clear();
this.dashboardUID = uid;
this.dashboardTitle = title;
// Recreate the Subject for the new dashboard (since this is a singleton, we need a fresh Subject)
// Complete the old Subject first to clean up any remaining subscriptions
if (!this.panelMetricsSubject.closed) {
this.panelMetricsSubject.complete();
}
this.panelMetricsSubject = new Subject<{ panelId: string; metrics: PanelAnalyticsMetrics }>();
}
public destroy() {
@@ -67,6 +76,8 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
this.panelMetrics.clear();
this.dashboardUID = '';
this.dashboardTitle = '';
// Note: We don't complete the Subject here since this is a singleton that will be reused.
// The Subject will be recreated in initialize() for the next dashboard.
}
/**
@@ -74,6 +85,7 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
*/
public clearMetrics() {
this.panelMetrics.clear();
// Note: We don't emit clear events as subscribers should handle empty metrics gracefully
}
/**
@@ -83,6 +95,37 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
return Array.from(this.panelMetrics.values());
}
/**
* Get panel metrics by panel ID
*/
public getPanelMetricsByPanelId(panelId: string): PanelAnalyticsMetrics | undefined {
for (const metrics of this.panelMetrics.values()) {
if (metrics.panelId === panelId) {
return metrics;
}
}
return undefined;
}
/**
* Subscribe to panel metrics updates for a specific panel ID
* Returns a subscription that emits when metrics for the given panel are updated
*/
public subscribeToPanelMetrics(panelId: string, callback: (metrics: PanelAnalyticsMetrics) => void): Subscription {
// Get initial metrics if available
const initialMetrics = this.getPanelMetricsByPanelId(panelId);
if (initialMetrics) {
callback(initialMetrics);
}
// Subscribe to future updates
return this.panelMetricsSubject.subscribe(({ panelId: updatedPanelId, metrics }) => {
if (updatedPanelId === panelId) {
callback(metrics);
}
});
}
// Dashboard-level events (we don't need to track these for panel analytics)
onDashboardInteractionStart = (data: performanceUtils.DashboardInteractionStartData): void => {
// Clear metrics when new dashboard interaction starts
@@ -101,12 +144,14 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
// Panel-level events
onPanelOperationStart = (data: performanceUtils.PanelPerformanceData): void => {
// Start events don't need aggregation, just ensure panel exists
this.ensurePanelExists(data.panelKey, data.panelId, data.pluginId, data.pluginVersion);
this.ensurePanelExists(data.panelKey, String(data.panelId), data.pluginId, data.pluginVersion);
};
onPanelOperationComplete = (data: performanceUtils.PanelPerformanceData): void => {
// Aggregate panel metrics without verbose logging (handled by ScenePerformanceLogger)
const panel = this.panelMetrics.get(data.panelKey);
// Ensure panel exists - it may not have been created by onPanelOperationStart if the panel
// was loaded from saved state or if start events were missed
let panel = this.panelMetrics.get(data.panelKey);
if (!panel) {
console.warn('Panel not found for operation completion:', data.panelKey);
return;
@@ -154,6 +199,8 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo
panel.pluginLoadTime += duration;
break;
}
this.panelMetricsSubject.next({ panelId: String(data.panelId), metrics: panel });
};
// Query-level events

View File

@@ -10,6 +10,7 @@ interface SceneInteractionProfileEvent {
}
let dashboardSceneProfiler: performanceUtils.SceneRenderProfiler | undefined;
let isProfilingEnabled = false;
export function getDashboardSceneProfiler() {
if (!dashboardSceneProfiler) {
@@ -23,6 +24,23 @@ export function getDashboardSceneProfiler() {
return dashboardSceneProfiler;
}
/**
* Toggle panel profiling on/off globally
* @returns The new profiling state (true if enabled, false if disabled)
*/
export function togglePanelProfiling(): boolean {
isProfilingEnabled = !isProfilingEnabled;
return isProfilingEnabled;
}
/**
* Get the current panel profiling state
* @returns true if profiling is enabled, false otherwise
*/
export function isPanelProfilingEnabled(): boolean {
return isProfilingEnabled;
}
export function getDashboardComponentInteractionCallback(uid: string, title: string) {
return (e: SceneInteractionProfileEvent) => {
const payload = {
@@ -61,8 +79,10 @@ export function getDashboardSceneProfilerWithMetadata(uid: string, title: string
// Function to enable panel profiling for a specific dashboard
export function enablePanelProfilingForDashboard(dashboard: SceneObject, uid: string) {
// Check if panel profiling should be enabled for this dashboard
// Check if panel profiling should be enabled
// First check the global toggle state, then fall back to config
const shouldEnablePanelProfiling =
isProfilingEnabled ||
config.dashboardPerformanceMetrics.findIndex((configUid) => configUid === '*' || configUid === uid) !== -1;
if (shouldEnablePanelProfiling) {

View File

@@ -6231,6 +6231,12 @@
}
}
},
"panel-performance-metrics": {
"query": "Query",
"render": "Render",
"total-time": "Total time",
"transform": "Transform"
},
"panel-viz-type-picker": {
"button": {
"close": "Back"
@@ -9397,6 +9403,7 @@
"toggle-panel-edit": "Toggle panel edit view",
"toggle-panel-fullscreen": "Toggle panel fullscreen view",
"toggle-panel-legend": "Toggle panel legend",
"toggle-performance-metrics": "Toggle performance metrics",
"zoom-in-time-range": "Zoom in time range",
"zoom-out-time-range": "Zoom out time range"
},