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/BUILD.bazel b/pkg/ui/workspaces/cluster-ui/BUILD.bazel index 37808e6d810a..93230f70c465 100644 --- a/pkg/ui/workspaces/cluster-ui/BUILD.bazel +++ b/pkg/ui/workspaces/cluster-ui/BUILD.bazel @@ -109,6 +109,7 @@ DEPENDENCIES = [ "@npm//source-map-loader", "@npm//style-loader", "@npm//ts-jest", + "@npm//uplot", "@npm//url-loader", "@npm//webpack", "@npm//webpack-cli", @@ -147,6 +148,7 @@ ts_project( "@npm//redux-saga", "@npm//redux-saga-test-plan", "@npm//reselect", + "@npm//uplot", ], ) diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx new file mode 100644 index 000000000000..1a3071a62876 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx @@ -0,0 +1,134 @@ +// 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 { storiesOf } from "@storybook/react"; +import { AlignedData, Options } from "uplot"; +import { BarGraphTimeSeries } from "./index"; +import { AxisUnits } from "../utils/domain"; +import { getBarsBuilder } from "./bars"; + +function generateTimestampsMillis(start: number, length: number): number[] { + return [...Array(length)].map( + (_, idx): number => (60 * 60 * idx + start) * 1000, + ); +} + +function genValuesInRange(range: [number, number], length: number): number[] { + return [...Array(length)].map((): number => + Math.random() > 0.1 ? Math.random() * (range[1] - range[0]) + range[0] : 0, + ); +} + +const mockData: AlignedData = [ + generateTimestampsMillis(1546300800, 20), + genValuesInRange([0, 100], 20), + genValuesInRange([0, 10000], 20), + genValuesInRange([0, 100000], 20), +]; +const mockDataSingle: AlignedData = [[1654115121], [0], [1], [2]]; +const mockDataDuration: AlignedData = [ + generateTimestampsMillis(1546300800, 20), + genValuesInRange([0, 1e7], 20), + genValuesInRange([0, 1e7], 20), + genValuesInRange([0, 1e7], 20), +]; + +const mockOpts: Partial = { + axes: [{}, { label: "values" }], + series: [ + {}, + { + label: "bar 1", + }, + { + label: "bar 2", + }, + { + label: "bar 3", + }, + ], +}; + +storiesOf("BarGraphTimeSeries", module) + .add("with stacked multi-series", () => { + return ( + This is an example stacked bar graph axis unit = Count. + } + yAxisUnits={AxisUnits.Count} + /> + ); + }) + .add("with single series", () => { + const data: AlignedData = [ + generateTimestampsMillis(1546300800, 50), + genValuesInRange([0, 1], 50), + ]; + const opts = { + series: [{}, { label: "bar", paths: getBarsBuilder(0.8, 20) }], + legend: { show: false }, + axes: [{}, { label: "mock" }], + }; + return ( + This is an example bar graph with axis unit = percent. + } + yAxisUnits={AxisUnits.Percentage} + /> + ); + }) + .add("with single stacked multi-series", () => { + return ( + This is an example stacked bar graph axis unit = Count. + } + yAxisUnits={AxisUnits.Count} + /> + ); + }) + .add("with duration stacked multi-series", () => { + return ( + This is an example stacked bar graph axis unit = Duration. + } + yAxisUnits={AxisUnits.Duration} + /> + ); + }) + .add("with bytes stacked multi-series", () => { + return ( + This is an example stacked bar graph axis unit = Bytes. + } + yAxisUnits={AxisUnits.Bytes} + /> + ); + }); diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bargraph.module.scss b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bargraph.module.scss new file mode 100644 index 000000000000..7562b5d96ee7 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bargraph.module.scss @@ -0,0 +1,19 @@ +:global { + @import "uplot/dist/uPlot.min"; +} + +.bargraph { + height: 100%; + :global(.uplot) { + display: flex; + flex-direction: column; + :global(.u-legend) { + text-align: left; + font-size: 10px; + margin-top: 20px; + z-index: 1; + width: fit-content; + padding: 10px; + } + } +} diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts new file mode 100644 index 000000000000..14e4f0c9b410 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts @@ -0,0 +1,200 @@ +// 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 { merge } from "lodash"; +import uPlot, { Options, Band, AlignedData } from "uplot"; +import { AxisUnits, AxisDomain } from "../utils/domain"; +import { barTooltipPlugin } from "./plugins"; + +const seriesPalette = [ + "#475872", + "#FFCD02", + "#F16969", + "#4E9FD1", + "#49D990", + "#D77FBF", + "#87326D", + "#A3415B", + "#B59153", + "#C9DB6D", + "#203D9B", + "#748BF2", + "#91C8F2", + "#FF9696", + "#EF843C", + "#DCCD4B", +]; + +// Aggregate the series. +export function stack( + data: AlignedData, + omit: (i: number) => boolean, +): AlignedData { + const stackedData = []; + const xAxisLength = data[0].length; + const accum = Array(xAxisLength); + accum.fill(0); + + for (let i = 1; i < data.length; i++) + stackedData.push( + omit(i) ? data[i] : data[i].map((v, i) => (accum[i] += v)), + ); + + return [data[0]].concat(stackedData) as AlignedData; +} + +function getStackedBands( + unstackedData: AlignedData, + omit: (i: number) => boolean, +): Band[] { + const bands = []; + + for (let i = 1; i < unstackedData.length; i++) + !omit(i) && + bands.push({ + series: [ + unstackedData.findIndex( + (_series, seriesIdx) => seriesIdx > i && !omit(seriesIdx), + ), + i, + ] as Band.Bounds, + }); + + return bands.filter(b => b.series[1] > -1); +} + +const { bars } = uPlot.paths; + +export const getBarsBuilder = ( + barWidthFactor: number, // percentage of space allocated to bar in the range [0, 1] + maxWidth: number, + minWidth = 10, + align: 0 | 1 | -1 = 1, // -1 = left aligned, 0 = center, 1 = right aligned +): uPlot.Series.PathBuilder => { + return bars({ size: [barWidthFactor, maxWidth, minWidth], align }); +}; + +export const getStackedBarOpts = ( + unstackedData: AlignedData, + userOptions: Partial, + xAxisDomain: AxisDomain, + yAxisDomain: AxisDomain, + yyAxisUnits: AxisUnits, + colourPalette = seriesPalette, +): Options => { + const options = getBarChartOpts( + userOptions, + xAxisDomain, + yAxisDomain, + yyAxisUnits, + colourPalette, + ); + + options.bands = getStackedBands(unstackedData, () => false); + + options.series.forEach(s => { + s.value = (_u, _v, si, i) => unstackedData[si][i]; + + s.points = s.points || { show: false }; + + // Scan raw unstacked data to return only real points. + s.points.filter = (_u, seriesIdx, show) => { + if (show) { + const pts: number[] = []; + unstackedData[seriesIdx].forEach((v, i) => { + v && pts.push(i); + }); + return pts; + } + }; + }); + + options.cursor = options.cursor || {}; + options.cursor.dataIdx = (_u, seriesIdx, closestIdx, _xValue) => { + return unstackedData[seriesIdx][closestIdx] == null ? null : closestIdx; + }; + + options.hooks = options.hooks || {}; + options.hooks.setSeries = options.hooks.setSeries || []; + options.hooks.setSeries.push(u => { + // Restack on toggle. + const bands = getStackedBands(unstackedData, i => !u.series[i].show); + const data = stack(unstackedData, i => !u.series[i].show); + u.delBand(null); // Clear bands. + bands.forEach(b => u.addBand(b)); + u.setData(data); + }); + + return options; +}; + +export const getBarChartOpts = ( + userOptions: Partial, + xAxisDomain: AxisDomain, + yAxisDomain: AxisDomain, + yAxisUnits: AxisUnits, + colourPalette = seriesPalette, +): Options => { + const { series, ...providedOpts } = userOptions; + const defaultBars = getBarsBuilder(0.9, 80); + + const opts: Options = { + // Default width and height. + width: 947, + height: 300, + ms: 1, // Interpret timestamps in milliseconds. + legend: { + isolate: true, // Isolate series on click. + live: false, + }, + scales: { + x: { + range: () => [xAxisDomain.extent[0], xAxisDomain.extent[1]], + }, + }, + axes: [ + { + values: (_u, vals) => vals.map(xAxisDomain.tickFormat), + splits: () => xAxisDomain.ticks, + }, + { + values: (_u, vals) => vals.map(yAxisDomain.tickFormat), + splits: () => [ + yAxisDomain.extent[0], + ...yAxisDomain.ticks, + yAxisDomain.extent[1], + ], + scale: "yAxis", + }, + ], + series: [ + { + value: (_u, millis) => xAxisDomain.guideFormat(millis), + }, + ...series.slice(1).map((s, i) => ({ + fill: colourPalette[i % colourPalette.length], + stroke: colourPalette[i % colourPalette.length], + width: 2, + paths: defaultBars, + points: { show: false }, + scale: "yAxis", + ...s, + })), + ], + plugins: [barTooltipPlugin(yAxisUnits)], + }; + + const combinedOpts = merge(opts, providedOpts); + + // Set y-axis label with units. + combinedOpts.axes[1].label += ` ${yAxisDomain.label}`; + + return combinedOpts; +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/index.tsx b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/index.tsx new file mode 100644 index 000000000000..12280349ef89 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/index.tsx @@ -0,0 +1,99 @@ +// 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, { useEffect, useRef } from "react"; +import classNames from "classnames/bind"; +import { getStackedBarOpts, stack } from "./bars"; +import uPlot, { AlignedData } from "uplot"; +import styles from "./bargraph.module.scss"; +import { Visualization } from "../visualization"; +import { + AxisUnits, + calculateXAxisDomainBarChart, + calculateYAxisDomain, +} from "../utils/domain"; +import { Options } from "uplot"; + +const cx = classNames.bind(styles); + +export type BarGraphTimeSeriesProps = { + alignedData?: AlignedData; + colourPalette?: string[]; // Series colour palette. + preCalcGraphSize?: boolean; + title: string; + tooltip?: React.ReactNode; + uPlotOptions: Partial; + yAxisUnits: AxisUnits; +}; + +// Currently this component only supports stacked multi-series bars. +export const BarGraphTimeSeries: React.FC = ({ + alignedData, + colourPalette, + preCalcGraphSize = true, + title, + tooltip, + uPlotOptions, + yAxisUnits, +}) => { + const graphRef = useRef(null); + const samplingIntervalMillis = + alignedData[0].length > 1 ? alignedData[0][1] - alignedData[0][0] : 1e3; + + useEffect(() => { + if (!alignedData) return; + + const xAxisDomain = calculateXAxisDomainBarChart( + alignedData[0][0], // startMillis + alignedData[0][alignedData[0].length - 1], // endMillis + samplingIntervalMillis, + ); + + const stackedData = stack(alignedData, () => false); + + const allYDomainPoints: number[] = []; + stackedData.slice(1).forEach(points => allYDomainPoints.push(...points)); + const yAxisDomain = calculateYAxisDomain(yAxisUnits, allYDomainPoints); + + const opts = getStackedBarOpts( + alignedData, + uPlotOptions, + xAxisDomain, + yAxisDomain, + yAxisUnits, + colourPalette, + ); + + const plot = new uPlot(opts, stackedData, graphRef.current); + + return () => { + plot?.destroy(); + }; + }, [ + alignedData, + colourPalette, + uPlotOptions, + yAxisUnits, + samplingIntervalMillis, + ]); + + return ( + +
+
+
+ + ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/plugins.ts b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/plugins.ts new file mode 100644 index 000000000000..aad3690adeed --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/plugins.ts @@ -0,0 +1,170 @@ +// 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 uPlot, { Plugin } from "uplot"; +import { AxisUnits, formatTimeStamp } from "../utils/domain"; +import { Bytes, Duration, Percentage, Count } from "../../util"; + +// Fallback color for series stroke if one is not defined. +const DEFAULT_STROKE = "#7e89a9"; + +// Generate a series legend within the provided div showing the data points +// relative to the cursor position. +const generateSeriesLegend = ( + uPlot: uPlot, + seriesLegend: HTMLDivElement, + yAxisUnits: AxisUnits, +) => { + // idx is the closest data index to the cursor position. + const { idx } = uPlot.cursor; + + if (idx === undefined || idx === null) { + return; + } + + // remove all previous child nodes + seriesLegend.innerHTML = ""; + + // Generate new child nodes. + uPlot.series.forEach((series: uPlot.Series, index: number) => { + if (index === 0 || series.show === false) { + // Skip the series for x axis or if series is hidden. + return; + } + + // series.stroke can be either a function that returns a canvas stroke + // value, or a function returning a stroke value. + const strokeColor = + typeof series.stroke === "function" + ? series.stroke(uPlot, idx) + : series.stroke; + + const container = document.createElement("div"); + container.style.display = "flex"; + container.style.alignItems = "center"; + + const colorBox = document.createElement("span"); + colorBox.style.height = "12px"; + colorBox.style.width = "12px"; + colorBox.style.background = String(strokeColor || DEFAULT_STROKE); + colorBox.style.display = "inline-block"; + colorBox.style.marginRight = "12px"; + + const label = document.createElement("span"); + label.textContent = series.label || ""; + + const dataValue = uPlot.data[index][idx]; + const value = document.createElement("div"); + value.style.textAlign = "right"; + value.style.flex = "1"; + value.style.fontFamily = "'Source Sans Pro', sans-serif"; + value.textContent = + series.value instanceof Function && dataValue + ? getFormattedValue( + Number(series.value(uPlot, dataValue, index, idx)), + yAxisUnits, + ) + : getFormattedValue(dataValue, yAxisUnits); + + container.appendChild(colorBox); + container.appendChild(label); + container.appendChild(value); + + seriesLegend.appendChild(container); + }); +}; + +// Formats the value according to its unit. +function getFormattedValue(value: number, yAxisUnits: AxisUnits): string { + switch (yAxisUnits) { + case AxisUnits.Bytes: + return Bytes(value); + case AxisUnits.Duration: + return Duration(value); + case AxisUnits.Percentage: + return Percentage(value, 1); + default: + return Count(value); + } +} + +// Tooltip legend plugin for bar charts. +export function barTooltipPlugin(yAxis: AxisUnits): Plugin { + const cursorToolTip = { + tooltip: document.createElement("div"), + timeStamp: document.createElement("div"), + seriesLegend: document.createElement("div"), + }; + + function setCursor(u: uPlot) { + const { tooltip, timeStamp, seriesLegend } = cursorToolTip; + const { left = 0, top = 0 } = u.cursor; + + // get the current timestamp from the x axis and formatting as + // the Tooltip header. + const closestDataPointTimeMillis = u.data[0][u.posToIdx(left)]; + timeStamp.textContent = formatTimeStamp(closestDataPointTimeMillis); + + // Generating the series legend based on current state of µPlot + generateSeriesLegend(u, seriesLegend, yAxis); + + // set the position of the Tooltip. Adjusting the tooltip away from the + // cursor for readability. + tooltip.style.left = `${left + 20}px`; + tooltip.style.top = `${top - 10}px`; + + if (tooltip.style.display === "none") { + tooltip.style.display = ""; + } + } + + function ready(u: uPlot) { + const plot = u.root.querySelector(".u-over"); + const { tooltip } = cursorToolTip; + + plot?.addEventListener("mouseleave", () => { + tooltip.style.display = "none"; + }); + } + + function init(u: uPlot) { + const plot = u.root.querySelector(".u-over"); + const { tooltip, timeStamp, seriesLegend } = cursorToolTip; + tooltip.style.display = "none"; + tooltip.style.pointerEvents = "none"; + tooltip.style.position = "absolute"; + tooltip.style.padding = "0 16px 16px"; + tooltip.style.minWidth = "230px"; + tooltip.style.background = "#fff"; + tooltip.style.borderRadius = "5px"; + tooltip.style.boxShadow = "0px 7px 13px rgba(71, 88, 114, 0.3)"; + tooltip.style.zIndex = "100"; + tooltip.style.whiteSpace = "nowrap"; + + // Set timeStamp. + timeStamp.textContent = "time"; + timeStamp.style.paddingTop = "12px"; + timeStamp.style.marginBottom = "16px"; + tooltip.appendChild(timeStamp); + + // appending seriesLegend empty. Content will be generated on mousemove. + tooltip.appendChild(seriesLegend); + + plot?.appendChild(tooltip); + } + + return { + hooks: { + init, + ready, + setCursor, + }, + }; +} 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..963d09dc8069 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts @@ -0,0 +1,355 @@ +// 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 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, +]; + +// converts a number from raw format to abbreviation k,m,b,t +// e.g. 1500000 -> 1.5m +// @params: num = number to abbreviate, fixed = max number of decimals +const abbreviateNumber = (num: number, fixedDecimals: number) => { + if (num === 0) { + return "0"; + } + + // number of decimal places to show + const fixed = fixedDecimals < 0 ? 0 : fixedDecimals; + + const parts = num.toPrecision(2).split("e"); + + // get power: floor at decimals, ceiling at trillions + const powerIdx = + parts.length === 1 + ? 0 + : Math.floor(Math.min(Number(parts[1].slice(1)), 14) / 3); + + // then divide by power + let numAbbrev = Number( + powerIdx < 1 + ? num.toFixed(fixed) + : (num / Math.pow(10, powerIdx * 3)).toFixed(fixed), + ); + + numAbbrev = numAbbrev < 0 ? numAbbrev : Math.abs(numAbbrev); // enforce -0 is 0 + + const abbreviatedString = numAbbrev + ["", "k", "m", "b", "t"][powerIdx]; // append power + + return abbreviatedString; +}; + +const formatPercentage = (n: number, fractionDigits: number) => { + return `${(n * 100).toFixed(fractionDigits)}%`; +}; + +// 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 = (n: number) => n.toFixed(1); + } else { + unitFormat = (n: number) => abbreviateNumber(n, 4); + } + 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 = (n: number) => abbreviateNumber(n, 4); + const decimalFormat = (n: number) => n.toFixed(4); + 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 = (n: number) => formatPercentage(n, 1); + axisDomain.guideFormat = (n: number) => formatPercentage(n, 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): 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 = (d: Date) => moment.utc(d).format("H:mm"); + } else { + tickDateFormatter = (d: Date) => moment.utc(d).format("MM/DD H:mm"); + } + 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 = [ + Math.min(...allDatapoints), + Math.max(...allDatapoints), + ] as Extent; + + 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, +): AxisDomain { + return ComputeTimeAxisDomain([startMillis, endMillis] as Extent); +} + +export function calculateXAxisDomainBarChart( + startMillis: number, + endMillis: number, + samplingIntervalMillis: number, +): AxisDomain { + // For bar charts, we want to render past endMillis to fully render the + // last bar. We should extend the x axis to the next sampling interval. + return ComputeTimeAxisDomain([ + startMillis, + endMillis + samplingIntervalMillis, + ] as Extent); +} 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 f1f956969afd..758f9b9b3e70 100644 --- a/pkg/ui/workspaces/cluster-ui/src/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/index.ts @@ -45,3 +45,4 @@ export * from "./text"; export { util, api }; export * from "./sessions"; export * from "./timeScaleDropdown"; +export * from "./graphs"; diff --git a/pkg/ui/workspaces/cluster-ui/src/util/format.ts b/pkg/ui/workspaces/cluster-ui/src/util/format.ts index b5fb75862ae6..5fa3feeae042 100644 --- a/pkg/ui/workspaces/cluster-ui/src/util/format.ts +++ b/pkg/ui/workspaces/cluster-ui/src/util/format.ts @@ -23,6 +23,7 @@ export const byteUnits: string[] = [ "YiB", ]; export const durationUnits: string[] = ["ns", "µs", "ms", "s"]; +export const countUnits: string[] = ["", "k", "m", "b"]; interface UnitValue { value: number; @@ -38,7 +39,7 @@ export function ComputePrefixExponent( value: number, prefixMultiple: number, prefixList: string[], -) { +): number { // Compute the metric prefix that will be used to label the axis. let maxUnits = Math.abs(value); let prefixScale: number; @@ -102,7 +103,7 @@ export function Bytes(bytes: number): string { * Cast bytes to provided scale units */ // tslint:disable-next-line: variable-name -export const BytesFitScale = (scale: string) => (bytes: number) => { +export const BytesFitScale = (scale: string) => (bytes: number): string => { if (!bytes) { return `0.00 ${scale}`; } @@ -156,7 +157,9 @@ export function Duration(nanoseconds: number): string { * Cast nanoseconds to provided scale units */ // tslint:disable-next-line: variable-name -export const DurationFitScale = (scale: string) => (nanoseconds: number) => { +export const DurationFitScale = (scale: string) => ( + nanoseconds: number, +): string => { if (!nanoseconds) { return `0.00 ${scale}`; } @@ -166,7 +169,7 @@ export const DurationFitScale = (scale: string) => (nanoseconds: number) => { export const DATE_FORMAT = "MMM DD, YYYY [at] H:mm"; -export function RenderCount(yesCount: Long, totalCount: Long) { +export function RenderCount(yesCount: Long, totalCount: Long): string { if (longToInt(yesCount) == 0) { return "No"; } @@ -176,3 +179,25 @@ export function RenderCount(yesCount: Long, totalCount: Long) { const noCount = longToInt(totalCount) - longToInt(yesCount); return `${longToInt(yesCount)} Yes / ${noCount} No`; } + +/** + * ComputeCountScale calculates an appropriate scale factor and unit to use + * to display a given count value, without actually converting the value. + */ +function ComputeCountScale(count: number): UnitValue { + const scale = ComputePrefixExponent(count, 1000, countUnits); + return { + value: Math.pow(1000, scale), + units: countUnits[scale], + }; +} + +/** + * Count creates a string representation for a count. + */ +export function Count(count: number): string { + const scale = ComputeCountScale(count); + const unitVal = count / scale.value; + const fractionDigits = Number.isInteger(unitVal) ? 0 : 1; + return unitVal.toFixed(fractionDigits) + " " + scale.units; +} 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 1b305d47efcb..f6f74bfb64cd 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 @@ -8,26 +8,16 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import d3 from "d3"; import React from "react"; import moment from "moment"; -import * as nvd3 from "nvd3"; import { createSelector } from "reselect"; -import * as protos from "src/js/protos"; import { hoverOff, hoverOn, HoverState } from "src/redux/hover"; import { findChildrenOfType } from "src/util/find"; import { - AxisDomain, - calculateXAxisDomain, - calculateYAxisDomain, - CHART_MARGINS, - ConfigureLineChart, - ConfigureLinkedGuideline, configureUPlotLineChart, formatMetricData, formattedSeries, - InitLineChart, } from "src/views/cluster/util/graphs"; import { Axis, @@ -35,20 +25,25 @@ import { Metric, MetricProps, MetricsDataComponentProps, - QueryTimeInfo, } from "src/views/shared/components/metricQuery"; -import Visualization from "src/views/cluster/components/visualization"; -import { TimeScale, util } 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"; - -type TSResponse = protos.cockroach.ts.tspb.TimeSeriesQueryResponse; +import _ from "lodash"; export interface LineGraphProps extends MetricsDataComponentProps { title?: string; @@ -62,253 +57,6 @@ export interface LineGraphProps extends MetricsDataComponentProps { preCalcGraphSize?: boolean; } -interface LineGraphStateOld { - lastData?: TSResponse; - lastTimeInfo?: QueryTimeInfo; -} - -/** - * LineGraph displays queried metrics in a line graph. It currently only - * supports a single Y-axis, but multiple metrics can be graphed on the same - * axis. - */ -export class LineGraphOld extends React.Component< - LineGraphProps, - LineGraphStateOld -> { - // The SVG Element reference in the DOM used to render the graph. - graphEl: React.RefObject = React.createRef(); - - // A configured NVD3 chart used to render the chart. - chart: nvd3.LineChart; - - axis = createSelector( - (props: { children?: React.ReactNode }) => props.children, - children => { - const axes: React.ReactElement[] = findChildrenOfType( - children as any, - Axis, - ); - if (axes.length === 0) { - console.warn( - "LineGraph requires the specification of at least one axis.", - ); - return null; - } - if (axes.length > 1) { - console.warn( - "LineGraph currently only supports a single axis; ignoring additional axes.", - ); - } - return axes[0]; - }, - ); - - metrics = createSelector( - (props: { children?: React.ReactNode }) => props.children, - children => { - return findChildrenOfType(children as any, Metric) as React.ReactElement< - MetricProps - >[]; - }, - ); - - initChart() { - const axis = this.axis(this.props); - if (!axis) { - // TODO: Figure out this error condition. - return; - } - - this.chart = nvd3.models.lineChart(); - InitLineChart(this.chart); - - if (axis.props.range) { - this.chart.forceY(axis.props.range); - } - } - - mouseMove = (e: any) => { - // TODO(couchand): handle the following cases: - // - first series is missing data points - // - series are missing data points at different timestamps - const datapoints = this.props.data.results[0].datapoints; - const timeScale = this.chart.xAxis.scale(); - - // To get the x-coordinate within the chart we subtract the left side of the SVG - // element and the left side margin. - const x = - e.clientX - - this.graphEl.current.getBoundingClientRect().left - - CHART_MARGINS.left; - // Find the time value of the coordinate by asking the scale to invert the value. - const t = Math.floor(timeScale.invert(x)); - - // Find which data point is closest to the x-coordinate. - let result: moment.Moment; - if (datapoints.length) { - const series: any = datapoints.map((d: any) => - util.NanoToMilli(d.timestamp_nanos.toNumber()), - ); - - const right = d3.bisectRight(series, t); - const left = right - 1; - - let index = 0; - - if (right >= series.length) { - // We're hovering over the rightmost point. - index = left; - } else if (left < 0) { - // We're hovering over the leftmost point. - index = right; - } else { - // The general case: we're hovering somewhere over the middle. - const leftDistance = t - series[left]; - const rightDistance = series[right] - t; - - index = leftDistance < rightDistance ? left : right; - } - - result = moment(new Date(series[index])); - } - - if (!this.props.hoverState || !result) { - return; - } - - // Only dispatch if we have something to change to avoid action spamming. - if ( - this.props.hoverState.hoverChart !== this.props.title || - !result.isSame(this.props.hoverState.hoverTime) - ) { - this.props.hoverOn({ - hoverChart: this.props.title, - hoverTime: result, - }); - } - }; - - mouseLeave = () => { - this.props.hoverOff(); - }; - - drawChart = () => { - // If the document is not visible (e.g. if the window is minimized) we don't - // attempt to redraw the chart. Redrawing the chart uses - // requestAnimationFrame, which isn't called when the tab is in the - // background, and is then apparently queued up and called en masse when the - // tab re-enters the foreground. This check prevents the issue in #8896 - // where switching to a tab with the graphs page open that had been in the - // background caused the UI to run out of memory and either lag or crash. - // NOTE: This might not work on Android: - // http://caniuse.com/#feat=pagevisibility - if (!document.hidden) { - const metrics = this.metrics(this.props); - const axis = this.axis(this.props); - if (!axis) { - return; - } - - ConfigureLineChart( - this.chart, - this.graphEl.current, - metrics, - axis, - this.props.data, - this.props.timeInfo, - ); - } - }; - - drawLine = () => { - if (!document.hidden) { - let hoverTime: moment.Moment; - if (this.props.hoverState) { - const { currentlyHovering, hoverChart } = this.props.hoverState; - // Don't draw the linked guideline on the hovered chart, NVD3 does that for us. - if (currentlyHovering && hoverChart !== this.props.title) { - hoverTime = this.props.hoverState.hoverTime; - } - } - - const axis = this.axis(this.props); - ConfigureLinkedGuideline( - this.chart, - this.graphEl.current, - axis, - this.props.data, - hoverTime, - ); - } - }; - - constructor(props: any) { - super(props); - this.state = { - lastData: null, - lastTimeInfo: null, - }; - } - - componentDidMount() { - this.initChart(); - this.drawChart(); - this.drawLine(); - // NOTE: This might not work on Android: - // http://caniuse.com/#feat=pagevisibility - // TODO (maxlang): Check if this element is visible based on scroll state. - document.addEventListener("visibilitychange", this.drawChart); - } - - componentWillUnmount() { - document.removeEventListener("visibilitychange", this.drawChart); - } - - componentDidUpdate() { - if ( - this.props.data !== this.state.lastData || - this.props.timeInfo !== this.state.lastTimeInfo - ) { - this.drawChart(); - this.setState({ - lastData: this.props.data, - lastTimeInfo: this.props.timeInfo, - }); - } - this.drawLine(); - } - - render() { - const { title, subtitle, tooltip, data } = this.props; - - let hoverProps: Partial> = {}; - if (this.props.hoverOn) { - hoverProps = { - onMouseMove: this.mouseMove, - onMouseLeave: this.mouseLeave, - }; - } - - return ( - -
- -
-
- ); - } -} - // touPlot formats our timeseries data into the format // uPlot expects which is a 2-dimensional array where the // first array contains the x-values (time). @@ -522,8 +270,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.spec.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/linegraph.spec.tsx index 4f5fbcbfde87..f31008d51fdb 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/linegraph.spec.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/components/linegraph/linegraph.spec.tsx @@ -23,8 +23,9 @@ import { Axis } from "src/views/shared/components/metricQuery"; import { calculateXAxisDomain, calculateYAxisDomain, - configureUPlotLineChart, -} from "src/views/cluster/util/graphs"; + util, +} from "@cockroachlabs/cluster-ui"; +import { configureUPlotLineChart } from "src/views/cluster/util/graphs"; import Long from "long"; describe("", function() { @@ -143,13 +144,20 @@ describe("", function() { _store: { validated: false }, }; const mockData: protos.cockroach.ts.tspb.TimeSeriesQueryResponse = new protos.cockroach.ts.tspb.TimeSeriesQueryResponse(); + const resultDatapoints = _.flatMap(mockData.results, result => + result.datapoints.map(dp => dp.value), + ); const mockOptions = configureUPlotLineChart( mockMetrics, mockAxis, mockData, instance.setNewTimeRange, - () => calculateYAxisDomain(0, mockData), - () => calculateXAxisDomain(mockProps.timeInfo), + () => calculateYAxisDomain(0, resultDatapoints), + () => + calculateXAxisDomain( + util.NanoToMilli(mockProps.timeInfo.start.toNumber()), + util.NanoToMilli(mockProps.timeInfo.end.toNumber()), + ), ); instance.u = new uPlot(mockOptions); const setDataSpy = sinon.spy(instance.u, "setData"); 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/containers/nodeGraphs/dashboards/changefeeds.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/changefeeds.tsx index d2596f96be9d..077f0c42e18e 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/changefeeds.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/changefeeds.tsx @@ -11,11 +11,8 @@ import React from "react"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps } from "./dashboardUtils"; diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/distributed.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/distributed.tsx index 2537a29673af..35da36259e34 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/distributed.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/distributed.tsx @@ -12,11 +12,8 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps, nodeDisplayName } from "./dashboardUtils"; diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/hardware.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/hardware.tsx index 333e9386c390..11fa9dd27a9a 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/hardware.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/hardware.tsx @@ -11,11 +11,7 @@ import React from "react"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; import { GraphDashboardProps, @@ -23,6 +19,7 @@ import { storeIDsForNode, } from "./dashboardUtils"; import { AvailableDiscCapacityGraphTooltip } from "src/views/cluster/containers/nodeGraphs/dashboards/graphTooltips"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; // TODO(vilterp): tooltips diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overload.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overload.tsx index 550fe549a231..efd5abb1c799 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overload.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overload.tsx @@ -12,11 +12,8 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps, diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overview.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overview.tsx index a995158ac268..8b246e5d4703 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overview.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/overview.tsx @@ -12,11 +12,7 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; import { GraphDashboardProps, @@ -24,6 +20,7 @@ import { storeIDsForNode, } from "./dashboardUtils"; import { CapacityGraphTooltip } from "src/views/cluster/containers/nodeGraphs/dashboards/graphTooltips"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; export default function(props: GraphDashboardProps) { const { diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/queues.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/queues.tsx index e1b79fd67f1f..97aaf7dd42a8 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/queues.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/queues.tsx @@ -11,11 +11,8 @@ import React from "react"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps } from "./dashboardUtils"; diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/replication.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/replication.tsx index cf18b4f0f329..344461fc39bd 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/replication.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/replication.tsx @@ -12,11 +12,8 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Axis, - AxisUnits, - Metric, -} from "src/views/shared/components/metricQuery"; +import { Axis, Metric } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps, diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/runtime.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/runtime.tsx index 9afbcd22b331..b67af38e211d 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/runtime.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/runtime.tsx @@ -12,11 +12,8 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps, nodeDisplayName } from "./dashboardUtils"; diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/sql.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/sql.tsx index e84968df338a..e5f46ea9a08d 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/sql.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/sql.tsx @@ -12,11 +12,8 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps, nodeDisplayName } from "./dashboardUtils"; import { diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/storage.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/storage.tsx index a287cc235dea..d1161bb07f5a 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/storage.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/storage.tsx @@ -12,11 +12,7 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; import { GraphDashboardProps, @@ -27,6 +23,7 @@ import { CapacityGraphTooltip, LiveBytesGraphTooltip, } from "src/views/cluster/containers/nodeGraphs/dashboards/graphTooltips"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; export default function(props: GraphDashboardProps) { const { diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/ttl.tsx b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/ttl.tsx index f3404be46a72..8501545630c9 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/ttl.tsx +++ b/pkg/ui/workspaces/db-console/src/views/cluster/containers/nodeGraphs/dashboards/ttl.tsx @@ -12,11 +12,8 @@ import React from "react"; import _ from "lodash"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps } from "./dashboardUtils"; diff --git a/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.spec.ts b/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.spec.ts index cf342923664b..559bb8f74525 100644 --- a/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.spec.ts +++ b/pkg/ui/workspaces/db-console/src/views/cluster/util/graphs.spec.ts @@ -8,7 +8,7 @@ // by the Apache License, Version 2.0, included in the file // licenses/APL.txt. -import { ComputeByteAxisDomain } from "oss/src/views/cluster/util/graphs"; +import { ComputeByteAxisDomain } from "@cockroachlabs/cluster-ui"; import assert from "assert"; describe("ComputeByteAxisDomain", () => { @@ -36,7 +36,7 @@ describe("ComputeByteAxisDomain", () => { // Now trying new domain with old ticks // The `m` in this case is "milli" meaning `10^-3` // see: https://github.com/d3/d3-format#locale_formatPrefix - assert.equal(newDomain.tickFormat(256000), "244.140625m"); + assert.equal(newDomain.tickFormat(256000), "0.2441"); assert.equal(newDomain.guideFormat(256000), "0.24 MiB"); }); }); 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 diff --git a/pkg/ui/workspaces/db-console/src/views/devtools/containers/raftMessages/messages.tsx b/pkg/ui/workspaces/db-console/src/views/devtools/containers/raftMessages/messages.tsx index 46efe1c7ec52..fd687143e0b6 100644 --- a/pkg/ui/workspaces/db-console/src/views/devtools/containers/raftMessages/messages.tsx +++ b/pkg/ui/workspaces/db-console/src/views/devtools/containers/raftMessages/messages.tsx @@ -11,11 +11,8 @@ import React from "react"; import { LineGraph } from "src/views/cluster/components/linegraph"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { GraphDashboardProps } from "src/views/cluster/containers/nodeGraphs/dashboards/dashboardUtils"; diff --git a/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/customMetric.tsx b/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/customMetric.tsx index 7a8ac62bf860..136b162044cf 100644 --- a/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/customMetric.tsx +++ b/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/customMetric.tsx @@ -13,7 +13,7 @@ import * as React from "react"; import Select from "react-select"; import * as protos from "src/js/protos"; -import { AxisUnits } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import Dropdown, { DropdownOption } from "src/views/shared/components/dropdown"; import { MetricOption } from "./metricOption"; diff --git a/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/index.tsx b/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/index.tsx index 9a83895262ef..cdf5ccc0516a 100644 --- a/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/index.tsx +++ b/pkg/ui/workspaces/db-console/src/views/reports/containers/customChart/index.tsx @@ -22,11 +22,8 @@ import { LineGraph } from "src/views/cluster/components/linegraph"; import TimeScaleDropdown from "src/views/cluster/containers/timeScaleDropdownWithSearchParams"; import { DropdownOption } from "src/views/shared/components/dropdown"; import { MetricsDataProvider } from "src/views/shared/containers/metricDataProvider"; -import { - Metric, - Axis, - AxisUnits, -} from "src/views/shared/components/metricQuery"; +import { Metric, Axis } from "src/views/shared/components/metricQuery"; +import { AxisUnits } from "@cockroachlabs/cluster-ui"; import { PageConfig, PageConfigItem, diff --git a/pkg/ui/workspaces/db-console/src/views/shared/components/metricQuery/index.tsx b/pkg/ui/workspaces/db-console/src/views/shared/components/metricQuery/index.tsx index 77d22c1389b8..5b2fdafeea22 100644 --- a/pkg/ui/workspaces/db-console/src/views/shared/components/metricQuery/index.tsx +++ b/pkg/ui/workspaces/db-console/src/views/shared/components/metricQuery/index.tsx @@ -39,29 +39,7 @@ import Long from "long"; import { History } from "history"; import { TimeWindow, TimeScale } from "src/redux/timeScale"; import { PayloadAction } from "src/interfaces/action"; - -/** - * 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, -} +import { AxisUnits } from "@cockroachlabs/cluster-ui"; /** * AxisProps represents the properties of an Axis being specified as part of a