TimeSeries: Render thresholds as lines and areas (#33357)

* TimeSeries: First take on uplot thresholds rendering

* Updating theme for color picker and fixing issues

* Updating theme for color picker and fixing issues

* Minor progress

* Added test dashboard

* Adding transparent and text colors to color picker and making them visible

* Good progress on migrations

* Fixed selected issue in color picker

* Fixes

* migration fix

* Fixed test

* Fixing line rendering and refactoring

* Minor ordering fix

* fixed test

* Draw thresholds before axes & series and other tweaks

* Update packages/grafana-ui/src/components/uPlot/config/UPlotThresholds.ts

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Torkel Ödegaard 2021-05-04 13:03:35 +02:00 committed by GitHub
parent 06dc2b24bf
commit b62e87f753
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1874 additions and 225 deletions

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,9 @@ export type Color =
| 'semi-dark-purple'
| 'light-purple'
| 'super-light-purple'
| 'panel-bg';
| 'panel-bg'
| 'transparent'
| 'text';
type ThemeVariants = {
dark: string;
@ -84,6 +86,8 @@ export function buildColorsMapForTheme(theme: GrafanaTheme): Record<Color, strin
}
colorsMap['panel-bg'] = theme.colors.panelBg;
colorsMap['transparent'] = 'rgba(0,0,0,0)';
colorsMap['text'] = theme.colors.text;
return colorsMap;
}

View File

