diff --git a/ui/package.json b/ui/package.json index 0ec58e24ac..684a6c964e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/shared/components/AlertCheckContainer.tsx b/ui/src/shared/components/AlertCheckContainer.tsx new file mode 100644 index 0000000000..c8bd5078db --- /dev/null +++ b/ui/src/shared/components/AlertCheckContainer.tsx @@ -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 = ({ + 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 + } + + 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}) => ( + + ), + }, + ], + } + + return ( +
+ {loading === RemoteDataState.Loading && } + {children(config)} +
+ ) +} + +export default AlertCheckContainer diff --git a/ui/src/shared/components/GreaterThresholdMarker.tsx b/ui/src/shared/components/GreaterThresholdMarker.tsx new file mode 100644 index 0000000000..8f3ebc46d6 --- /dev/null +++ b/ui/src/shared/components/GreaterThresholdMarker.tsx @@ -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 + yDomain: number[] + threshold: GreaterThresholdConfig + onChangePos: (e: DragEvent) => void +} + +const GreaterThresholdMarker: FunctionComponent = ({ + yDomain, + yScale, + threshold: {level, value}, + onChangePos, +}) => { + const y = yScale(clamp(value, yDomain)) + + return ( + <> + {isInDomain(value, yDomain) && ( + + )} + {value <= yDomain[1] && ( + + )} + + ) +} + +export default GreaterThresholdMarker diff --git a/ui/src/shared/components/LessThresholdMarker.tsx b/ui/src/shared/components/LessThresholdMarker.tsx new file mode 100644 index 0000000000..a84e764317 --- /dev/null +++ b/ui/src/shared/components/LessThresholdMarker.tsx @@ -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 + yDomain: number[] + threshold: LessThresholdConfig + onChangePos: (e: DragEvent) => void +} + +const LessThresholdMarker: FunctionComponent = ({ + yScale, + yDomain, + threshold: {level, value}, + onChangePos, +}) => { + const y = yScale(clamp(value, yDomain)) + + return ( + <> + {isInDomain(value, yDomain) && ( + + )} + {value >= yDomain[0] && ( + + )} + + ) +} + +export default LessThresholdMarker diff --git a/ui/src/shared/components/RangeThresholdMarkers.tsx b/ui/src/shared/components/RangeThresholdMarkers.tsx new file mode 100644 index 0000000000..88d4a0407f --- /dev/null +++ b/ui/src/shared/components/RangeThresholdMarkers.tsx @@ -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 + yDomain: number[] + threshold: RangeThresholdConfig + onChangeMaxPos: (e: DragEvent) => void + onChangeMinPos: (e: DragEvent) => void +} + +const RangeThresholdMarkers: FunctionComponent = ({ + 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) && ( + + )} + {isInDomain(maxValue, yDomain) && ( + + )} + {within ? ( + + ) : ( + <> + {maxValue <= yDomain[1] && ( + + )} + {minValue >= yDomain[0] && ( + + )} + + )} + + ) +} + +export default RangeThresholdMarkers diff --git a/ui/src/shared/components/ThresholdMarker.tsx b/ui/src/shared/components/ThresholdMarker.tsx new file mode 100644 index 0000000000..e50558405a --- /dev/null +++ b/ui/src/shared/components/ThresholdMarker.tsx @@ -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 = ({level, y, onDrag}) => { + const dragTargetProps = useDragBehavior(onDrag) + const levelClass = `threshold-marker--${level.toLowerCase()}` + const style = {top: `${y}px`} + + return ( + <> +
+
+ + ) +} + +export default ThresholdMarker diff --git a/ui/src/shared/components/ThresholdMarkerArea.tsx b/ui/src/shared/components/ThresholdMarkerArea.tsx new file mode 100644 index 0000000000..2f4ba8fefd --- /dev/null +++ b/ui/src/shared/components/ThresholdMarkerArea.tsx @@ -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 = ({ + level, + top, + height, +}) => ( +
+) + +export default ThresholdMarkerArea diff --git a/ui/src/shared/components/ThresholdMarkers.scss b/ui/src/shared/components/ThresholdMarkers.scss new file mode 100644 index 0000000000..596e2492ed --- /dev/null +++ b/ui/src/shared/components/ThresholdMarkers.scss @@ -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; + } +} diff --git a/ui/src/shared/components/ThresholdMarkers.tsx b/ui/src/shared/components/ThresholdMarkers.tsx new file mode 100644 index 0000000000..fd197c2653 --- /dev/null +++ b/ui/src/shared/components/ThresholdMarkers.tsx @@ -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 + yDomain: number[] +} + +const ThresholdMarkers: FunctionComponent = ({ + yScale, + yDomain, + thresholds, + onSetThresholds, +}) => { + const originRef = useRef(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 ( +
+ {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 ( + + ) + case 'less': + return ( + + ) + case 'range': + return ( + + ) + default: + return null + } + })} +
+ ) +} + +export default ThresholdMarkers diff --git a/ui/src/shared/components/cells/Cell.scss b/ui/src/shared/components/cells/Cell.scss index cd85069720..6e81e780d1 100644 --- a/ui/src/shared/components/cells/Cell.scss +++ b/ui/src/shared/components/cells/Cell.scss @@ -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 { diff --git a/ui/src/shared/utils/useDragBehavior.ts b/ui/src/shared/utils/useDragBehavior.ts new file mode 100644 index 0000000000..e421103289 --- /dev/null +++ b/ui/src/shared/utils/useDragBehavior.ts @@ -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 + +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} +} diff --git a/ui/src/shared/utils/vis.ts b/ui/src/shared/utils/vis.ts index eb6707439d..6bc680a25a 100644 --- a/ui/src/shared/utils/vis.ts +++ b/ui/src/shared/utils/vis.ts @@ -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 +} diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 8662a282e8..3eddd41e9d 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -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'; diff --git a/ui/src/types/dashboards.ts b/ui/src/types/dashboards.ts index 67e9364ef2..24f18f3d64 100644 --- a/ui/src/types/dashboards.ts +++ b/ui/src/types/dashboards.ts @@ -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 { diff --git a/ui/yarn.lock b/ui/yarn.lock index 92807061be..1af41d25e0 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -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"