mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
[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:
@@ -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() },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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} </span>}
|
||||
{showCounters.dataChanged && <span>Data: {this.counters.dataChanged} </span>}
|
||||
{showCounters.schemaChanged && <span>Schema: {this.counters.schemaChanged} </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} />;
|
||||
}
|
||||
}
|
||||
|
||||
75
public/app/plugins/panel/debug/EventBusLogger.tsx
Normal file
75
public/app/plugins/panel/debug/EventBusLogger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
109
public/app/plugins/panel/debug/RenderInfoViewer.tsx
Normal file
109
public/app/plugins/panel/debug/RenderInfoViewer.tsx
Normal 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} </span>}
|
||||
{showCounters.dataChanged && <span>Data: {this.counters.dataChanged} </span>}
|
||||
{showCounters.schemaChanged && <span>Schema: {this.counters.schemaChanged} </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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,12 @@ export type UpdateCounters = {
|
||||
schemaChanged: number;
|
||||
};
|
||||
|
||||
export enum DebugMode {
|
||||
Render = 'render',
|
||||
Events = 'events',
|
||||
}
|
||||
|
||||
export interface DebugPanelOptions {
|
||||
mode: DebugMode;
|
||||
counters?: UpdateConfig;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user