@ -1,12 +1,12 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { ColorPicker } from './ColorPicker';
import { ColorPickerTrigger } from './ColorPickerTrigger';
import { ColorSwatch } from './ColorSwatch';
describe('ColorPicker', () => {
it('renders ColorPickerTrigger component by default', () => {
expect(
renderer.create(<ColorPicker color="#EAB839" onChange={() => {}} />).root.findByType(ColorPickerTrigger)
renderer.create(<ColorPicker color="#EAB839" onChange={() => {}} />).root.findByType(ColorSwatch)
).toBeTruthy();
});

View File

@ -2,12 +2,12 @@ import React, { Component, createRef } from 'react';
import { PopoverController } from '../Tooltip/PopoverController';
import { Popover } from '../Tooltip/Popover';
import { ColorPickerPopover, ColorPickerProps, ColorPickerChangeHandler } from './ColorPickerPopover';
import { getColorForTheme, GrafanaTheme } from '@grafana/data';
import { getColorForTheme, GrafanaTheme2 } from '@grafana/data';
import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
import { css } from '@emotion/css';
import { withTheme, stylesFactory } from '../../themes';
import { ColorPickerTrigger } from './ColorPickerTrigger';
import { withTheme2, stylesFactory } from '../../themes';
import { ColorSwatch } from './ColorSwatch';
/**
* If you need custom trigger for the color picker you can do that with a render prop pattern and supply a function
@ -71,11 +71,11 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
hideColorPicker: hidePopper,
})
) : (
<ColorPickerTrigger
<ColorSwatch
ref={this.pickerTriggerRef}
onClick={showPopper}
onMouseLeave={hidePopper}
color={getColorForTheme(this.props.color || '#000000', theme)}
color={getColorForTheme(this.props.color || '#000000', theme.v1)}
/>
)}
</>
@ -87,15 +87,15 @@ export const colorPickerFactory = <T extends ColorPickerProps>(
};
};
export const ColorPicker = withTheme(colorPickerFactory(ColorPickerPopover, 'ColorPicker'));
export const SeriesColorPicker = withTheme(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker'));
export const ColorPicker = withTheme2(colorPickerFactory(ColorPickerPopover, 'ColorPicker'));
export const SeriesColorPicker = withTheme2(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker'));
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
colorPicker: css`
position: absolute;
z-index: ${theme.zIndex.tooltip};
color: ${theme.colors.text};
color: ${theme.colors.text.primary};
max-width: 400px;
font-size: ${theme.typography.size.sm};
// !important because these styles are also provided to popper via .popper classes from Tooltip component

View File

@ -1,9 +1,9 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { ColorPickerPopover } from './ColorPickerPopover';
import { ColorSwatch } from './NamedColorsGroup';
import { flatten } from 'lodash';
import { getNamedColorPalette, getColorFromHexRgbOrName } from '@grafana/data';
import { ColorSwatch } from './ColorSwatch';
const allColors = flatten(Array.from(getNamedColorPalette().values()));
@ -15,7 +15,7 @@ describe('ColorPickerPopover', () => {
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere((node) => node.prop('isSelected') === false);
expect(selectedSwatch.length).toBe(1);
expect(notSelectedSwatches.length).toBe(allColors.length - 1);
expect(notSelectedSwatches.length).toBe(allColors.length + 1);
expect(selectedSwatch.prop('isSelected')).toBe(true);
});
@ -26,7 +26,7 @@ describe('ColorPickerPopover', () => {
const notSelectedSwatches = wrapper.find(ColorSwatch).filterWhere((node) => node.prop('isSelected') === false);
expect(selectedSwatch.length).toBe(1);
expect(notSelectedSwatches.length).toBe(allColors.length - 1);
expect(notSelectedSwatches.length).toBe(allColors.length + 1);
expect(selectedSwatch.prop('isSelected')).toBe(true);
});
});

View File

@ -2,15 +2,15 @@ import React from 'react';
import { NamedColorsPalette } from './NamedColorsPalette';
import { PopoverContentProps } from '../Tooltip/Tooltip';
import SpectrumPalette from './SpectrumPalette';
import { Themeable } from '../../types/theme';
import { Themeable2 } from '../../types/theme';
import { warnAboutColorPickerPropsDeprecation } from './warnAboutColorPickerPropsDeprecation';
import { css, cx } from '@emotion/css';
import { GrafanaTheme, GrafanaThemeType, getColorForTheme } from '@grafana/data';
import { stylesFactory, withTheme } from '../../themes';
import { css } from '@emotion/css';
import { GrafanaTheme2, getColorForTheme } from '@grafana/data';
import { stylesFactory, withTheme2 } from '../../themes';
export type ColorPickerChangeHandler = (color: string) => void;
export interface ColorPickerProps extends Themeable {
export interface ColorPickerProps extends Themeable2 {
color: string;
onChange: ColorPickerChangeHandler;
@ -59,7 +59,7 @@ class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Reac
if (enableNamedColors) {
return changeHandler(color);
}
changeHandler(getColorForTheme(color, theme));
changeHandler(getColorForTheme(color, theme.v1));
};
onTabChange = (tab: PickerType | keyof T) => {
@ -68,13 +68,13 @@ class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Reac
renderPicker = () => {
const { activePicker } = this.state;
const { color, theme } = this.props;
const { color } = this.props;
switch (activePicker) {
case 'spectrum':
return <SpectrumPalette color={color} onChange={this.handleChange} />;
case 'palette':
return <NamedColorsPalette color={color} onChange={this.handleChange} theme={theme} />;
return <NamedColorsPalette color={color} onChange={this.handleChange} />;
default:
return this.renderCustomPicker(activePicker);
}
@ -117,12 +117,7 @@ class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Reac
const { theme } = this.props;
const styles = getStyles(theme);
return (
<div
className={cx(
styles.colorPickerPopover,
theme.type === GrafanaThemeType.Light ? styles.colorPickerPopoverLight : styles.colorPickerPopoverDark
)}
>
<div className={styles.colorPickerPopover}>
<div className={styles.colorPickerPopoverTabs}>
<div className={this.getTabClassName('palette')} onClick={this.onTabChange('palette')}>
Colors
@ -132,62 +127,50 @@ class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Reac
</div>
{this.renderCustomPickerTabs()}
</div>
<div className={styles.colorPickerPopoverContent}>{this.renderPicker()}</div>
</div>
);
}
}
export const ColorPickerPopover = withTheme(UnThemedColorPickerPopover);
export const ColorPickerPopover = withTheme2(UnThemedColorPickerPopover);
ColorPickerPopover.displayName = 'ColorPickerPopover';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
colorPickerPopover: css`
border-radius: ${theme.border.radius.md};
`,
colorPickerPopoverLight: css`
color: ${theme.palette.black};
background: linear-gradient(180deg, ${theme.palette.white} 0%, #f7f8fa 104.25%);
box-shadow: 0px 2px 4px #dde4ed, 0px 0px 2px #dde4ed;
border-radius: ${theme.shape.borderRadius()};
box-shadow: ${theme.shadows.z3};
background: ${theme.colors.background.primary};
.ColorPickerPopover__tab {
width: 50%;
text-align: center;
padding: ${theme.spacing.sm} 0;
background: #dde4ed;
}
.ColorPickerPopover__tab--active {
background: ${theme.palette.white};
}
`,
colorPickerPopoverDark: css`
color: #d8d9da;
background: linear-gradient(180deg, #1e2028 0%, #161719 104.25%);
box-shadow: 0px 2px 4px ${theme.palette.black}, 0px 0px 2px ${theme.palette.black};
.ColorPickerPopover__tab {
width: 50%;
text-align: center;
padding: ${theme.spacing.sm} 0;
background: #303133;
color: ${theme.palette.white};
padding: ${theme.spacing(1, 0)};
background: ${theme.colors.background.secondary};
color: ${theme.colors.text.secondary};
cursor: pointer;
}
.ColorPickerPopover__tab--active {
background: none;
color: ${theme.colors.text.primary};
font-weight: ${theme.typography.fontWeightMedium};
background: ${theme.colors.background.primary};
}
`,
colorPickerPopoverContent: css`
width: 336px;
font-size: ${theme.typography.bodySmall.fontSize};
min-height: 184px;
padding: ${theme.spacing.lg};
padding: ${theme.spacing(2)};
display: flex;
align-items: center;
justify-content: center;
`,
colorPickerPopoverTabs: css`
display: flex;
width: 100%;
border-radius: ${theme.border.radius.md} ${theme.border.radius.md} 0 0;
border-radius: ${theme.shape.borderRadius()} ${theme.shape.borderRadius()} 0 0;
overflow: hidden;
`,
};

View File

@ -1,56 +0,0 @@
import React, { forwardRef } from 'react';
interface ColorPickerTriggerProps {
onClick: () => void;
onMouseLeave: () => void;
color: string;
}
export const ColorPickerTrigger = forwardRef(function ColorPickerTrigger(
props: ColorPickerTriggerProps,
ref: React.Ref<HTMLDivElement>
) {
return (
<div
ref={ref}
onClick={props.onClick}
onMouseLeave={props.onMouseLeave}
style={{
overflow: 'hidden',
background: 'inherit',
border: 'none',
color: 'inherit',
padding: 0,
borderRadius: 10,
cursor: 'pointer',
}}
>
<div
style={{
position: 'relative',
width: 15,
height: 15,
border: 'none',
margin: 0,
float: 'left',
zIndex: 0,
backgroundImage:
// eslint-disable-next-line max-len
'url(data:image/png,base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)',
}}
>
<div
style={{
backgroundColor: props.color,
display: 'block',
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
/>
</div>
</div>
);
});

View File

@ -0,0 +1,60 @@
import React, { CSSProperties } from 'react';
import tinycolor from 'tinycolor2';
import { useTheme2 } from '../../themes/ThemeContext';
/** @internal */
export enum ColorSwatchVariant {
Small = 'small',
Large = 'large',
}
/** @internal */
export interface Props extends React.DOMAttributes<HTMLDivElement> {
color: string;
label?: string;
variant?: ColorSwatchVariant;
isSelected?: boolean;
}
/** @internal */
export const ColorSwatch = React.forwardRef<HTMLDivElement, Props>(
({ color, label, variant = ColorSwatchVariant.Small, isSelected, ...otherProps }, ref) => {
const theme = useTheme2();
const tc = tinycolor(color);
const isSmall = variant === ColorSwatchVariant.Small;
const hasLabel = !!label;
const swatchSize = isSmall ? '16px' : '32px';
const swatchStyles: CSSProperties = {
width: swatchSize,
height: swatchSize,
borderRadius: '50%',
background: `${color}`,
marginRight: hasLabel ? '8px' : '0px',
boxShadow: isSelected
? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.getContrastText(color)}`
: 'none',
};
if (tc.getAlpha() < 0.1) {
swatchStyles.border = `2px solid ${theme.colors.border.medium}`;
}
return (
<div
ref={ref}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
}}
{...otherProps}
>
<div style={swatchStyles} />
{hasLabel && <span>{label}</span>}
</div>
);
}
);
ColorSwatch.displayName = 'ColorSwatch';

View File

@ -1,59 +1,13 @@
import React, { FunctionComponent } from 'react';
import { Themeable } from '../../types';
import { ColorDefinition } from '@grafana/data';
import { Color } from 'csstype';
import { upperFirst, find } from 'lodash';
import { ColorSwatch, ColorSwatchVariant } from './ColorSwatch';
import { useTheme2 } from '../../themes/ThemeContext';
type ColorChangeHandler = (color: ColorDefinition) => void;
export enum ColorSwatchVariant {
Small = 'small',
Large = 'large',
}
interface ColorSwatchProps extends Themeable, React.DOMAttributes<HTMLDivElement> {
color: string;
label?: string;
variant?: ColorSwatchVariant;
isSelected?: boolean;
}
export const ColorSwatch: FunctionComponent<ColorSwatchProps> = ({
color,
label,
variant = ColorSwatchVariant.Small,
isSelected,
theme,
...otherProps
}) => {
const isSmall = variant === ColorSwatchVariant.Small;
const swatchSize = isSmall ? '16px' : '32px';
const swatchStyles = {
width: swatchSize,
height: swatchSize,
borderRadius: '50%',
background: `${color}`,
marginRight: isSmall ? '0px' : '8px',
boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.bg1}` : 'none',
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
}}
{...otherProps}
>
<div style={swatchStyles} />
{variant === ColorSwatchVariant.Large && <span>{label}</span>}
</div>
);
};
interface NamedColorsGroupProps extends Themeable {
interface NamedColorsGroupProps {
colors: ColorDefinition[];
selectedColor?: Color;
onColorSelect: ColorChangeHandler;
@ -64,9 +18,9 @@ const NamedColorsGroup: FunctionComponent<NamedColorsGroupProps> = ({
colors,
selectedColor,
onColorSelect,
theme,
...otherProps
}) => {
const theme = useTheme2();
const primaryColor = find(colors, (color) => !!color.isPrimary);
return (
@ -76,10 +30,9 @@ const NamedColorsGroup: FunctionComponent<NamedColorsGroupProps> = ({
key={primaryColor.name}
isSelected={primaryColor.name === selectedColor}
variant={ColorSwatchVariant.Large}
color={primaryColor.variants[theme.type]}
color={primaryColor.variants[theme.colors.mode]}
label={upperFirst(primaryColor.hue)}
onClick={() => onColorSelect(primaryColor)}
theme={theme}
/>
)}
<div
@ -95,9 +48,8 @@ const NamedColorsGroup: FunctionComponent<NamedColorsGroupProps> = ({
<ColorSwatch
key={color.name}
isSelected={color.name === selectedColor}
color={color.variants[theme.type]}
color={color.variants[theme.colors.mode]}
onClick={() => onColorSelect(color)}
theme={theme}
/>
</div>
)

View File

@ -1,9 +1,7 @@
import React from 'react';
import React, { useState } from 'react';
import { NamedColorsPalette, NamedColorsPaletteProps } from './NamedColorsPalette';
import { Meta, Story } from '@storybook/react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { renderComponentWithTheme } from '../../utils/storybook/withTheme';
import { UseState } from '../../utils/storybook/UseState';
import mdx from './ColorPicker.mdx';
export default {
@ -31,17 +29,10 @@ interface StoryProps extends Partial<NamedColorsPaletteProps> {
}
export const NamedColors: Story<StoryProps> = ({ selectedColor }) => {
return (
<UseState initialState={selectedColor}>
{(selectedColor, updateSelectedColor) => {
return renderComponentWithTheme(NamedColorsPalette, {
color: selectedColor,
onChange: updateSelectedColor,
});
}}
</UseState>
);
const [color, setColor] = useState('green');
return <NamedColorsPalette color={color} onChange={setColor} />;
};
NamedColors.args = {
selectedColor: 'red',
color: 'green',
};

View File

@ -1,9 +1,9 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { NamedColorsPalette } from './NamedColorsPalette';
import { ColorSwatch } from './NamedColorsGroup';
import { getColorDefinitionByName, GrafanaThemeType } from '@grafana/data';
import { getTheme } from '../../themes';
import { createTheme, getColorDefinitionByName } from '@grafana/data';
import { ColorSwatch } from './ColorSwatch';
import { ThemeContext } from '../../themes';
describe('NamedColorsPalette', () => {
const BasicGreen = getColorDefinitionByName('green');
@ -16,22 +16,21 @@ describe('NamedColorsPalette', () => {
});
it('should render provided color variant specific for theme', () => {
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} theme={getTheme()} onChange={() => {}} />);
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} onChange={() => {}} />);
selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
wrapper.unmount();
wrapper = mount(
<NamedColorsPalette color={BasicGreen.name} theme={getTheme(GrafanaThemeType.Light)} onChange={() => {}} />
const withLightTheme = (
<ThemeContext.Provider value={createTheme({ colors: { mode: 'light' } })}>
<NamedColorsPalette color={BasicGreen.name} onChange={() => {}} />
</ThemeContext.Provider>
);
wrapper = mount(withLightTheme);
selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.light);
});
it('should render dar variant of provided color when theme not provided', () => {
wrapper = mount(<NamedColorsPalette color={BasicGreen.name} onChange={() => {}} theme={getTheme()} />);
selectedSwatch = wrapper.find(ColorSwatch).findWhere((node) => node.key() === BasicGreen.name);
expect(selectedSwatch.prop('color')).toBe(BasicGreen.variants.dark);
});
});
});

