DataLinks: enable data links in Gauge, BarGauge and SingleStat2 panel (#18605)

* datalink on field

* add dataFrame to view

* Use scoped variables to pass series name and value time to data links interpolation

* Use scoped variables to pass series name and value time to data links interpolation

* Enable value specific variable suggestions when Gauge is displaying values

* Fix prettier

* Add basic context menu with data links to GaugePanel

* Fix incorrect import in grafana/ui

* Add custom cursor indicating datalinks available via context menu (in Gauge only now)

* Add data links to SingleStat2

* Minor refactor

* Retrieve data links in a lazy way

* Update test to respect links retrieval being lazy

* delay link creation

* cleanup

* Add origin to LinkModel and introduce field & panel links suppliers

* Add value time and series name field link supplier

* Remove links prop from visualization and implement common UI for data links context menu

* Update snapshot

* Rename className prop to clickTargetClassName

* Simplify condition

* Updated drilldown dashboard and minor changes

* Use class name an onClick handler on the top level dom element in visualization

* Enable series name interpolation when presented value is a calculation
This commit is contained in:
Ryan McKinley
2019-08-27 23:50:43 -07:00
committed by Dominik Prokop
parent e1924608a2
commit ff6b8c5adc
38 changed files with 708 additions and 243 deletions

View File

@@ -26,6 +26,8 @@ export interface Props extends Themeable {
orientation: VizOrientation;
itemSpacing?: number;
displayMode: 'basic' | 'lcd' | 'gradient';
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
}
export class BarGauge extends PureComponent<Props> {
@@ -43,16 +45,20 @@ export class BarGauge extends PureComponent<Props> {
};
render() {
const { onClick, className } = this.props;
const { title } = this.props.value;
if (!title) {
return this.renderBarAndValue();
}
const styles = getTitleStyles(this.props);
if (!title) {
return (
<div style={styles.wrapper} onClick={onClick} className={className}>
{this.renderBarAndValue()}
</div>
);
}
return (
<div style={styles.wrapper}>
<div style={styles.wrapper} onClick={onClick} className={className}>
<div style={styles.title}>{title}</div>
{this.renderBarAndValue()}
</div>

View File

@@ -4,41 +4,51 @@ exports[`BarGauge Render with basic options should render 1`] = `
<div
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row-reverse",
"justifyContent": "flex-end",
"flexDirection": "column",
"overflow": "hidden",
}
}
>
<div
className="bar-gauge__value"
style={
Object {
"alignItems": "center",
"color": "#73BF69",
"display": "flex",
"fontSize": "27.2727px",
"height": "300px",
"lineHeight": 1,
"paddingLeft": "10px",
"width": "60px",
"flexDirection": "row-reverse",
"justifyContent": "flex-end",
}
}
>
25
</div>
<div
style={
Object {
"background": "rgba(115, 191, 105, 0.25)",
"borderRadius": "3px",
"borderRight": "2px solid #73BF69",
"height": "300px",
"transition": "width 1s",
"width": "60px",
<div
className="bar-gauge__value"
style={
Object {
"alignItems": "center",
"color": "#73BF69",
"display": "flex",
"fontSize": "27.2727px",
"height": "300px",
"lineHeight": 1,
"paddingLeft": "10px",
"width": "60px",
}
}
}
/>
>
25
</div>
<div
style={
Object {
"background": "rgba(115, 191, 105, 0.25)",
"borderRadius": "3px",
"borderRight": "2px solid #73BF69",
"height": "300px",
"transition": "width 1s",
"width": "60px",
}
}
/>
</div>
</div>
`;

View File

@@ -1,7 +1,7 @@
// Library
import React, { PureComponent, ReactNode, CSSProperties } from 'react';
import $ from 'jquery';
import { css } from 'emotion';
import { css, cx } from 'emotion';
import { DisplayValue } from '@grafana/data';
// Utils
@@ -27,6 +27,8 @@ export interface Props extends Themeable {
suffix?: DisplayValue;
sparkline?: BigValueSparkline;
backgroundColor?: string;
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
}
/*
@@ -119,15 +121,19 @@ export class BigValue extends PureComponent<Props> {
}
render() {
const { height, width, value, prefix, suffix, sparkline, backgroundColor } = this.props;
const { height, width, value, prefix, suffix, sparkline, backgroundColor, onClick, className } = this.props;
return (
<div
className={css({
position: 'relative',
display: 'table',
})}
className={cx(
css({
position: 'relative',
display: 'table',
}),
className
)}
style={{ width, height, backgroundColor }}
onClick={onClick}
>
{value.title && (
<div
@@ -143,6 +149,7 @@ export class BigValue extends PureComponent<Props> {
{value.title}
</div>
)}
<span
className={css({
lineHeight: 1,

View File

@@ -3,10 +3,11 @@ import { css, cx } from 'emotion';
import useClickAway from 'react-use/lib/useClickAway';
import { GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
import { Portal, List } from '../index';
import { LinkTarget } from '@grafana/data';
export interface ContextMenuItem {
label: string;
target?: string;
target?: LinkTarget;
icon?: string;
url?: string;
onClick?: (event?: React.SyntheticEvent<HTMLElement>) => void;

View File

@@ -0,0 +1,35 @@
import React, { useState } from 'react';
import { ContextMenu, ContextMenuGroup } from '../ContextMenu/ContextMenu';
interface WithContextMenuProps {
children: (props: { openMenu: React.MouseEventHandler<HTMLElement> }) => JSX.Element;
getContextMenuItems: () => ContextMenuGroup[];
}
export const WithContextMenu: React.FC<WithContextMenuProps> = ({ children, getContextMenuItems }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuPosition, setMenuPositon] = useState({ x: 0, y: 0 });
return (
<>
{children({
openMenu: e => {
setIsMenuOpen(true);
setMenuPositon({
x: e.pageX,
y: e.pageY,
});
},
})}
{isMenuOpen && (
<ContextMenu
onClose={() => setIsMenuOpen(false)}
x={menuPosition.x}
y={menuPosition.y}
items={getContextMenuItems()}
/>
)}
</>
);
};

View File

@@ -59,6 +59,7 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
onBlur={onTitleBlur}
inputWidth={15}
labelWidth={5}
placeholder="Show details"
/>
<FormField

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { WithContextMenu } from '../ContextMenu/WithContextMenu';
import { LinkModelSupplier } from '@grafana/data';
import { linkModelToContextMenuItems } from '../../utils/dataLinks';
import { css } from 'emotion';
interface DataLinksContextMenuProps {
children: (props: { openMenu?: React.MouseEventHandler<HTMLElement>; targetClassName?: string }) => JSX.Element;
links?: LinkModelSupplier<any>;
}
export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ children, links }) => {
if (!links) {
return children({});
}
const getDataLinksContextMenuItems = () => {
return [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
};
// Use this class name (exposed via render prop) to add context menu indicator to the click target of the visualization
const targetClassName = css`
cursor: context-menu;
`;
return (
<WithContextMenu getContextMenuItems={getDataLinksContextMenuItems}>
{({ openMenu }) => {
return children({ openMenu, targetClassName });
}}
</WithContextMenu>
);
};

View File

@@ -68,7 +68,7 @@ export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, on
{(!value || (value && value.length < (maxLinks || Infinity))) && (
<Button variant="inverse" icon="fa fa-plus" onClick={() => onAdd()}>
Create link
Add link
</Button>
)}
</>

View File

@@ -2,7 +2,7 @@
margin-bottom: $space-xxs;
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
text-align: left;
position: relative;

View File

@@ -15,6 +15,8 @@ export interface Props extends Themeable {
showThresholdLabels: boolean;
width: number;
value: DisplayValue;
onClick?: React.MouseEventHandler<HTMLElement>;
className?: string;
}
const FONT_SCALE = 1;
@@ -133,24 +135,16 @@ export class Gauge extends PureComponent<Props> {
}
}
render() {
const { width, value, height } = this.props;
renderVisualization = () => {
const { width, value, height, onClick } = this.props;
const autoProps = calculateGaugeAutoProps(width, height, value.title);
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<>
<div
style={{ height: `${autoProps.gaugeHeight}px`, width: '100%' }}
ref={element => (this.canvasElement = element)}
onClick={onClick}
/>
{autoProps.showLabel && (
<div
@@ -163,11 +157,30 @@ export class Gauge extends PureComponent<Props> {
position: 'relative',
width: '100%',
top: '-4px',
cursor: 'default',
}}
>
{value.title}
</div>
)}
</>
);
};
render() {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
overflow: 'hidden',
}}
className={this.props.className}
>
{this.renderVisualization()}
</div>
);
}

View File

@@ -59,7 +59,7 @@ describe('Next id to add', () => {
it('should be 4', () => {
const { instance } = setup();
instance.addMapping();
instance.onAddMapping();
expect(instance.state.nextIdToAdd).toEqual(4);
});

View File

@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
import MappingRow from './MappingRow';
import { MappingType, ValueMapping } from '@grafana/data';
import { Button } from '../index';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
export interface Props {
@@ -30,7 +31,7 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
}
addMapping = () =>
onAddMapping = () =>
this.setState(prevState => ({
valueMappings: [
...prevState.valueMappings,
@@ -81,16 +82,21 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
const { valueMappings } = this.state;
return (
<PanelOptionsGroup title="Add value mapping" onAdd={this.addMapping}>
{valueMappings.length > 0 &&
valueMappings.map((valueMapping, index) => (
<MappingRow
key={`${valueMapping.text}-${index}`}
valueMapping={valueMapping}
updateValueMapping={this.updateGauge}
removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
/>
))}
<PanelOptionsGroup title="Value mappings">
<div>
{valueMappings.length > 0 &&
valueMappings.map((valueMapping, index) => (
<MappingRow
key={`${valueMapping.text}-${index}`}
valueMapping={valueMapping}
updateValueMapping={this.updateGauge}
removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
/>
))}
<Button variant="inverse" icon="fa fa-plus" onClick={this.onAddMapping}>
Add mapping
</Button>
</div>
</PanelOptionsGroup>
);
}

View File

@@ -2,37 +2,45 @@
exports[`Render should render component 1`] = `
<Component
onAdd={[Function]}
title="Add value mapping"
title="Value mappings"
>
<MappingRow
key="Ok-0"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
<div>
<MappingRow
key="Ok-0"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
}
}
}
/>
<MappingRow
key="Meh-1"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
/>
<MappingRow
key="Meh-1"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
}
}
}
/>
/>
<Button
icon="fa fa-plus"
onClick={[Function]}
variant="inverse"
>
Add mapping
</Button>
</div>
</Component>
`;

View File

@@ -76,4 +76,5 @@ export { CallToActionCard } from './CallToActionCard/CallToActionCard';
export { ContextMenu, ContextMenuItem, ContextMenuGroup, ContextMenuProps } from './ContextMenu/ContextMenu';
export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggestions';
export { DataLinksEditor } from './DataLinks/DataLinksEditor';
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
export { SeriesIcon } from './Legend/SeriesIcon';

View File

@@ -0,0 +1,24 @@
import { ContextMenuItem } from '../components/ContextMenu/ContextMenu';
import { LinkModelSupplier } from '@grafana/data';
export const DataLinkBuiltInVars = {
keepTime: '__url_time_range',
includeVars: '__all_variables',
seriesName: '__series_name',
valueTime: '__value_time',
};
/**
* Delays creating links until we need to open the ContextMenu
*/
export const linkModelToContextMenuItems: (links: LinkModelSupplier<any>) => ContextMenuItem[] = links => {
return links.getLinks().map(link => {
return {
label: link.title,
// TODO: rename to href
url: link.href,
target: link.target,
icon: `fa ${link.target === '_self' ? 'fa-link' : 'fa-external-link'}`,
};
});
};

View File

@@ -6,6 +6,7 @@ import {
FieldConfig,
DisplayValue,
GraphSeriesValue,
DataFrameView,
} from '@grafana/data';
import toNumber from 'lodash/toNumber';
@@ -14,6 +15,7 @@ import toString from 'lodash/toString';
import { GrafanaTheme, InterpolateFunction, ScopedVars } from '../types/index';
import { getDisplayProcessor } from './displayProcessor';
import { getFlotPairs } from './flotPairs';
import { DataLinkBuiltInVars } from '../utils/dataLinks';
export interface FieldDisplayOptions {
values?: boolean; // If true show each row value
@@ -23,7 +25,7 @@ export interface FieldDisplayOptions {
defaults: FieldConfig; // Use these values unless otherwise stated
override: FieldConfig; // Set these values regardless of the source
}
// TODO: use built in variables, same as for data links?
export const VAR_SERIES_NAME = '__series_name';
export const VAR_FIELD_NAME = '__field_name';
export const VAR_CALC = '__calc';
@@ -59,10 +61,15 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat
}
export interface FieldDisplay {
name: string; // NOT title!
name: string; // The field name (title is in display)
field: FieldConfig;
display: DisplayValue;
sparkline?: GraphSeriesValue[][];
// Expose to the original values for delayed inspection (DataLinks etc)
view?: DataFrameView;
column?: number; // The field column index
row?: number; // only filled in when the value is from a row (ie, not a reduction)
}
export interface GetFieldDisplayValuesOptions {
@@ -75,8 +82,19 @@ export interface GetFieldDisplayValuesOptions {
export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25;
const getTimeColumnIdx = (series: DataFrame) => {
let timeColumn = -1;
for (let i = 0; i < series.fields.length; i++) {
if (series.fields[i].type === FieldType.time) {
timeColumn = i;
break;
}
}
return timeColumn;
};
export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => {
const { data, replaceVariables, fieldOptions, sparkline } = options;
const { data, replaceVariables, fieldOptions } = options;
const { defaults, override } = fieldOptions;
const calcs = fieldOptions.calcs.length ? fieldOptions.calcs : [ReducerID.last];
@@ -96,17 +114,11 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
name: series.refId ? series.refId : `Series[${s}]`,
};
}
scopedVars[VAR_SERIES_NAME] = { text: 'Series', value: series.name };
let timeColumn = -1;
if (sparkline) {
for (let i = 0; i < series.fields.length; i++) {
if (series.fields[i].type === FieldType.time) {
timeColumn = i;
break;
}
}
}
scopedVars[DataLinkBuiltInVars.seriesName] = { text: 'Series', value: series.name };
const timeColumn = getTimeColumnIdx(series);
const view = new DataFrameView(series);
for (let i = 0; i < series.fields.length && !hitLimit; i++) {
const field = series.fields[i];
@@ -131,7 +143,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
const title = config.title ? config.title : defaultTitle;
// Show all number fields
// Show all rows
if (fieldOptions.values) {
const usesCellValues = title.indexOf(VAR_CELL_PREFIX) >= 0;
@@ -154,6 +166,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
name,
field: config,
display: displayValue,
view,
column: i,
row: j,
});
if (values.length >= limit) {
@@ -166,15 +181,15 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
field,
reducers: calcs, // The stats to calculate
});
let sparkline: GraphSeriesValue[][] | undefined = undefined;
// Single sparkline for a field
const points =
timeColumn < 0
? undefined
: getFlotPairs({
xField: series.fields[timeColumn],
yField: series.fields[i],
});
// Single sparkline for every reducer
if (options.sparkline && timeColumn >= 0) {
sparkline = getFlotPairs({
xField: series.fields[timeColumn],
yField: series.fields[i],
});
}
for (const calc of calcs) {
scopedVars[VAR_CALC] = { value: calc, text: calc };
@@ -184,7 +199,9 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
name,
field: config,
display: displayValue,
sparkline: points,
sparkline,
view,
column: i,
});
}
}

View File

@@ -6,6 +6,7 @@ export * from './fieldDisplay';
export * from './validate';
export { getFlotPairs } from './flotPairs';
export * from './slate';
export * from './dataLinks';
export { default as ansicolor } from './ansicolor';
// Export with a namespace