diff --git a/public/app/core/components/Switch/Switch.tsx b/public/app/core/components/Switch/Switch.tsx index ba09267ebd2..a4bb73a291b 100644 --- a/public/app/core/components/Switch/Switch.tsx +++ b/public/app/core/components/Switch/Switch.tsx @@ -5,6 +5,7 @@ export interface Props { label: string; checked: boolean; labelClass?: string; + small?: boolean; switchClass?: string; onChange: (event) => any; } @@ -24,10 +25,14 @@ export class Switch extends PureComponent { }; render() { - const { labelClass, switchClass, label, checked } = this.props; + const { labelClass = '', switchClass = '', label, checked, small } = this.props; const labelId = `check-${this.state.id}`; - const labelClassName = `gf-form-label ${labelClass} pointer`; - const switchClassName = `gf-form-switch ${switchClass}`; + let labelClassName = `gf-form-label ${labelClass} pointer`; + let switchClassName = `gf-form-switch ${switchClass}`; + if (small) { + labelClassName += ' gf-form-label--small'; + switchClassName += ' gf-form-switch--small'; + } return (
diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index e6f317dbeb7..ab0a3f26a88 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -1,4 +1,6 @@ import _ from 'lodash'; +import { TimeSeries } from 'app/core/core'; +import colors from 'app/core/utils/colors'; export enum LogLevel { crit = 'crit', @@ -8,8 +10,20 @@ export enum LogLevel { info = 'info', debug = 'debug', trace = 'trace', + none = 'none', } +export const LogLevelColor = { + [LogLevel.crit]: colors[7], + [LogLevel.warn]: colors[1], + [LogLevel.err]: colors[4], + [LogLevel.error]: colors[4], + [LogLevel.info]: colors[0], + [LogLevel.debug]: colors[3], + [LogLevel.trace]: colors[3], + [LogLevel.none]: '#eee', +}; + export interface LogSearchMatch { start: number; length: number; @@ -17,27 +31,72 @@ export interface LogSearchMatch { } export interface LogRow { - key: string; entry: string; + key: string; // timestamp + labels + labels: string; logLevel: LogLevel; - timestamp: string; - timeFromNow: string; - timeLocal: string; searchWords?: string[]; + timestamp: string; // ISO with nanosec precision + timeFromNow: string; + timeEpochMs: number; + timeLocal: string; + uniqueLabels?: string; +} + +export interface LogsMetaItem { + label: string; + value: string; } export interface LogsModel { + meta?: LogsMetaItem[]; rows: LogRow[]; + series?: TimeSeries[]; } -export function mergeStreams(streams: LogsModel[], limit?: number): LogsModel { - const combinedEntries = streams.reduce((acc, stream) => { - return [...acc, ...stream.rows]; +export interface LogsStream { + labels: string; + entries: LogsStreamEntry[]; + search?: string; + parsedLabels?: LogsStreamLabels; + uniqueLabels?: string; +} + +export interface LogsStreamEntry { + line: string; + timestamp: string; +} + +export interface LogsStreamLabels { + [key: string]: string; +} + +export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] { + // Graph time series by log level + const seriesByLevel = {}; + rows.forEach(row => { + if (!seriesByLevel[row.logLevel]) { + seriesByLevel[row.logLevel] = { lastTs: null, datapoints: [], alias: row.logLevel }; + } + const levelSeries = seriesByLevel[row.logLevel]; + + // Bucket to nearest minute + const time = Math.round(row.timeEpochMs / intervalMs / 10) * intervalMs * 10; + // Entry for time + if (time === levelSeries.lastTs) { + levelSeries.datapoints[levelSeries.datapoints.length - 1][0]++; + } else { + levelSeries.datapoints.push([1, time]); + levelSeries.lastTs = time; + } + }); + + return Object.keys(seriesByLevel).reduce((acc, level) => { + if (seriesByLevel[level]) { + const gs = new TimeSeries(seriesByLevel[level]); + gs.setColor(LogLevelColor[level]); + acc.push(gs); + } + return acc; }, []); - const sortedEntries = _.chain(combinedEntries) - .sortBy('timestamp') - .reverse() - .slice(0, limit || combinedEntries.length) - .value(); - return { rows: sortedEntries }; } diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts index e8a7366beb5..16214679996 100644 --- a/public/app/core/utils/colors.ts +++ b/public/app/core/utils/colors.ts @@ -10,16 +10,16 @@ export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)'; export const REGION_FILL_ALPHA = 0.09; const colors = [ - '#7EB26D', - '#EAB839', - '#6ED0E0', - '#EF843C', - '#E24D42', - '#1F78C1', - '#BA43A9', - '#705DA0', - '#508642', - '#CCA300', + '#7EB26D', // 0: pale green + '#EAB839', // 1: mustard + '#6ED0E0', // 2: light blue + '#EF843C', // 3: orange + '#E24D42', // 4: red + '#1F78C1', // 5: ocean + '#BA43A9', // 6: purple + '#705DA0', // 7: violet + '#508642', // 8: dark green + '#CCA300', // 9: dark sand '#447EBC', '#C15C17', '#890F02', diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index af771bad5dd..753f158fd9f 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -25,10 +25,20 @@ import ErrorBoundary from './ErrorBoundary'; import TimePicker from './TimePicker'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; import { DataSource } from 'app/types/datasources'; -import { mergeStreams } from 'app/core/logs_model'; const MAX_HISTORY_ITEMS = 100; +function getIntervals(range: RawTimeRange, datasource, resolution: number): { interval: string; intervalMs: number } { + if (!datasource || !resolution) { + return { interval: '1s', intervalMs: 1000 }; + } + const absoluteRange: RawTimeRange = { + from: parseDate(range.from, false), + to: parseDate(range.to, true), + }; + return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); +} + function makeTimeSeriesList(dataList, options) { return dataList.map((seriesData, index) => { const datapoints = seriesData.datapoints || []; @@ -471,12 +481,7 @@ export class Explore extends React.PureComponent { targetOptions: { format: string; hinting?: boolean; instant?: boolean } ) { const { datasource, range } = this.state; - const resolution = this.el.offsetWidth; - const absoluteRange: RawTimeRange = { - from: parseDate(range.from, false), - to: parseDate(range.to, true), - }; - const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval); + const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth); const targets = [ { ...targetOptions, @@ -491,6 +496,7 @@ export class Explore extends React.PureComponent { return { interval, + intervalMs, targets, range: queryRange, }; @@ -759,6 +765,7 @@ export class Explore extends React.PureComponent { const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const exploreClass = split ? 'explore explore-split' : 'explore'; const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined; + const graphRangeIntervals = getIntervals(graphRange, datasource, this.el ? this.el.offsetWidth : 0); const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); @@ -770,9 +777,15 @@ export class Explore extends React.PureComponent { new TableModel(), ...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result) ); - const logsResult = mergeStreams( - queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result) - ); + const logsResult = + datasource && datasource.mergeStreams + ? datasource.mergeStreams( + _.flatten( + queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result) + ), + graphRangeIntervals.intervalMs + ) + : undefined; const loading = queryTransactions.some(qt => !qt.done); const showStartPages = StartPage && queryTransactions.length === 0; const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length; @@ -894,6 +907,7 @@ export class Explore extends React.PureComponent { height={graphHeight} loading={graphLoading} id={`explore-graph-${position}`} + onChangeTime={this.onChangeTime} range={graphRange} split={split} /> @@ -903,7 +917,15 @@ export class Explore extends React.PureComponent { ) : null} - {supportsLogs && showingLogs ? : null} + {supportsLogs && showingLogs ? ( + + ) : null} )} diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index bbb055067a2..9e4fea0d3de 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -5,6 +5,8 @@ import { withSize } from 'react-sizeme'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.time'; +import 'vendor/flot/jquery.flot.selection'; +import 'vendor/flot/jquery.flot.stack'; import { RawTimeRange } from 'app/types/series'; import * as dateMath from 'app/core/utils/datemath'; @@ -62,10 +64,10 @@ const FLOT_OPTIONS = { margin: { left: 0, right: 0 }, labelMarginX: 0, }, - // selection: { - // mode: 'x', - // color: '#666', - // }, + selection: { + mode: 'x', + color: '#666', + }, // crosshair: { // mode: 'x', // }, @@ -79,6 +81,8 @@ interface GraphProps { range: RawTimeRange; split?: boolean; size?: { width: number; height: number }; + userOptions?: any; + onChangeTime?: (range: RawTimeRange) => void; } interface GraphState { @@ -86,6 +90,8 @@ interface GraphState { } export class Graph extends PureComponent { + $el: any; + state = { showAllTimeSeries: false, }; @@ -98,6 +104,8 @@ export class Graph extends PureComponent { componentDidMount() { this.draw(); + this.$el = $(`#${this.props.id}`); + this.$el.bind('plotselected', this.onPlotSelected); } componentDidUpdate(prevProps: GraphProps) { @@ -112,6 +120,20 @@ export class Graph extends PureComponent { } } + componentWillUnmount() { + this.$el.unbind('plotselected', this.onPlotSelected); + } + + onPlotSelected = (event, ranges) => { + if (this.props.onChangeTime) { + const range = { + from: moment(ranges.xaxis.from), + to: moment(ranges.xaxis.to), + }; + this.props.onChangeTime(range); + } + }; + onShowAllTimeSeries = () => { this.setState( { @@ -122,7 +144,7 @@ export class Graph extends PureComponent { }; draw() { - const { range, size } = this.props; + const { range, size, userOptions = {} } = this.props; const data = this.getGraphData(); const $el = $(`#${this.props.id}`); @@ -153,12 +175,14 @@ export class Graph extends PureComponent { max: max, label: 'Datetime', ticks: ticks, + timezone: 'browser', timeformat: time_format(ticks, min, max), }, }; const options = { ...FLOT_OPTIONS, ...dynamicOptions, + ...userOptions, }; $.plot($el, series, options); } diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 278c5ee016d..edde5acba92 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -1,29 +1,135 @@ import React, { Fragment, PureComponent } from 'react'; import Highlighter from 'react-highlight-words'; +import { RawTimeRange } from 'app/types/series'; import { LogsModel } from 'app/core/logs_model'; import { findHighlightChunksInText } from 'app/core/utils/text'; +import { Switch } from 'app/core/components/Switch/Switch'; + +import Graph from './Graph'; + +const graphOptions = { + series: { + bars: { + show: true, + lineWidth: 5, + // barWidth: 10, + }, + // stack: true, + }, + yaxis: { + tickDecimals: 0, + }, +}; interface LogsProps { className?: string; data: LogsModel; loading: boolean; + position: string; + range?: RawTimeRange; + onChangeTime?: (range: RawTimeRange) => void; } -export default class Logs extends PureComponent { +interface LogsState { + showLabels: boolean; + showLocalTime: boolean; + showUtc: boolean; +} + +export default class Logs extends PureComponent { + state = { + showLabels: true, + showLocalTime: true, + showUtc: false, + }; + + onChangeLabels = (event: React.SyntheticEvent) => { + const target = event.target as HTMLInputElement; + this.setState({ + showLabels: target.checked, + }); + }; + + onChangeLocalTime = (event: React.SyntheticEvent) => { + const target = event.target as HTMLInputElement; + this.setState({ + showLocalTime: target.checked, + }); + }; + + onChangeUtc = (event: React.SyntheticEvent) => { + const target = event.target as HTMLInputElement; + this.setState({ + showUtc: target.checked, + }); + }; + render() { - const { className = '', data, loading = false } = this.props; + const { className = '', data, loading = false, position, range } = this.props; + const { showLabels, showLocalTime, showUtc } = this.state; const hasData = data && data.rows && data.rows.length > 0; + const cssColumnSizes = ['4px']; + if (showUtc) { + cssColumnSizes.push('minmax(100px, max-content)'); + } + if (showLocalTime) { + cssColumnSizes.push('minmax(100px, max-content)'); + } + if (showLabels) { + cssColumnSizes.push('minmax(100px, 25%)'); + } + cssColumnSizes.push('1fr'); + const logEntriesStyle = { + gridTemplateColumns: cssColumnSizes.join(' '), + }; + return (
+
+ +
+ +
+
+ + + + {hasData && + data.meta && ( +
+ {data.meta.map(item => ( +
+ {item.label}: + {item.value} +
+ ))} +
+ )} +
+
+
{loading &&
} -
+
{hasData && data.rows.map(row => (
-
{row.timeLocal}
+ {showUtc &&
{row.timestamp}
} + {showLocalTime &&
{row.timeLocal}
} + {showLabels && ( +
+ {row.labels} +
+ )}
{ const queryTargets = options.targets .filter(target => target.expr) .map(target => this.prepareQueryTarget(target, options)); @@ -91,17 +98,16 @@ export default class LoggingDatasource { return Promise.all(queries).then((results: any[]) => { // Flatten streams from multiple queries - const allStreams = results.reduce((acc, response, i) => { - const streams = response.data.streams || []; + const allStreams: LogsStream[] = results.reduce((acc, response, i) => { + const streams: LogsStream[] = response.data.streams || []; // Inject search for match highlighting - const search = queryTargets[i].regexp; + const search: string = queryTargets[i].regexp; streams.forEach(s => { s.search = search; }); return [...acc, ...streams]; }, []); - const model = processStreams(allStreams, DEFAULT_LIMIT); - return { data: model }; + return { data: allStreams }; }); } diff --git a/public/app/plugins/datasource/logging/result_transformer.test.ts b/public/app/plugins/datasource/logging/result_transformer.test.ts index c1e6913a388..6f88c301fcd 100644 --- a/public/app/plugins/datasource/logging/result_transformer.test.ts +++ b/public/app/plugins/datasource/logging/result_transformer.test.ts @@ -1,14 +1,21 @@ -import { LogLevel } from 'app/core/logs_model'; +import { LogLevel, LogsStream } from 'app/core/logs_model'; -import { getLogLevel } from './result_transformer'; +import { + findCommonLabels, + findUniqueLabels, + formatLabels, + getLogLevel, + mergeStreamsToLogs, + parseLabels, +} from './result_transformer'; describe('getLoglevel()', () => { it('returns no log level on empty line', () => { - expect(getLogLevel('')).toBe(undefined); + expect(getLogLevel('')).toBe(LogLevel.none); }); it('returns no log level on when level is part of a word', () => { - expect(getLogLevel('this is a warning')).toBe(undefined); + expect(getLogLevel('this is a warning')).toBe(LogLevel.none); }); it('returns log level on line contains a log level', () => { @@ -20,3 +27,129 @@ describe('getLoglevel()', () => { expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn); }); }); + +describe('parseLabels()', () => { + it('returns no labels on emtpy labels string', () => { + expect(parseLabels('')).toEqual({}); + expect(parseLabels('{}')).toEqual({}); + }); + + it('returns labels on labels string', () => { + expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: '"bar"', baz: '"42"' }); + }); +}); + +describe('formatLabels()', () => { + it('returns no labels on emtpy label set', () => { + expect(formatLabels({})).toEqual(''); + expect(formatLabels({}, 'foo')).toEqual('foo'); + }); + + it('returns label string on label set', () => { + expect(formatLabels({ foo: '"bar"', baz: '"42"' })).toEqual('{baz="42", foo="bar"}'); + }); +}); + +describe('findCommonLabels()', () => { + it('returns no common labels on empty sets', () => { + expect(findCommonLabels([{}])).toEqual({}); + expect(findCommonLabels([{}, {}])).toEqual({}); + }); + + it('returns no common labels on differing sets', () => { + expect(findCommonLabels([{ foo: '"bar"' }, {}])).toEqual({}); + expect(findCommonLabels([{}, { foo: '"bar"' }])).toEqual({}); + expect(findCommonLabels([{ baz: '42' }, { foo: '"bar"' }])).toEqual({}); + expect(findCommonLabels([{ foo: '42', baz: '"bar"' }, { foo: '"bar"' }])).toEqual({}); + }); + + it('returns the single labels set as common labels', () => { + expect(findCommonLabels([{ foo: '"bar"' }])).toEqual({ foo: '"bar"' }); + }); +}); + +describe('findUniqueLabels()', () => { + it('returns no uncommon labels on empty sets', () => { + expect(findUniqueLabels({}, {})).toEqual({}); + }); + + it('returns all labels given no common labels', () => { + expect(findUniqueLabels({ foo: '"bar"' }, {})).toEqual({ foo: '"bar"' }); + }); + + it('returns all labels except the common labels', () => { + expect(findUniqueLabels({ foo: '"bar"', baz: '"42"' }, { foo: '"bar"' })).toEqual({ baz: '"42"' }); + }); +}); + +describe('mergeStreamsToLogs()', () => { + it('returns empty logs given no streams', () => { + expect(mergeStreamsToLogs([]).rows).toEqual([]); + }); + + it('returns processed logs from single stream', () => { + const stream1: LogsStream = { + labels: '{foo="bar"}', + entries: [ + { + line: 'WARN boooo', + timestamp: '1970-01-01T00:00:00Z', + }, + ], + }; + expect(mergeStreamsToLogs([stream1]).rows).toMatchObject([ + { + entry: 'WARN boooo', + labels: '{foo="bar"}', + key: 'EK1970-01-01T00:00:00Z{foo="bar"}', + logLevel: 'warn', + uniqueLabels: '', + }, + ]); + }); + + it('returns merged logs from multiple streams sorted by time and with unique labels', () => { + const stream1: LogsStream = { + labels: '{foo="bar", baz="1"}', + entries: [ + { + line: 'WARN boooo', + timestamp: '1970-01-01T00:00:01Z', + }, + ], + }; + const stream2: LogsStream = { + labels: '{foo="bar", baz="2"}', + entries: [ + { + line: 'INFO 1', + timestamp: '1970-01-01T00:00:00Z', + }, + { + line: 'INFO 2', + timestamp: '1970-01-01T00:00:02Z', + }, + ], + }; + expect(mergeStreamsToLogs([stream1, stream2]).rows).toMatchObject([ + { + entry: 'INFO 2', + labels: '{foo="bar", baz="2"}', + logLevel: 'info', + uniqueLabels: '{baz="2"}', + }, + { + entry: 'WARN boooo', + labels: '{foo="bar", baz="1"}', + logLevel: 'warn', + uniqueLabels: '{baz="1"}', + }, + { + entry: 'INFO 1', + labels: '{foo="bar", baz="2"}', + logLevel: 'info', + uniqueLabels: '{baz="2"}', + }, + ]); + }); +}); diff --git a/public/app/plugins/datasource/logging/result_transformer.ts b/public/app/plugins/datasource/logging/result_transformer.ts index 526a9c7da2c..3d04deb7ed2 100644 --- a/public/app/plugins/datasource/logging/result_transformer.ts +++ b/public/app/plugins/datasource/logging/result_transformer.ts @@ -1,11 +1,26 @@ import _ from 'lodash'; import moment from 'moment'; -import { LogLevel, LogsModel, LogRow } from 'app/core/logs_model'; +import { + LogLevel, + LogsMetaItem, + LogsModel, + LogRow, + LogsStream, + LogsStreamEntry, + LogsStreamLabels, +} from 'app/core/logs_model'; +import { DEFAULT_LIMIT } from './datasource'; +/** + * Returns the log level of a log line. + * Parse the line for level words. If no level is found, it returns `LogLevel.none`. + * + * Example: `getLogLevel('WARN 1999-12-31 this is great') // LogLevel.warn` + */ export function getLogLevel(line: string): LogLevel { if (!line) { - return undefined; + return LogLevel.none; } let level: LogLevel; Object.keys(LogLevel).forEach(key => { @@ -16,37 +31,149 @@ export function getLogLevel(line: string): LogLevel { } } }); + if (!level) { + level = LogLevel.none; + } return level; } -export function processEntry(entry: { line: string; timestamp: string }, stream): LogRow { +/** + * Regexp to extract Prometheus-style labels + */ +const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g; + +/** + * Returns a map of label keys to value from an input selector string. + * + * Example: `parseLabels('{job="foo", instance="bar"}) // {job: "foo", instance: "bar"}` + */ +export function parseLabels(labels: string): LogsStreamLabels { + const labelsByKey: LogsStreamLabels = {}; + labels.replace(labelRegexp, (_, key, operator, value) => { + labelsByKey[key] = value; + return ''; + }); + return labelsByKey; +} + +/** + * Returns a map labels that are common to the given label sets. + */ +export function findCommonLabels(labelsSets: LogsStreamLabels[]): LogsStreamLabels { + return labelsSets.reduce((acc, labels) => { + if (!labels) { + throw new Error('Need parsed labels to find common labels.'); + } + if (!acc) { + // Initial set + acc = { ...labels }; + } else { + // Remove incoming labels that are missing or not matching in value + Object.keys(labels).forEach(key => { + if (acc[key] === undefined || acc[key] !== labels[key]) { + delete acc[key]; + } + }); + // Remove common labels that are missing from incoming label set + Object.keys(acc).forEach(key => { + if (labels[key] === undefined) { + delete acc[key]; + } + }); + } + return acc; + }, undefined); +} + +/** + * Returns a map of labels that are in `labels`, but not in `commonLabels`. + */ +export function findUniqueLabels(labels: LogsStreamLabels, commonLabels: LogsStreamLabels): LogsStreamLabels { + const uncommonLabels: LogsStreamLabels = { ...labels }; + Object.keys(commonLabels).forEach(key => { + delete uncommonLabels[key]; + }); + return uncommonLabels; +} + +/** + * Serializes the given labels to a string. + */ +export function formatLabels(labels: LogsStreamLabels, defaultValue = ''): string { + if (!labels || Object.keys(labels).length === 0) { + return defaultValue; + } + const labelKeys = Object.keys(labels).sort(); + const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(', '); + return ['{', cleanSelector, '}'].join(''); +} + +export function processEntry(entry: LogsStreamEntry, labels: string, uniqueLabels: string, search: string): LogRow { const { line, timestamp } = entry; - const { labels } = stream; + // Assumes unique-ness, needs nanosec precision for timestamp const key = `EK${timestamp}${labels}`; const time = moment(timestamp); + const timeEpochMs = time.valueOf(); const timeFromNow = time.fromNow(); const timeLocal = time.format('YYYY-MM-DD HH:mm:ss'); const logLevel = getLogLevel(line); return { key, + labels, logLevel, timeFromNow, + timeEpochMs, timeLocal, + uniqueLabels, entry: line, - searchWords: [stream.search], + searchWords: search ? [search] : [], timestamp: timestamp, }; } -export function processStreams(streams, limit?: number): LogsModel { - const combinedEntries = streams.reduce((acc, stream) => { - return [...acc, ...stream.entries.map(entry => processEntry(entry, stream))]; - }, []); - const sortedEntries = _.chain(combinedEntries) +export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT): LogsModel { + // Find unique labels for each stream + streams = streams.map(stream => ({ + ...stream, + parsedLabels: parseLabels(stream.labels), + })); + const commonLabels = findCommonLabels(streams.map(model => model.parsedLabels)); + streams = streams.map(stream => ({ + ...stream, + uniqueLabels: formatLabels(findUniqueLabels(stream.parsedLabels, commonLabels)), + })); + + // Merge stream entries into single list of log rows + const sortedRows: LogRow[] = _.chain(streams) + .reduce( + (acc: LogRow[], stream: LogsStream) => [ + ...acc, + ...stream.entries.map(entry => processEntry(entry, stream.labels, stream.uniqueLabels, stream.search)), + ], + [] + ) .sortBy('timestamp') .reverse() - .slice(0, limit || combinedEntries.length) .value(); - return { rows: sortedEntries }; + + // Meta data to display in status + const meta: LogsMetaItem[] = []; + if (_.size(commonLabels) > 0) { + meta.push({ + label: 'Common labels', + value: formatLabels(commonLabels), + }); + } + if (limit) { + meta.push({ + label: 'Limit', + value: `${limit} (${sortedRows.length} returned)`, + }); + } + + return { + meta, + rows: sortedRows, + }; } diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 0de386f3f68..6d83fc6cf0b 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -116,6 +116,11 @@ $input-border: 1px solid $input-border-color; color: $critical; } + &--small { + padding: ($input-padding-y / 2) ($input-padding-x / 2); + font-size: $font-size-xs; + } + &:disabled { color: $text-color-weak; } diff --git a/public/sass/components/_switch.scss b/public/sass/components/_switch.scss index 6eb01ecc32d..c368d8ead67 100644 --- a/public/sass/components/_switch.scss +++ b/public/sass/components/_switch.scss @@ -41,7 +41,6 @@ bottom: 0; right: 0; color: #fff; - font-size: $font-size-sm; text-align: center; font-size: 150%; display: flex; @@ -91,6 +90,20 @@ transform: rotateY(0); } + &--small { + max-width: 2rem; + min-width: 1.5rem; + + input + label { + height: 25px; + } + + input + label::before, + input + label::after { + font-size: $font-size-sm; + } + } + &--table-cell { margin-bottom: 0; margin-right: 0; diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index b70b058879c..0ca6d17fcf0 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -214,7 +214,42 @@ display: grid; grid-column-gap: 1rem; grid-row-gap: 0.1rem; - grid-template-columns: 4px minmax(100px, max-content) 1fr; + grid-template-columns: 4px minmax(100px, max-content) minmax(100px, 25%) 1fr; + font-family: $font-family-monospace; + font-size: 12px; + } + + .logs-controls { + display: flex; + + > * { + margin-right: 1em; + } + } + + .logs-options, + .logs-graph { + margin-bottom: $panel-margin; + } + + .logs-meta { + flex: 1; + color: $text-color-weak; + padding: 2px 0; + } + + .logs-meta-item { + display: inline-block; + margin-right: 1em; + } + + .logs-meta-item__label { + margin-right: 0.5em; + font-size: 0.9em; + font-weight: 500; + } + + .logs-meta-item__value { font-family: $font-family-monospace; } @@ -235,18 +270,26 @@ opacity: 0.8; } - .logs-row-level-crit, + .logs-row-level-crit { + background-color: #705da0; + } + .logs-row-level-error, .logs-row-level-err { - background-color: $red; + background-color: #e24d42; } .logs-row-level-warn { - background-color: $orange; + background-color: #eab839; } .logs-row-level-info { - background-color: $green; + background-color: #7eb26d; + } + + .logs-row-level-trace, + .logs-row-level-debug { + background-color: #1f78c1; } } }