Переглянути джерело

Merge pull request #13942 from grafana/davkal/explore-logging-graph

Explore: Logging graph overview and view options
David 7 роки тому
батько
коміт
203078280f

+ 8 - 3
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<Props, State> {
   };
 
   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 (
       <div className="gf-form">

+ 71 - 12
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;
+  searchWords?: string[];
+  timestamp: string; // ISO with nanosec precision
   timeFromNow: string;
+  timeEpochMs: number;
   timeLocal: string;
-  searchWords?: string[];
+  uniqueLabels?: string;
+}
+
+export interface LogsMetaItem {
+  label: string;
+  value: string;
 }
 
 export interface LogsModel {
+  meta?: LogsMetaItem[];
   rows: LogRow[];
+  series?: TimeSeries[];
+}
+
+export interface LogsStream {
+  labels: string;
+  entries: LogsStreamEntry[];
+  search?: string;
+  parsedLabels?: LogsStreamLabels;
+  uniqueLabels?: string;
 }
 
-export function mergeStreams(streams: LogsModel[], limit?: number): LogsModel {
-  const combinedEntries = streams.reduce((acc, stream) => {
-    return [...acc, ...stream.rows];
+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 };
 }

+ 10 - 10
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',

+ 33 - 11
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<ExploreProps, ExploreState> {
     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<ExploreProps, ExploreState> {
 
     return {
       interval,
+      intervalMs,
       targets,
       range: queryRange,
     };
@@ -759,6 +765,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     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<ExploreProps, ExploreState> {
       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<ExploreProps, ExploreState> {
                           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<ExploreProps, ExploreState> {
                         <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
                       </div>
                     ) : null}
-                    {supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
+                    {supportsLogs && showingLogs ? (
+                      <Logs
+                        data={logsResult}
+                        loading={logsLoading}
+                        position={position}
+                        onChangeTime={this.onChangeTime}
+                        range={range}
+                      />
+                    ) : null}
                   </>
                 )}
               </ErrorBoundary>

+ 29 - 5
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<GraphProps, GraphState> {
+  $el: any;
+
   state = {
     showAllTimeSeries: false,
   };
@@ -98,6 +104,8 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
 
   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<GraphProps, GraphState> {
     }
   }
 
+  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<GraphProps, GraphState> {
   };
 
   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<GraphProps, GraphState> {
         max: max,
         label: 'Datetime',
         ticks: ticks,
+        timezone: 'browser',
         timeformat: time_format(ticks, min, max),
       },
     };
     const options = {
       ...FLOT_OPTIONS,
       ...dynamicOptions,
+      ...userOptions,
     };
     $.plot($el, series, options);
   }

+ 110 - 4
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;
+}
+
+interface LogsState {
+  showLabels: boolean;
+  showLocalTime: boolean;
+  showUtc: boolean;
 }
 
-export default class Logs extends PureComponent<LogsProps, {}> {
+export default class Logs extends PureComponent<LogsProps, LogsState> {
+  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 (
       <div className={`${className} logs`}>
+        <div className="logs-graph">
+          <Graph
+            data={data.series}
+            height="100px"
+            range={range}
+            id={`explore-logs-graph-${position}`}
+            onChangeTime={this.props.onChangeTime}
+            userOptions={graphOptions}
+          />
+        </div>
+
+        <div className="panel-container logs-options">
+          <div className="logs-controls">
+            <Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} small />
+            <Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} small />
+            <Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} small />
+            {hasData &&
+              data.meta && (
+                <div className="logs-meta">
+                  {data.meta.map(item => (
+                    <div className="logs-meta-item" key={item.label}>
+                      <span className="logs-meta-item__label">{item.label}:</span>
+                      <span className="logs-meta-item__value">{item.value}</span>
+                    </div>
+                  ))}
+                </div>
+              )}
+          </div>
+        </div>
+
         <div className="panel-container">
           {loading && <div className="explore-panel__loader" />}
