[grafana/UI] Hoovering over a legend label highlights the corresponding pie slice (#32941)

* Hoovering over a legend label hightlighs that pie slice

* Change to event bus

* Adds EventBusWithSource to help identify the origin of the event

* Add tests and fix bug with incorrect source

* Clean up PieChart and EventBus a bit

* Fix bug when payload.source is undefined

* Add some documentation and adjust naming

* useState instead of useSetState

* Clean up some more documentation

* Move eventbus to state

* add event bus actions to the debug panel

* add event bus actions to the debug panel

* Try to make the naming a bit clearer

* Try passing eventbus as context

* Fix lint issues

* Move event bus context to panel chrome

* Fix event handler functions

* switch to using useCallback for legend item callbacks

* Remove unused parameters

* Add id to panel fixture of PanelChrome test

* Simplify event source

* Place eventBus inside more generic context

* Push handling of context up the tree to VizLegend

only export usePanelContext and PanelContextProvider

implement isOwnEvent on EventBus

some cleanup

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Oscar Kilhed 2021-04-26 16:13:15 +02:00 committed by GitHub
parent e21d1ccba4
commit d0239ac958
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 587 additions and 119 deletions

View File

@ -1,6 +1,7 @@
import { EventBusSrv } from './EventBus';
import { BusEventWithPayload } from './types';
import { EventBusSrv, EventBusWithSource } from './EventBus';
import { BusEvent, BusEventWithPayload } from './types';
import { eventFactory } from './eventFactory';
import { DataHoverEvent } from './common';
interface LoginEventPayload {
logins: number;
@ -46,6 +47,27 @@ describe('EventBus', () => {
expect(events.length).toBe(1);
});
describe('EventBusWithSource', () => {
it('can add sources to the source path', () => {
const bus = new EventBusSrv();
const busWithSource = new EventBusWithSource(bus, 'foo');
expect(busWithSource.source).toEqual('foo');
});
it('adds the source to the event payload', () => {
const bus = new EventBusSrv();
let events: BusEvent[] = [];
bus.subscribe(DataHoverEvent, (event) => events.push(event));
const busWithSource = new EventBusWithSource(bus, 'foo');
busWithSource.publish({ type: DataHoverEvent.type });
expect(events.length).toEqual(1);
expect(events[0].payload.source).toEqual('foo');
});
});
describe('Legacy emitter behavior', () => {
it('Supports legacy events', () => {
const bus = new EventBusSrv();

View File

@ -1,5 +1,6 @@
import EventEmitter from 'eventemitter3';
import { Unsubscribable, Observable } from 'rxjs';
import { PayloadWithSource } from './common';
import {
EventBus,
LegacyEmitter,
@ -8,6 +9,7 @@ import {
LegacyEventHandler,
BusEvent,
AppEvent,
BusEventWithPayload,
} from './types';
/**
@ -90,3 +92,42 @@ export class EventBusSrv implements EventBus, LegacyEmitter {
this.emitter.removeAllListeners();
}
}
/**
* @alpha
*
* Wraps EventBus and adds a source to help with identifying if a subscriber should react to the event or not.
*/
export class EventBusWithSource implements EventBus {
source: string;
eventBus: EventBus;
constructor(eventBus: EventBus, source: string) {
this.eventBus = eventBus;
this.source = source;
}
publish<T extends BusEvent>(event: T): void {
const decoratedEvent = {
...event,
...{ payload: { ...event.payload, ...{ source: this.source } } },
};
this.eventBus.publish(decoratedEvent);
}
subscribe<T extends BusEvent>(eventType: BusEventType<T>, handler: BusEventHandler<T>): Unsubscribable {
return this.eventBus.subscribe(eventType, handler);
}
getStream<T extends BusEvent>(eventType: BusEventType<T>): Observable<T> {
return this.eventBus.getStream(eventType);
}
removeAllListeners(): void {
this.eventBus.removeAllListeners();
}
isOwnEvent(event: BusEventWithPayload<PayloadWithSource>): boolean {
return event.payload.source === this.source;
}
}

View File

@ -0,0 +1,38 @@
import { DataFrame } from '../types';
import { BusEventWithPayload } from './types';
/** @alpha */
export interface PayloadWithSource {
source?: string; // source from where the event originates
}
/** @alpha */
export interface DataHoverPayload extends PayloadWithSource {
raw: any; // Original mouse event (includes pageX etc)
x: Record<string, any>; // { time: 5678 },
y: Record<string, any>; // { __fixed: 123, lengthft: 456 } // each axis|scale gets a value
data?: DataFrame; // source data
rowIndex?: number; // the hover row
columnIndex?: number; // the hover column
dataId?: string; // identifying string to correlate data between publishers and subscribers
// When dragging, this will capture the original state
down?: Omit<DataHoverPayload, 'down'>;
}
/** @alpha */
export class DataHoverEvent extends BusEventWithPayload<DataHoverPayload> {
static type = 'data-hover';
}
/** @alpha */
export class DataHoverClearEvent extends BusEventWithPayload<DataHoverPayload> {
static type = 'data-hover-clear';
}
/** @alpha */
export class DataSelectEvent extends BusEventWithPayload<DataHoverPayload> {
static type = 'data-select';
}

View File

@ -1,3 +1,4 @@
export * from './eventFactory';
export * from './types';
export * from './EventBus';
export * from './common';

View File

@ -0,0 +1,18 @@
import { EventBusWithSource } from '@grafana/data';
import React from 'react';
interface PanelContext {
eventBus?: EventBusWithSource;
}
const PanelContextRoot = React.createContext<PanelContext>({});
/**
* @alpha
*/
export const PanelContextProvider = PanelContextRoot.Provider;
/**
* @alpha
*/
export const usePanelContext = () => React.useContext(PanelContextRoot);

View File

@ -36,3 +36,5 @@ export {
ErrorIndicator as PanelChromeErrorIndicator,
ErrorIndicatorProps as PanelChromeErrorIndicatorProps,
} from './ErrorIndicator';
export { usePanelContext, PanelContextProvider } from './PanelContext';

View File

@ -1,5 +1,7 @@
import React, { FC, ReactNode } from 'react';
import React, { FC, ReactNode, useState } from 'react';
import {
DataHoverClearEvent,
DataHoverEvent,
FALLBACK_COLOR,
FieldDisplay,
formattedValueToString,
@ -30,6 +32,7 @@ import {
} from './types';
import { getTooltipContainerStyles } from '../../themes/mixins';
import { SeriesTable, SeriesTableRowProps, VizTooltipOptions } from '../VizTooltip';
import { usePanelContext } from '../PanelChrome';
const defaultLegendOptions: PieChartLegendOptions = {
displayMode: LegendDisplayMode.List,
@ -38,6 +41,9 @@ const defaultLegendOptions: PieChartLegendOptions = {
values: [PieChartLegendValues.Percent],
};
/**
* @beta
*/
export const PieChart: FC<PieChartProps> = ({
data,
timeZone,
@ -52,6 +58,25 @@ export const PieChart: FC<PieChartProps> = ({
...restProps
}) => {
const theme = useTheme();
const [highlightedTitle, setHighlightedTitle] = useState<string>();
const { eventBus } = usePanelContext();
if (eventBus) {
const setHighlightedSlice = (event: DataHoverEvent) => {
if (eventBus.isOwnEvent(event)) {
setHighlightedTitle(event.payload.dataId);
}
};
const resetHighlightedSlice = (event: DataHoverClearEvent) => {
if (eventBus.isOwnEvent(event)) {
setHighlightedTitle(undefined);
}
};
eventBus.subscribe(DataHoverEvent, setHighlightedSlice);
eventBus.subscribe(DataHoverClearEvent, resetHighlightedSlice);
}
const getLegend = (fields: FieldDisplay[], legendOptions: PieChartLegendOptions) => {
if (legendOptions.displayMode === LegendDisplayMode.Hidden) {
@ -117,6 +142,7 @@ export const PieChart: FC<PieChartProps> = ({
<PieChartSvg
width={vizWidth}
height={vizHeight}
highlightedTitle={highlightedTitle}
fieldDisplayValues={fieldDisplayValues}
tooltipOptions={tooltipOptions}
{...restProps}
@ -132,6 +158,7 @@ export const PieChartSvg: FC<PieChartSvgProps> = ({
pieType,
width,
height,
highlightedTitle,
useGradients = true,
displayLabels = [],
tooltipOptions,
@ -194,6 +221,7 @@ export const PieChartSvg: FC<PieChartSvgProps> = ({
{(pie) => {
return pie.arcs.map((arc) => {
const color = arc.data.display.color ?? FALLBACK_COLOR;
const highlighted = highlightedTitle === arc.data.display.title;
const label = showLabel ? (
<PieLabel
arc={arc}
@ -210,6 +238,7 @@ export const PieChartSvg: FC<PieChartSvgProps> = ({
{(api) => (
<PieSlice
tooltip={tooltip}
highlighted={highlighted}
arc={arc}
pie={pie}
fill={getGradientColor(color)}
@ -225,6 +254,7 @@ export const PieChartSvg: FC<PieChartSvgProps> = ({
return (
<PieSlice
key={arc.index}
highlighted={highlighted}
tooltip={tooltip}
arc={arc}
pie={pie}
@ -260,11 +290,12 @@ const PieSlice: FC<{
children: ReactNode;
arc: PieArcDatum<FieldDisplay>;
pie: ProvidedProps<FieldDisplay>;
highlighted?: boolean;
fill: string;
tooltip: UseTooltipParams<SeriesTableRowProps[]>;
tooltipOptions: VizTooltipOptions;
openMenu?: (event: React.MouseEvent<SVGElement>) => void;
}> = ({ arc, children, pie, openMenu, fill, tooltip, tooltipOptions }) => {
}> = ({ arc, children, pie, highlighted, openMenu, fill, tooltip, tooltipOptions }) => {
const theme = useTheme();
const styles = useStyles(getStyles);
@ -280,7 +311,7 @@ const PieSlice: FC<{
return (
<g
key={arc.data.display.title}
className={styles.svgArg}
className={highlighted ? styles.svgArg.highlighted : styles.svgArg.normal}
onMouseMove={tooltipOptions.mode !== 'none' ? onMouseMoveOverArc : undefined}
onMouseOut={tooltip.hideTooltip}
onClick={openMenu}
@ -420,12 +451,18 @@ const getStyles = (theme: GrafanaTheme) => {
align-items: center;
justify-content: center;
`,
svgArg: css`
transition: all 200ms ease-in-out;
&:hover {
svgArg: {
normal: css`
transition: all 200ms ease-in-out;
&:hover {
transform: scale3d(1.03, 1.03, 1);
}
`,
highlighted: css`
transition: all 200ms ease-in-out;
transform: scale3d(1.03, 1.03, 1);
}
`,
`,
},
tooltipPortal: css`
${getTooltipContainerStyles(theme)}
`,

View File

@ -2,18 +2,31 @@ import { DataFrame, FieldConfigSource, FieldDisplay, InterpolateFunction, Reduce
import { VizTooltipOptions } from '../VizTooltip';
import { VizLegendOptions } from '..';
/**
* @beta
*/
export interface PieChartSvgProps {
height: number;
width: number;
fieldDisplayValues: FieldDisplay[];
pieType: PieChartType;
highlightedTitle?: string;
displayLabels?: PieChartLabels[];
useGradients?: boolean;
onSeriesColorChange?: (label: string, color: string) => void;
tooltipOptions: VizTooltipOptions;
}
export interface PieChartProps extends Omit<PieChartSvgProps, 'fieldDisplayValues'> {
/**
* @beta
*/
export interface PieChartProps {
height: number;
width: number;
pieType: PieChartType;
displayLabels?: PieChartLabels[];
useGradients?: boolean;
onSeriesColorChange?: (label: string, color: string) => void;
legendOptions?: PieChartLegendOptions;
tooltipOptions: VizTooltipOptions;
reduceOptions: ReduceDataOptions;
@ -23,22 +36,34 @@ export interface PieChartProps extends Omit<PieChartSvgProps, 'fieldDisplayValue
timeZone?: string;
}
/**
* @beta
*/
export enum PieChartType {
Pie = 'pie',
Donut = 'donut',
}
/**
* @beta
*/
export enum PieChartLegendValues {
Value = 'value',
Percent = 'percent',
}
/**
* @beta
*/
export enum PieChartLabels {
Name = 'name',
Value = 'value',
Percent = 'percent',
}
/**
* @beta
*/
export interface PieChartLegendOptions extends VizLegendOptions {
values: PieChartLegendValues[];
}

View File

@ -1,8 +1,10 @@
import React from 'react';
import { LegendProps } from './types';
import React, { useCallback } from 'react';
import { LegendProps, VizLegendItem } from './types';
import { LegendDisplayMode } from './models.gen';
import { VizLegendTable } from './VizLegendTable';
import { VizLegendList } from './VizLegendList';
import { DataHoverClearEvent, DataHoverEvent } from '@grafana/data';
import { usePanelContext } from '../PanelChrome';
/**
* @public
@ -18,6 +20,38 @@ export const VizLegend: React.FunctionComponent<LegendProps> = ({
placement,
className,
}) => {
const { eventBus } = usePanelContext();
const onMouseEnter = useCallback(
(item: VizLegendItem, event: React.MouseEvent<HTMLElement, MouseEvent>) => {
eventBus?.publish({
type: DataHoverEvent.type,
payload: {
raw: event,
x: 0,
y: 0,
dataId: item.label,
},
});
},
[eventBus]
);
const onMouseOut = useCallback(
(item: VizLegendItem, event: React.MouseEvent<HTMLElement, MouseEvent>) => {
eventBus?.publish({
type: DataHoverClearEvent.type,
payload: {
raw: event,
x: 0,
y: 0,
dataId: item.label,
},
});
},
[eventBus]
);
switch (displayMode) {
case LegendDisplayMode.Table:
return (
@ -29,6 +63,8 @@ export const VizLegend: React.FunctionComponent<LegendProps> = ({
sortDesc={sortDesc}
onLabelClick={onLabelClick}
onToggleSort={onToggleSort}
onLabelMouseEnter={onMouseEnter}
onLabelMouseOut={onMouseOut}
onSeriesColorChange={onSeriesColorChange}
/>
);
@ -38,6 +74,8 @@ export const VizLegend: React.FunctionComponent<LegendProps> = ({
className={className}
items={items}
placement={placement}
onLabelMouseEnter={onMouseEnter}
onLabelMouseOut={onMouseOut}
onLabelClick={onLabelClick}
onSeriesColorChange={onSeriesColorChange}
/>

View File

@ -17,6 +17,8 @@ export const VizLegendList: React.FunctionComponent<Props> = ({
itemRenderer,
onSeriesColorChange,
onLabelClick,
onLabelMouseEnter,
onLabelMouseOut,
placement,
className,
}) => {
@ -25,7 +27,13 @@ export const VizLegendList: React.FunctionComponent<Props> = ({
if (!itemRenderer) {
/* eslint-disable-next-line react/display-name */
itemRenderer = (item) => (
<VizLegendListItem item={item} onLabelClick={onLabelClick} onSeriesColorChange={onSeriesColorChange} />
<VizLegendListItem
item={item}
onLabelClick={onLabelClick}
onSeriesColorChange={onSeriesColorChange}
onLabelMouseEnter={onLabelMouseEnter}
onLabelMouseOut={onLabelMouseOut}
/>
);
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { css, cx } from '@emotion/css';
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
import { VizLegendItem, SeriesColorChangeHandler } from './types';
@ -11,31 +11,65 @@ export interface Props {
className?: string;
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
onSeriesColorChange?: SeriesColorChangeHandler;
onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
}
/**
* @internal
*/
export const VizLegendListItem: React.FunctionComponent<Props> = ({ item, onSeriesColorChange, onLabelClick }) => {
export const VizLegendListItem: React.FunctionComponent<Props> = ({
item,
onSeriesColorChange,
onLabelClick,
onLabelMouseEnter,
onLabelMouseOut,
}) => {
const styles = useStyles(getStyles);
const onMouseEnter = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (onLabelMouseEnter) {
onLabelMouseEnter(item, event);
}
},
[item, onLabelMouseEnter]
);
const onMouseOut = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (onLabelMouseOut) {
onLabelMouseOut(item, event);
}
},
[item, onLabelMouseOut]
);
const onClick = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (onLabelClick) {
onLabelClick(item, event);
}
},
[item, onLabelClick]
);
const onColorChange = useCallback(
(color: string) => {
if (onSeriesColorChange) {
onSeriesColorChange(item.label, color);
}
},
[item, onSeriesColorChange]
);
return (
<div className={styles.itemWrapper}>
<VizLegendSeriesIcon
disabled={!onSeriesColorChange}
color={item.color}
onColorChange={(color) => {
if (onSeriesColorChange) {
onSeriesColorChange(item.label, color);
}
}}
/>
<VizLegendSeriesIcon disabled={!onSeriesColorChange} color={item.color} onColorChange={onColorChange} />
<div
onClick={(event) => {
if (onLabelClick) {
onLabelClick(item, event);
}
}}
onMouseEnter={onMouseEnter}
onMouseOut={onMouseOut}
onClick={onClick}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}

View File

@ -18,6 +18,8 @@ export const VizLegendTable: FC<VizLegendTableProps> = ({
className,
onToggleSort,
onLabelClick,
onLabelMouseEnter,
onLabelMouseOut,
onSeriesColorChange,
}) => {
const styles = useStyles(getStyles);
@ -57,6 +59,8 @@ export const VizLegendTable: FC<VizLegendTableProps> = ({
item={item}
onSeriesColorChange={onSeriesColorChange}
onLabelClick={onLabelClick}
onLabelMouseEnter={onLabelMouseEnter}
onLabelMouseOut={onLabelMouseOut}
/>
);
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { css, cx } from '@emotion/css';
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
import { VizLegendItem, SeriesColorChangeHandler } from './types';
@ -12,6 +12,8 @@ export interface Props {
className?: string;
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
onSeriesColorChange?: SeriesColorChangeHandler;
onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
}
/**
@ -21,29 +23,57 @@ export const LegendTableItem: React.FunctionComponent<Props> = ({
item,
onSeriesColorChange,
onLabelClick,
onLabelMouseEnter,
onLabelMouseOut,
className,
}) => {
const styles = useStyles(getStyles);
const onMouseEnter = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (onLabelMouseEnter) {
onLabelMouseEnter(item, event);
}
},
[item, onLabelMouseEnter]
);
const onMouseOut = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (onLabelMouseOut) {
onLabelMouseOut(item, event);
}
},
[item, onLabelMouseOut]
);
const onClick = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (onLabelClick) {
onLabelClick(item, event);
}
},
[item, onLabelClick]
);
const onColorChange = useCallback(
(color: string) => {
if (onSeriesColorChange) {
onSeriesColorChange(item.label, color);
}
},
[item, onSeriesColorChange]
);
return (
<tr className={cx(styles.row, className)}>
<td>
<span className={styles.itemWrapper}>
<VizLegendSeriesIcon
disabled={!onSeriesColorChange}
color={item.color}
onColorChange={(color) => {
if (onSeriesColorChange) {
onSeriesColorChange(item.label, color);
}
}}
/>
<VizLegendSeriesIcon disabled={!onSeriesColorChange} color={item.color} onColorChange={onColorChange} />
<div
onClick={(event) => {
if (onLabelClick) {
onLabelClick(item, event);
}
}}
onMouseEnter={onMouseEnter}
onMouseOut={onMouseOut}
onClick={onClick}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>}

View File

@ -1,4 +1,5 @@
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data';
import React from 'react';
import { LegendDisplayMode, LegendPlacement } from './models.gen';
export interface VizLegendBaseProps {
@ -8,6 +9,8 @@ export interface VizLegendBaseProps {
itemRenderer?: (item: VizLegendItem, index: number) => JSX.Element;
onSeriesColorChange?: SeriesColorChangeHandler;
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
}
export interface VizLegendTableProps extends VizLegendBaseProps {

View File

@ -94,6 +94,8 @@ export {
PanelChromeLoadingIndicatorProps,
PanelChromeErrorIndicator,
PanelChromeErrorIndicatorProps,
PanelContextProvider,
usePanelContext,
} from './PanelChrome';
export { VizLayout, VizLayoutComponentType, VizLayoutLegendProps, VizLayoutProps } from './VizLayout/VizLayout';
export { VizLegendItem } from './VizLegend/types';

View File

@ -32,6 +32,7 @@ function setupTestContext(options: Partial<Props>) {
setTimeSrv(timeSrv);
const defaults: Props = {
panel: ({
id: 123,
hasTitle: jest.fn(),
replaceVariables: jest.fn(),
events: { subscribe: jest.fn() },

View File

@ -4,7 +4,7 @@ import classNames from 'classnames';
import { Subscription } from 'rxjs';
// Components
import { PanelHeader } from './PanelHeader/PanelHeader';
import { ErrorBoundary } from '@grafana/ui';
import { ErrorBoundary, PanelContextProvider } from '@grafana/ui';
// Utils & Services
import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
@ -15,6 +15,7 @@ import { DashboardModel, PanelModel } from '../state';
import { PANEL_BORDER } from 'app/core/constants';
import {
AbsoluteTimeRange,
EventBusWithSource,
FieldConfigSource,
getDefaultTimeRange,
LoadingState,
@ -46,6 +47,7 @@ export interface State {
renderCounter: number;
errorMessage?: string;
refreshWhenInView: boolean;
eventBus: EventBusWithSource;
data: PanelData;
}
@ -60,6 +62,7 @@ export class PanelChrome extends Component<Props, State> {
isFirstLoad: true,
renderCounter: 0,
refreshWhenInView: false,
eventBus: new EventBusWithSource(props.dashboard.events, `panel-${props.panel.id}`),
data: {
state: LoadingState.NotStarted,
series: [],
@ -286,24 +289,26 @@ export class PanelChrome extends Component<Props, State> {
return (
<>
<div className={panelContentClassNames}>
<PanelComponent
id={panel.id}
data={data}
title={panel.title}
timeRange={timeRange}
timeZone={this.props.dashboard.getTimezone()}
options={panelOptions}
fieldConfig={panel.fieldConfig}
transparent={panel.transparent}
width={panelWidth}
height={innerPanelHeight}
renderCounter={renderCounter}
replaceVariables={panel.replaceVariables}
onOptionsChange={this.onOptionsChange}
onFieldConfigChange={this.onFieldConfigChange}
onChangeTimeRange={this.onChangeTimeRange}
eventBus={dashboard.events}
/>
<PanelContextProvider value={{ eventBus: this.state.eventBus }}>
<PanelComponent
id={panel.id}
data={data}
title={panel.title}
timeRange={timeRange}
timeZone={this.props.dashboard.getTimezone()}
options={panelOptions}
fieldConfig={panel.fieldConfig}
transparent={panel.transparent}
width={panelWidth}
height={innerPanelHeight}
renderCounter={renderCounter}
replaceVariables={panel.replaceVariables}
onOptionsChange={this.onOptionsChange}
onFieldConfigChange={this.onFieldConfigChange}
onChangeTimeRange={this.onChangeTimeRange}
eventBus={dashboard.events}
/>
</PanelContextProvider>
</div>
</>
);

View File

@ -1,8 +1,9 @@
import React, { Component } from 'react';
import { fieldReducers, getFieldDisplayName, getFrameDisplayName, PanelProps, ReducerID } from '@grafana/data';
import { PanelProps } from '@grafana/data';
import { DebugPanelOptions, UpdateCounters, UpdateConfig } from './types';
import { IconButton } from '@grafana/ui';
import { DebugPanelOptions, DebugMode, UpdateCounters } from './types';
import { EventBusLoggerPanel } from './EventBusLogger';
import { RenderInfoViewer } from './RenderInfoViewer';
type Props = PanelProps<DebugPanelOptions>;
@ -40,57 +41,11 @@ export class DebugPanel extends Component<Props> {
};
render() {
const { data, options } = this.props;
const showCounters = options.counters ?? ({} as UpdateConfig);
this.counters.render++;
const now = Date.now();
const elapsed = now - this.lastRender;
this.lastRender = now;
const { options } = this.props;
if (options.mode === DebugMode.Events) {
return <EventBusLoggerPanel eventBus={this.props.eventBus} />;
}
const reducer = fieldReducers.get(ReducerID.lastNotNull);
return (
<div>
<div>
<IconButton name="step-backward" title="reset counters" onClick={this.resetCounters} />
<span>
{showCounters.render && <span>Render: {this.counters.render}&nbsp;</span>}
{showCounters.dataChanged && <span>Data: {this.counters.dataChanged}&nbsp;</span>}
{showCounters.schemaChanged && <span>Schema: {this.counters.schemaChanged}&nbsp;</span>}
<span>TIME: {elapsed}ms</span>
</span>
</div>
{data.series &&
data.series.map((frame, idx) => (
<div key={`${idx}/${frame.refId}`}>
<h4>
{getFrameDisplayName(frame, idx)} ({frame.length})
</h4>
<table className="filter-table">
<thead>
<tr>
<td>Field</td>
<td>Type</td>
<td>Last</td>
</tr>
</thead>
<tbody>
{frame.fields.map((field, idx) => {
const v = reducer.reduce!(field, false, false)[reducer.id];
return (
<tr key={`${idx}/${field.name}`}>
<td>{getFieldDisplayName(field, frame, data.series)}</td>
<td>{field.type}</td>
<td>{`${v}`}</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
</div>
);
return <RenderInfoViewer {...this.props} />;
}
}

View File

@ -0,0 +1,75 @@
import React, { PureComponent } from 'react';
import { CustomScrollbar } from '@grafana/ui';
import {
BusEvent,
CircularVector,
DataHoverPayload,
DataHoverEvent,
DataHoverClearEvent,
DataSelectEvent,
EventBus,
BusEventHandler,
} from '@grafana/data';
import { PartialObserver, Unsubscribable } from 'rxjs';
interface Props {
eventBus: EventBus;
}
interface State {
isError?: boolean;
counter: number;
}
interface BusEventEx {
key: number;
type: string;
payload: DataHoverPayload;
}
let counter = 100;
export class EventBusLoggerPanel extends PureComponent<Props, State> {
history = new CircularVector<BusEventEx>({ capacity: 40, append: 'head' });
subs: Unsubscribable[] = [];
constructor(props: Props) {
super(props);
this.state = { counter };
this.subs.push(props.eventBus.subscribe(DataHoverEvent, this.hoverHandler));
props.eventBus.getStream(DataHoverClearEvent).subscribe(this.eventObserver);
props.eventBus.getStream(DataSelectEvent).subscribe(this.eventObserver);
}
componentWillUnmount() {
for (const sub of this.subs) {
sub.unsubscribe();
}
}
hoverHandler: BusEventHandler<DataHoverEvent> = (event: DataHoverEvent) => {
this.history.add({
key: counter++,
type: event.type,
payload: event.payload,
});
this.setState({ counter });
};
eventObserver: PartialObserver<BusEvent> = {
next: (v: BusEvent) => {},
};
render() {
return (
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
{this.history.map((v, idx) => (
<div key={v.key}>
{v.key} {v.type} / X:{JSON.stringify(v.payload.x)} / Y:{JSON.stringify(v.payload.y)}
</div>
))}
</CustomScrollbar>
);
}
}

View File

@ -0,0 +1,109 @@
import React, { Component } from 'react';
import {
compareArrayValues,
compareDataFrameStructures,
fieldReducers,
getFieldDisplayName,
getFrameDisplayName,
PanelProps,
ReducerID,
} from '@grafana/data';
import { DebugPanelOptions, UpdateCounters, UpdateConfig } from './types';
import { IconButton } from '@grafana/ui';
type Props = PanelProps<DebugPanelOptions>;
export class RenderInfoViewer extends Component<Props> {
// Intentionally not state to avoid overhead -- yes, things will be 1 tick behind
lastRender = Date.now();
counters: UpdateCounters = {
render: 0,
dataChanged: 0,
schemaChanged: 0,
};
shouldComponentUpdate(prevProps: Props) {
const { data, options } = this.props;
if (prevProps.data !== data) {
this.counters.dataChanged++;
if (options.counters?.schemaChanged) {
const oldSeries = prevProps.data?.series;
const series = data.series;
if (series && oldSeries) {
const sameStructure = compareArrayValues(series, oldSeries, compareDataFrameStructures);
if (!sameStructure) {
this.counters.schemaChanged++;
}
}
}
}
return true; // always render?
}
resetCounters = () => {
this.counters = {
render: 0,
dataChanged: 0,
schemaChanged: 0,
};
this.forceUpdate();
};
render() {
const { data, options } = this.props;
const showCounters = options.counters ?? ({} as UpdateConfig);
this.counters.render++;
const now = Date.now();
const elapsed = now - this.lastRender;
this.lastRender = now;
const reducer = fieldReducers.get(ReducerID.lastNotNull);
return (
<div>
<div>
<IconButton name="step-backward" title="reset counters" onClick={this.resetCounters} />
<span>
{showCounters.render && <span>Render: {this.counters.render}&nbsp;</span>}
{showCounters.dataChanged && <span>Data: {this.counters.dataChanged}&nbsp;</span>}
{showCounters.schemaChanged && <span>Schema: {this.counters.schemaChanged}&nbsp;</span>}
<span>TIME: {elapsed}ms</span>
</span>
</div>
{data.series &&
data.series.map((frame, idx) => (
<div key={`${idx}/${frame.refId}`}>
<h4>
{getFrameDisplayName(frame, idx)} ({frame.length})
</h4>
<table className="filter-table">
<thead>
<tr>
<td>Field</td>
<td>Type</td>
<td>Last</td>
</tr>
</thead>
<tbody>
{frame.fields.map((field, idx) => {
const v = reducer.reduce!(field, false, false)[reducer.id];
return (
<tr key={`${idx}/${field.name}`}>
<td>{getFieldDisplayName(field, frame, data.series)}</td>
<td>{field.type}</td>
<td>{`${v}`}</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
</div>
);
}
}

View File

@ -1,22 +1,36 @@
import { PanelPlugin } from '@grafana/data';
import { DebugPanel } from './DebugPanel';
import { DebugPanelOptions } from './types';
import { DebugMode, DebugPanelOptions } from './types';
export const plugin = new PanelPlugin<DebugPanelOptions>(DebugPanel).useFieldConfig().setPanelOptions((builder) => {
builder
.addRadio({
path: 'mode',
name: 'Mode',
defaultValue: DebugMode.Render,
settings: {
options: [
{ label: 'Render', value: DebugMode.Render },
{ label: 'Events', value: DebugMode.Events },
],
},
})
.addBooleanSwitch({
path: 'counters.render',
name: 'Render Count',
defaultValue: true,
showIf: ({ mode }) => mode === DebugMode.Render,
})
.addBooleanSwitch({
path: 'counters.dataChanged',
name: 'Data Changed Count',
defaultValue: true,
showIf: ({ mode }) => mode === DebugMode.Render,
})
.addBooleanSwitch({
path: 'counters.schemaChanged',
name: 'Schema Changed Count',
defaultValue: true,
showIf: ({ mode }) => mode === DebugMode.Render,
});
});

View File

@ -8,6 +8,12 @@ export type UpdateCounters = {
schemaChanged: number;
};
export enum DebugMode {
Render = 'render',
Events = 'events',
}
export interface DebugPanelOptions {
mode: DebugMode;
counters?: UpdateConfig;
}