diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index c05f5bab866..1efe26d28ef 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -45,6 +45,13 @@ export interface LogRow { uniqueLabels?: LogsStreamLabels; } +export interface LogsLabelStat { + active?: boolean; + count: number; + proportion: number; + value: string; +} + export enum LogsMetaKind { Number, String, @@ -88,6 +95,22 @@ export enum LogsDedupStrategy { signature = 'signature', } +export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] { + // Consider only rows that have the given label + const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); + const rowCount = rowsWithLabel.length; + + // Get label value counts for eligible rows + const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]); + const sortedCounts = _.chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g; function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean { switch (strategy) { diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts index 5e427468339..22673278b13 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/public/app/core/specs/logs_model.test.ts @@ -1,4 +1,4 @@ -import { dedupLogRows, LogsDedupStrategy, LogsModel } from '../logs_model'; +import { calculateLogsLabelStats, dedupLogRows, LogsDedupStrategy, LogsModel } from '../logs_model'; describe('dedupLogRows()', () => { test('should return rows as is when dedup is set to none', () => { @@ -106,3 +106,56 @@ describe('dedupLogRows()', () => { ]); }); }); + +describe('calculateLogsLabelStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateLogsLabelStats([], '')).toEqual([]); + }); + + test('should return no stats of label is not found', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]); + }); + + test('should return stats for found labels', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + { + entry: 'foo 0', + labels: { + foo: 'xxx', + }, + }, + { + entry: 'foo 2', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([ + { + value: 'bar', + count: 2, + }, + { + value: 'xxx', + count: 1, + }, + ]); + }); +}); diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx new file mode 100644 index 00000000000..91e2d44e517 --- /dev/null +++ b/public/app/features/explore/LogLabels.tsx @@ -0,0 +1,141 @@ +import _ from 'lodash'; +import React, { PureComponent } from 'react'; +import classnames from 'classnames'; + +import { calculateLogsLabelStats, LogsLabelStat, LogsStreamLabels, LogRow } from 'app/core/logs_model'; + +function StatsRow({ active, count, proportion, value }: LogsLabelStat) { + const percent = `${Math.round(proportion * 100)}%`; + const barStyle = { width: percent }; + const className = classnames('logs-stats-row', { 'logs-stats-row--active': active }); + + return ( +
+
+
{value}
+
{count}
+
{percent}
+
+
+
+
+
+ ); +} + +const STATS_ROW_LIMIT = 5; +class Stats extends PureComponent<{ + stats: LogsLabelStat[]; + label: string; + value: string; + rowCount: number; + onClickClose: () => void; +}> { + render() { + const { label, rowCount, stats, value, onClickClose } = this.props; + const topRows = stats.slice(0, STATS_ROW_LIMIT); + let activeRow = topRows.find(row => row.value === value); + let otherRows = stats.slice(STATS_ROW_LIMIT); + const insertActiveRow = !activeRow; + // Remove active row from other to show extra + if (insertActiveRow) { + activeRow = otherRows.find(row => row.value === value); + otherRows = otherRows.filter(row => row.value !== value); + } + const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); + const topCount = topRows.reduce((sum, row) => sum + row.count, 0); + const total = topCount + otherCount; + const otherProportion = otherCount / total; + + return ( + <> +
+ {label}: {total} of {rowCount} rows have that label + +
+ {topRows.map(stat => )} + {insertActiveRow && } + {otherCount > 0 && } + + ); + } +} + +class Label extends PureComponent< + { + allRows?: LogRow[]; + label: string; + plain?: boolean; + value: string; + onClickLabel?: (label: string, value: string) => void; + }, + { showStats: boolean; stats: LogsLabelStat[] } +> { + state = { + stats: null, + showStats: false, + }; + + onClickClose = () => { + this.setState({ showStats: false }); + }; + + onClickLabel = () => { + const { onClickLabel, label, value } = this.props; + if (onClickLabel) { + onClickLabel(label, value); + } + }; + + onClickStats = () => { + this.setState(state => { + if (state.showStats) { + return { showStats: false, stats: null }; + } + const stats = calculateLogsLabelStats(this.props.allRows, this.props.label); + return { showStats: true, stats }; + }); + }; + + render() { + const { allRows, label, plain, value } = this.props; + const { showStats, stats } = this.state; + const tooltip = `${label}: ${value}`; + return ( + + + {value} + + {!plain && ( + + )} + {!plain && allRows && } + {showStats && ( + + + + )} + + ); + } +} + +export default class LogLabels extends PureComponent<{ + allRows?: LogRow[]; + labels: LogsStreamLabels; + plain?: boolean; + onClickLabel?: (label: string, value: string) => void; +}> { + render() { + const { allRows, labels, onClickLabel, plain } = this.props; + return Object.keys(labels).map(key => ( +