From a1b6b507e55e442f0f443b9506a138e53fedcfa3 Mon Sep 17 00:00:00 2001 From: Christopher Henn Date: Fri, 22 Jun 2018 13:23:34 -0700 Subject: [PATCH] Show stacked histogram in log viewer --- ui/package.json | 2 + ui/src/logs/actions/index.ts | 55 ++-- ui/src/logs/components/LogViewerChart.tsx | 33 --- .../{LogsGraph.tsx => LogsGraphContainer.tsx} | 0 ui/src/logs/containers/LogsPage.tsx | 55 ++-- ui/src/logs/reducers/index.ts | 8 +- ui/src/logs/utils/index.ts | 41 ++- ui/src/shared/components/HistogramChart.tsx | 245 ++++++++++++++++++ .../shared/components/HistogramChartAxes.tsx | 99 +++++++ .../shared/components/HistogramChartBars.tsx | 131 ++++++++++ .../components/HistogramChartSkeleton.tsx | 37 +++ .../components/HistogramChartTooltip.tsx | 42 +++ ui/src/shared/components/XBrush.tsx | 163 ++++++++++++ ui/src/style/chronograf.scss | 1 + ui/src/style/components/histogram-chart.scss | 99 +++++++ ui/src/style/pages/logs-viewer.scss | 111 +++++++- ui/src/types/histogram.ts | 24 ++ ui/src/types/logs.ts | 9 +- ui/src/utils/extentBy.tsx | 25 ++ ui/yarn.lock | 53 ++++ 20 files changed, 1145 insertions(+), 88 deletions(-) delete mode 100644 ui/src/logs/components/LogViewerChart.tsx rename ui/src/logs/components/{LogsGraph.tsx => LogsGraphContainer.tsx} (100%) create mode 100644 ui/src/shared/components/HistogramChart.tsx create mode 100644 ui/src/shared/components/HistogramChartAxes.tsx create mode 100644 ui/src/shared/components/HistogramChartBars.tsx create mode 100644 ui/src/shared/components/HistogramChartSkeleton.tsx create mode 100644 ui/src/shared/components/HistogramChartTooltip.tsx create mode 100644 ui/src/shared/components/XBrush.tsx create mode 100644 ui/src/style/components/histogram-chart.scss create mode 100644 ui/src/types/histogram.ts create mode 100644 ui/src/utils/extentBy.tsx diff --git a/ui/package.json b/ui/package.json index cd617c0e3..41376536c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -37,6 +37,7 @@ "@types/chai": "^4.1.2", "@types/chroma-js": "^1.3.4", "@types/codemirror": "^0.0.56", + "@types/d3-scale": "^2.0.1", "@types/dygraphs": "^1.1.6", "@types/enzyme": "^3.1.9", "@types/jest": "^22.1.4", @@ -127,6 +128,7 @@ "chroma-js": "^1.3.6", "classnames": "^2.2.3", "codemirror": "^5.36.0", + "d3-scale": "^2.1.0", "dygraphs": "2.1.0", "enzyme-adapter-react-16": "^1.1.1", "eslint-plugin-babel": "^4.1.2", diff --git a/ui/src/logs/actions/index.ts b/ui/src/logs/actions/index.ts index 05d603adb..928ecf9aa 100644 --- a/ui/src/logs/actions/index.ts +++ b/ui/src/logs/actions/index.ts @@ -1,15 +1,21 @@ import moment from 'moment' import _ from 'lodash' -import {Source, Namespace, TimeRange, QueryConfig} from 'src/types' +import { + Source, + Namespace, + TimeRange, + QueryConfig, + RemoteDataState, +} from 'src/types' import {getSource} from 'src/shared/apis' import {getDatabasesWithRetentionPolicies} from 'src/shared/apis/databases' import { buildHistogramQueryConfig, buildTableQueryConfig, buildLogQuery, + parseHistogramQueryResponse, } from 'src/logs/utils' import {getDeep} from 'src/utils/wrappers' -import buildQuery from 'src/utils/influxql' import {executeQueryAsync} from 'src/logs/api' import {LogsState, Filter, TableData} from 'src/types/logs' @@ -41,6 +47,7 @@ export enum ActionTypes { SetNamespace = 'LOGS_SET_NAMESPACE', SetHistogramQueryConfig = 'LOGS_SET_HISTOGRAM_QUERY_CONFIG', SetHistogramData = 'LOGS_SET_HISTOGRAM_DATA', + SetHistogramDataStatus = 'LOGS_SET_HISTOGRAM_DATA_STATUS', SetTableQueryConfig = 'LOGS_SET_TABLE_QUERY_CONFIG', SetTableData = 'LOGS_SET_TABLE_DATA', ChangeZoom = 'LOGS_CHANGE_ZOOM', @@ -132,6 +139,11 @@ interface SetHistogramData { } } +interface SetHistogramDataStatus { + type: ActionTypes.SetHistogramDataStatus + payload: RemoteDataState +} + interface SetTableQueryConfig { type: ActionTypes.SetTableQueryConfig payload: { @@ -156,7 +168,6 @@ interface SetSearchTerm { interface ChangeZoomAction { type: ActionTypes.ChangeZoom payload: { - data: object[] timeRange: TimeRange } } @@ -168,6 +179,7 @@ export type Action = | SetNamespaceAction | SetHistogramQueryConfig | SetHistogramData + | SetHistogramDataStatus | ChangeZoomAction | SetTableData | SetTableQueryConfig @@ -220,9 +232,16 @@ export const removeFilter = (id: string): RemoveFilterAction => ({ payload: {id}, }) -const setHistogramData = (response): SetHistogramData => ({ +const setHistogramData = (data): SetHistogramData => ({ type: ActionTypes.SetHistogramData, - payload: {data: [{response}]}, + payload: {data}, +}) + +const setHistogramDataStatus = ( + status: RemoteDataState +): SetHistogramDataStatus => ({ + type: ActionTypes.SetHistogramDataStatus, + payload: status, }) export const executeHistogramQueryAsync = () => async ( @@ -240,9 +259,18 @@ export const executeHistogramQueryAsync = () => async ( if (_.every([queryConfig, timeRange, namespace, proxyLink])) { const query = buildLogQuery(timeRange, queryConfig, filters, searchTerm) - const response = await executeQueryAsync(proxyLink, namespace, query) - dispatch(setHistogramData(response)) + try { + dispatch(setHistogramDataStatus(RemoteDataState.Loading)) + + const response = await executeQueryAsync(proxyLink, namespace, query) + const data = parseHistogramQueryResponse(response) + + dispatch(setHistogramData(data)) + dispatch(setHistogramDataStatus(RemoteDataState.Done)) + } catch { + dispatch(setHistogramDataStatus(RemoteDataState.Error)) + } } } @@ -465,23 +493,10 @@ export const changeZoomAsync = (timeRange: TimeRange) => async ( getState: GetState ): Promise => { const state = getState() - const namespace = getNamespace(state) const proxyLink = getProxyLink(state) if (namespace && proxyLink) { - const queryConfig = buildHistogramQueryConfig(namespace, timeRange) - const query = buildQuery(timeRange, queryConfig) - const response = await executeQueryAsync(proxyLink, namespace, query) - - dispatch({ - type: ActionTypes.ChangeZoom, - payload: { - data: [{response}], - timeRange, - }, - }) - await dispatch(setTimeRangeAsync(timeRange)) await dispatch(executeTableQueryAsync()) } diff --git a/ui/src/logs/components/LogViewerChart.tsx b/ui/src/logs/components/LogViewerChart.tsx deleted file mode 100644 index f5c4823a0..000000000 --- a/ui/src/logs/components/LogViewerChart.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, {PureComponent} from 'react' -import LineGraph from 'src/shared/components/LineGraph' -import {DEFAULT_LINE_COLORS} from 'src/shared/constants/graphColorPalettes' - -import {TimeRange} from 'src/types' - -interface Props { - onZoom: (timeRange: TimeRange) => void - timeRange: TimeRange - data: object[] -} - -class LogViewerChart extends PureComponent { - public render() { - const {timeRange, data, onZoom} = this.props - return ( - - ) - } - - private setResolution = () => {} -} - -export default LogViewerChart diff --git a/ui/src/logs/components/LogsGraph.tsx b/ui/src/logs/components/LogsGraphContainer.tsx similarity index 100% rename from ui/src/logs/components/LogsGraph.tsx rename to ui/src/logs/components/LogsGraphContainer.tsx diff --git a/ui/src/logs/containers/LogsPage.tsx b/ui/src/logs/containers/LogsPage.tsx index f67249166..4a46486d2 100644 --- a/ui/src/logs/containers/LogsPage.tsx +++ b/ui/src/logs/containers/LogsPage.tsx @@ -1,6 +1,9 @@ import React, {PureComponent} from 'react' import uuid from 'uuid' +import _ from 'lodash' import {connect} from 'react-redux' +import {AutoSizer} from 'react-virtualized' + import { getSourceAndPopulateNamespacesAsync, setTimeRangeAsync, @@ -15,15 +18,16 @@ import { } from 'src/logs/actions' import {getSourcesAsync} from 'src/shared/actions/sources' import LogViewerHeader from 'src/logs/components/LogViewerHeader' -import Graph from 'src/logs/components/LogsGraph' +import HistogramChart from 'src/shared/components/HistogramChart' +import LogsGraphContainer from 'src/logs/components/LogsGraphContainer' import SearchBar from 'src/logs/components/LogsSearchBar' import FilterBar from 'src/logs/components/LogsFilterBar' -import LogViewerChart from 'src/logs/components/LogViewerChart' import LogsTable from 'src/logs/components/LogsTable' import {getDeep} from 'src/utils/wrappers' -import {Source, Namespace, TimeRange} from 'src/types' +import {Source, Namespace, TimeRange, RemoteDataState} from 'src/types' import {Filter} from 'src/types/logs' +import {HistogramData, TimePeriod} from 'src/types/histogram' interface Props { sources: Source[] @@ -42,7 +46,8 @@ interface Props { removeFilter: (id: string) => void changeFilter: (id: string, operator: string, value: string) => void timeRange: TimeRange - histogramData: object[] + histogramData: HistogramData + histogramDataStatus: RemoteDataState tableData: { columns: string[] values: string[] @@ -97,7 +102,7 @@ class LogsPage extends PureComponent {
{this.header}
- {this.chart} + {this.chart} { private get histogramTotal(): number { const {histogramData} = this.props - const values = getDeep>( - histogramData, - '0.response.results.0.series.0.values', - [] - ) - - return values.reduce((acc, v) => acc + v[1], 0) + return _.sumBy(histogramData, 'value') } private get chart(): JSX.Element { - const {histogramData, timeRange} = this.props + const {histogramData, histogramDataStatus} = this.props return ( - + + {({width, height}) => ( + + )} + ) } @@ -262,11 +267,15 @@ class LogsPage extends PureComponent { this.props.setNamespaceAsync(namespace) } - private handleChartZoom = (timeRange: TimeRange) => { - if (timeRange.lower) { - this.props.changeZoomAsync(timeRange) - this.setState({liveUpdating: true}) + private handleChartZoom = (t: TimePeriod) => { + const {start, end} = t + const timeRange = { + lower: new Date(start).toISOString(), + upper: new Date(end).toISOString(), } + + this.props.changeZoomAsync(timeRange) + this.setState({liveUpdating: true}) } private fetchNewDataset() { @@ -283,6 +292,7 @@ const mapStateToProps = ({ timeRange, currentNamespace, histogramData, + histogramDataStatus, tableData, searchTerm, filters, @@ -295,6 +305,7 @@ const mapStateToProps = ({ timeRange, currentNamespace, histogramData, + histogramDataStatus, tableData, searchTerm, filters, diff --git a/ui/src/logs/reducers/index.ts b/ui/src/logs/reducers/index.ts index f36052aa3..5ee9ba7c9 100644 --- a/ui/src/logs/reducers/index.ts +++ b/ui/src/logs/reducers/index.ts @@ -9,6 +9,8 @@ import { IncrementQueryCountAction, ConcatMoreLogsAction, } from 'src/logs/actions' + +import {RemoteDataState} from 'src/types' import {LogsState} from 'src/types/logs' const defaultState: LogsState = { @@ -20,6 +22,7 @@ const defaultState: LogsState = { tableQueryConfig: null, tableData: {columns: [], values: []}, histogramData: [], + histogramDataStatus: RemoteDataState.NotStarted, searchTerm: '', filters: [], queryCount: 0, @@ -108,13 +111,14 @@ export default (state: LogsState = defaultState, action: Action) => { return {...state, histogramQueryConfig: action.payload.queryConfig} case ActionTypes.SetHistogramData: return {...state, histogramData: action.payload.data} + case ActionTypes.SetHistogramDataStatus: + return {...state, histogramDataStatus: action.payload} case ActionTypes.SetTableQueryConfig: return {...state, tableQueryConfig: action.payload.queryConfig} case ActionTypes.SetTableData: return {...state, tableData: action.payload.data} case ActionTypes.ChangeZoom: - const {timeRange, data} = action.payload - return {...state, timeRange, histogramData: data} + return {...state, timeRange: action.payload.timeRange} case ActionTypes.SetSearchTerm: const {searchTerm} = action.payload return {...state, searchTerm} diff --git a/ui/src/logs/utils/index.ts b/ui/src/logs/utils/index.ts index 29a7782bf..fb0184616 100644 --- a/ui/src/logs/utils/index.ts +++ b/ui/src/logs/utils/index.ts @@ -4,6 +4,7 @@ import uuid from 'uuid' import {Filter} from 'src/types/logs' import {TimeRange, Namespace, QueryConfig} from 'src/types' import {NULL_STRING} from 'src/shared/constants/queryFillOptions' +import {getDeep} from 'src/utils/wrappers' import { quoteIfTimestamp, buildSelect, @@ -12,6 +13,8 @@ import { buildFill, } from 'src/utils/influxql' +import {HistogramData} from 'src/types/histogram' + const BIN_COUNT = 30 const histogramFields = [ @@ -156,7 +159,7 @@ const computeSeconds = (range: TimeRange) => { const createGroupBy = (range: TimeRange) => { const seconds = computeSeconds(range) const time = `${Math.max(Math.floor(seconds / BIN_COUNT), 1)}s` - const tags = [] + const tags = ['severity'] return {time, tags} } @@ -197,3 +200,39 @@ export const buildTableQueryConfig = ( fill: null, } } + +export const parseHistogramQueryResponse = ( + response: object +): HistogramData => { + const series = getDeep(response, 'results.0.series', []) + const data = series.reduce((acc, current) => { + const group = getDeep(current, 'tags.severity', '') + + if (!current.columns || !current.values) { + return acc + } + + const timeColIndex = current.columns.findIndex(v => v === 'time') + const countColIndex = current.columns.findIndex(v => v === 'count') + + if (timeColIndex < 0 || countColIndex < 0) { + return acc + } + + const vs = current.values.map(v => { + const time = v[timeColIndex] + const value = v[countColIndex] + + return { + key: `${group} ${value} ${time}`, + time, + value, + group, + } + }) + + return [...acc, ...vs] + }, []) + + return data +} diff --git a/ui/src/shared/components/HistogramChart.tsx b/ui/src/shared/components/HistogramChart.tsx new file mode 100644 index 000000000..2ec3fb4c7 --- /dev/null +++ b/ui/src/shared/components/HistogramChart.tsx @@ -0,0 +1,245 @@ +import React, {PureComponent, MouseEvent} from 'react' +import _ from 'lodash' +import {scaleLinear, scaleTime, ScaleLinear, ScaleTime} from 'd3-scale' + +import HistogramChartAxes from 'src/shared/components/HistogramChartAxes' +import HistogramChartBars from 'src/shared/components/HistogramChartBars' +import HistogramChartTooltip from 'src/shared/components/HistogramChartTooltip' +import HistogramChartSkeleton from 'src/shared/components/HistogramChartSkeleton' +import XBrush from 'src/shared/components/XBrush' + +import extentBy from 'src/utils/extentBy' +import {getDeep} from 'src/utils/wrappers' + +import {RemoteDataState} from 'src/types' +import { + TimePeriod, + HistogramData, + HistogramDatum, + Margins, + TooltipAnchor, +} from 'src/types/histogram' + +const PADDING_TOP = 0.2 +const TOOLTIP_HORIZONTAL_MARGIN = 5 +const TOOLTIP_REFLECT_DIST = 100 + +// Rather than use these magical constants, we could also render a digit and +// capture its measured width with as state before rendering anything else. +// Doing so would be robust but overkill. +const DIGIT_WIDTH = 7 +const PERIOD_DIGIT_WIDTH = 4 + +interface Props { + data: HistogramData + dataStatus: RemoteDataState + width: number + height: number + onZoom: (TimePeriod) => void +} + +interface State { + hoverX: number + hoverY: number + hoverDatum?: HistogramDatum + hoverAnchor: TooltipAnchor +} + +class HistogramChart extends PureComponent { + constructor(props) { + super(props) + + this.state = {hoverX: -1, hoverY: -1, hoverAnchor: 'left'} + } + + public render() { + const {width, height, data} = this.props + const {margins} = this + + if (width === 0 || height === 0) { + return null + } + + if (!data.length) { + return ( + + ) + } + + const {hoverDatum, hoverX, hoverY, hoverAnchor} = this.state + const { + xScale, + yScale, + adjustedWidth, + adjustedHeight, + bodyTransform, + loadingClass, + } = this + + return ( + <> + + + + + + + + + + + + + + + + + + + ) + } + + private get xScale(): ScaleTime { + const {adjustedWidth} = this + const {data} = this.props + + const [t0, t1] = extentBy(data, d => d.time) + + return scaleTime() + .domain([new Date(t0.time), new Date(t1.time)]) + .range([0, adjustedWidth]) + } + + private get yScale(): ScaleLinear { + const {adjustedHeight, maxAggregateCount} = this + + return scaleLinear() + .domain([0, maxAggregateCount + PADDING_TOP * maxAggregateCount]) + .range([adjustedHeight, 0]) + } + + private get adjustedWidth(): number { + const {margins} = this + + return this.props.width - margins.left - margins.right + } + + private get adjustedHeight(): number { + const {margins} = this + + return this.props.height - margins.top - margins.bottom + } + + private get bodyTransform(): string { + const {margins} = this + + return `translate(${margins.left}, ${margins.top})` + } + + private get margins(): Margins { + const {maxAggregateCount} = this + + const domainTop = maxAggregateCount + PADDING_TOP * maxAggregateCount + const left = domainTop.toString().length * DIGIT_WIDTH + PERIOD_DIGIT_WIDTH + + return {top: 5, right: 0, bottom: 20, left} + } + + private get maxAggregateCount(): number { + const {data} = this.props + + if (!data.length) { + return 0 + } + + const groups = _.groupBy(data, 'time') + const counts = Object.values(groups).map(group => + group.reduce((sum, current) => sum + current.value, 0) + ) + + return Math.max(...counts) + } + + private get loadingClass(): string { + const {dataStatus} = this.props + + return dataStatus === RemoteDataState.Loading ? 'loading' : '' + } + + private handleBrush = (t: TimePeriod): void => { + this.props.onZoom(t) + this.setState({hoverDatum: null}) + } + + private handleMouseMove = (e: MouseEvent): void => { + const key = getDeep(e, 'target.dataset.key', '') + + if (!key) { + return + } + + const {data} = this.props + const hoverDatum = data.find(d => d.key === key) + + if (!hoverDatum) { + return + } + + const bar = e.target as SVGRectElement + const barRect = bar.getBoundingClientRect() + const barRectHeight = barRect.bottom - barRect.top + const hoverY = barRect.top + barRectHeight / 2 + + let hoverX = barRect.right + TOOLTIP_HORIZONTAL_MARGIN + let hoverAnchor: TooltipAnchor = 'left' + + if (hoverX >= window.innerWidth - TOOLTIP_REFLECT_DIST) { + hoverX = window.innerWidth - barRect.left + TOOLTIP_HORIZONTAL_MARGIN + hoverAnchor = 'right' + } + + this.setState({hoverDatum, hoverX, hoverY, hoverAnchor}) + } + + private handleMouseOut = (): void => { + this.setState({hoverDatum: null}) + } +} + +export default HistogramChart diff --git a/ui/src/shared/components/HistogramChartAxes.tsx b/ui/src/shared/components/HistogramChartAxes.tsx new file mode 100644 index 000000000..b43a772c2 --- /dev/null +++ b/ui/src/shared/components/HistogramChartAxes.tsx @@ -0,0 +1,99 @@ +import React, {PureComponent} from 'react' +import uuid from 'uuid' +import {ScaleLinear, ScaleTime} from 'd3-scale' + +import {Margins} from 'src/types/histogram' + +const Y_TICK_COUNT = 5 +const Y_TICK_PADDING_RIGHT = 5 +const X_TICK_COUNT = 10 +const X_TICK_PADDING_TOP = 8 + +interface Props { + width: number + height: number + margins: Margins + xScale: ScaleTime + yScale: ScaleLinear +} + +class HistogramChartAxes extends PureComponent { + public render() { + const {xTickData, yTickData} = this + + return ( + <> + {this.renderYTicks(yTickData)} + {this.renderYLabels(yTickData)} + {this.renderXLabels(xTickData)} + + ) + } + + private renderYTicks(yTickData) { + return yTickData.map(({x1, x2, y}) => ( + + )) + } + + private renderYLabels(yTickData) { + return yTickData.map(({x1, y, label}) => ( + + {label} + + )) + } + + private renderXLabels(xTickData) { + return xTickData.map(({x, y, label}) => ( + + {label} + + )) + } + + private get xTickData() { + const {margins, xScale, width, height} = this.props + + const y = height - margins.bottom + X_TICK_PADDING_TOP + const formatTime = xScale.tickFormat() + + return xScale + .ticks(X_TICK_COUNT) + .filter(val => { + const x = xScale(val) + + // Don't render labels that will be cut off + return x > margins.left && x < width - margins.right + }) + .map(val => { + const x = xScale(val) + + return { + x, + y, + label: formatTime(val), + } + }) + } + + private get yTickData() { + const {width, margins, yScale} = this.props + + return yScale.ticks(Y_TICK_COUNT).map(val => { + return { + label: val, + x1: margins.left, + x2: margins.left + width, + y: margins.top + yScale(val), + } + }) + } +} + +export default HistogramChartAxes diff --git a/ui/src/shared/components/HistogramChartBars.tsx b/ui/src/shared/components/HistogramChartBars.tsx new file mode 100644 index 000000000..a42e09b04 --- /dev/null +++ b/ui/src/shared/components/HistogramChartBars.tsx @@ -0,0 +1,131 @@ +import React, {PureComponent} from 'react' +import uuid from 'uuid' +import _ from 'lodash' +import {ScaleLinear, ScaleTime} from 'd3-scale' + +import {HistogramData, HistogramDatum} from 'src/types/histogram' + +const BAR_BORDER_RADIUS = 4 +const BAR_PADDING_SIDES = 4 + +interface Props { + width: number + height: number + data: HistogramData + xScale: ScaleTime + yScale: ScaleLinear +} + +class HistogramChartBars extends PureComponent { + public render() { + return this.renderData.map(group => { + const {key, clip, bars} = group + + return ( + + + + + + + {bars.map(d => ( + + ))} + + ) + }) + } + + private get renderData() { + const {data, xScale, yScale} = this.props + const {barWidth, sortFn} = this + + const visibleData = data.filter(d => d.value !== 0) + const groups = Object.values(_.groupBy(visibleData, 'time')) + + for (const group of groups) { + group.sort(sortFn) + } + + return groups.map(group => { + const x = xScale(group[0].time) - barWidth / 2 + const groupTotal = _.sumBy(group, 'value') + + const renderData = { + key: uuid.v4(), + clip: { + x, + y: yScale(groupTotal), + width: barWidth, + height: yScale(0) - yScale(groupTotal) + BAR_BORDER_RADIUS, + }, + bars: [], + } + + let offset = 0 + + group.forEach((d: HistogramDatum) => { + const height = yScale(0) - yScale(d.value) + + renderData.bars.push({ + key: d.key, + group: d.group, + x, + y: yScale(d.value) - offset, + width: barWidth, + height, + }) + + offset += height + }) + + return renderData + }) + } + + private get sortFn() { + const {data} = this.props + + const counts = {} + + for (const d of data) { + if (counts[d.group]) { + counts[d.group] += d.value + } else { + counts[d.group] = d.value + } + } + + return (a, b) => counts[b.group] - counts[a.group] + } + + private get barWidth() { + const {data, xScale, width} = this.props + + const dataInView = data.filter( + d => xScale(d.time) >= 0 && xScale(d.time) <= width + ) + const barCount = Object.values(_.groupBy(dataInView, 'time')).length + + return Math.round(width / barCount - BAR_PADDING_SIDES) + } +} + +export default HistogramChartBars diff --git a/ui/src/shared/components/HistogramChartSkeleton.tsx b/ui/src/shared/components/HistogramChartSkeleton.tsx new file mode 100644 index 000000000..58f42a8a5 --- /dev/null +++ b/ui/src/shared/components/HistogramChartSkeleton.tsx @@ -0,0 +1,37 @@ +import React, {SFC} from 'react' +import _ from 'lodash' + +import {Margins} from 'src/types/histogram' + +const NUM_TICKS = 5 + +interface Props { + width: number + height: number + margins: Margins +} + +const HistogramChartSkeleton: SFC = props => { + const {margins, width, height} = props + + const spacing = (height - margins.top - margins.bottom) / NUM_TICKS + const y1 = height - margins.bottom + const tickYs = _.range(0, NUM_TICKS).map(i => y1 - i * spacing) + + return ( + + {tickYs.map((y, i) => ( + + ))} + + ) +} + +export default HistogramChartSkeleton diff --git a/ui/src/shared/components/HistogramChartTooltip.tsx b/ui/src/shared/components/HistogramChartTooltip.tsx new file mode 100644 index 000000000..d5b2e3e91 --- /dev/null +++ b/ui/src/shared/components/HistogramChartTooltip.tsx @@ -0,0 +1,42 @@ +import React, {SFC, CSSProperties} from 'react' + +import {HistogramDatum, TooltipAnchor} from 'src/types/histogram' + +interface Props { + datum: HistogramDatum + x: number + y: number + anchor?: TooltipAnchor +} + +const HistogramChartTooltip: SFC = props => { + const {datum, x, y, anchor = 'left'} = props + + if (!datum) { + return null + } + + const style: CSSProperties = { + position: 'fixed', + top: y, + } + + if (anchor === 'left') { + style.left = x + } else { + style.right = x + } + + return ( +
+
{datum.value}
+
{datum.group}
+
+ ) +} + +export default HistogramChartTooltip diff --git a/ui/src/shared/components/XBrush.tsx b/ui/src/shared/components/XBrush.tsx new file mode 100644 index 000000000..5b57d00e8 --- /dev/null +++ b/ui/src/shared/components/XBrush.tsx @@ -0,0 +1,163 @@ +import React, { + PureComponent, + RefObject, + MouseEvent as ReactMouseEvent, +} from 'react' +import {ScaleTime} from 'd3-scale' + +import {TimePeriod} from 'src/types/histogram' + +interface Props { + width: number + height: number + xScale: ScaleTime + onBrush?: (t: TimePeriod) => void + onDoubleClick?: () => void +} + +interface State { + dragging: boolean + dragStartPos: number + dragPos: number +} + +class XBrush extends PureComponent { + private draggableArea: RefObject + + constructor(props) { + super(props) + + this.state = { + dragging: false, + dragStartPos: 0, + dragPos: 0, + } + + this.draggableArea = React.createRef() + } + + public componentWillUnmount() { + // These are usually cleaned up on handleDragEnd; this ensures they will + // also be cleaned up if the component is destroyed mid-brush + document.removeEventListener('movemove', this.handleDrag) + document.removeEventListener('mouseup', this.handleDragEnd) + } + + public render() { + const {width, height} = this.props + + return ( + <> + {this.renderSelection()} + + + ) + } + + private renderSelection(): JSX.Element { + const {height} = this.props + const {dragging, dragStartPos, dragPos} = this.state + + if (!dragging) { + return null + } + + const x = Math.min(dragStartPos, dragPos) + const width = Math.abs(dragStartPos - dragPos) + + return ( + + ) + } + + private handleDragStart = (e: ReactMouseEvent): void => { + // A user can mousedown (start a brush) then move outside of the current + // element while still holding the mouse down, therfore we must listen to + // mouse events everywhere, not just within this component. + document.addEventListener('mousemove', this.handleDrag) + document.addEventListener('mouseup', this.handleDragEnd) + + const x = this.getX(e) + + this.setState({ + dragging: true, + dragStartPos: x, + dragPos: x, + }) + } + + private handleDrag = (e: MouseEvent): void => { + const {dragging} = this.state + + if (!dragging) { + return + } + + this.setState({dragPos: this.getX(e)}) + } + + private handleDragEnd = (): void => { + document.removeEventListener('movemove', this.handleDrag) + document.removeEventListener('mouseup', this.handleDragEnd) + + const {xScale, onBrush} = this.props + const {dragging, dragPos, dragStartPos} = this.state + + if (!dragging) { + return + } + + this.setState({dragging: false}) + + if (!onBrush || Math.round(dragPos) === Math.round(dragStartPos)) { + return + } + + const startX = Math.min(dragStartPos, dragPos) + const endX = Math.max(dragStartPos, dragPos) + const start = xScale.invert(startX).getTime() + const end = xScale.invert(endX).getTime() + + onBrush({start, end}) + } + + private handleDoubleClick = (): void => { + const {onDoubleClick} = this.props + + if (onDoubleClick) { + onDoubleClick() + } + } + + private getX = (e: MouseEvent | ReactMouseEvent): number => { + const {width} = this.props + + const {left} = this.draggableArea.current.getBoundingClientRect() + const x = e.pageX - left + + if (x < 0) { + return 0 + } + + if (x > width) { + return width + } + + return x + } +} + +export default XBrush diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 1dd60cf22..58dea91b9 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -73,6 +73,7 @@ @import 'components/threshold-controls'; @import 'components/kapacitor-logs-table'; @import 'components/dropdown-placeholder'; +@import 'components/histogram-chart'; // Pages @import 'pages/config-endpoints'; diff --git a/ui/src/style/components/histogram-chart.scss b/ui/src/style/components/histogram-chart.scss new file mode 100644 index 000000000..c04e41a99 --- /dev/null +++ b/ui/src/style/components/histogram-chart.scss @@ -0,0 +1,99 @@ +@keyframes blur-in { + from { + filter: blur(0); + } + + to { + filter: blur(2px); + } +} + +@keyframes blur-out { + from { + filter: blur(2px); + } + + to { + filter: blur(0); + } +} + +.histogram-chart { + user-select: none; + + &:not(.loading) { + animation-duration: 0.1s; + animation-name: blur-out; + } + + &.loading { + animation-duration: 0.3s; + animation-name: blur-in; + animation-fill-mode: forwards; + } +} + +.histogram-chart-bars--bar { + shape-rendering: crispEdges; + fill: $c-amethyst; + opacity: 1; + pointer: cursor; + shape-rendering: crispEdges; +} + +.histogram-chart--axes, .histogram-chart-skeleton { + .x-label, .y-label { + fill: $g13-mist; + font-size: 12px; + font-weight: bold; + } + + .x-label { + text-anchor: middle; + alignment-baseline: hanging; + } + + .y-label { + text-anchor: end; + alignment-baseline: middle; + } + + .y-tick { + stroke-width: 1; + stroke: $g5-pepper; + shape-rendering: crispEdges; + } +} + + +.histogram-chart-skeleton, .histogram-chart:not(.loading) .x-brush--area { + cursor: crosshair; +} + +.histogram-chart .x-brush--area { + visibility: hidden; + pointer-events: all; +} + +.histogram-chart .x-brush--selection { + fill: gray; + opacity: 0.5; +} + +.histogram-chart-tooltip { + padding: 8px; + background-color: $g0-obsidian; + border-radius: 3px; + @extend %drop-shadow; + font-size: 12px; + font-weight: 600; + color: $g13-mist; + display: flex; + align-items: space-between; + transform: translate(0, -50%); + pointer-events: none; + + .histogram-chart-tooltip--value { + margin-right: 10px; + } +} diff --git a/ui/src/style/pages/logs-viewer.scss b/ui/src/style/pages/logs-viewer.scss index ded203e53..2b59b4722 100644 --- a/ui/src/style/pages/logs-viewer.scss +++ b/ui/src/style/pages/logs-viewer.scss @@ -3,12 +3,29 @@ ---------------------------------------------------------------------------- */ -$logs-viewer-graph-height: 180px; +$logs-viewer-graph-height: 220px; $logs-viewer-search-height: 46px; $logs-viewer-filter-height: 42px; $logs-viewer-results-text-indent: 33px; $logs-viewer-gutter: 60px; +$severity-emerg: $c-ruby; +$severity-alert: $c-fire; +$severity-crit: $c-curacao; +$severity-err: $c-tiger; +$severity-warning: $c-pineapple; +$severity-notice: $c-rainforest; +$severity-info: $c-star; +$severity-debug: $g9-mountain; +$severity-emerg-intense: $c-fire; +$severity-alert-intense: $c-curacao; +$severity-crit-intense: $c-tiger; +$severity-err-intense: $c-pineapple; +$severity-warning-intense: $c-thunder; +$severity-notice-intense: $c-honeydew; +$severity-info-intense: $c-comet; +$severity-debug-intense: $g10-wolf; + .logs-viewer { display: flex; flex-direction: column; @@ -232,28 +249,28 @@ $logs-viewer-gutter: 60px; margin-left: 2px; &.emerg-severity { - @include gradient-diag-up($c-ruby, $c-fire); + @include gradient-diag-up($severity-emerg, $severity-emerg-intense); } &.alert-severity { - @include gradient-diag-up($c-fire, $c-curacao); + @include gradient-diag-up($severity-alert, $severity-alert-intense); } &.crit-severity { - @include gradient-diag-up($c-curacao, $c-tiger); + @include gradient-diag-up($severity-crit, $severity-crit-intense); } &.err-severity { - @include gradient-diag-up($c-tiger, $c-pineapple); + @include gradient-diag-up($severity-err, $severity-err-intense); } &.warning-severity { - @include gradient-diag-up($c-pineapple, $c-thunder); + @include gradient-diag-up($severity-warning, $severity-warning-intense); } &.notice-severity { - @include gradient-diag-up($c-rainforest, $c-honeydew); + @include gradient-diag-up($severity-notice, $severity-notice-intense); } &.info-severity { - @include gradient-diag-up($c-star, $c-comet); + @include gradient-diag-up($severity-info, $severity-info-intense); } &.debug-severity { - @include gradient-diag-up($g9-mountain, $g10-wolf); + @include gradient-diag-up($severity-debug, $severity-debug-intense); } } @@ -310,3 +327,79 @@ $logs-viewer-gutter: 60px; background-color: $c-laser; } } + +.logs-viewer .histogram-chart-bars--bar, .logs-viewer .histogram-chart-tooltip { + &[data-group="emerg"] { + fill: $severity-emerg; + color: $severity-emerg; + } + + &[data-group="alert"] { + fill: $severity-alert; + color: $severity-alert; + } + + &[data-group="crit"] { + fill: $severity-crit; + color: $severity-crit; + } + + &[data-group="err"] { + fill: $severity-err; + color: $severity-err; + } + + &[data-group="warning"] { + fill: $severity-warning; + color: $severity-warning; + } + + &[data-group="notice"] { + fill: $severity-notice; + color: $severity-notice; + } + + &[data-group="info"] { + fill: $severity-info; + color: $severity-info; + } + + &[data-group="debug"] { + fill: $severity-debug; + color: $severity-debug; + } +} + +.logs-viewer .histogram-chart-bars--bar:hover { + &[data-group="emerg"] { + fill: $severity-emerg-intense; + } + + &[data-group="alert"] { + fill: $severity-alert-intense; + } + + &[data-group="crit"] { + fill: $severity-crit-intense; + } + + &[data-group="err"] { + fill: $severity-err-intense; + } + + &[data-group="warning"] { + fill: $severity-warning-intense; + } + + &[data-group="notice"] { + fill: $severity-notice-intense; + } + + &[data-group="info"] { + fill: $severity-info-intense; + } + + &[data-group="debug"] { + fill: $severity-debug-intense; + } +} diff --git a/ui/src/types/histogram.ts b/ui/src/types/histogram.ts new file mode 100644 index 000000000..d5e794fc3 --- /dev/null +++ b/ui/src/types/histogram.ts @@ -0,0 +1,24 @@ +type UnixTime = number + +export interface HistogramDatum { + key: string + time: UnixTime + value: number + group: string +} + +export interface TimePeriod { + start: UnixTime + end: UnixTime +} + +export type HistogramData = HistogramDatum[] + +export type TooltipAnchor = 'left' | 'right' + +export interface Margins { + top: number + right: number + bottom: number + left: number +} diff --git a/ui/src/types/logs.ts b/ui/src/types/logs.ts index cbc606f55..975af5248 100644 --- a/ui/src/types/logs.ts +++ b/ui/src/types/logs.ts @@ -1,4 +1,10 @@ -import {QueryConfig, TimeRange, Namespace, Source} from 'src/types' +import { + QueryConfig, + TimeRange, + Namespace, + Source, + RemoteDataState, +} from 'src/types' export interface Filter { id: string @@ -19,6 +25,7 @@ export interface LogsState { timeRange: TimeRange histogramQueryConfig: QueryConfig | null histogramData: object[] + histogramDataStatus: RemoteDataState tableQueryConfig: QueryConfig | null tableData: TableData searchTerm: string | null diff --git a/ui/src/utils/extentBy.tsx b/ui/src/utils/extentBy.tsx new file mode 100644 index 000000000..4bdc2346c --- /dev/null +++ b/ui/src/utils/extentBy.tsx @@ -0,0 +1,25 @@ +export default function extentBy( + collection: T[], + keyFn: (v: any) => number +): T[] { + let min = Infinity + let max = -Infinity + let minItem + let maxItem + + for (const item of collection) { + const val = keyFn(item) + + if (val <= min) { + min = val + minItem = item + } + + if (val >= max) { + max = val + maxItem = item + } + } + + return [minItem, maxItem] +} diff --git a/ui/yarn.lock b/ui/yarn.lock index c758905f4..4fc53bd14 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -32,6 +32,16 @@ version "0.0.56" resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-0.0.56.tgz#1fcf68df0d0a49791d843dadda7d94891ac88669" +"@types/d3-scale@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-2.0.1.tgz#f94cd991c50422b2e68d8f43be3f9fffdb1ae7be" + dependencies: + "@types/d3-time" "*" + +"@types/d3-time@*": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-1.0.8.tgz#6c083127b330b3c2fc65cd0f3a6e9cbd9607b28c" + "@types/dygraphs@^1.1.6": version "1.1.6" resolved "https://registry.yarnpkg.com/@types/dygraphs/-/dygraphs-1.1.6.tgz#20ff1a01e353e813ff97898c0fee5defc66626be" @@ -2606,6 +2616,49 @@ cyclist@~0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" +d3-array@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.1.tgz#d1ca33de2f6ac31efadb8e050a021d7e2396d5dc" + +d3-collection@1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.4.tgz#342dfd12837c90974f33f1cc0a785aea570dcdc2" + +d3-color@1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.2.0.tgz#d1ea19db5859c86854586276ec892cf93148459a" + +d3-format@1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.0.tgz#a3ac44269a2011cdb87c7b5693040c18cddfff11" + +d3-interpolate@1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.2.0.tgz#40d81bd8e959ff021c5ea7545bc79b8d22331c41" + dependencies: + d3-color "1" + +d3-scale@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.1.0.tgz#8d3fd3e2a7c9080782a523c08507c5248289eef8" + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + +d3-time-format@2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.1.tgz#85b7cdfbc9ffca187f14d3c456ffda268081bb31" + dependencies: + d3-time "1" + +d3-time@1: + version "1.0.8" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.8.tgz#dbd2d6007bf416fe67a76d17947b784bffea1e84" + d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"