mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Context tooltip to copy labels and values from graph (#21405)
This commit is contained in:
parent
26f72ccc4e
commit
c738a889ed
8
packages/grafana-data/src/types/flot.ts
Normal file
8
packages/grafana-data/src/types/flot.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface FlotDataPoint {
|
||||
dataIndex: number;
|
||||
datapoint: number[];
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
series: any;
|
||||
seriesIndex: number;
|
||||
}
|
@ -22,6 +22,7 @@ export * from './thresholds';
|
||||
export * from './fieldColor';
|
||||
export * from './theme';
|
||||
export * from './orgs';
|
||||
export * from './flot';
|
||||
|
||||
import * as AppEvents from './appEvents';
|
||||
import { AppEvent } from './appEvents';
|
||||
|
@ -5,7 +5,6 @@ import { TimeRange } from '../types/time';
|
||||
|
||||
// Types
|
||||
// import { NullValueMode, GraphSeriesValue, Field, TimeRange } from '@grafana/data';
|
||||
|
||||
export interface FlotPairsOptions {
|
||||
xField: Field;
|
||||
yField: Field;
|
||||
|
@ -22,6 +22,7 @@ const getTooltipContainerStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
max-width: 800px;
|
||||
padding: ${theme.spacing.sm};
|
||||
border-radius: ${theme.border.radius.sm};
|
||||
z-index: ${theme.zIndex.tooltip};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -25,7 +25,7 @@ export interface ContextMenuProps {
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
items?: ContextMenuGroup[];
|
||||
renderHeader?: () => JSX.Element;
|
||||
renderHeader?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
const getContextMenuStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
@ -181,10 +181,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = React.memo(({ x, y, onClo
|
||||
});
|
||||
|
||||
const styles = getContextMenuStyles(theme);
|
||||
const header = renderHeader && renderHeader();
|
||||
return (
|
||||
<Portal>
|
||||
<div ref={menuRef} style={positionStyles} className={styles.wrapper}>
|
||||
{renderHeader && <div className={styles.header}>{renderHeader()}</div>}
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
<List
|
||||
items={items || []}
|
||||
renderItem={(item, index) => {
|
||||
|
@ -3,11 +3,20 @@ import $ from 'jquery';
|
||||
import React, { PureComponent } from 'react';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
// Types
|
||||
import { TimeRange, GraphSeriesXY, TimeZone, DefaultTimeZone, createDimension } from '@grafana/data';
|
||||
import {
|
||||
TimeRange,
|
||||
GraphSeriesXY,
|
||||
TimeZone,
|
||||
DefaultTimeZone,
|
||||
createDimension,
|
||||
DateTimeInput,
|
||||
dateTime,
|
||||
} from '@grafana/data';
|
||||
import _ from 'lodash';
|
||||
import { FlotPosition, FlotItem } from './types';
|
||||
import { TooltipProps, TooltipContentProps, ActiveDimensions, Tooltip } from '../Chart/Tooltip';
|
||||
import { GraphTooltip } from './GraphTooltip/GraphTooltip';
|
||||
import { GraphContextMenu, GraphContextMenuProps, ContextDimensions } from './GraphContextMenu';
|
||||
import { GraphDimensions } from './GraphTooltip/types';
|
||||
|
||||
export interface GraphProps {
|
||||
@ -27,8 +36,11 @@ export interface GraphProps {
|
||||
|
||||
interface GraphState {
|
||||
pos?: FlotPosition;
|
||||
contextPos?: FlotPosition;
|
||||
isTooltipVisible: boolean;
|
||||
isContextVisible: boolean;
|
||||
activeItem?: FlotItem<GraphSeriesXY>;
|
||||
contextItem?: FlotItem<GraphSeriesXY>;
|
||||
}
|
||||
|
||||
export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
@ -42,6 +54,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
|
||||
state: GraphState = {
|
||||
isTooltipVisible: false,
|
||||
isContextVisible: false,
|
||||
};
|
||||
|
||||
element: HTMLElement | null = null;
|
||||
@ -59,6 +72,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
this.$element = $(this.element);
|
||||
this.$element.bind('plotselected', this.onPlotSelected);
|
||||
this.$element.bind('plothover', this.onPlotHover);
|
||||
this.$element.bind('plotclick', this.onPlotClick);
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +95,15 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
});
|
||||
};
|
||||
|
||||
onPlotClick = (event: JQueryEventObject, contextPos: FlotPosition, item?: FlotItem<GraphSeriesXY>) => {
|
||||
this.setState({
|
||||
isContextVisible: true,
|
||||
isTooltipVisible: false,
|
||||
contextItem: item,
|
||||
contextPos,
|
||||
});
|
||||
};
|
||||
|
||||
getYAxes(series: GraphSeriesXY[]) {
|
||||
if (series.length === 0) {
|
||||
return [{ show: true, min: -1, max: 1 }];
|
||||
@ -179,6 +202,68 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
});
|
||||
};
|
||||
|
||||
renderContextMenu = () => {
|
||||
const { series } = this.props;
|
||||
const { contextPos, contextItem, isContextVisible } = this.state;
|
||||
|
||||
if (!isContextVisible || !contextPos || !contextItem || series.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Indicates column(field) index in y-axis dimension
|
||||
const seriesIndex = contextItem ? contextItem.series.seriesIndex : 0;
|
||||
// Indicates row index in context field values
|
||||
const rowIndex = contextItem ? contextItem.dataIndex : undefined;
|
||||
|
||||
const contextDimensions: ContextDimensions<GraphDimensions> = {
|
||||
// Described x-axis context item
|
||||
xAxis: [seriesIndex, rowIndex],
|
||||
// Describes y-axis context item
|
||||
yAxis: contextItem ? [contextItem.series.seriesIndex, contextItem.dataIndex] : null,
|
||||
};
|
||||
|
||||
const dimensions: GraphDimensions = {
|
||||
// time/value dimension columns are index-aligned - see getGraphSeriesModel
|
||||
xAxis: createDimension(
|
||||
'xAxis',
|
||||
series.map(s => s.timeField)
|
||||
),
|
||||
yAxis: createDimension(
|
||||
'yAxis',
|
||||
series.map(s => s.valueField)
|
||||
),
|
||||
};
|
||||
|
||||
const formatDate = (date: DateTimeInput, format?: string) => {
|
||||
return dateTime(date)?.format(format);
|
||||
};
|
||||
|
||||
const closeContext = () => this.setState({ isContextVisible: false });
|
||||
|
||||
const getContextMenuSource = () => {
|
||||
return {
|
||||
datapoint: contextItem.datapoint,
|
||||
dataIndex: contextItem.dataIndex,
|
||||
series: contextItem.series,
|
||||
seriesIndex: contextItem.series.seriesIndex,
|
||||
pageX: contextPos.pageX,
|
||||
pageY: contextPos.pageY,
|
||||
};
|
||||
};
|
||||
|
||||
const contextContentProps: GraphContextMenuProps = {
|
||||
x: contextPos.pageX,
|
||||
y: contextPos.pageY,
|
||||
onClose: closeContext,
|
||||
getContextMenuSource: getContextMenuSource,
|
||||
formatSourceDate: formatDate,
|
||||
dimensions,
|
||||
contextDimensions,
|
||||
};
|
||||
|
||||
return <GraphContextMenu {...contextContentProps} />;
|
||||
};
|
||||
|
||||
getBarWidth = () => {
|
||||
const { series } = this.props;
|
||||
return Math.min(...series.map(s => s.timeStep));
|
||||
@ -285,6 +370,8 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
render() {
|
||||
const { height, width, series } = this.props;
|
||||
const noDataToBeDisplayed = series.length === 0;
|
||||
const tooltip = this.renderTooltip();
|
||||
const context = this.renderContextMenu();
|
||||
return (
|
||||
<div className="graph-panel">
|
||||
<div
|
||||
@ -295,9 +382,9 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
this.setState({ isTooltipVisible: false });
|
||||
}}
|
||||
/>
|
||||
|
||||
{noDataToBeDisplayed && <div className="datapoints-warning">No data</div>}
|
||||
{this.renderTooltip()}
|
||||
{tooltip}
|
||||
{context}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,99 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { ContextMenu, ContextMenuProps } from '../ContextMenu/ContextMenu';
|
||||
import { ThemeContext } from '../../themes';
|
||||
import { SeriesIcon } from '../Legend/SeriesIcon';
|
||||
import { GraphDimensions } from './GraphTooltip/types';
|
||||
import {
|
||||
DateTimeInput,
|
||||
FlotDataPoint,
|
||||
getValueFromDimension,
|
||||
getDisplayProcessor,
|
||||
formattedValueToString,
|
||||
Dimensions,
|
||||
MS_DATE_TIME_FORMAT,
|
||||
DEFAULT_DATE_TIME_FORMAT,
|
||||
} from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
|
||||
export type ContextDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
|
||||
|
||||
export type GraphContextMenuProps = ContextMenuProps & {
|
||||
getContextMenuSource: () => FlotDataPoint | null;
|
||||
formatSourceDate: (date: DateTimeInput, format?: string) => string;
|
||||
dimensions?: GraphDimensions;
|
||||
contextDimensions?: ContextDimensions;
|
||||
};
|
||||
|
||||
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
getContextMenuSource,
|
||||
formatSourceDate,
|
||||
items,
|
||||
dimensions,
|
||||
contextDimensions,
|
||||
...otherProps
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const source = getContextMenuSource();
|
||||
|
||||
// Do not render items that do not have label specified
|
||||
const itemsToRender = items
|
||||
? items.map(group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => item.label),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const renderHeader = () => {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If dimensions supplied, we can calculate and display value
|
||||
let value;
|
||||
if (dimensions?.yAxis && contextDimensions?.yAxis?.[1]) {
|
||||
const valueFromDimensions = getValueFromDimension(
|
||||
dimensions.yAxis,
|
||||
contextDimensions.yAxis[0],
|
||||
contextDimensions.yAxis[1]
|
||||
);
|
||||
const display = source.series.valueField.display ?? getDisplayProcessor({ field: source.series.valueField });
|
||||
value = display(valueFromDimensions);
|
||||
}
|
||||
|
||||
const timeFormat = source.series.hasMsResolution ? MS_DATE_TIME_FORMAT : DEFAULT_DATE_TIME_FORMAT;
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
z-index: ${theme.zIndex.tooltip};
|
||||
`}
|
||||
>
|
||||
<strong>{formatSourceDate(source.datapoint[0], timeFormat)}</strong>
|
||||
<div>
|
||||
<SeriesIcon color={source.series.color} />
|
||||
<span
|
||||
className={css`
|
||||
white-space: nowrap;
|
||||
padding-left: ${theme.spacing.xs};
|
||||
`}
|
||||
>
|
||||
{source.series.alias || source.series.label}
|
||||
</span>
|
||||
{value && (
|
||||
<span
|
||||
className={css`
|
||||
white-space: nowrap;
|
||||
padding-left: ${theme.spacing.md};
|
||||
`}
|
||||
>
|
||||
{formattedValueToString(value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
|
||||
};
|
@ -22,6 +22,7 @@ const getSeriesTableRowStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
`,
|
||||
seriesTableRow: css`
|
||||
display: table-row;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
`,
|
||||
seriesTableCell: css`
|
||||
display: table-cell;
|
||||
@ -35,6 +36,10 @@ const getSeriesTableRowStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
activeSeries: css`
|
||||
font-weight: ${theme.typography.weight.bold};
|
||||
`,
|
||||
timestamp: css`
|
||||
font-weight: ${theme.typography.weight.bold};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
@ -60,9 +65,15 @@ interface SeriesTableProps {
|
||||
}
|
||||
|
||||
export const SeriesTable: React.FC<SeriesTableProps> = ({ timestamp, series }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSeriesTableRowStyles(theme);
|
||||
return (
|
||||
<>
|
||||
{timestamp && <div aria-label="Timestamp">{timestamp}</div>}
|
||||
{timestamp && (
|
||||
<div className={styles.timestamp} aria-label="Timestamp">
|
||||
{timestamp}
|
||||
</div>
|
||||
)}
|
||||
{series.map(s => {
|
||||
return <SeriesTableRow isActive={s.isActive} label={s.label} color={s.color} value={s.value} key={s.label} />;
|
||||
})}
|
||||
|
@ -66,6 +66,7 @@ export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { GraphLegend } from './Graph/GraphLegend';
|
||||
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
||||
export { GraphContextMenu } from './Graph/GraphContextMenu';
|
||||
export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge';
|
||||
export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
|
||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||
|
@ -15,10 +15,10 @@ import {
|
||||
UnitPicker,
|
||||
DataLinksEditor,
|
||||
DataSourceHttpSettings,
|
||||
GraphContextMenu,
|
||||
} from '@grafana/ui';
|
||||
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
|
||||
import { SearchField } from './components/search/SearchField';
|
||||
import { GraphContextMenu } from 'app/plugins/panel/graph/GraphContextMenu';
|
||||
import ReactProfileWrapper from 'app/features/profile/ReactProfileWrapper';
|
||||
import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/components/AnnotationsQueryEditor';
|
||||
import { HelpModal } from './components/help/HelpModal';
|
||||
|
@ -1,62 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { FlotDataPoint } from './GraphContextMenuCtrl';
|
||||
import { ContextMenu, ContextMenuProps, SeriesIcon, ThemeContext } from '@grafana/ui';
|
||||
import { DateTimeInput } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
|
||||
type GraphContextMenuProps = ContextMenuProps & {
|
||||
getContextMenuSource: () => FlotDataPoint | null;
|
||||
formatSourceDate: (date: DateTimeInput, format?: string) => string;
|
||||
};
|
||||
|
||||
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
||||
getContextMenuSource,
|
||||
formatSourceDate,
|
||||
items,
|
||||
...otherProps
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const source = getContextMenuSource();
|
||||
|
||||
// Do not render items that do not have label specified
|
||||
const itemsToRender = items
|
||||
? items.map(group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => item.label),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const renderHeader = source
|
||||
? () => {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeFormat = source.series.hasMsResolution ? 'YYYY-MM-DD HH:mm:ss.SSS' : 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
`}
|
||||
>
|
||||
<strong>{formatSourceDate(source.datapoint[0], timeFormat)}</strong>
|
||||
<div>
|
||||
<SeriesIcon color={source.series.color} />
|
||||
<span
|
||||
className={css`
|
||||
white-space: nowrap;
|
||||
padding-left: ${theme.spacing.xs};
|
||||
`}
|
||||
>
|
||||
{source.series.alias}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
: null;
|
||||
|
||||
return <ContextMenu {...otherProps} items={itemsToRender} renderHeader={renderHeader} />;
|
||||
};
|
@ -1,13 +1,5 @@
|
||||
import { ContextMenuItem } from '@grafana/ui';
|
||||
|
||||
export interface FlotDataPoint {
|
||||
dataIndex: number;
|
||||
datapoint: number[];
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
series: any;
|
||||
seriesIndex: number;
|
||||
}
|
||||
import { FlotDataPoint } from '@grafana/data';
|
||||
|
||||
export class GraphContextMenuCtrl {
|
||||
private source?: FlotDataPoint | null;
|
||||
|
Loading…
Reference in New Issue
Block a user