From 46757aba4fd9009af066b3e012dd6965f54a3347 Mon Sep 17 00:00:00 2001 From: Xin Hao Zhang Date: Fri, 1 Apr 2022 11:48:05 -0400 Subject: [PATCH] ui: move shared graph components to cluster-ui Part of #74516 This commit moves shared graph functions and components to cluster-ui package. This is to enable the new bar chart component to share axes utilities and containers with the older line graph component in db-console. Release note: None --- pkg/ui/workspaces/cluster-ui/.eslintrc.json | 3 +- .../workspaces/cluster-ui/src/graphs/index.ts | 12 + .../cluster-ui/src/graphs/utils/domain.ts | 308 ++++++++++++ .../src/graphs/visualization/index.tsx | 93 ++++ .../visualization/visualizations.module.scss | 85 ++++ pkg/ui/workspaces/cluster-ui/src/index.ts | 1 + .../cluster/components/linegraph/index.tsx | 26 +- .../components/linegraph/linegraph.styl | 71 +++ .../components/visualization/index.tsx | 88 ---- .../visualization/visualizations.styl | 126 ----- .../src/views/cluster/util/graphs.ts | 464 +----------------- 11 files changed, 592 insertions(+), 685 deletions(-) create mode 100644 pkg/ui/workspaces/cluster-ui/src/graphs/index.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/graphs/visualization/index.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/graphs/visualization/visualizations.module.scss create mode 100644 pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/linegraph.styl delete mode 100644 pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/index.tsx delete mode 100644 pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/visualizations.styl diff --git a/pkg/ui/workspaces/cluster-ui/.eslintrc.json b/pkg/ui/workspaces/cluster-ui/.eslintrc.json index e09c1118f3c8..d92f34cdc0e1 100644 --- a/pkg/ui/workspaces/cluster-ui/.eslintrc.json +++ b/pkg/ui/workspaces/cluster-ui/.eslintrc.json @@ -8,6 +8,7 @@ "rules": { "@typescript-eslint/interface-name-prefix": "off", "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-namespace": "off" + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] } } diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/index.ts b/pkg/ui/workspaces/cluster-ui/src/graphs/index.ts new file mode 100644 index 000000000000..c5a3d5e6d175 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/index.ts @@ -0,0 +1,12 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +export * from "./visualization"; +export * from "./utils/domain"; diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts b/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts new file mode 100644 index 000000000000..31b11643b869 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts @@ -0,0 +1,308 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import _ from "lodash"; +import d3 from "d3"; +import moment from "moment"; + +import { + BytesFitScale, + ComputeByteScale, + ComputeDurationScale, + DurationFitScale, +} from "src/util/format"; + +/** + * AxisUnits is an enumeration used to specify the type of units being displayed + * on an Axis. + */ +export enum AxisUnits { + /** + * Units are a simple count. + */ + Count, + /** + * Units are a count of bytes. + */ + Bytes, + /** + * Units are durations expressed in nanoseconds. + */ + Duration, + /** + * Units are percentages expressed as fractional values of 1 (1.0 = 100%). + */ + Percentage, +} + +// The number of ticks to display on a Y axis. +const Y_AXIS_TICK_COUNT = 3; + +// The number of ticks to display on an X axis. +const X_AXIS_TICK_COUNT = 10; + +// A tuple of numbers for the minimum and maximum values of an axis. +export type Extent = [number, number]; + +/** + * AxisDomain is a class that describes the domain of a graph axis; this + * includes the minimum/maximum extend, tick values, and formatting information + * for axis values as displayed in various contexts. + */ +export class AxisDomain { + // the values at the ends of the axis. + extent: Extent; + // numbers at which an intermediate tick should be displayed on the axis. + ticks: number[] = [0, 1]; + // label returns the label for the axis. + label = ""; + // tickFormat returns a function used to format the tick values for display. + tickFormat: (n: number) => string = n => n.toString(); + // guideFormat returns a function used to format the axis values in the + // chart's interactive guideline. + guideFormat: (n: number) => string = n => n.toString(); + + // constructs a new AxisDomain with the given minimum and maximum value, with + // ticks placed at intervals of the given increment in between the min and + // max. Ticks are always "aligned" to values that are even multiples of + // increment. Min and max are also aligned by default - the aligned min will + // be <= the provided min, while the aligned max will be >= the provided max. + constructor(extent: Extent, increment: number, alignMinMax = true) { + let min = extent[0]; + let max = extent[1]; + if (alignMinMax) { + min = min - (min % increment); + if (max % increment !== 0) { + max = max - (max % increment) + increment; + } + } + + this.extent = [min, max]; + + this.ticks = []; + for ( + let nextTick = min - (min % increment) + increment; + nextTick < this.extent[1]; + nextTick += increment + ) { + this.ticks.push(nextTick); + } + } +} + +const countIncrementTable = [ + 0.1, + 0.2, + 0.25, + 0.3, + 0.4, + 0.5, + 0.6, + 0.7, + 0.75, + 0.8, + 0.9, + 1.0, +]; + +// computeNormalizedIncrement computes a human-friendly increment between tick +// values on an axis with a range of the given size. The provided size is taken +// to be the minimum range needed to display all values present on the axis. +// The increment is computed by dividing this minimum range into the correct +// number of segments for the supplied tick count, and then increasing this +// increment to the nearest human-friendly increment. +// +// "Human-friendly" increments are taken from the supplied countIncrementTable, +// which should include decimal values between 0 and 1. +function computeNormalizedIncrement( + range: number, + incrementTbl: number[] = countIncrementTable, +) { + if (range === 0) { + throw new Error("cannot compute tick increment with zero range"); + } + + let rawIncrement = range / (Y_AXIS_TICK_COUNT + 1); + // Compute X such that 0 <= rawIncrement/10^x <= 1 + let x = 0; + while (rawIncrement > 1) { + x++; + rawIncrement = rawIncrement / 10; + } + const normalizedIncrementIdx = _.sortedIndex(incrementTbl, rawIncrement); + return incrementTbl[normalizedIncrementIdx] * Math.pow(10, x); +} + +function computeAxisDomain(extent: Extent, factor = 1): AxisDomain { + const range = extent[1] - extent[0]; + + // Compute increment on min/max after conversion to the appropriate prefix unit. + const increment = computeNormalizedIncrement(range / factor); + + // Create axis domain by multiplying computed increment by prefix factor. + const axisDomain = new AxisDomain(extent, increment * factor); + + // If the tick increment is fractional (e.g. 0.2), we display a decimal + // point. For non-fractional increments, we display with no decimal points + // but with a metric prefix for large numbers (i.e. 1000 will display as "1k") + let unitFormat: (v: number) => string; + if (Math.floor(increment) !== increment) { + unitFormat = d3.format(".1f"); + } else { + unitFormat = d3.format("s"); + } + axisDomain.tickFormat = (v: number) => unitFormat(v / factor); + + return axisDomain; +} + +function ComputeCountAxisDomain(extent: Extent): AxisDomain { + const axisDomain = computeAxisDomain(extent); + + // For numbers larger than 1, the tooltip displays fractional values with + // metric multiplicative prefixes (e.g. kilo, mega, giga). For numbers smaller + // than 1, we simply display the fractional value without converting to a + // fractional metric prefix; this is because the use of fractional metric + // prefixes (i.e. milli, micro, nano) have proved confusing to users. + const metricFormat = d3.format(".4s"); + const decimalFormat = d3.format(".4f"); + axisDomain.guideFormat = (n: number) => { + if (n < 1) { + return decimalFormat(n); + } + return metricFormat(n); + }; + + return axisDomain; +} + +export function ComputeByteAxisDomain(extent: Extent): AxisDomain { + // Compute an appropriate unit for the maximum value to be displayed. + const scale = ComputeByteScale(extent[1]); + const prefixFactor = scale.value; + + const axisDomain = computeAxisDomain(extent, prefixFactor); + + axisDomain.label = scale.units; + + axisDomain.guideFormat = BytesFitScale(scale.units); + return axisDomain; +} + +function ComputeDurationAxisDomain(extent: Extent): AxisDomain { + const scale = ComputeDurationScale(extent[1]); + const prefixFactor = scale.value; + + const axisDomain = computeAxisDomain(extent, prefixFactor); + + axisDomain.label = scale.units; + + axisDomain.guideFormat = DurationFitScale(scale.units); + return axisDomain; +} + +const percentIncrementTable = [0.25, 0.5, 0.75, 1.0]; + +function ComputePercentageAxisDomain(min: number, max: number) { + const range = max - min; + const increment = computeNormalizedIncrement(range, percentIncrementTable); + const axisDomain = new AxisDomain([min, max], increment); + axisDomain.label = "percentage"; + axisDomain.tickFormat = d3.format(".0%"); + axisDomain.guideFormat = d3.format(".2%"); + return axisDomain; +} + +const timeIncrementDurations = [ + moment.duration(1, "m"), + moment.duration(5, "m"), + moment.duration(10, "m"), + moment.duration(15, "m"), + moment.duration(30, "m"), + moment.duration(1, "h"), + moment.duration(2, "h"), + moment.duration(3, "h"), + moment.duration(6, "h"), + moment.duration(12, "h"), + moment.duration(24, "h"), + moment.duration(1, "week"), +]; +const timeIncrements: number[] = timeIncrementDurations.map(inc => + inc.asMilliseconds(), +); + +export function formatTimeStamp(timeMillis: number): string { + return moment.utc(timeMillis).format("HH:mm:ss on MMM Do, YYYY"); +} + +function ComputeTimeAxisDomain(extent: Extent, isBarChart = false): AxisDomain { + // Compute increment; for time scales, this is taken from a table of allowed + // values. + let increment = 0; + { + const rawIncrement = (extent[1] - extent[0]) / (X_AXIS_TICK_COUNT + 1); + // Compute X such that 0 <= rawIncrement/10^x <= 1 + const tbl = timeIncrements; + let normalizedIncrementIdx = _.sortedIndex(tbl, rawIncrement); + if (normalizedIncrementIdx === tbl.length) { + normalizedIncrementIdx--; + } + increment = tbl[normalizedIncrementIdx]; + } + + if (isBarChart) { + extent[1] = extent[1] + increment; + } + + // Do not normalize min/max for time axis. + const axisDomain = new AxisDomain(extent, increment, false); + + axisDomain.label = "time"; + + let tickDateFormatter: (d: Date) => string; + if (increment < moment.duration(24, "hours").asMilliseconds()) { + tickDateFormatter = d3.time.format.utc("%H:%M"); + } else { + tickDateFormatter = d3.time.format.utc("%m/%d %H:%M"); + } + axisDomain.tickFormat = (n: number) => { + return tickDateFormatter(new Date(n)); + }; + + axisDomain.guideFormat = formatTimeStamp; + return axisDomain; +} + +export function calculateYAxisDomain( + axisUnits: AxisUnits, + data: number[], +): AxisDomain { + const allDatapoints = data.concat([0, 1]); + const yExtent = d3.extent(allDatapoints); + + switch (axisUnits) { + case AxisUnits.Bytes: + return ComputeByteAxisDomain(yExtent); + case AxisUnits.Duration: + return ComputeDurationAxisDomain(yExtent); + case AxisUnits.Percentage: + return ComputePercentageAxisDomain(yExtent[0], yExtent[1]); + default: + return ComputeCountAxisDomain(yExtent); + } +} + +export function calculateXAxisDomain( + startMillis: number, + endMillis: number, + isBarChart = false, +): AxisDomain { + return ComputeTimeAxisDomain([startMillis, endMillis] as Extent, isBarChart); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/visualization/index.tsx b/pkg/ui/workspaces/cluster-ui/src/graphs/visualization/index.tsx new file mode 100644 index 000000000000..b46303152e21 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/visualization/index.tsx @@ -0,0 +1,93 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import React from "react"; +import classNames from "classnames/bind"; +import spinner from "src/assets/spinner.gif"; +import { Tooltip } from "antd"; + +import styles from "./visualizations.module.scss"; +const cx = classNames.bind(styles); + +interface VisualizationProps { + title: string; + subtitle?: string; + tooltip?: React.ReactNode; + // If stale is true, the visualization is faded + // and the icon is changed to a warning icon. + stale?: boolean; + // If loading is true a spinner is shown instead of the graph. + loading?: boolean; + preCalcGraphSize?: boolean; + children: React.ReactNode; +} + +/** + * Visualization is a container for a variety of visual elements (such as + * charts). It surrounds a visual element with some standard information, such + * as a title and a tooltip icon. + */ +export const Visualization: React.FC = ({ + title, + subtitle, + tooltip, + stale, + loading, + preCalcGraphSize, + children, +}) => { + const chartTitle: React.ReactNode = ( +
+ + {title} + + {subtitle && ( + {subtitle} + )} +
+ ); + + const tooltipNode: React.ReactNode = tooltip ? ( + + {chartTitle} + + ) : ( + chartTitle + ); + + return ( +
+
{tooltipNode}
+
+ {loading ? ( + + ) : ( + children + )} +
+
+ ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/visualization/visualizations.module.scss b/pkg/ui/workspaces/cluster-ui/src/graphs/visualization/visualizations.module.scss new file mode 100644 index 000000000000..58c7cf2fed23 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/visualization/visualizations.module.scss @@ -0,0 +1,85 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +@import "src/core/index.module"; + +.visualization { + width: fit-content; + position: relative; + background-color: #fff; + color: $headings-color; + border-radius: 5px; + border: 1px solid rgba(0, 0, 0, 0.1); + margin-bottom: 10px; + .icon-warning { + color: $warning-color; + } +} + +.visualization-content { + padding: 0 10px 15px; + .visualization-loading { + display: flex; + justify-content: center; + align-items: center; + } +} + +.visualization-header { + display: flex; + justify-content: center; + align-items: center; + padding: 15px 25px 0; +} + +.visualization-title { + font-size: 14px; + font-family: $font-family--bold; + color: $body-color; +} +.visualization-underline { + border-bottom: 1px dashed $colors--neutral-5; +} + +.visualization-subtitle { + color: $tooltip-color; + margin-left: 5px; + font-size: 12px; + font-family: $font-family--base; +} + +.visualization-spinner { + width: 40px; + height: 40px; +} + +.visualization-graph-sizing { + height: calc(30% - 200px); + min-height: 300px; +} + +.anchor-light { + color: #fff; + text-decoration: underline; + &:hover { + color: #808080; + text-decoration: underline; + } +} + +.visualization-faded .visualization .number, +.visualization-faded .nv-group, +.visualization-faded .nv-areaWrap { + opacity: 0.2; +} + +.linked-guideline-line { + stroke: $link-color; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/index.ts b/pkg/ui/workspaces/cluster-ui/src/index.ts index 0a5a27df6aab..f766ef310ce9 100644 --- a/pkg/ui/workspaces/cluster-ui/src/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/index.ts @@ -46,3 +46,4 @@ export { util, api }; export * from "./sessions"; export * from "./timeScaleDropdown"; export * from "./activeExecutions"; +export * from "./graphs"; diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/index.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/index.tsx index ca1d8ce30d46..734bcade75a0 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/index.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/index.tsx @@ -15,9 +15,6 @@ import { createSelector } from "reselect"; import { hoverOff, hoverOn, HoverState } from "src/redux/hover"; import { findChildrenOfType } from "src/util/find"; import { - AxisDomain, - calculateXAxisDomain, - calculateYAxisDomain, configureUPlotLineChart, formatMetricData, formattedSeries, @@ -29,16 +26,25 @@ import { MetricProps, MetricsDataComponentProps, } from "src/views/shared/components/metricQuery"; -import Visualization from "src/views/cluster/components/visualization"; -import { TimeScale, util } from "@cockroachlabs/cluster-ui"; +import {} from "@cockroachlabs/cluster-ui"; +import { + calculateXAxisDomain, + calculateYAxisDomain, + AxisDomain, + TimeScale, + Visualization, + util, +} from "@cockroachlabs/cluster-ui"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; +import "./linegraph.styl"; import Long from "long"; import { findClosestTimeScale, defaultTimeScaleOptions, TimeWindow, } from "@cockroachlabs/cluster-ui"; +import _ from "lodash"; export interface LineGraphProps extends MetricsDataComponentProps { title?: string; @@ -265,8 +271,14 @@ export class LineGraph extends React.Component { // and are called when recomputing certain axis and // series options. This lets us use updated domains // when redrawing the uPlot chart on data change. - this.yAxisDomain = calculateYAxisDomain(axis.props.units, data); - this.xAxisDomain = calculateXAxisDomain(this.props.timeInfo); + const resultDatapoints = _.flatMap(data.results, result => + result.datapoints.map(dp => dp.value), + ); + this.yAxisDomain = calculateYAxisDomain(axis.props.units, resultDatapoints); + this.xAxisDomain = calculateXAxisDomain( + util.NanoToMilli(this.props.timeInfo.start.toNumber()), + util.NanoToMilli(this.props.timeInfo.end.toNumber()), + ); const prevKeys = prevProps.data && prevProps.data.results diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/linegraph.styl b/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/linegraph.styl new file mode 100644 index 000000000000..abdc09d80b59 --- /dev/null +++ b/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/linegraph.styl @@ -0,0 +1,71 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +@require nib +@require "~styl/base/palette.styl" +@require "~src/components/core/index.styl" + +$viz-bottom = 65px +$viz-sides = 62px + +.anchor-light + color white + text-decoration underline + &:hover + color gray + text-decoration underline + +.linegraph + height 100% + .uplot + display flex + > .u-legend + display none + position absolute + left 0 + top 0 + font-size 10px + background-color white + text-align left + margin-top 20px + z-index 100 + pointer-events: none; + width: fit-content; + border-radius: 5px + padding: 10px; + border 1px solid rgba(0,0,0,0.1) + + // stick the legend to the char's bottom. + // Chart height is hardcoded to 300px (pkg/ui/src/views/cluster/util/graphs.ts) + > .u-legend.u-legend--place-bottom + top 300px!important + left 0!important + width auto + margin-left -1px + margin-right -1px + border-top none + border-radius 0px 0px 5px 5px + padding-left 50px + + // the first line in the legend represents Y-axis and it occupies an entire line + // to stand out from the rest of x-axis values + > tr:first-child + width 100% + font-size $font-size--small + + > tr + min-width 30% + + .u-legend:not(.u-legend--place-bottom) + .u-series + display block + +.linked-guideline__line + stroke $link-color diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/index.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/index.tsx deleted file mode 100644 index 8b31ed2c7029..000000000000 --- a/pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/index.tsx +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2018 The Cockroach Authors. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0, included in the file -// licenses/APL.txt. - -import React from "react"; -import classNames from "classnames"; -import "./visualizations.styl"; -import spinner from "assets/spinner.gif"; -import { Tooltip } from "antd"; - -interface VisualizationProps { - title: string; - subtitle?: string; - tooltip?: React.ReactNode; - // If warning or warningTitle exist, they are appended to the tooltip - // and the icon is changed to the warning icon. - warningTitle?: string; - warning?: React.ReactNode; - // If stale is true, the visualization is faded - // and the icon is changed to a warning icon. - stale?: boolean; - // If loading is true a spinner is shown instead of the graph. - loading?: boolean; - preCalcGraphSize?: boolean; -} - -/** - * Visualization is a container for a variety of visual elements (such as - * charts). It surrounds a visual element with some standard information, such - * as a title and a tooltip icon. - */ -export default class extends React.Component { - render() { - const { title, subtitle, tooltip, stale, preCalcGraphSize } = this.props; - const vizClasses = classNames("visualization", { - "visualization--faded": stale || false, - "visualization__graph-sizing": preCalcGraphSize, - }); - const contentClasses = classNames("visualization__content", { - "visualization--loading": this.props.loading, - }); - - let titleClass = "visualization__title"; - if (tooltip) { - titleClass += " visualization__underline"; - } - - const chartSubtitle = subtitle ? ( - {subtitle} - ) : null; - - const chartTitle: React.ReactNode = ( -
- {title} - {chartSubtitle} -
- ); - - let tooltipNode: React.ReactNode = chartTitle; - - if (tooltip) { - tooltipNode = ( - - {chartTitle} - - ); - } - - return ( -
-
{tooltipNode}
-
- {this.props.loading ? ( - - ) : ( - this.props.children - )} -
-
- ); - } -} diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/visualizations.styl b/pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/visualizations.styl deleted file mode 100644 index 8d0a853b9d72..000000000000 --- a/pkg/ui/workspaces/db-console/src/views/cluster/components/visualization/visualizations.styl +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2019 The Cockroach Authors. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0, included in the file -// licenses/APL.txt. - -@require nib -@require "~styl/base/palette.styl" -@require "~src/components/core/index.styl" - -$viz-bottom = 65px -$viz-sides = 62px - -.visualization - position relative - background-color white - color $headings-color - border-radius 5px - border 1px solid rgba(0, 0, 0, .1) - // prevents borders from doubling up - margin-bottom 10px - - &__content - padding 0 10px 15px - &.visualization--loading - display flex - justify-content center - align-items center - - &__header - display flex - justify-content center - align-items center - padding 15px 25px 0 - - &__title - font-size 14px - font-family $font-family--bold - color $body-color - - &__underline - border-bottom 1px dashed $colors--neutral-5; - - &__subtitle - color $tooltip-color - margin-left 5px - font-size 12px - font-family $font-family--base - - &__spinner - width 40px - height 40px - - &__graph-sizing - height calc(30% - 200px) - min-height 300px - width 100% - - .icon-warning - color $warning-color - -.anchor-light - color white - text-decoration underline - &:hover - color gray - text-decoration underline - -.linegraph - height 100% - -.visualization--faded - .visualization .number - .nv-group - .nv-areaWrap - opacity .2 - -.linked-guideline__line - stroke $link-color - -.uplot - display flex - > .u-legend - display none - position absolute - left 0 - top 0 - font-size 10px - background-color white - text-align left - margin-top 20px - z-index 100 - pointer-events: none; - width: fit-content; - border-radius: 5px - padding: 10px; - border 1px solid rgba(0,0,0,0.1) - - // stick the legend to the char's bottom. - // Chart height is hardcoded to 300px (pkg/ui/src/views/cluster/util/graphs.ts) - > .u-legend.u-legend--place-bottom - top 300px!important - left 0!important - width auto - margin-left -1px - margin-right -1px - border-top none - border-radius 0px 0px 5px 5px - padding-left 50px - - // the first line in the legend represents Y-axis and it occupies an entire line - // to stand out from the rest of x-axis values - > tr:first-child - width 100% - font-size $font-size--small - - > tr - min-width 30% - -.u-legend:not(.u-legend--place-bottom) - .u-series - display block diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.ts b/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.ts index f0f8d7933f17..71ef2e128a73 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.ts +++ b/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.ts @@ -10,24 +10,12 @@ import React from "react"; import _ from "lodash"; -import * as nvd3 from "nvd3"; -import * as d3 from "d3"; -import moment from "moment"; - import * as protos from "src/js/protos"; -import { util } from "@cockroachlabs/cluster-ui"; -import { - BytesFitScale, - ComputeByteScale, - ComputeDurationScale, - DurationFitScale, -} from "src/util/format"; +import { AxisDomain } from "@cockroachlabs/cluster-ui"; import { AxisProps, - AxisUnits, MetricProps, - QueryTimeInfo, } from "src/views/shared/components/metricQuery"; import uPlot from "uplot"; @@ -53,284 +41,6 @@ const seriesPalette = [ "#DCCD4B", ]; -// Chart margins to match design. -export const CHART_MARGINS: nvd3.Margin = { - top: 30, - right: 20, - bottom: 20, - left: 55, -}; - -// Maximum number of series we will show in the legend. If there are more we hide the legend. -const MAX_LEGEND_SERIES: number = 4; - -// The number of ticks to display on a Y axis. -const Y_AXIS_TICK_COUNT: number = 3; - -// The number of ticks to display on an X axis. -const X_AXIS_TICK_COUNT: number = 10; - -// A tuple of numbers for the minimum and maximum values of an axis. -export type Extent = [number, number]; - -/** - * AxisDomain is a class that describes the domain of a graph axis; this - * includes the minimum/maximum extend, tick values, and formatting information - * for axis values as displayed in various contexts. - */ -export class AxisDomain { - // the values at the ends of the axis. - extent: Extent; - // numbers at which an intermediate tick should be displayed on the axis. - ticks: number[] = [0, 1]; - // label returns the label for the axis. - label: string = ""; - // tickFormat returns a function used to format the tick values for display. - tickFormat: (n: number) => string = _.identity; - // guideFormat returns a function used to format the axis values in the - // chart's interactive guideline. - guideFormat: (n: number) => string = _.identity; - - // constructs a new AxisDomain with the given minimum and maximum value, with - // ticks placed at intervals of the given increment in between the min and - // max. Ticks are always "aligned" to values that are even multiples of - // increment. Min and max are also aligned by default - the aligned min will - // be <= the provided min, while the aligned max will be >= the provided max. - constructor(extent: Extent, increment: number, alignMinMax: boolean = true) { - const min = extent[0]; - const max = extent[1]; - if (alignMinMax) { - const alignedMin = min - (min % increment); - let alignedMax = max; - if (max % increment !== 0) { - alignedMax = max - (max % increment) + increment; - } - this.extent = [alignedMin, alignedMax]; - } else { - this.extent = extent; - } - - this.ticks = []; - for ( - let nextTick = min - (min % increment) + increment; - nextTick < this.extent[1]; - nextTick += increment - ) { - this.ticks.push(nextTick); - } - } -} - -const countIncrementTable = [ - 0.1, - 0.2, - 0.25, - 0.3, - 0.4, - 0.5, - 0.6, - 0.7, - 0.75, - 0.8, - 0.9, - 1.0, -]; - -// computeNormalizedIncrement computes a human-friendly increment between tick -// values on an axis with a range of the given size. The provided size is taken -// to be the minimum range needed to display all values present on the axis. -// The increment is computed by dividing this minimum range into the correct -// number of segments for the supplied tick count, and then increasing this -// increment to the nearest human-friendly increment. -// -// "Human-friendly" increments are taken from the supplied countIncrementTable, -// which should include decimal values between 0 and 1. -function computeNormalizedIncrement( - range: number, - incrementTbl: number[] = countIncrementTable, -) { - if (range === 0) { - throw new Error("cannot compute tick increment with zero range"); - } - - let rawIncrement = range / (Y_AXIS_TICK_COUNT + 1); - // Compute X such that 0 <= rawIncrement/10^x <= 1 - let x = 0; - while (rawIncrement > 1) { - x++; - rawIncrement = rawIncrement / 10; - } - const normalizedIncrementIdx = _.sortedIndex(incrementTbl, rawIncrement); - return incrementTbl[normalizedIncrementIdx] * Math.pow(10, x); -} - -function computeAxisDomain(extent: Extent, factor: number = 1): AxisDomain { - const range = extent[1] - extent[0]; - - // Compute increment on min/max after conversion to the appropriate prefix unit. - const increment = computeNormalizedIncrement(range / factor); - - // Create axis domain by multiplying computed increment by prefix factor. - const axisDomain = new AxisDomain(extent, increment * factor); - - // If the tick increment is fractional (e.g. 0.2), we display a decimal - // point. For non-fractional increments, we display with no decimal points - // but with a metric prefix for large numbers (i.e. 1000 will display as "1k") - let unitFormat: (v: number) => string; - if (Math.floor(increment) !== increment) { - unitFormat = d3.format(".1f"); - } else { - unitFormat = d3.format("s"); - } - axisDomain.tickFormat = (v: number) => unitFormat(v / factor); - - return axisDomain; -} - -function ComputeCountAxisDomain(extent: Extent): AxisDomain { - const axisDomain = computeAxisDomain(extent); - - // For numbers larger than 1, the tooltip displays fractional values with - // metric multiplicative prefixes (e.g. kilo, mega, giga). For numbers smaller - // than 1, we simply display the fractional value without converting to a - // fractional metric prefix; this is because the use of fractional metric - // prefixes (i.e. milli, micro, nano) have proved confusing to users. - const metricFormat = d3.format(".4s"); - const decimalFormat = d3.format(".4f"); - axisDomain.guideFormat = (n: number) => { - if (n < 1) { - return decimalFormat(n); - } - return metricFormat(n); - }; - - return axisDomain; -} - -export function ComputeByteAxisDomain(extent: Extent): AxisDomain { - // Compute an appropriate unit for the maximum value to be displayed. - const scale = ComputeByteScale(extent[1]); - const prefixFactor = scale.value; - - const axisDomain = computeAxisDomain(extent, prefixFactor); - - axisDomain.label = scale.units; - - axisDomain.guideFormat = BytesFitScale(scale.units); - return axisDomain; -} - -function ComputeDurationAxisDomain(extent: Extent): AxisDomain { - const scale = ComputeDurationScale(extent[1]); - const prefixFactor = scale.value; - - const axisDomain = computeAxisDomain(extent, prefixFactor); - - axisDomain.label = scale.units; - - axisDomain.guideFormat = DurationFitScale(scale.units); - return axisDomain; -} - -const percentIncrementTable = [0.25, 0.5, 0.75, 1.0]; - -function ComputePercentageAxisDomain(min: number, max: number) { - const range = max - min; - const increment = computeNormalizedIncrement(range, percentIncrementTable); - const axisDomain = new AxisDomain([min, max], increment); - axisDomain.label = "percentage"; - axisDomain.tickFormat = d3.format(".0%"); - axisDomain.guideFormat = d3.format(".2%"); - return axisDomain; -} - -const timeIncrementDurations = [ - moment.duration(1, "m"), - moment.duration(5, "m"), - moment.duration(10, "m"), - moment.duration(15, "m"), - moment.duration(30, "m"), - moment.duration(1, "h"), - moment.duration(2, "h"), - moment.duration(3, "h"), - moment.duration(6, "h"), - moment.duration(12, "h"), - moment.duration(24, "h"), - moment.duration(1, "week"), -]; -const timeIncrements: number[] = _.map(timeIncrementDurations, inc => - inc.asMilliseconds(), -); - -function ComputeTimeAxisDomain(extent: Extent): AxisDomain { - // Compute increment; for time scales, this is taken from a table of allowed - // values. - let increment = 0; - { - const rawIncrement = (extent[1] - extent[0]) / (X_AXIS_TICK_COUNT + 1); - // Compute X such that 0 <= rawIncrement/10^x <= 1 - const tbl = timeIncrements; - let normalizedIncrementIdx = _.sortedIndex(tbl, rawIncrement); - if (normalizedIncrementIdx === tbl.length) { - normalizedIncrementIdx--; - } - increment = tbl[normalizedIncrementIdx]; - } - - // Do not normalize min/max for time axis. - const axisDomain = new AxisDomain(extent, increment, false); - - axisDomain.label = "time"; - - let tickDateFormatter: (d: Date) => string; - if (increment < moment.duration(24, "hours").asMilliseconds()) { - tickDateFormatter = d3.time.format.utc("%H:%M"); - } else { - tickDateFormatter = d3.time.format.utc("%m/%d %H:%M"); - } - axisDomain.tickFormat = (n: number) => { - return tickDateFormatter(new Date(n)); - }; - - axisDomain.guideFormat = num => { - return moment(num) - .utc() - .format("HH:mm:ss on MMM Do, YYYY"); - }; - return axisDomain; -} - -export function calculateYAxisDomain( - axisUnits: AxisUnits, - data: TSResponse, -): AxisDomain { - const resultDatapoints = _.flatMap(data.results, result => - _.map(result.datapoints, dp => dp.value), - ); - // TODO(couchand): Remove these random datapoints when NVD3 is gone. - const allDatapoints = resultDatapoints.concat([0, 1]); - const yExtent = d3.extent(allDatapoints); - - switch (axisUnits) { - case AxisUnits.Bytes: - return ComputeByteAxisDomain(yExtent); - case AxisUnits.Duration: - return ComputeDurationAxisDomain(yExtent); - case AxisUnits.Percentage: - return ComputePercentageAxisDomain(yExtent[0], yExtent[1]); - default: - return ComputeCountAxisDomain(yExtent); - } -} - -export function calculateXAxisDomain(timeInfo: QueryTimeInfo): AxisDomain { - const xExtent: Extent = [ - util.NanoToMilli(timeInfo.start.toNumber()), - util.NanoToMilli(timeInfo.end.toNumber()), - ]; - return ComputeTimeAxisDomain(xExtent); -} - export type formattedSeries = { values: protos.cockroach.ts.tspb.ITimeSeriesDatapoint[]; key: string; @@ -359,178 +69,6 @@ export function formatMetricData( return formattedData; } -function filterInvalidDatapoints( - formattedData: formattedSeries[], - timeInfo: QueryTimeInfo, -): formattedSeries[] { - return _.map(formattedData, datum => { - // Drop any returned points at the beginning that have a lower timestamp - // than the explicitly queried domain. This works around a bug in NVD3 - // which causes the interactive guideline to highlight the wrong points. - // https://github.com/novus/nvd3/issues/1913 - const filteredValues = _.dropWhile(datum.values, dp => { - return dp.timestamp_nanos.toNumber() < timeInfo.start.toNumber(); - }); - - return { - ...datum, - values: filteredValues, - }; - }); -} - -export function InitLineChart(chart: nvd3.LineChart) { - chart - .x( - (d: protos.cockroach.ts.tspb.TimeSeriesDatapoint) => - new Date(util.NanoToMilli(d && d.timestamp_nanos.toNumber())), - ) - .y((d: protos.cockroach.ts.tspb.TimeSeriesDatapoint) => d && d.value) - .useInteractiveGuideline(true) - .showLegend(true) - .showYAxis(true) - .color(seriesPalette) - .margin(CHART_MARGINS); - chart.xAxis.showMaxMin(false); - chart.yAxis.showMaxMin(true).axisLabelDistance(-10); -} - -/** - * ConfigureLineChart renders the given NVD3 chart with the updated data. - */ -export function ConfigureLineChart( - chart: nvd3.LineChart, - svgEl: SVGElement, - metrics: React.ReactElement[], - axis: React.ReactElement, - data: TSResponse, - timeInfo: QueryTimeInfo, -) { - chart.showLegend(metrics.length > 1 && metrics.length <= MAX_LEGEND_SERIES); - let formattedData: formattedSeries[]; - let xAxisDomain, yAxisDomain: AxisDomain; - - if (data) { - const formattedRaw = formatMetricData(metrics, data); - formattedData = filterInvalidDatapoints(formattedRaw, timeInfo); - - xAxisDomain = calculateXAxisDomain(timeInfo); - yAxisDomain = calculateYAxisDomain(axis.props.units, data); - - chart.yDomain(yAxisDomain.extent); - if (axis.props.label && yAxisDomain.label) { - chart.yAxis.axisLabel(`${axis.props.label} (${yAxisDomain.label})`); - } else if (axis.props.label) { - chart.yAxis.axisLabel(axis.props.label); - } else { - chart.yAxis.axisLabel(yAxisDomain.label); - } - chart.xDomain(xAxisDomain.extent); - - chart.yAxis.tickFormat(yAxisDomain.tickFormat); - chart.interactiveLayer.tooltip.valueFormatter(yAxisDomain.guideFormat); - chart.xAxis.tickFormat(xAxisDomain.tickFormat); - chart.interactiveLayer.tooltip.headerFormatter(xAxisDomain.guideFormat); - - // always set the tick values to the lowest axis value, the highest axis - // value, and one value in between - chart.yAxis.tickValues(yAxisDomain.ticks); - chart.xAxis.tickValues(xAxisDomain.ticks); - } - try { - d3.select(svgEl) - .datum(formattedData) - .transition() - .duration(500) - .call(chart); - - // Reduce radius of circles in the legend, if present. This is done through - // d3 because it is not exposed as an option by NVD3. - d3.select(svgEl) - .selectAll("circle") - .attr("r", 3); - } catch (e) { - console.log("Error rendering graph: ", e); - } -} - -/** - * ConfigureLinkedGuide renders the linked guideline for a chart. - */ -export function ConfigureLinkedGuideline( - chart: nvd3.LineChart, - svgEl: SVGElement, - axis: React.ReactElement, - data: TSResponse, - hoverTime: moment.Moment, -) { - if (data) { - const xScale = chart.xAxis.scale(); - const yScale = chart.yAxis.scale(); - const yAxisDomain = calculateYAxisDomain(axis.props.units, data); - const yExtent: Extent = data - ? [yScale(yAxisDomain.extent[0]), yScale(yAxisDomain.extent[1])] - : [0, 1]; - updateLinkedGuideline(svgEl, xScale, yExtent, hoverTime); - } -} - -// updateLinkedGuideline is responsible for maintaining "linked" guidelines on -// all other graphs on the page; a "linked" guideline highlights the same X-axis -// coordinate on different graphs currently visible on the same page. This -// allows the user to visually correlate a single X-axis coordinate across -// multiple visible graphs. -function updateLinkedGuideline( - svgEl: SVGElement, - x: d3.scale.Linear, - yExtent: Extent, - hoverTime?: moment.Moment, -) { - // Construct a data array for use by d3; this allows us to use d3's - // "enter()/exit()" functions to cleanly add and remove the guideline. - const data = !_.isNil(hoverTime) ? [x(hoverTime.valueOf())] : []; - - // Linked guideline will be inserted inside of the "nv-wrap" element of the - // nvd3 graph. This element has several translations applied to it by nvd3 - // which allow us to easily display the linked guideline at the correct - // position. - const wrapper = d3.select(svgEl).select(".nv-wrap"); - if (wrapper.empty()) { - // In cases where no data is available for a chart, it will not have - // an "nv-wrap" element and thus should not get a linked guideline. - return; - } - - const container = wrapper - .selectAll("g.linked-guideline__container") - .data(data); - - // If there is no guideline on the currently hovered graph, data is empty - // and this exit statement will remove the linked guideline from this graph - // if it is already present. This occurs, for example, when the user moves - // the mouse off of a graph. - container.exit().remove(); - - // If there is a guideline on the currently hovered graph, this enter - // statement will add a linked guideline element to the current graph (if it - // does not already exist). - container - .enter() - .append("g") - .attr("class", "linked-guideline__container") - .append("line") - .attr("class", "linked-guideline__line"); - - // Update linked guideline (if present) to match the necessary attributes of - // the current guideline. - container - .select(".linked-guideline__line") - .attr("x1", d => d) - .attr("x2", d => d) - .attr("y1", () => yExtent[0]) - .attr("y2", () => yExtent[1]); -} - // configureUPlotLineChart constructs the uplot Options object based on // information about the metrics, axis, and data that we'd like to plot. // Most of the settings are defined as functions instead of static values