feat(ui): add initial alert threshold visualization

pull/14356/head
Christopher Henn 2019-07-15 14:36:25 -07:00
parent ee7ff84d8b
commit 98e8dc9ad8
15 changed files with 652 additions and 7 deletions

View File

@ -140,7 +140,7 @@
},
"dependencies": {
"@influxdata/clockface": "0.0.13",
"@influxdata/giraffe": "0.15.0",
"@influxdata/giraffe": "0.16.0",
"@influxdata/influx": "0.4.0",
"@influxdata/influxdb-templates": "0.2.0",
"@influxdata/react-custom-scrollbars": "4.3.8",

View File

@ -0,0 +1,132 @@
// Libraries
import React, {useState, FunctionComponent} from 'react'
import {Config, Table} from '@influxdata/giraffe'
import {flatMap} from 'lodash'
// Components
import EmptyGraphMessage from 'src/shared/components/EmptyGraphMessage'
import GraphLoadingDots from 'src/shared/components/GraphLoadingDots'
import ThresholdMarkers from 'src/shared/components/ThresholdMarkers'
// Utils
import {getFormatter, filterNoisyColumns} from 'src/shared/utils/vis'
import {useVisDomainSettings} from 'src/shared/utils/useVisDomainSettings'
// Constants
import {VIS_THEME} from 'src/shared/constants'
import {INVALID_DATA_COPY} from 'src/shared/copy/cell'
// Types
import {RemoteDataState, CheckView, TimeZone, ThresholdConfig} from 'src/types'
const X_COLUMN = '_time'
const Y_COLUMN = '_value'
const THRESHOLDS: ThresholdConfig[] = [
{
type: 'less',
allValues: false,
level: 'UNKNOWN',
value: 20,
},
]
interface Props {
table: Table
fluxGroupKeyUnion: string[]
loading: RemoteDataState
timeZone: TimeZone
viewProperties: CheckView
children: (config: Config) => JSX.Element
}
const AlertCheckContainer: FunctionComponent<Props> = ({
table,
fluxGroupKeyUnion,
loading,
children,
timeZone,
viewProperties: {yDomain: storedYDomain},
}) => {
const [thresholds, setThresholds] = useState(THRESHOLDS)
const [yDomain, onSetYDomain, onResetYDomain] = useVisDomainSettings(
storedYDomain,
table.getColumn(Y_COLUMN, 'number')
)
const columnKeys = table.columnKeys
const isValidView =
columnKeys.includes(X_COLUMN) && columnKeys.includes(Y_COLUMN)
if (!isValidView) {
return <EmptyGraphMessage message={INVALID_DATA_COPY} />
}
const groupKey = [...fluxGroupKeyUnion, 'result']
const xFormatter = getFormatter(table.getColumnType(X_COLUMN), {
timeZone,
trimZeros: false,
})
const yFormatter = getFormatter(table.getColumnType(Y_COLUMN), {
timeZone,
trimZeros: false,
})
const legendColumns = filterNoisyColumns(
[...groupKey, X_COLUMN, Y_COLUMN],
table
)
const yTicks = flatMap(thresholds, (t: any) => [
t.value,
t.minValue,
t.maxValue,
]).filter(t => t !== undefined)
const config: Config = {
...VIS_THEME,
table,
legendColumns,
yTicks,
yDomain,
onSetYDomain,
onResetYDomain,
valueFormatters: {
[X_COLUMN]: xFormatter,
[Y_COLUMN]: yFormatter,
},
layers: [
{
type: 'line',
x: X_COLUMN,
y: Y_COLUMN,
fill: groupKey,
interpolation: 'monotoneX',
},
{
type: 'custom',
render: ({yScale, yDomain}) => (
<ThresholdMarkers
key="custom"
thresholds={thresholds}
onSetThresholds={setThresholds}
yScale={yScale}
yDomain={yDomain}
/>
),
},
],
}
return (
<div className="vis-plot-container vis-plot-container--alert-check">
{loading === RemoteDataState.Loading && <GraphLoadingDots />}
{children(config)}
</div>
)
}
export default AlertCheckContainer

View File

@ -0,0 +1,47 @@
// Libraries
import React, {FunctionComponent} from 'react'
import {Scale} from '@influxdata/giraffe'
// Components
import ThresholdMarker from 'src/shared/components/ThresholdMarker'
import ThresholdMarkerArea from 'src/shared/components/ThresholdMarkerArea'
// Utils
import {isInDomain, clamp} from 'src/shared/utils/vis'
import {DragEvent} from 'src/shared/utils/useDragBehavior'
// Types
import {GreaterThresholdConfig} from 'src/types'
interface Props {
yScale: Scale<number, number>
yDomain: number[]
threshold: GreaterThresholdConfig
onChangePos: (e: DragEvent) => void
}
const GreaterThresholdMarker: FunctionComponent<Props> = ({
yDomain,
yScale,
threshold: {level, value},
onChangePos,
}) => {
const y = yScale(clamp(value, yDomain))
return (
<>
{isInDomain(value, yDomain) && (
<ThresholdMarker level={level} y={y} onDrag={onChangePos} />
)}
{value <= yDomain[1] && (
<ThresholdMarkerArea
level={level}
top={yScale(yDomain[1])}
height={y - yScale(yDomain[1])}
/>
)}
</>
)
}
export default GreaterThresholdMarker

View File

@ -0,0 +1,47 @@
// Libraries
import React, {FunctionComponent} from 'react'
import {Scale} from '@influxdata/giraffe'
// Components
import ThresholdMarker from 'src/shared/components/ThresholdMarker'
import ThresholdMarkerArea from 'src/shared/components/ThresholdMarkerArea'
// Utils
import {clamp, isInDomain} from 'src/shared/utils/vis'
import {DragEvent} from 'src/shared/utils/useDragBehavior'
// Types
import {LessThresholdConfig} from 'src/types'
interface Props {
yScale: Scale<number, number>
yDomain: number[]
threshold: LessThresholdConfig
onChangePos: (e: DragEvent) => void
}
const LessThresholdMarker: FunctionComponent<Props> = ({
yScale,
yDomain,
threshold: {level, value},
onChangePos,
}) => {
const y = yScale(clamp(value, yDomain))
return (
<>
{isInDomain(value, yDomain) && (
<ThresholdMarker level={level} y={y} onDrag={onChangePos} />
)}
{value >= yDomain[0] && (
<ThresholdMarkerArea
level={level}
top={y}
height={yScale(yDomain[0]) - y}
/>
)}
</>
)
}
export default LessThresholdMarker

View File

@ -0,0 +1,66 @@
// Libraries
import React, {FunctionComponent} from 'react'
import {Scale} from '@influxdata/giraffe'
// Components
import ThresholdMarker from 'src/shared/components/ThresholdMarker'
import ThresholdMarkerArea from 'src/shared/components/ThresholdMarkerArea'
// Utils
import {isInDomain, clamp} from 'src/shared/utils/vis'
import {DragEvent} from 'src/shared/utils/useDragBehavior'
// Types
import {RangeThresholdConfig} from 'src/types'
interface Props {
yScale: Scale<number, number>
yDomain: number[]
threshold: RangeThresholdConfig
onChangeMaxPos: (e: DragEvent) => void
onChangeMinPos: (e: DragEvent) => void
}
const RangeThresholdMarkers: FunctionComponent<Props> = ({
yScale,
yDomain,
threshold: {level, within, minValue, maxValue},
onChangeMinPos,
onChangeMaxPos,
}) => {
const minY = yScale(clamp(minValue, yDomain))
const maxY = yScale(clamp(maxValue, yDomain))
return (
<>
{isInDomain(minValue, yDomain) && (
<ThresholdMarker level={level} y={minY} onDrag={onChangeMinPos} />
)}
{isInDomain(maxValue, yDomain) && (
<ThresholdMarker level={level} y={maxY} onDrag={onChangeMaxPos} />
)}
{within ? (
<ThresholdMarkerArea level={level} top={maxY} height={minY - maxY} />
) : (
<>
{maxValue <= yDomain[1] && (
<ThresholdMarkerArea
level={level}
top={yScale(yDomain[1])}
height={maxY - yScale(yDomain[1])}
/>
)}
{minValue >= yDomain[0] && (
<ThresholdMarkerArea
level={level}
top={minY}
height={yScale(yDomain[0]) - minY}
/>
)}
</>
)}
</>
)
}
export default RangeThresholdMarkers

View File

@ -0,0 +1,33 @@
// Libraries
import React, {FunctionComponent} from 'react'
// Utils
import {useDragBehavior, DragEvent} from 'src/shared/utils/useDragBehavior'
// Types
import {CheckStatusLevel} from 'src/types'
interface Props {
level: CheckStatusLevel
y: number
onDrag: (e: DragEvent) => void
}
const ThresholdMarker: FunctionComponent<Props> = ({level, y, onDrag}) => {
const dragTargetProps = useDragBehavior(onDrag)
const levelClass = `threshold-marker--${level.toLowerCase()}`
const style = {top: `${y}px`}
return (
<>
<div className={`threshold-marker--line ${levelClass}`} style={style} />
<div
className={`threshold-marker--handle ${levelClass}`}
style={style}
{...dragTargetProps}
/>
</>
)
}
export default ThresholdMarker

View File

@ -0,0 +1,27 @@
// Libraries
import React, {FunctionComponent} from 'react'
// Types
import {CheckStatusLevel} from 'src/types'
interface Props {
level: CheckStatusLevel
top: number
height: number
}
const ThresholdMarkerArea: FunctionComponent<Props> = ({
level,
top,
height,
}) => (
<div
className={`threshold-marker--area threshold-marker--${level.toLowerCase()}`}
style={{
top: `${top}px`,
height: `${height}px`,
}}
/>
)
export default ThresholdMarkerArea

View File

@ -0,0 +1,75 @@
$threshold-marker--handle-height: 18px;
$threshold-marker--handle-width: 30px;
$threshold-marker--caret-width: 12px;
@mixin threshold-marker--color($color, $color-varient) {
background-color: $color;
&.threshold-marker--handle {
background: linear-gradient(115deg, $color 0%, $color 35%, $color-varient 100%);
&:before {
border-right: $threshold-marker--caret-width solid $color;
}
}
}
.threshold-markers {
width: 100%;
height: 100%;
}
.threshold-marker--crit {
@include threshold-marker--color($c-fire, $c-curacao);
}
.threshold-marker--warn {
@include threshold-marker--color($c-tiger, $c-pineapple);
}
.threshold-marker--ok {
@include threshold-marker--color($c-rainforest, $c-honeydew);
}
.threshold-marker--info {
@include threshold-marker--color($c-pool, $c-laser);
}
.threshold-marker--unknown {
@include threshold-marker--color($c-amethyst, $c-amethyst);
}
.threshold-marker--area {
position: absolute;
width: 100%;
opacity: 0.07;
}
.threshold-marker--line {
position: absolute;
width: 100%;
height: 1px;
}
.threshold-marker--handle {
position: absolute;
right: 1px - $threshold-marker--handle-width;
width: $threshold-marker--handle-width - $threshold-marker--caret-width;
height: $threshold-marker--handle-height;
border-radius: 0 $radius-small $radius-small 0;
transform: translateY(-50%);
cursor: grab;
&:active {
cursor: grabbing;
}
&:before {
content: "";
border-top: ($threshold-marker--handle-height / 2) solid transparent;
border-bottom: ($threshold-marker--handle-height / 2) solid transparent;
display: block;
left: 0 - $threshold-marker--caret-width;
position: absolute;
}
}

View File

@ -0,0 +1,102 @@
// Libraries
import React, {useRef, FunctionComponent} from 'react'
import {Scale} from '@influxdata/giraffe'
// Components
import RangeThresholdMarkers from 'src/shared/components/RangeThresholdMarkers'
import LessThresholdMarker from 'src/shared/components/LessThresholdMarker'
import GreaterThresholdMarker from 'src/shared/components/GreaterThresholdMarker'
// Utils
import {clamp} from 'src/shared/utils/vis'
// Types
import {ThresholdConfig} from 'src/types'
interface Props {
thresholds: ThresholdConfig[]
onSetThresholds: (newThresholds: ThresholdConfig[]) => void
yScale: Scale<number, number>
yDomain: number[]
}
const ThresholdMarkers: FunctionComponent<Props> = ({
yScale,
yDomain,
thresholds,
onSetThresholds,
}) => {
const originRef = useRef<HTMLDivElement>(null)
const handleDrag = (index: number, field: string, y: number) => {
const yRelative = y - originRef.current.getBoundingClientRect().top
const yValue = clamp(yScale.invert(yRelative), yDomain)
const nextThreshold = {...thresholds[index], [field]: yValue}
if (
nextThreshold.type === 'range' &&
nextThreshold.minValue > nextThreshold.maxValue
) {
// If the user drags the min past the max or vice versa, we swap the
// values that are set so that the min is always at most the max
const maxValue = nextThreshold.minValue
nextThreshold.minValue = nextThreshold.maxValue
nextThreshold.maxValue = maxValue
}
const nextThresholds = thresholds.map((t, i) =>
i === index ? nextThreshold : t
)
onSetThresholds(nextThresholds)
}
return (
<div className="threshold-markers" ref={originRef}>
{thresholds.map((threshold, index) => {
const onChangePos = ({y}) => handleDrag(index, 'value', y)
const onChangeMaxPos = ({y}) => handleDrag(index, 'maxValue', y)
const onChangeMinPos = ({y}) => handleDrag(index, 'minValue', y)
switch (threshold.type) {
case 'greater':
return (
<GreaterThresholdMarker
key={index}
yScale={yScale}
yDomain={yDomain}
threshold={threshold}
onChangePos={onChangePos}
/>
)
case 'less':
return (
<LessThresholdMarker
key={index}
yScale={yScale}
yDomain={yDomain}
threshold={threshold}
onChangePos={onChangePos}
/>
)
case 'range':
return (
<RangeThresholdMarkers
key={index}
yScale={yScale}
yDomain={yDomain}
threshold={threshold}
onChangeMinPos={onChangeMinPos}
onChangeMaxPos={onChangeMaxPos}
/>
)
default:
return null
}
})}
</div>
)
}
export default ThresholdMarkers

View File

@ -174,6 +174,11 @@ $cell--header-size: 36px;
width: 100%;
height: 100%;
padding: $ix-marg-c;
&.vis-plot-container--alert-check {
padding-right: $ix-marg-c + 30px;
overflow: hidden;
}
}
.giraffe-tooltip-container {

View File

@ -0,0 +1,51 @@
import * as React from 'react'
import {useCallback} from 'react'
export interface DragEvent {
x: number
y: number
type: 'dragStart' | 'drag' | 'dragStop'
}
type MouseDownEvent = React.MouseEvent<Element, MouseEvent>
export const useDragBehavior = (
onDrag: (e: DragEvent) => any
): {onMouseDown: (e: MouseDownEvent) => any} => {
const onMouseDown = useCallback(
(mouseDownEvent: MouseDownEvent) => {
mouseDownEvent.stopPropagation()
onDrag({
type: 'dragStart',
x: mouseDownEvent.pageX,
y: mouseDownEvent.pageY,
})
const onMouseMove = mouseMoveEvent => {
onDrag({
type: 'drag',
x: mouseMoveEvent.pageX,
y: mouseMoveEvent.pageY,
})
}
const onMouseUp = mouseUpEvent => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
onDrag({
type: 'dragStop',
x: mouseUpEvent.pageX,
y: mouseUpEvent.pageY,
})
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
},
[onDrag]
)
return {onMouseDown}
}

View File

@ -45,11 +45,12 @@ interface GetFormatterOptions {
suffix?: string
base?: Base
timeZone?: TimeZone
trimZeros?: boolean
}
export const getFormatter = (
columnType: ColumnType,
{prefix, suffix, base, timeZone}: GetFormatterOptions = {}
{prefix, suffix, base, timeZone, trimZeros = true}: GetFormatterOptions = {}
): null | ((x: any) => string) => {
if (columnType === 'number' && base === '2') {
return binaryPrefixFormatter({
@ -64,6 +65,7 @@ export const getFormatter = (
prefix,
suffix,
significantDigits: VIS_SIG_DIGITS,
trimZeros,
})
}
@ -223,3 +225,18 @@ export const defaultYColumn = (
return null
}
export const isInDomain = (value: number, domain: number[]) =>
value >= domain[0] && value <= domain[1]
export const clamp = (value: number, domain: number[]) => {
if (value < domain[0]) {
return domain[0]
}
if (value > domain[1]) {
return domain[1]
}
return value
}

View File

@ -108,7 +108,7 @@
@import 'src/templates/components/createFromTemplateOverlay/CreateFromTemplateOverlay.scss';
@import 'src/onboarding/components/SigninForm.scss';
@import 'src/shared/components/ThresholdsSettings.scss';
@import 'src/shared/components/ThresholdMarkers.scss';
// External
@import '../../node_modules/@influxdata/react-custom-scrollbars/dist/styles.css';

View File

@ -127,6 +127,7 @@ export type ViewProperties =
| HistogramView
| HeatmapView
| ScatterView
| CheckView
export type QueryViewProperties = Extract<
ViewProperties,
@ -286,6 +287,47 @@ export interface ScatterView {
showNoteWhenEmpty: boolean
}
export type CheckStatusLevel = 'OK' | 'INFO' | 'WARN' | 'CRIT' | 'UNKNOWN'
export interface GreaterThresholdConfig {
type: 'greater'
level: CheckStatusLevel
allValues: boolean
value: number
}
export interface LessThresholdConfig {
type: 'less'
level: CheckStatusLevel
allValues: boolean
value: number
}
export interface RangeThresholdConfig {
type: 'range'
level: CheckStatusLevel
allValues: boolean
minValue: number
maxValue: number
within: boolean
}
export type ThresholdConfig =
| GreaterThresholdConfig
| LessThresholdConfig
| RangeThresholdConfig
export interface CheckView {
type: ViewType.Check
shape: ViewShape.ChronografV2
queries: DashboardQuery[]
thresholds: ThresholdConfig[]
yDomain: [number, number]
colors: string[]
note: string
showNoteWhenEmpty: boolean
}
export interface MarkdownView {
type: ViewType.Markdown
shape: ViewShape.ChronografV2
@ -308,6 +350,7 @@ export enum ViewType {
Histogram = 'histogram',
Heatmap = 'heatmap',
Scatter = 'scatter',
Check = 'check',
}
export interface DashboardFile {

View File

@ -748,10 +748,10 @@
react-scrollbars-custom "^4.0.0-alpha.10"
storybook-addon-jsx "^7.1.0"
"@influxdata/giraffe@0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@influxdata/giraffe/-/giraffe-0.15.0.tgz#e580095a44b0ece4d20ff04dcd393632205e3775"
integrity sha512-PtEqcckyOU7KAnsWfjlk59BCPvZrKHzm1M/Qz4rL6WYLDn7NDKrjCuXr6ztMIC8AD1S5MAPrB/w1cNB8Rhe/Wg==
"@influxdata/giraffe@0.16.0":
version "0.16.0"
resolved "https://registry.yarnpkg.com/@influxdata/giraffe/-/giraffe-0.16.0.tgz#b04a304460a7c9449fe1a9fa2a3f5db97949e916"
integrity sha512-nDVQgx5Lq3fjsMXTzYwzf0HXKjSxzsBJibitObwtE0wefpzU4LW0IEUrcCBNIQyXj1OxyBNL9a67gZ4DyV7M2w==
"@influxdata/influx@0.4.0":
version "0.4.0"