Show stacked histogram in log viewer

pull/3781/head
Christopher Henn 2018-06-22 13:23:34 -07:00
parent 2ce5c5a4ec
commit a1b6b507e5
No known key found for this signature in database
GPG Key ID: 909E48D5E1C526FA
20 changed files with 1145 additions and 88 deletions

View File

@ -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",

View File

@ -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<void> => {
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())
}

View File

@ -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<Props> {
public render() {
const {timeRange, data, onZoom} = this.props
return (
<LineGraph
onZoom={onZoom}
queries={[]}
data={data}
displayOptions={{animatedZooms: false}}
setResolution={this.setResolution}
isBarGraph={true}
timeRange={timeRange}
colors={DEFAULT_LINE_COLORS}
/>
)
}
private setResolution = () => {}
}
export default LogViewerChart

View File

@ -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<Props, State> {
<div className="page">
{this.header}
<div className="page-contents logs-viewer">
<Graph>{this.chart}</Graph>
<LogsGraphContainer>{this.chart}</LogsGraphContainer>
<SearchBar
searchString={searchTerm}
onSearch={this.handleSubmitSearch}
@ -170,24 +175,24 @@ class LogsPage extends PureComponent<Props, State> {
private get histogramTotal(): number {
const {histogramData} = this.props
const values = getDeep<Array<[number, number]>>(
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 (
<LogViewerChart
timeRange={timeRange}
data={histogramData}
onZoom={this.handleChartZoom}
/>
<AutoSizer>
{({width, height}) => (
<HistogramChart
data={histogramData}
dataStatus={histogramDataStatus}
width={width}
height={height}
onZoom={this.handleChartZoom}
/>
)}
</AutoSizer>
)
}
@ -262,11 +267,15 @@ class LogsPage extends PureComponent<Props, State> {
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,

View File

@ -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}

View File

@ -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<any[]>(response, 'results.0.series', [])
const data = series.reduce((acc, current) => {
const group = getDeep<string>(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
}

View File

@ -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<Props, State> {
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 (
<HistogramChartSkeleton
width={width}
height={height}
margins={margins}
/>
)
}
const {hoverDatum, hoverX, hoverY, hoverAnchor} = this.state
const {
xScale,
yScale,
adjustedWidth,
adjustedHeight,
bodyTransform,
loadingClass,
} = this
return (
<>
<svg
width={width}
height={height}
className={`histogram-chart ${loadingClass}`}
onMouseOver={this.handleMouseMove}
onMouseOut={this.handleMouseOut}
>
<defs>
<clipPath id="histogram-chart--bars-clip">
<rect x="0" y="0" width={adjustedWidth} height={adjustedHeight} />
</clipPath>
</defs>
<g className="histogram-chart--axes">
<HistogramChartAxes
width={width}
height={height}
margins={margins}
xScale={xScale}
yScale={yScale}
/>
</g>
<g className="histogram-chart--brush" transform={bodyTransform}>
<XBrush
xScale={xScale}
width={adjustedWidth}
height={adjustedHeight}
onBrush={this.handleBrush}
/>
</g>
<g
transform={bodyTransform}
className="histogram-chart--bars"
clipPath="url(#histogram-chart--bars-clip)"
>
<HistogramChartBars
width={adjustedWidth}
height={adjustedHeight}
data={data}
xScale={xScale}
yScale={yScale}
/>
</g>
</svg>
<HistogramChartTooltip
datum={hoverDatum}
x={hoverX}
y={hoverY}
anchor={hoverAnchor}
/>
</>
)
}
private get xScale(): ScaleTime<number, number> {
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<number, number> {
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<SVGElement>): void => {
const key = getDeep<string>(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

View File

@ -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<number, number>
yScale: ScaleLinear<number, number>
}
class HistogramChartAxes extends PureComponent<Props> {
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}) => (
<line className="y-tick" key={uuid.v4()} x1={x1} x2={x2} y1={y} y2={y} />
))
}
private renderYLabels(yTickData) {
return yTickData.map(({x1, y, label}) => (
<text
className="y-label"
key={uuid.v4()}
x={x1 - Y_TICK_PADDING_RIGHT}
y={y}
>
{label}
</text>
))
}
private renderXLabels(xTickData) {
return xTickData.map(({x, y, label}) => (
<text className="x-label" key={uuid.v4()} y={y} x={x}>
{label}
</text>
))
}
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

View File

@ -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<number, number>
yScale: ScaleLinear<number, number>
}
class HistogramChartBars extends PureComponent<Props> {
public render() {
return this.renderData.map(group => {
const {key, clip, bars} = group
return (
<g key={key} className="histogram-chart-bars--bars">
<defs>
<clipPath id={`histogram-chart-bars--clip-${key}`}>
<rect
x={clip.x}
y={clip.y}
width={clip.width}
height={clip.height}
rx={BAR_BORDER_RADIUS}
ry={BAR_BORDER_RADIUS}
/>
</clipPath>
</defs>
{bars.map(d => (
<rect
key={d.key}
className="histogram-chart-bars--bar"
x={d.x}
y={d.y}
width={d.width}
height={d.height}
clipPath={`url(#histogram-chart-bars--clip-${key})`}
data-group={d.group}
data-key={d.key}
/>
))}
</g>
)
})
}
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

View File

@ -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> = 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 (
<svg className="histogram-chart-skeleton" width={width} height={height}>
{tickYs.map((y, i) => (
<line
key={i}
className="y-tick"
x1={margins.left}
x2={width - margins.right}
y1={y}
y2={y}
/>
))}
</svg>
)
}
export default HistogramChartSkeleton

View File

@ -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> = 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 (
<div
className="histogram-chart-tooltip"
style={style}
data-group={datum.group}
>
<div className="histogram-chart-tooltip--value">{datum.value}</div>
<div className="histogram-chart-tooltip--group">{datum.group}</div>
</div>
)
}
export default HistogramChartTooltip

View File

@ -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<number, number>
onBrush?: (t: TimePeriod) => void
onDoubleClick?: () => void
}
interface State {
dragging: boolean
dragStartPos: number
dragPos: number
}
class XBrush extends PureComponent<Props, State> {
private draggableArea: RefObject<SVGRectElement>
constructor(props) {
super(props)
this.state = {
dragging: false,
dragStartPos: 0,
dragPos: 0,
}
this.draggableArea = React.createRef<SVGRectElement>()
}
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()}
<rect
ref={this.draggableArea}
className="x-brush--area"
width={width}
height={height}
onMouseDown={this.handleDragStart}
onDoubleClick={this.handleDoubleClick}
/>
</>
)
}
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 (
<rect
className="x-brush--selection"
y={0}
height={height}
x={x}
width={width}
/>
)
}
private handleDragStart = (e: ReactMouseEvent<SVGRectElement>): 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<SVGRectElement>): 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

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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;
}
}

24
ui/src/types/histogram.ts Normal file
View File

@ -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
}

View File

@ -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

25
ui/src/utils/extentBy.tsx Normal file
View File

@ -0,0 +1,25 @@
export default function extentBy<T>(
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]
}

View File

@ -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"