View File

@ -1,20 +1,23 @@
import React from 'react';
import { getNamedColorPalette } from '@grafana/data';
import { Themeable } from '../../types/index';
import NamedColorsGroup from './NamedColorsGroup';
import { VerticalGroup } from '../Layout/Layout';
import { ColorSwatch } from './ColorSwatch';
import { useTheme2 } from '../../themes/ThemeContext';
export interface NamedColorsPaletteProps extends Themeable {
export interface NamedColorsPaletteProps {
color?: string;
onChange: (colorName: string) => void;
}
export const NamedColorsPalette = ({ color, onChange, theme }: NamedColorsPaletteProps) => {
export const NamedColorsPalette = ({ color, onChange }: NamedColorsPaletteProps) => {
const theme = useTheme2();
const swatches: JSX.Element[] = [];
getNamedColorPalette().forEach((colors, hue) => {
swatches.push(
<NamedColorsGroup
key={hue}
theme={theme}
selectedColor={color}
colors={colors}
onColorSelect={(color) => {
@ -25,15 +28,30 @@ export const NamedColorsPalette = ({ color, onChange, theme }: NamedColorsPalett
});
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridRowGap: '24px',
gridColumnGap: '24px',
}}
>
{swatches}
</div>
<VerticalGroup spacing="md">
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridRowGap: theme.spacing(2),
gridColumnGap: theme.spacing(2),
flexGrow: 1,
}}
>
{swatches}
<ColorSwatch
isSelected={color === 'transparent'}
color={'rgba(0,0,0,0)'}
label="Transparent"
onClick={() => onChange('transparent')}
/>
<ColorSwatch
isSelected={color === 'text'}
color={theme.colors.text.primary}
label="Text color"
onClick={() => onChange('text')}
/>
</div>
</VerticalGroup>
);
};

View File

@ -4,7 +4,7 @@ import { ColorPickerPopover, ColorPickerProps } from './ColorPickerPopover';
import { PopoverContentProps } from '../Tooltip/Tooltip';
import { Switch } from '../Forms/Legacy/Switch/Switch';
import { css } from '@emotion/css';
import { withTheme, useStyles } from '../../themes';
import { withTheme2, useStyles } from '../../themes';
import { Button } from '../Button';
export interface SeriesColorPickerPopoverProps extends ColorPickerProps, PopoverContentProps {
@ -89,7 +89,7 @@ export class AxisSelector extends React.PureComponent<AxisSelectorProps, AxisSel
}
// This component is to enable SeriesColorPickerPopover usage via series-color-picker-popover directive
export const SeriesColorPickerPopoverWithTheme = withTheme(SeriesColorPickerPopover);
export const SeriesColorPickerPopoverWithTheme = withTheme2(SeriesColorPickerPopover);
const getStyles = () => {
return {

View File

@ -5,7 +5,7 @@ import tinycolor from 'tinycolor2';
import ColorInput from './ColorInput';
import { GrafanaTheme, getColorForTheme } from '@grafana/data';
import { css, cx } from '@emotion/css';
import { useStyles, useTheme } from '../../themes';
import { useStyles, useTheme2 } from '../../themes';
import { useThrottleFn } from 'react-use';
export interface SpectrumPaletteProps {
@ -17,25 +17,27 @@ const SpectrumPalette: React.FunctionComponent<SpectrumPaletteProps> = ({ color,
const [currentColor, setColor] = useState(color);
useThrottleFn(onChange, 500, [currentColor]);
const theme = useTheme();
const theme = useTheme2();
const styles = useStyles(getStyles);
const rgbaString = useMemo(() => {
return currentColor.startsWith('rgba')
? currentColor
: tinycolor(getColorForTheme(currentColor, theme)).toRgbString();
: tinycolor(getColorForTheme(currentColor, theme.v1)).toRgbString();
}, [currentColor, theme]);
return (
<>
<div className={styles.wrapper}>
<RgbaStringColorPicker className={cx(styles.root)} color={rgbaString} onChange={setColor} />
<ColorInput theme={theme} color={currentColor} onChange={setColor} className={styles.colorInput} />
</>
</div>
);
};
const getStyles = (theme: GrafanaTheme) => ({
wrapper: css`
flex-grow: 1;
`,
root: css`
&.react-colorful {
width: auto;

View File

@ -22,6 +22,7 @@ import {
AxisPlacement,
DrawStyle,
GraphFieldConfig,
GraphTresholdsStyleMode,
PointVisibility,
ScaleDirection,
ScaleOrientation,
@ -224,6 +225,19 @@ export function preparePlotConfigBuilder(
hideInLegend: customConfig.hideFrom?.legend,
});
// Render thresholds in graph
if (customConfig.thresholdsStyle && config.thresholds) {
const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off;
if (thresholdDisplay !== GraphTresholdsStyleMode.Off) {
builder.addThresholds({
config: customConfig.thresholdsStyle,
thresholds: config.thresholds,
scaleKey,
theme,
});
}
}
collectStackingGroups(field, stackingGroups, seriesIndex);
}

View File

@ -193,7 +193,7 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false, width }:
css`
label: input-suffix;
padding-left: ${theme.spacing(1)};
padding-right: ${theme.spacing(0.5)};
padding-right: ${theme.spacing(1)};
margin-bottom: -2px;
border-left: none;
border-top-left-radius: 0;

View File

@ -3,7 +3,7 @@ import { getColorForTheme, GrafanaTheme } from '@grafana/data';
import { ColorPicker } from '../ColorPicker/ColorPicker';
import { stylesFactory, useTheme } from '../../themes';
import { css } from '@emotion/css';
import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger';
import { ColorSwatch } from '../ColorPicker/ColorSwatch';
/**
* @alpha
@ -26,7 +26,7 @@ export const ColorValueEditor: React.FC<ColorValueEditorProps> = ({ value, onCha
return (
<div className={styles.spot} onBlur={hideColorPicker}>
<div className={styles.colorPicker}>
<ColorPickerTrigger
<ColorSwatch
ref={ref}
onClick={showColorPicker}
onMouseLeave={hideColorPicker}

View File

@ -16,7 +16,6 @@ import { stylesFactory } from '../../themes';
import { Icon } from '../Icon/Icon';
import { RadioButtonGroup } from '../Forms/RadioButtonGroup/RadioButtonGroup';
import { Button } from '../Button';
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
import { Label } from '../Forms/Label';
import { isNumber } from 'lodash';
@ -232,9 +231,7 @@ export class ThresholdsEditor extends PureComponent<Props, State> {
<div>
<Label description="Percentage means thresholds relative to min & max">Thresholds mode</Label>
<FullWidthButtonContainer>
<RadioButtonGroup size="sm" options={modes} onChange={this.onModeChanged} value={thresholds.mode} />
</FullWidthButtonContainer>
<RadioButtonGroup options={modes} onChange={this.onModeChanged} value={thresholds.mode} />
</div>
</div>
);

View File

@ -192,6 +192,24 @@ export interface StackingConfig {
group?: string;
}
/**
* @alpha
*/
export enum GraphTresholdsStyleMode {
Off = 'off',
Line = 'line',
Area = 'area',
LineAndArea = 'line+area',
Series = 'series',
}
/**
* @alpha
*/
export interface GraphThresholdsStyleConfig {
mode: GraphTresholdsStyleMode;
}
/**
* @alpha
*/
@ -205,6 +223,7 @@ export interface GraphFieldConfig
drawStyle?: DrawStyle;
gradientMode?: GraphGradientMode;
stacking?: StackingConfig;
thresholdsStyle?: GraphThresholdsStyleConfig;
}
/**
@ -254,4 +273,11 @@ export const graphFieldOptions = {
{ label: 'Off', value: StackingMode.None },
{ label: 'Normal', value: StackingMode.Normal },
] as Array<SelectableValue<StackingMode>>,
thresholdsDisplayModes: [
{ label: 'Off', value: GraphTresholdsStyleMode.Off },
{ label: 'As lines', value: GraphTresholdsStyleMode.Line },
{ label: 'As filled regions', value: GraphTresholdsStyleMode.Area },
{ label: 'As filled regions and lines', value: GraphTresholdsStyleMode.LineAndArea },
] as Array<SelectableValue<GraphTresholdsStyleMode>>,
};

View File

@ -9,8 +9,9 @@ import {
ScaleDistribution,
ScaleOrientation,
ScaleDirection,
GraphTresholdsStyleMode,
} from '../config';
import { createTheme } from '@grafana/data';
import { createTheme, ThresholdsMode } from '@grafana/data';
describe('UPlotConfigBuilder', () => {
const darkTheme = createTheme();
@ -658,4 +659,37 @@ describe('UPlotConfigBuilder', () => {
`);
});
});
describe('Thresholds', () => {
it('Only adds one threshold per scale', () => {
const builder = new UPlotConfigBuilder();
const addHookFn = jest.fn();
builder.addHook = addHookFn;
builder.addThresholds({
scaleKey: 'A',
thresholds: {
mode: ThresholdsMode.Absolute,
steps: [],
},
config: {
mode: GraphTresholdsStyleMode.Area,
},
theme: darkTheme,
});
builder.addThresholds({
scaleKey: 'A',
thresholds: {
mode: ThresholdsMode.Absolute,
steps: [],
},
config: {
mode: GraphTresholdsStyleMode.Area,
},
theme: darkTheme,
});
expect(addHookFn).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -7,6 +7,7 @@ import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
import { defaultsDeep } from 'lodash';
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
import { pluginLog } from '../utils';
import { getThresholdsDrawHook, UPlotThresholdOptions } from './UPlotThresholds';
export class UPlotConfigBuilder {
private series: UPlotSeriesBuilder[] = [];
@ -21,6 +22,8 @@ export class UPlotConfigBuilder {
private hasBottomAxis = false;
private hooks: Hooks.Arrays = {};
private tz: string | undefined = undefined;
// to prevent more than one threshold per scale
private thresholds: Record<string, UPlotThresholdOptions> = {};
constructor(getTimeZone = () => DefaultTimeZone) {
this.tz = getTimeZoneInfo(getTimeZone(), Date.now())?.ianaName;
@ -36,6 +39,13 @@ export class UPlotConfigBuilder {
this.hooks[type]!.push(hook as any);
}
addThresholds(options: UPlotThresholdOptions) {
if (!this.thresholds[options.scaleKey]) {
this.thresholds[options.scaleKey] = options;
this.addHook('drawClear', getThresholdsDrawHook(options));
}
}
addAxis(props: AxisProps) {
props.placement = props.placement ?? AxisPlacement.Auto;

View File

@ -0,0 +1,126 @@
import { getColorForTheme, GrafanaTheme2, ThresholdsConfig } from '@grafana/data';
import tinycolor from 'tinycolor2';
import { GraphThresholdsStyleConfig, GraphTresholdsStyleMode } from '../config';
export interface UPlotThresholdOptions {
scaleKey: string;
thresholds: ThresholdsConfig;
config: GraphThresholdsStyleConfig;
theme: GrafanaTheme2;
}
export function getThresholdsDrawHook(options: UPlotThresholdOptions) {
return (u: uPlot) => {
const ctx = u.ctx;
const { scaleKey, thresholds, theme, config } = options;
const { steps } = thresholds;
const { min: xMin, max: xMax } = u.scales.x;
const { min: yMin, max: yMax } = u.scales[scaleKey];
if (xMin === undefined || xMax === undefined || yMin === undefined || yMax === undefined) {
return;
}
function addLines() {
// Thresholds below a transparent threshold is treated like "less than", and line drawn previous threshold
let transparentIndex = 0;
for (let idx = 0; idx < steps.length; idx++) {
const step = steps[idx];
if (step.color === 'transparent') {
transparentIndex = idx;
break;
}
}
// Ignore the base -Infinity threshold by always starting on index 1
for (let idx = 1; idx < steps.length; idx++) {
const step = steps[idx];
let color: tinycolor.Instance;
// if we are below a transparent index treat this a less then threshold, use previous thresholds color
if (transparentIndex >= idx && idx > 0) {
color = tinycolor(getColorForTheme(steps[idx - 1].color, theme.v1));
} else {
color = tinycolor(getColorForTheme(step.color, theme.v1));
}
// Unless alpha specififed set to default value
if (color.getAlpha() === 1) {
color.setAlpha(0.7);
}
let x0 = u.valToPos(xMin!, 'x', true);
let y0 = u.valToPos(step.value, scaleKey, true);
let x1 = u.valToPos(xMax!, 'x', true);
let y1 = u.valToPos(step.value, scaleKey, true);
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = color.toString();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
ctx.closePath();
}
}
function addAreas() {
for (let idx = 0; idx < steps.length; idx++) {
const step = steps[idx];
// skip thresholds that cannot be seen
if (step.value > yMax!) {
continue;
}
// if this is the last step make the next step the same color but +Infinity
const nextStep =
idx + 1 < steps.length
? steps[idx + 1]
: {
...step,
value: Infinity,
};
let color = tinycolor(getColorForTheme(step.color, theme.v1));
// Ignore fully transparent colors
const alpha = color.getAlpha();
if (alpha === 0) {
continue;
}
/// if no alpha set automatic alpha
if (alpha === 1) {
color = color.setAlpha(0.15);
}
let value = step.value === -Infinity ? yMin : step.value;
let nextValue = nextStep.value === Infinity || nextStep.value > yMax! ? yMax : nextStep.value;
let x0 = u.valToPos(xMin ?? 0, 'x', true);
let y0 = u.valToPos(value ?? 0, scaleKey, true);
let x1 = u.valToPos(xMax ?? 1, 'x', true);
let y1 = u.valToPos(nextValue ?? 1, scaleKey, true);
ctx.save();
ctx.fillStyle = color.toString();
ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
ctx.restore();
}
}
switch (config.mode) {
case GraphTresholdsStyleMode.Line:
addLines();
break;
case GraphTresholdsStyleMode.Area:
addAreas();
break;
case GraphTresholdsStyleMode.LineAndArea:
addLines();
addAreas();
}
};
}

View File

@ -190,6 +190,16 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
addStackingConfig(builder, cfg.stacking);
addAxisConfig(builder, cfg);
addHideFrom(builder);
builder.addSelect({
path: 'thresholdsStyle.mode',
name: 'Show thresholds',
category: ['Thresholds'],
defaultValue: graphFieldOptions.thresholdsDisplayModes[0].value,
settings: {
options: graphFieldOptions.thresholdsDisplayModes,
},
});
},
};
}

View File

@ -67,6 +67,130 @@ describe('Graph Migrations', () => {
expect(panel).toMatchSnapshot();
});
});
describe('thresholds', () => {
test('Only gt thresholds', () => {
const old: any = {
angular: {
thresholds: [
{
colorMode: 'critical',
fill: true,
line: false,
op: 'gt',
value: 80,
yaxis: 'left',
},
{
colorMode: 'warning',
fill: true,
line: false,
op: 'gt',
value: 50,
yaxis: 'left',
},
],
},
};
const panel = {} as PanelModel;
panel.options = graphPanelChangedHandler(panel, 'graph', old);
expect(panel.fieldConfig.defaults.custom.thresholdsStyle.mode).toBe('area');
expect(panel.fieldConfig.defaults.thresholds?.steps).toMatchInlineSnapshot(`
Array [
Object {
"color": "transparent",
"value": -Infinity,
},
Object {
"color": "orange",
"value": 50,
},
Object {
"color": "red",
"value": 80,
},
]
`);
});
test('gt & lt thresholds', () => {
const old: any = {
angular: {
thresholds: [
{
colorMode: 'critical',
fill: true,
line: true,
op: 'gt',
value: 80,
yaxis: 'left',
},
{
colorMode: 'warning',
fill: true,
line: true,
op: 'lt',
value: 40,
yaxis: 'left',
},
],
},
};
const panel = {} as PanelModel;
panel.options = graphPanelChangedHandler(panel, 'graph', old);
expect(panel.fieldConfig.defaults.custom.thresholdsStyle.mode).toBe('line+area');
expect(panel.fieldConfig.defaults.thresholds?.steps).toMatchInlineSnapshot(`
Array [
Object {
"color": "orange",
"value": -Infinity,
},
Object {
"color": "transparent",
"value": 40,
},
Object {
"color": "red",
"value": 80,
},
]
`);
});
test('Only lt thresholds', () => {
const old: any = {
angular: {
thresholds: [
{
colorMode: 'warning',
fill: true,
line: true,
op: 'lt',
value: 40,
yaxis: 'left',
},
],
},
};
const panel = {} as PanelModel;
panel.options = graphPanelChangedHandler(panel, 'graph', old);
expect(panel.fieldConfig.defaults.custom.thresholdsStyle.mode).toBe('line+area');
expect(panel.fieldConfig.defaults.thresholds?.steps).toMatchInlineSnapshot(`
Array [
Object {
"color": "orange",
"value": -Infinity,
},
Object {
"color": "transparent",
"value": 40,
},
]
`);
});
});
});
const stairscase = {

View File

@ -9,12 +9,15 @@ import {
fieldReducers,
NullValueMode,
PanelModel,
Threshold,
ThresholdsMode,
} from '@grafana/data';
import {
AxisPlacement,
DrawStyle,
GraphFieldConfig,
GraphGradientMode,
GraphTresholdsStyleMode,
LegendDisplayMode,
LineInterpolation,
LineStyle,
@ -312,6 +315,83 @@ export function flotToGraphOptions(angular: any): { fieldConfig: FieldConfigSour
}
}
if (angular.thresholds && angular.thresholds.length > 0) {
let steps: Threshold[] = [];
let area = false;
let line = false;
const sorted = (angular.thresholds as AngularThreshold[]).sort((a, b) => (a.value > b.value ? 1 : -1));
for (let idx = 0; idx < sorted.length; idx++) {
const threshold = sorted[idx];
const next = sorted.length > idx + 1 ? sorted[idx + 1] : null;
if (threshold.fill) {
area = true;
}
if (threshold.line) {
line = true;
}
if (threshold.op === 'gt') {
steps.push({
value: threshold.value,
color: getThresholdColor(threshold),
});
}
if (threshold.op === 'lt') {
if (steps.length === 0) {
steps.push({
value: -Infinity,
color: getThresholdColor(threshold),
});
}
// next op is gt and there is a gap set color to transparent
if (next && next.op === 'gt' && next.value > threshold.value) {
steps.push({
value: threshold.value,
color: 'transparent',
});
// if next is a lt we need to use it's color
} else if (next && next.op === 'lt') {
steps.push({
value: threshold.value,
color: getThresholdColor(next),
});
} else {
steps.push({
value: threshold.value,
color: 'transparent',
});
}
}
}
// if now less then threshold add an -Infinity base that is transparent
if (steps.length > 0 && steps[0].value !== -Infinity) {
steps.unshift({
color: 'transparent',
value: -Infinity,
});
}
let displayMode = area ? GraphTresholdsStyleMode.Area : GraphTresholdsStyleMode.Line;
if (line && area) {
displayMode = GraphTresholdsStyleMode.LineAndArea;
}
// TODO move into standard ThresholdConfig ?
y1.custom.thresholdsStyle = { mode: displayMode };
y1.thresholds = {
mode: ThresholdsMode.Absolute,
steps,
};
}
return {
fieldConfig: {
defaults: omitBy(y1, isNil),
@ -321,6 +401,33 @@ export function flotToGraphOptions(angular: any): { fieldConfig: FieldConfigSour
};
}
function getThresholdColor(threshold: AngularThreshold): string {
if (threshold.colorMode === 'critical') {
return 'red';
}
if (threshold.colorMode === 'warning') {
return 'orange';
}
if (threshold.colorMode === 'custom') {
return threshold.fillColor || threshold.lineColor;
}
return 'red';
}
interface AngularThreshold {
op: string;
fill: boolean;
line: boolean;
value: number;
colorMode: 'critical' | 'warning' | 'custom';
yaxis?: 'left' | 'right';
fillColor: string;
lineColor: string;
}
// {
// "label": "Y111",
// "show": true,