-          <div className="logs-entries">
+          <div className="logs-entries" style={logEntriesStyle}>
             {hasData &&
               data.rows.map(row => (
                 <Fragment key={row.key}>
                   <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
-                  <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
+                  {showUtc && <div title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>{row.timestamp}</div>}
+                  {showLocalTime && <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>}
+                  {showLabels && (
+                    <div className="max-width" title={row.labels}>
+                      {row.labels}
+                    </div>
+                  )}
                   <div>
                     <Highlighter
                       textToHighlight={row.entry}

+ 24 - 5
public/app/features/explore/TimePicker.tsx

@@ -16,6 +16,9 @@ export const DEFAULT_RANGE = {
  * @param value Epoch or relative time
  */
 export function parseTime(value: string, isUtc = false): string {
+  if (moment.isMoment(value)) {
+    return value;
+  }
   if (value.indexOf('now') !== -1) {
     return value;
   }
@@ -39,7 +42,8 @@ interface TimePickerState {
   isOpen: boolean;
   isUtc: boolean;
   rangeString: string;
-  refreshInterval: string;
+  refreshInterval?: string;
+  initialRange?: RawTimeRange;
 
   // Input-controlled text, keep these in a shape that is human-editable
   fromRaw: string;
@@ -52,6 +56,22 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
   constructor(props) {
     super(props);
 
+    this.state = {
+      isOpen: props.isOpen,
+      isUtc: props.isUtc,
+      rangeString: '',
+      fromRaw: '',
+      toRaw: '',
+      initialRange: DEFAULT_RANGE,
+      refreshInterval: '',
+    };
+  }
+
+  static getDerivedStateFromProps(props, state) {
+    if (state.initialRange && state.initialRange === props.range) {
+      return state;
+    }
+
     const from = props.range ? props.range.from : DEFAULT_RANGE.from;
     const to = props.range ? props.range.to : DEFAULT_RANGE.to;
 
@@ -63,13 +83,12 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
       to: toRaw,
     };
 
-    this.state = {
+    return {
+      ...state,
       fromRaw,
       toRaw,
-      isOpen: props.isOpen,
-      isUtc: props.isUtc,
+      initialRange: props.range,
       rangeString: rangeUtil.describeTimeRange(range),
-      refreshInterval: '',
     };
   }
 

+ 14 - 8
public/app/plugins/datasource/logging/datasource.ts

@@ -3,9 +3,10 @@ import _ from 'lodash';
 import * as dateMath from 'app/core/utils/datemath';
 
 import LanguageProvider from './language_provider';
-import { processStreams } from './result_transformer';
+import { mergeStreamsToLogs } from './result_transformer';
+import { LogsStream, LogsModel, makeSeriesForLogs } from 'app/core/logs_model';
 
-const DEFAULT_LIMIT = 100;
+export const DEFAULT_LIMIT = 1000;
 
 const DEFAULT_QUERY_PARAMS = {
   direction: 'BACKWARD',
@@ -67,6 +68,12 @@ export default class LoggingDatasource {
     return this.backendSrv.datasourceRequest(req);
   }
 
+  mergeStreams(streams: LogsStream[], intervalMs: number): LogsModel {
+    const logs = mergeStreamsToLogs(streams);
+    logs.series = makeSeriesForLogs(logs.rows, intervalMs);
+    return logs;
+  }
+
   prepareQueryTarget(target, options) {
     const interpolated = this.templateSrv.replace(target.expr);
     const start = this.getTime(options.range.from, false);
@@ -79,7 +86,7 @@ export default class LoggingDatasource {
     };
   }
 
-  query(options) {
+  query(options): Promise<{ data: LogsStream[] }> {
     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 };
     });
   }
 

+ 137 - 4
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"}',
+      },
+    ]);
+  });
+});

+ 139 - 12
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,
+  };
 }

+ 5 - 0
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;
   }

+ 14 - 1
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;

+ 48 - 5
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;
     }
   }
 }