From d0239ac9580bbab05c3f2890ca576b778a29c018 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Mon, 26 Apr 2021 16:13:15 +0200 Subject: [PATCH] [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 Co-authored-by: Dominik Prokop --- .../grafana-data/src/events/EventBus.test.ts | 26 ++++- packages/grafana-data/src/events/EventBus.ts | 41 +++++++ packages/grafana-data/src/events/common.ts | 38 ++++++ packages/grafana-data/src/events/index.ts | 1 + .../components/PanelChrome/PanelContext.ts | 18 +++ .../src/components/PanelChrome/index.ts | 2 + .../src/components/PieChart/PieChart.tsx | 53 +++++++-- .../src/components/PieChart/types.ts | 27 ++++- .../src/components/VizLegend/VizLegend.tsx | 42 ++++++- .../components/VizLegend/VizLegendList.tsx | 10 +- .../VizLegend/VizLegendListItem.tsx | 66 ++++++++--- .../components/VizLegend/VizLegendTable.tsx | 4 + .../VizLegend/VizLegendTableItem.tsx | 60 +++++++--- .../src/components/VizLegend/types.ts | 3 + packages/grafana-ui/src/components/index.ts | 2 + .../dashboard/dashgrid/PanelChrome.test.tsx | 1 + .../dashboard/dashgrid/PanelChrome.tsx | 43 ++++--- public/app/plugins/panel/debug/DebugPanel.tsx | 63 ++-------- .../plugins/panel/debug/EventBusLogger.tsx | 75 ++++++++++++ .../plugins/panel/debug/RenderInfoViewer.tsx | 109 ++++++++++++++++++ public/app/plugins/panel/debug/module.tsx | 16 ++- public/app/plugins/panel/debug/types.ts | 6 + 22 files changed, 587 insertions(+), 119 deletions(-) create mode 100644 packages/grafana-data/src/events/common.ts create mode 100644 packages/grafana-ui/src/components/PanelChrome/PanelContext.ts create mode 100644 public/app/plugins/panel/debug/EventBusLogger.tsx create mode 100644 public/app/plugins/panel/debug/RenderInfoViewer.tsx diff --git a/packages/grafana-data/src/events/EventBus.test.ts b/packages/grafana-data/src/events/EventBus.test.ts index e8ea84e9265..b28e49053bd 100644 --- a/packages/grafana-data/src/events/EventBus.test.ts +++ b/packages/grafana-data/src/events/EventBus.test.ts @@ -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(); diff --git a/packages/grafana-data/src/events/EventBus.ts b/packages/grafana-data/src/events/EventBus.ts index a9d1020b626..fd616adcba0 100644 --- a/packages/grafana-data/src/events/EventBus.ts +++ b/packages/grafana-data/src/events/EventBus.ts @@ -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(event: T): void { + const decoratedEvent = { + ...event, + ...{ payload: { ...event.payload, ...{ source: this.source } } }, + }; + this.eventBus.publish(decoratedEvent); + } + + subscribe(eventType: BusEventType, handler: BusEventHandler): Unsubscribable { + return this.eventBus.subscribe(eventType, handler); + } + + getStream(eventType: BusEventType): Observable { + return this.eventBus.getStream(eventType); + } + + removeAllListeners(): void { + this.eventBus.removeAllListeners(); + } + + isOwnEvent(event: BusEventWithPayload): boolean { + return event.payload.source === this.source; + } +} diff --git a/packages/grafana-data/src/events/common.ts b/packages/grafana-data/src/events/common.ts new file mode 100644 index 00000000000..280666091a9 --- /dev/null +++ b/packages/grafana-data/src/events/common.ts @@ -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; // { time: 5678 }, + y: Record; // { __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; +} + +/** @alpha */ +export class DataHoverEvent extends BusEventWithPayload { + static type = 'data-hover'; +} + +/** @alpha */ +export class DataHoverClearEvent extends BusEventWithPayload { + static type = 'data-hover-clear'; +} + +/** @alpha */ +export class DataSelectEvent extends BusEventWithPayload { + static type = 'data-select'; +} diff --git a/packages/grafana-data/src/events/index.ts b/packages/grafana-data/src/events/index.ts index 69c33f4300d..358756536ff 100644 --- a/packages/grafana-data/src/events/index.ts +++ b/packages/grafana-data/src/events/index.ts @@ -1,3 +1,4 @@ export * from './eventFactory'; export * from './types'; export * from './EventBus'; +export * from './common'; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts new file mode 100644 index 00000000000..2436c0ae885 --- /dev/null +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -0,0 +1,18 @@ +import { EventBusWithSource } from '@grafana/data'; +import React from 'react'; + +interface PanelContext { + eventBus?: EventBusWithSource; +} + +const PanelContextRoot = React.createContext({}); + +/** + * @alpha + */ +export const PanelContextProvider = PanelContextRoot.Provider; + +/** + * @alpha + */ +export const usePanelContext = () => React.useContext(PanelContextRoot); diff --git a/packages/grafana-ui/src/components/PanelChrome/index.ts b/packages/grafana-ui/src/components/PanelChrome/index.ts index 52fde79ecfb..7883b128a0e 100644 --- a/packages/grafana-ui/src/components/PanelChrome/index.ts +++ b/packages/grafana-ui/src/components/PanelChrome/index.ts @@ -36,3 +36,5 @@ export { ErrorIndicator as PanelChromeErrorIndicator, ErrorIndicatorProps as PanelChromeErrorIndicatorProps, } from './ErrorIndicator'; + +export { usePanelContext, PanelContextProvider } from './PanelContext'; diff --git a/packages/grafana-ui/src/components/PieChart/PieChart.tsx b/packages/grafana-ui/src/components/PieChart/PieChart.tsx index 9dd3f780c23..1ce67377c81 100644 --- a/packages/grafana-ui/src/components/PieChart/PieChart.tsx +++ b/packages/grafana-ui/src/components/PieChart/PieChart.tsx @@ -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 = ({ data, timeZone, @@ -52,6 +58,25 @@ export const PieChart: FC = ({ ...restProps }) => { const theme = useTheme(); + const [highlightedTitle, setHighlightedTitle] = useState(); + 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 = ({ = ({ pieType, width, height, + highlightedTitle, useGradients = true, displayLabels = [], tooltipOptions, @@ -194,6 +221,7 @@ export const PieChartSvg: FC = ({ {(pie) => { return pie.arcs.map((arc) => { const color = arc.data.display.color ?? FALLBACK_COLOR; + const highlighted = highlightedTitle === arc.data.display.title; const label = showLabel ? ( = ({ {(api) => ( = ({ return ( ; pie: ProvidedProps; + highlighted?: boolean; fill: string; tooltip: UseTooltipParams; tooltipOptions: VizTooltipOptions; openMenu?: (event: React.MouseEvent) => 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 ( { 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)} `, diff --git a/packages/grafana-ui/src/components/PieChart/types.ts b/packages/grafana-ui/src/components/PieChart/types.ts index 1e437765173..0f75466e449 100644 --- a/packages/grafana-ui/src/components/PieChart/types.ts +++ b/packages/grafana-ui/src/components/PieChart/types.ts @@ -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 { +/** + * @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 = ({ placement, className, }) => { + const { eventBus } = usePanelContext(); + + const onMouseEnter = useCallback( + (item: VizLegendItem, event: React.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) => { + 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 = ({ sortDesc={sortDesc} onLabelClick={onLabelClick} onToggleSort={onToggleSort} + onLabelMouseEnter={onMouseEnter} + onLabelMouseOut={onMouseOut} onSeriesColorChange={onSeriesColorChange} /> ); @@ -38,6 +74,8 @@ export const VizLegend: React.FunctionComponent = ({ className={className} items={items} placement={placement} + onLabelMouseEnter={onMouseEnter} + onLabelMouseOut={onMouseOut} onLabelClick={onLabelClick} onSeriesColorChange={onSeriesColorChange} /> diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx index e9782e1a3b2..e9680a821a8 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx @@ -17,6 +17,8 @@ export const VizLegendList: React.FunctionComponent = ({ itemRenderer, onSeriesColorChange, onLabelClick, + onLabelMouseEnter, + onLabelMouseOut, placement, className, }) => { @@ -25,7 +27,13 @@ export const VizLegendList: React.FunctionComponent = ({ if (!itemRenderer) { /* eslint-disable-next-line react/display-name */ itemRenderer = (item) => ( - + ); } diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx index 6cec71deff4..3a2c71b91a7 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx @@ -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) => void; onSeriesColorChange?: SeriesColorChangeHandler; + onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent) => void; + onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent) => void; } /** * @internal */ -export const VizLegendListItem: React.FunctionComponent = ({ item, onSeriesColorChange, onLabelClick }) => { +export const VizLegendListItem: React.FunctionComponent = ({ + item, + onSeriesColorChange, + onLabelClick, + onLabelMouseEnter, + onLabelMouseOut, +}) => { const styles = useStyles(getStyles); + const onMouseEnter = useCallback( + (event: React.MouseEvent) => { + if (onLabelMouseEnter) { + onLabelMouseEnter(item, event); + } + }, + [item, onLabelMouseEnter] + ); + + const onMouseOut = useCallback( + (event: React.MouseEvent) => { + if (onLabelMouseOut) { + onLabelMouseOut(item, event); + } + }, + [item, onLabelMouseOut] + ); + + const onClick = useCallback( + (event: React.MouseEvent) => { + if (onLabelClick) { + onLabelClick(item, event); + } + }, + [item, onLabelClick] + ); + + const onColorChange = useCallback( + (color: string) => { + if (onSeriesColorChange) { + onSeriesColorChange(item.label, color); + } + }, + [item, onSeriesColorChange] + ); + return (
- { - if (onSeriesColorChange) { - onSeriesColorChange(item.label, color); - } - }} - /> +
{ - if (onLabelClick) { - onLabelClick(item, event); - } - }} + onMouseEnter={onMouseEnter} + onMouseOut={onMouseOut} + onClick={onClick} className={cx(styles.label, item.disabled && styles.labelDisabled)} > {item.label} diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index d2b78f6cc39..b1ddb025867 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -18,6 +18,8 @@ export const VizLegendTable: FC = ({ className, onToggleSort, onLabelClick, + onLabelMouseEnter, + onLabelMouseOut, onSeriesColorChange, }) => { const styles = useStyles(getStyles); @@ -57,6 +59,8 @@ export const VizLegendTable: FC = ({ item={item} onSeriesColorChange={onSeriesColorChange} onLabelClick={onLabelClick} + onLabelMouseEnter={onLabelMouseEnter} + onLabelMouseOut={onLabelMouseOut} /> ); } diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 7286c1e043e..cae4af0e7dc 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -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) => void; onSeriesColorChange?: SeriesColorChangeHandler; + onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent) => void; + onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent) => void; } /** @@ -21,29 +23,57 @@ export const LegendTableItem: React.FunctionComponent = ({ item, onSeriesColorChange, onLabelClick, + onLabelMouseEnter, + onLabelMouseOut, className, }) => { const styles = useStyles(getStyles); + const onMouseEnter = useCallback( + (event: React.MouseEvent) => { + if (onLabelMouseEnter) { + onLabelMouseEnter(item, event); + } + }, + [item, onLabelMouseEnter] + ); + + const onMouseOut = useCallback( + (event: React.MouseEvent) => { + if (onLabelMouseOut) { + onLabelMouseOut(item, event); + } + }, + [item, onLabelMouseOut] + ); + + const onClick = useCallback( + (event: React.MouseEvent) => { + if (onLabelClick) { + onLabelClick(item, event); + } + }, + [item, onLabelClick] + ); + + const onColorChange = useCallback( + (color: string) => { + if (onSeriesColorChange) { + onSeriesColorChange(item.label, color); + } + }, + [item, onSeriesColorChange] + ); + return ( - { - if (onSeriesColorChange) { - onSeriesColorChange(item.label, color); - } - }} - /> +
{ - if (onLabelClick) { - onLabelClick(item, event); - } - }} + onMouseEnter={onMouseEnter} + onMouseOut={onMouseOut} + onClick={onClick} className={cx(styles.label, item.disabled && styles.labelDisabled)} > {item.label} {item.yAxis === 2 && (right y-axis)} diff --git a/packages/grafana-ui/src/components/VizLegend/types.ts b/packages/grafana-ui/src/components/VizLegend/types.ts index 73be4a4bd50..e323da46214 100644 --- a/packages/grafana-ui/src/components/VizLegend/types.ts +++ b/packages/grafana-ui/src/components/VizLegend/types.ts @@ -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) => void; + onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent) => void; + onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent) => void; } export interface VizLegendTableProps extends VizLegendBaseProps { diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 8529621bb38..bc8588057ed 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx index fef5ef8c719..03098c37755 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx @@ -32,6 +32,7 @@ function setupTestContext(options: Partial) { setTimeSrv(timeSrv); const defaults: Props = { panel: ({ + id: 123, hasTitle: jest.fn(), replaceVariables: jest.fn(), events: { subscribe: jest.fn() }, diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 14384eb4e44..8fb70150550 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -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 { 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 { return ( <>
- + + +
); diff --git a/public/app/plugins/panel/debug/DebugPanel.tsx b/public/app/plugins/panel/debug/DebugPanel.tsx index a5fdb66dcf6..2c78d502350 100644 --- a/public/app/plugins/panel/debug/DebugPanel.tsx +++ b/public/app/plugins/panel/debug/DebugPanel.tsx @@ -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; @@ -40,57 +41,11 @@ export class DebugPanel extends Component { }; 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 ; + } - const reducer = fieldReducers.get(ReducerID.lastNotNull); - - return ( -
-
- - - {showCounters.render && Render: {this.counters.render} } - {showCounters.dataChanged && Data: {this.counters.dataChanged} } - {showCounters.schemaChanged && Schema: {this.counters.schemaChanged} } - TIME: {elapsed}ms - -
- - {data.series && - data.series.map((frame, idx) => ( -
-

- {getFrameDisplayName(frame, idx)} ({frame.length}) -

- - - - - - - - - - {frame.fields.map((field, idx) => { - const v = reducer.reduce!(field, false, false)[reducer.id]; - return ( - - - - - - ); - })} - -
FieldTypeLast
{getFieldDisplayName(field, frame, data.series)}{field.type}{`${v}`}
-
- ))} -
- ); + return ; } } diff --git a/public/app/plugins/panel/debug/EventBusLogger.tsx b/public/app/plugins/panel/debug/EventBusLogger.tsx new file mode 100644 index 00000000000..99aad27db2a --- /dev/null +++ b/public/app/plugins/panel/debug/EventBusLogger.tsx @@ -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 { + history = new CircularVector({ 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 = (event: DataHoverEvent) => { + this.history.add({ + key: counter++, + type: event.type, + payload: event.payload, + }); + this.setState({ counter }); + }; + + eventObserver: PartialObserver = { + next: (v: BusEvent) => {}, + }; + + render() { + return ( + + {this.history.map((v, idx) => ( +
+ {v.key} {v.type} / X:{JSON.stringify(v.payload.x)} / Y:{JSON.stringify(v.payload.y)} +
+ ))} +
+ ); + } +} diff --git a/public/app/plugins/panel/debug/RenderInfoViewer.tsx b/public/app/plugins/panel/debug/RenderInfoViewer.tsx new file mode 100644 index 00000000000..d090b04d849 --- /dev/null +++ b/public/app/plugins/panel/debug/RenderInfoViewer.tsx @@ -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; + +export class RenderInfoViewer extends Component { + // 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 ( +
+
+ + + {showCounters.render && Render: {this.counters.render} } + {showCounters.dataChanged && Data: {this.counters.dataChanged} } + {showCounters.schemaChanged && Schema: {this.counters.schemaChanged} } + TIME: {elapsed}ms + +
+ + {data.series && + data.series.map((frame, idx) => ( +
+

+ {getFrameDisplayName(frame, idx)} ({frame.length}) +

+ + + + + + + + + + {frame.fields.map((field, idx) => { + const v = reducer.reduce!(field, false, false)[reducer.id]; + return ( + + + + + + ); + })} + +
FieldTypeLast
{getFieldDisplayName(field, frame, data.series)}{field.type}{`${v}`}
+
+ ))} +
+ ); + } +} diff --git a/public/app/plugins/panel/debug/module.tsx b/public/app/plugins/panel/debug/module.tsx index e792d573a0b..f15f44cb9d4 100644 --- a/public/app/plugins/panel/debug/module.tsx +++ b/public/app/plugins/panel/debug/module.tsx @@ -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(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, }); }); diff --git a/public/app/plugins/panel/debug/types.ts b/public/app/plugins/panel/debug/types.ts index 90007b57c8d..8e88fb6cb28 100644 --- a/public/app/plugins/panel/debug/types.ts +++ b/public/app/plugins/panel/debug/types.ts @@ -8,6 +8,12 @@ export type UpdateCounters = { schemaChanged: number; }; +export enum DebugMode { + Render = 'render', + Events = 'events', +} + export interface DebugPanelOptions { + mode: DebugMode; counters?: UpdateConfig; }