diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx index 86b83bd0ed8..c313b4f2fce 100644 --- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx @@ -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 { + 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) { + return; + } + + const aggregator = getDashboardAnalyticsAggregator(); + const panelIdStr = String(panelId); + + // Subscribe to metrics updates - callback will be called with initial metrics if available + this._subs.add( + aggregator.subscribeToPanelMetrics(panelIdStr, (updatedMetrics) => { + this.setState({ metrics: updatedMetrics }); + }) + ); + }; + + 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) { + const panel = model.getPanel(); + const styles = useStyles2(getStyles); + const { metrics } = model.useState(); + const [fakeQueryTime, setFakeQueryTime] = useState(0); + const intervalRef = useRef(null); + const startTimeRef = useRef(null); + + // 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; + + // Manage fake timer for query time + useEffect(() => { + // If we have a real query time, stop the fake timer + if (lastQueryTime > 0) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setFakeQueryTime(0); + startTimeRef.current = null; + return; + } + + // If no query operations yet, start the fake timer + if (metrics && metrics.queryOperations.length === 0) { + // Start timer if not already running + if (!intervalRef.current) { + startTimeRef.current = Date.now(); + intervalRef.current = setInterval(() => { + if (startTimeRef.current) { + setFakeQueryTime(Date.now() - startTimeRef.current); + } + }, 166); // Update every 166ms for smooth counting + } + } + + // Cleanup on unmount + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [lastQueryTime, metrics]); + + // Use fake query time if real one is 0 + const displayQueryTime = lastQueryTime > 0 ? lastQueryTime : fakeQueryTime; + const lastTotalTime = displayQueryTime + lastRenderTime + lastTransformTime; + + // Don't render if panel or metrics are not available (and no fake timer running) + if (!panel || (!metrics && fakeQueryTime === 0)) { + return null; + } + + // Show component if we have any metrics or fake query time is running + if (lastTotalTime === 0 && fakeQueryTime === 0) { + return null; + } + + const renderMetricRow = (label: string, current: number) => { + return ( +
+ {/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */} + {label}: {formatDuration(current)} +
+ ); + }; + + const tooltipContent = ( +
+ {renderMetricRow('Query', displayQueryTime)} + {renderMetricRow('Render', lastRenderTime)} + {renderMetricRow('Transform', lastTransformTime)} +
+ {/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */} + Total: {formatDuration(lastTotalTime)} +
+
+ ); + + const metricsText = [ + displayQueryTime > 0 && `Q:${formatDuration(displayQueryTime)}`, + lastRenderTime > 0 && `R:${formatDuration(lastRenderTime)}`, + lastTransformTime > 0 && `T:${formatDuration(lastTransformTime)}`, + ] + .filter(Boolean) + .join(' '); + + return ( + + + + +
{metricsText}
+
+
+
+ ); +} + +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), + }, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index fc634339607..2bc4dafa448 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -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), diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 48763ba4363..df36d555ba9 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -46,6 +46,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,8 +313,9 @@ export function createDashboardSceneFromDashboardModel( // Create profiler once and reuse to avoid duplicate metadata setting const dashboardProfiler = getDashboardSceneProfilerWithMetadata(oldModel.uid, oldModel.title); + // HACK always on const enableProfiling = - config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1; + config.dashboardPerformanceMetrics.findIndex((uid) => uid === '*' || uid === oldModel.uid) !== -1 || true; const queryController = new behaviors.SceneQueryController( { enableProfiling, @@ -430,6 +432,7 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { ); titleItems.push(new PanelNotices()); + titleItems.push(new PanelPerformanceMetrics()); const timeOverrideShown = (panel.timeFrom || panel.timeShift) && !panel.hideTimeOverride; diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 6c486021e62..012cfff7843 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -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({ diff --git a/public/app/features/dashboard/services/DashboardAnalyticsAggregator.ts b/public/app/features/dashboard/services/DashboardAnalyticsAggregator.ts index 8671c2b13e5..9a4d242361f 100644 --- a/public/app/features/dashboard/services/DashboardAnalyticsAggregator.ts +++ b/public/app/features/dashboard/services/DashboardAnalyticsAggregator.ts @@ -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,6 +56,7 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo private panelMetrics = new Map(); 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 @@ -67,6 +70,8 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo this.panelMetrics.clear(); this.dashboardUID = ''; this.dashboardTitle = ''; + // Complete the subject to clean up subscriptions + this.panelMetricsSubject.complete(); } /** @@ -74,6 +79,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 +89,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 @@ -100,13 +137,17 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo // Panel-level events onPanelOperationStart = (data: performanceUtils.PanelPerformanceData): void => { + console.log('onPanelOperationStart', data.operation); // Start events don't need aggregation, just ensure panel exists this.ensurePanelExists(data.panelKey, data.panelId, data.pluginId, data.pluginVersion); }; onPanelOperationComplete = (data: performanceUtils.PanelPerformanceData): void => { + console.log('onPanelOperationComplete', data.operation); // 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 +195,8 @@ export class DashboardAnalyticsAggregator implements performanceUtils.ScenePerfo panel.pluginLoadTime += duration; break; } + + this.panelMetricsSubject.next({ panelId: data.panelId, metrics: panel }); }; // Query-level events diff --git a/public/app/features/dashboard/services/DashboardProfiler.ts b/public/app/features/dashboard/services/DashboardProfiler.ts index 01fe67eb451..217172a32f6 100644 --- a/public/app/features/dashboard/services/DashboardProfiler.ts +++ b/public/app/features/dashboard/services/DashboardProfiler.ts @@ -62,8 +62,9 @@ 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 + // HACK always on const shouldEnablePanelProfiling = - config.dashboardPerformanceMetrics.findIndex((configUid) => configUid === '*' || configUid === uid) !== -1; + config.dashboardPerformanceMetrics.findIndex((configUid) => configUid === '*' || configUid === uid) !== -1 || true; // HACK always on if (shouldEnablePanelProfiling) { const profiler = getDashboardSceneProfiler();