Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edf18e9c5f | |||
| 2f7ef05eda | |||
| c4ee6a6425 | |||
| b5320defd1 | |||
| 7bea99a54b | |||
| 6291c18ba1 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ test.describe(
|
||||
refetchItems(dashboardPage, selectors);
|
||||
};
|
||||
|
||||
const applyAndcloseModal = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups) => {
|
||||
const closeModal = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups) => {
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton)
|
||||
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.closeButton)
|
||||
.click();
|
||||
};
|
||||
|
||||
@@ -149,7 +149,7 @@ test.describe(
|
||||
await removeItem(dashboardPage, selectors, 2);
|
||||
await checkRows(3);
|
||||
await checkPreview(dashboardPage, selectors, ['first value', 'second label', 'fourth value']);
|
||||
await applyAndcloseModal(dashboardPage, selectors);
|
||||
await closeModal(dashboardPage, selectors);
|
||||
|
||||
// assert variable is visible and has the correct values
|
||||
const variableLabel = dashboardPage.getByGrafanaSelector(
|
||||
|
||||
@@ -4677,4 +4677,4 @@
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,9 +567,6 @@ export const versionedPages = {
|
||||
closeButton: {
|
||||
[MIN_GRAFANA_VERSION]: 'data-testid custom-variable-close-button',
|
||||
},
|
||||
applyButton: {
|
||||
[MIN_GRAFANA_VERSION]: 'data-testid custom-variable-apply-button',
|
||||
},
|
||||
},
|
||||
IntervalVariable: {
|
||||
intervalsValueInput: {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import { Meta, StoryFn, StoryObj } from '@storybook/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Field } from '../Forms/Field';
|
||||
|
||||
import { Combobox, ComboboxProps } from './Combobox';
|
||||
import mdx from './Combobox.mdx';
|
||||
import { MENU_ITEM_FONT_SIZE, MENU_ITEM_LINE_HEIGHT, MENU_OPTION_HEIGHT } from './getComboboxStyles';
|
||||
import { fakeSearchAPI, generateGroupingOptions, generateOptions } from './storyUtils';
|
||||
import { ComboboxOption } from './types';
|
||||
|
||||
@@ -172,65 +171,6 @@ export const ManyOptions: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const NodeOptions: Story = {
|
||||
args: {
|
||||
numberOfOptions: 350,
|
||||
options: undefined,
|
||||
value: undefined,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
// necessary to keep storybook from choking on the option generation code
|
||||
type: 'code',
|
||||
},
|
||||
},
|
||||
},
|
||||
render: ({ numberOfOptions, ...args }: PropsAndCustomArgs) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [dynamicArgs, setArgs] = useArgs();
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const options: ComboboxOption[] = useMemo(() => {
|
||||
return Array.from({ length: numberOfOptions }, (_, index) => {
|
||||
const label =
|
||||
index % 2 === 0
|
||||
? {
|
||||
node: (
|
||||
<>
|
||||
{`Option ${index}`}
|
||||
<br />
|
||||
(Even)
|
||||
</>
|
||||
),
|
||||
text: `Option ${index}`,
|
||||
size: MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT + MENU_OPTION_HEIGHT,
|
||||
}
|
||||
: `Option ${index}`;
|
||||
return {
|
||||
label,
|
||||
value: String(index),
|
||||
};
|
||||
});
|
||||
}, [numberOfOptions]);
|
||||
|
||||
const { onChange, ...rest } = args;
|
||||
return (
|
||||
<Field label="Test input" description="Input with some custom rendered options">
|
||||
<Combobox
|
||||
{...rest}
|
||||
{...dynamicArgs}
|
||||
options={options}
|
||||
onChange={(value: ComboboxOption | null) => {
|
||||
setArgs({ value: value?.value || null });
|
||||
onChangeAction(value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function loadOptionsWithLabels(inputValue: string) {
|
||||
loadOptionsAction(inputValue);
|
||||
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer, type Range } from '@tanstack/react-virtual';
|
||||
import { useCombobox } from 'downshift';
|
||||
import React, { ComponentProps, useId, useMemo } from 'react';
|
||||
import { Subject } from 'rxjs';
|
||||
import React, { ComponentProps, useCallback, useId, useMemo } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
@@ -14,10 +14,11 @@ import { Portal } from '../Portal/Portal';
|
||||
import { ComboboxList } from './ComboboxList';
|
||||
import { SuffixIcon } from './SuffixIcon';
|
||||
import { itemToString } from './filter';
|
||||
import { getComboboxStyles } from './getComboboxStyles';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { ComboboxOption } from './types';
|
||||
import { useComboboxFloat } from './useComboboxFloat';
|
||||
import { useOptions } from './useOptions';
|
||||
import { isNewGroup } from './utils';
|
||||
|
||||
// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async),
|
||||
// then the onChange handler emits ComboboxOption with the label as non-undefined.
|
||||
@@ -153,6 +154,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
|
||||
const {
|
||||
options: filteredOptions,
|
||||
groupStartIndices,
|
||||
updateOptions,
|
||||
asyncLoading,
|
||||
asyncError,
|
||||
@@ -193,34 +195,49 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
|
||||
// Maybe need to move this to ComboboxList? Is this actually necessary? it's only expanding the returned
|
||||
// array of indexes, and that's already expanded by the overscan setting
|
||||
// Injects the group header for the first rendered item into the range to render.
|
||||
// Accepts the range that useVirtualizer wants to render, and then returns indexes
|
||||
// to actually render.
|
||||
// const rangeExtractor = useCallback(
|
||||
// (range: Range) => {
|
||||
// const startIndex = Math.max(0, range.startIndex - range.overscan);
|
||||
// const endIndex = Math.min(filteredOptions.length - 1, range.endIndex + range.overscan);
|
||||
// const rangeToReturn = Array.from({ length: endIndex - startIndex + 1 }, (_, i) => startIndex + i);
|
||||
const rangeExtractor = useCallback(
|
||||
(range: Range) => {
|
||||
const startIndex = Math.max(0, range.startIndex - range.overscan);
|
||||
const endIndex = Math.min(filteredOptions.length - 1, range.endIndex + range.overscan);
|
||||
const rangeToReturn = Array.from({ length: endIndex - startIndex + 1 }, (_, i) => startIndex + i);
|
||||
|
||||
// // If the first item doesn't have a group, no need to find a header for it
|
||||
// const firstDisplayedOption = filteredOptions[rangeToReturn[0]];
|
||||
// if (firstDisplayedOption?.group) {
|
||||
// const groupStartIndex = groupStartIndices.get(firstDisplayedOption.group);
|
||||
// if (groupStartIndex !== undefined && groupStartIndex < rangeToReturn[0]) {
|
||||
// rangeToReturn.unshift(groupStartIndex);
|
||||
// }
|
||||
// }
|
||||
// If the first item doesn't have a group, no need to find a header for it
|
||||
const firstDisplayedOption = filteredOptions[rangeToReturn[0]];
|
||||
if (firstDisplayedOption?.group) {
|
||||
const groupStartIndex = groupStartIndices.get(firstDisplayedOption.group);
|
||||
if (groupStartIndex !== undefined && groupStartIndex < rangeToReturn[0]) {
|
||||
rangeToReturn.unshift(groupStartIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// return rangeToReturn;
|
||||
// },
|
||||
// [filteredOptions, groupStartIndices]
|
||||
// );
|
||||
return rangeToReturn;
|
||||
},
|
||||
[filteredOptions, groupStartIndices]
|
||||
);
|
||||
|
||||
const scrollToIndexObservable = useMemo(() => {
|
||||
return new Subject<number>();
|
||||
}, []);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredOptions.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index: number) => {
|
||||
const firstGroupItem = isNewGroup(filteredOptions[index], index > 0 ? filteredOptions[index - 1] : undefined);
|
||||
const hasDescription = 'description' in filteredOptions[index];
|
||||
const hasGroup = 'group' in filteredOptions[index];
|
||||
|
||||
let itemHeight = MENU_OPTION_HEIGHT;
|
||||
if (hasDescription) {
|
||||
itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION;
|
||||
}
|
||||
if (firstGroupItem && hasGroup) {
|
||||
itemHeight += MENU_OPTION_HEIGHT;
|
||||
}
|
||||
return itemHeight;
|
||||
},
|
||||
overscan: VIRTUAL_OVERSCAN_ITEMS,
|
||||
rangeExtractor,
|
||||
});
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -273,7 +290,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
|
||||
onHighlightedIndexChange: ({ highlightedIndex, type }) => {
|
||||
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
|
||||
scrollToIndexObservable.next(highlightedIndex);
|
||||
rowVirtualizer.scrollToIndex(highlightedIndex);
|
||||
}
|
||||
},
|
||||
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
|
||||
@@ -401,7 +418,6 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
scrollRef={scrollRef}
|
||||
getItemProps={getItemProps}
|
||||
error={asyncError}
|
||||
scrollToIndexObservable={scrollToIndexObservable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { UseComboboxPropGetters } from 'downshift';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Subject } from 'rxjs';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
|
||||
import { AsyncError, LoadingOptions, NotFoundError } from './MessageRows';
|
||||
import { DESCRIPTION_HEIGHT, getComboboxStyles, MENU_OPTION_HEIGHT } from './getComboboxStyles';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
||||
import { isNewGroup } from './utils';
|
||||
|
||||
@@ -25,7 +24,6 @@ interface ComboboxListProps<T extends string | number> {
|
||||
isMultiSelect?: boolean;
|
||||
error?: boolean;
|
||||
loading?: boolean;
|
||||
scrollToIndexObservable?: Subject<number>;
|
||||
}
|
||||
|
||||
export const ComboboxList = <T extends string | number>({
|
||||
@@ -38,7 +36,6 @@ export const ComboboxList = <T extends string | number>({
|
||||
isMultiSelect = false,
|
||||
error = false,
|
||||
loading = false,
|
||||
scrollToIndexObservable,
|
||||
}: ComboboxListProps<T>) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
|
||||
@@ -49,11 +46,8 @@ export const ComboboxList = <T extends string | number>({
|
||||
const hasGroup = 'group' in options[index];
|
||||
|
||||
let itemHeight = MENU_OPTION_HEIGHT;
|
||||
if (typeof options[index].label === 'object') {
|
||||
itemHeight = options[index].label.size;
|
||||
}
|
||||
if (hasDescription) {
|
||||
itemHeight += DESCRIPTION_HEIGHT;
|
||||
itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION;
|
||||
}
|
||||
if (firstGroupItem && hasGroup) {
|
||||
itemHeight += MENU_OPTION_HEIGHT;
|
||||
@@ -70,19 +64,6 @@ export const ComboboxList = <T extends string | number>({
|
||||
overscan: VIRTUAL_OVERSCAN_ITEMS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToIndexObservable) {
|
||||
const subscription = scrollToIndexObservable.subscribe((index) => {
|
||||
rowVirtualizer.scrollToIndex(index);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [scrollToIndexObservable, rowVirtualizer]);
|
||||
|
||||
const isOptionSelected = useCallback(
|
||||
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
|
||||
[selectedItems]
|
||||
@@ -109,8 +90,6 @@ export const ComboboxList = <T extends string | number>({
|
||||
// the option for aria-describedby.
|
||||
const groupHeaderId = groupHeaderItem ? `combobox-option-group-${groupHeaderItem.value}` : undefined;
|
||||
|
||||
const label = typeof item.label === 'object' ? item.label.node : (item.label ?? item.value);
|
||||
|
||||
return (
|
||||
// Wrapping div should have no styling other than virtual list positioning.
|
||||
// It's children (header and option) should appear as flat list items.
|
||||
@@ -172,7 +151,7 @@ export const ComboboxList = <T extends string | number>({
|
||||
)}
|
||||
|
||||
<div className={styles.optionBody}>
|
||||
<div className={styles.optionLabel}>{label}</div>
|
||||
<div className={styles.optionLabel}>{item.label ?? item.value}</div>
|
||||
|
||||
{item.description && <div className={styles.optionDescription}>{item.description}</div>}
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,6 @@ export function itemToString<T extends string | number>(item?: ComboboxOption<T>
|
||||
if (item == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof item.label === 'object') {
|
||||
return item.label.text;
|
||||
}
|
||||
|
||||
return item.label ?? item.value.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ export const MENU_ITEM_LINE_HEIGHT = 1.5;
|
||||
|
||||
// Used with Downshift to get the height of each item
|
||||
export const MENU_OPTION_HEIGHT = MENU_ITEM_GAP + MENU_ITEM_PADDING * 2 + MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
|
||||
export const DESCRIPTION_HEIGHT = MENU_ITEM_DESCRIPTION_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
|
||||
export const MENU_OPTION_HEIGHT_DESCRIPTION = MENU_OPTION_HEIGHT + DESCRIPTION_HEIGHT;
|
||||
export const MENU_OPTION_HEIGHT_DESCRIPTION =
|
||||
MENU_OPTION_HEIGHT + MENU_ITEM_DESCRIPTION_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
|
||||
export const POPOVER_MAX_HEIGHT = MENU_OPTION_HEIGHT * 8.5;
|
||||
|
||||
export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ComboboxStringOption } from './types';
|
||||
import { ComboboxOption } from './types';
|
||||
|
||||
let fakeApiOptions: Array<ComboboxStringOption<string>>;
|
||||
export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxStringOption<string>>> {
|
||||
let fakeApiOptions: Array<ComboboxOption<string>>;
|
||||
export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOption<string>>> {
|
||||
const searchParams = new URL(urlString).searchParams;
|
||||
|
||||
const errorOnQuery = searchParams.get('errorOnQuery')?.toLowerCase();
|
||||
@@ -26,19 +26,19 @@ export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxSt
|
||||
|
||||
const delay = searchQuery.length % 2 === 0 ? 200 : 1000;
|
||||
|
||||
return new Promise<Array<ComboboxStringOption<string>>>((resolve) => {
|
||||
return new Promise<Array<ComboboxOption<string>>>((resolve) => {
|
||||
setTimeout(() => resolve(filteredOptions), delay);
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateOptions(amount: number): Promise<ComboboxStringOption[]> {
|
||||
export async function generateOptions(amount: number): Promise<ComboboxOption[]> {
|
||||
return Array.from({ length: amount }, (_, index) => ({
|
||||
label: 'Option ' + index,
|
||||
value: index.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateGroupingOptions(amount: number): Promise<ComboboxStringOption[]> {
|
||||
export async function generateGroupingOptions(amount: number): Promise<ComboboxOption[]> {
|
||||
return Array.from({ length: amount }, (_, index) => ({
|
||||
label: 'Option ' + index,
|
||||
value: index.toString(),
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__';
|
||||
|
||||
interface NodeOption {
|
||||
node: ReactNode;
|
||||
/**
|
||||
* text property much exactly match the text in the `node` ReactNode property
|
||||
*/
|
||||
text: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type ComboboxOption<T extends string | number = string> = {
|
||||
label?: string | NodeOption;
|
||||
value: T;
|
||||
description?: string;
|
||||
group?: string;
|
||||
infoOption?: boolean;
|
||||
};
|
||||
|
||||
export interface ComboboxStringOption<T extends string | number = string> {
|
||||
label?: string;
|
||||
value: T;
|
||||
description?: string;
|
||||
group?: string;
|
||||
infoOption?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,8 +67,7 @@ export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>,
|
||||
const itemsToLookAt = Math.min(items.length, WIDTH_CALCULATION_LIMIT_ITEMS);
|
||||
|
||||
for (let i = 0; i < itemsToLookAt; i++) {
|
||||
const label = items[i].label;
|
||||
const itemLabel = (typeof label === 'object' ? label.text : label) ?? items[i].value.toString();
|
||||
const itemLabel = items[i].label ?? items[i].value.toString();
|
||||
longestItem = itemLabel.length > longestItem.length ? itemLabel : longestItem;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ export function useMeasureMulti<T extends string | number>(
|
||||
let currWidth = 0;
|
||||
for (let i = 0; i < selectedItems.length; i++) {
|
||||
// Measure text width and add size of padding, separator and close button
|
||||
const item = selectedItems[i];
|
||||
const text = typeof item.label === 'object' ? item.label.text : item.label || '';
|
||||
currWidth += measureText(text, FONT_SIZE).width + (disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE);
|
||||
currWidth +=
|
||||
measureText(selectedItems[i].label || '', FONT_SIZE).width +
|
||||
(disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE);
|
||||
if (currWidth > maxWidth) {
|
||||
// If there is no space for that item, show the current number of items,
|
||||
// but always show at least 1 item. Cap at maximum number of items.
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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 +',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
{previewOptions.map((o, index) => (
|
||||
<InlineFieldRow key={`${o.value}-${index}`} className={styles.optionContainer}>
|
||||
<InlineLabel data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption}>
|
||||
<div className={styles.label}>{o.label || String(o.value)}</div>
|
||||
<div className={styles.label}>{o.label}</div>
|
||||
</InlineLabel>
|
||||
</InlineFieldRow>
|
||||
))}
|
||||
|
||||
+18
-74
@@ -1,103 +1,47 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { Button, Modal, Stack } from '@grafana/ui';
|
||||
|
||||
import { dashboardEditActions } from '../../../../edit-pane/shared';
|
||||
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
|
||||
import { ValuesBuilder } from './ValuesBuilder';
|
||||
import { ValuesPreview } from './ValuesPreview';
|
||||
|
||||
interface ModalEditorProps {
|
||||
variable: CustomVariable;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ModalEditor(props: ModalEditorProps) {
|
||||
const { formRef, onCloseModal, options, onChangeOptions, onAddNewOption, onSaveOptions } = useModalEditor(props);
|
||||
export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
|
||||
const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
|
||||
isOpen={true}
|
||||
onDismiss={onCloseModal}
|
||||
closeOnBackdropClick={false}
|
||||
closeOnEscape={false}
|
||||
isOpen={isOpen}
|
||||
onDismiss={onClose}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
|
||||
<VariableValuesPreview options={options} />
|
||||
<ValuesBuilder variable={variable} ref={formRef} />
|
||||
<ValuesPreview variable={variable} />
|
||||
</Stack>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
onClick={onCloseModal}
|
||||
onClick={onClose}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.closeButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.discard">Discard</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSaveOptions}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.apply">Apply</Trans>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.close">Close</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function useModalEditor({ variable, onClose }: ModalEditorProps) {
|
||||
const { query } = variable.state;
|
||||
const [options, setOptions] = useState(() => transformQueryToOptions(variable, query));
|
||||
const initialQueryRef = useRef(query);
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
|
||||
return {
|
||||
formRef,
|
||||
onCloseModal: onClose,
|
||||
options,
|
||||
onChangeOptions: setOptions,
|
||||
onAddNewOption() {
|
||||
formRef.current?.addItem();
|
||||
},
|
||||
onSaveOptions() {
|
||||
dashboardEditActions.edit({
|
||||
source: variable,
|
||||
description: t('dashboard.edit-pane.variable.custom-options.change-value', 'Change variable value'),
|
||||
perform: () => {
|
||||
variable.setState({ query: transformOptionsToQuery(options) });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
undo: () => {
|
||||
variable.setState({ query: initialQueryRef.current });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const transformQueryToOptions = (variable: ModalEditorProps['variable'], query: string) =>
|
||||
variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
|
||||
value,
|
||||
label: value === label ? '' : label,
|
||||
}));
|
||||
|
||||
const formatOption = (option: VariableValueOption) => {
|
||||
if (!option.label || option.label === option.value) {
|
||||
return escapeEntities(option.value);
|
||||
}
|
||||
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
|
||||
};
|
||||
|
||||
const escapeEntities = (text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,');
|
||||
|
||||
const transformOptionsToQuery = (options: VariableValueOption[]) => options.map(formatOption).join(', ');
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export function PaneItem({ variable }: PaneItemProps) {
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.open-editor">Open variable editor</Trans>
|
||||
</Button>
|
||||
</Box>
|
||||
{isOpen && <ModalEditor variable={variable} onClose={() => setIsOpen(false)} />}
|
||||
<ModalEditor variable={variable} isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
|
||||
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
|
||||
interface ValuesBuilderProps {
|
||||
variable: CustomVariable;
|
||||
}
|
||||
|
||||
export const ValuesBuilder = forwardRef<VariableStaticOptionsFormRef, ValuesBuilderProps>(function (
|
||||
{ variable }: ValuesBuilderProps,
|
||||
ref
|
||||
) {
|
||||
const { query } = variable.useState();
|
||||
|
||||
const options = variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
|
||||
value,
|
||||
label: value === label ? '' : label,
|
||||
}));
|
||||
|
||||
const escapeEntities = useCallback((text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,'), []);
|
||||
|
||||
const formatOption = useCallback(
|
||||
(option: VariableValueOption) => {
|
||||
if (!option.label || option.label === option.value) {
|
||||
return escapeEntities(option.value);
|
||||
}
|
||||
|
||||
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
|
||||
},
|
||||
[escapeEntities]
|
||||
);
|
||||
|
||||
const generateQuery = useCallback(
|
||||
(options: VariableValueOption[]) => options.map(formatOption).join(', '),
|
||||
[formatOption]
|
||||
);
|
||||
|
||||
const handleOptionsChange = useCallback(
|
||||
async (options: VariableValueOption[]) => {
|
||||
variable.setState({ query: generateQuery(options) });
|
||||
await lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
[variable, generateQuery]
|
||||
);
|
||||
|
||||
return <VariableStaticOptionsForm options={options} onChange={handleOptionsChange} ref={ref} isInModal />;
|
||||
});
|
||||
|
||||
ValuesBuilder.displayName = 'ValuesBuilder';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
import { hasVariableOptions } from '../../utils';
|
||||
|
||||
export function ValuesPreview({ variable }: { variable: CustomVariable }) {
|
||||
// Workaround to toggle a component refresh when values change so that the preview is updated
|
||||
variable.useState();
|
||||
|
||||
const isHasVariableOptions = hasVariableOptions(variable);
|
||||
|
||||
return isHasVariableOptions ? <VariableValuesPreview options={variable.getOptionsForSelect(false)} /> : null;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4811,9 +4811,7 @@
|
||||
},
|
||||
"variable": {
|
||||
"custom-options": {
|
||||
"apply": "Apply",
|
||||
"change-value": "Change variable value",
|
||||
"discard": "Discard",
|
||||
"close": "Close",
|
||||
"modal-title": "Custom Variable",
|
||||
"values": "Values separated by comma"
|
||||
},
|
||||
@@ -6233,6 +6231,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-performance-metrics": {
|
||||
"query": "Query",
|
||||
"render": "Render",
|
||||
"total-time": "Total time",
|
||||
"transform": "Transform"
|
||||
},
|
||||
"panel-viz-type-picker": {
|
||||
"button": {
|
||||
"close": "Back"
|
||||
@@ -9399,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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user