Browse Source

Explore: Logging label filtering

- adds a custom label renderer to Logs viewer in Explore
- labels are no longer treated as strings, they are passed as parsed objects to the log row
- label renderer supports onClick handler for an action
- renamed Explore's `onClickTableCell` to `onClickLabel` and wired up log label renderers
- reuse Prometheus `addLabelToSelector` to modify Logging queries via click on label
- added tests to `addLabelToSelector`, changed to include the surrounding `{}`
- use label render also for common labels in the controls panel
- logging meta data section has now a custom renderer that can render numbers, strings, and labels
- style adjustments
David Kaltschmidt 7 years ago
parent
commit
c3b67f3a13

+ 11 - 4
public/app/core/logs_model.ts

@@ -35,19 +35,26 @@ export interface LogRow {
   duplicates?: number;
   entry: string;
   key: string; // timestamp + labels
-  labels: string;
+  labels: LogsStreamLabels;
   logLevel: LogLevel;
   searchWords?: string[];
   timestamp: string; // ISO with nanosec precision
   timeFromNow: string;
   timeEpochMs: number;
   timeLocal: string;
-  uniqueLabels?: string;
+  uniqueLabels?: LogsStreamLabels;
+}
+
+export enum LogsMetaKind {
+  Number,
+  String,
+  LabelsMap,
 }
 
 export interface LogsMetaItem {
   label: string;
-  value: string;
+  value: string | number | LogsStreamLabels;
+  kind: LogsMetaKind;
 }
 
 export interface LogsModel {
@@ -61,7 +68,7 @@ export interface LogsStream {
   entries: LogsStreamEntry[];
   search?: string;
   parsedLabels?: LogsStreamLabels;
-  uniqueLabels?: string;
+  uniqueLabels?: LogsStreamLabels;
 }
 
 export interface LogsStreamEntry {

+ 4 - 3
public/app/features/explore/Explore.tsx

@@ -429,8 +429,8 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     );
   };
 
-  onClickTableCell = (columnKey: string, rowValue: string) => {
-    this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
+  onClickLabel = (key: string, value: string) => {
+    this.onModifyQueries({ type: 'ADD_FILTER', key, value });
   };
 
   onModifyQueries = (action, index?: number) => {
@@ -931,7 +931,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
                         isOpen={showingTable}
                         onToggle={this.onClickTableButton}
                       >
-                        <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
+                        <Table data={tableResult} loading={tableLoading} onClickCell={this.onClickLabel} />
                       </Panel>
                     )}
                     {supportsLogs && (
@@ -941,6 +941,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
                           loading={logsLoading}
                           position={position}
                           onChangeTime={this.onChangeTime}
+                          onClickLabel={this.onClickLabel}
                           onStartScanning={this.onStartScanning}
                           onStopScanning={this.onStopScanning}
                           range={range}

+ 75 - 9
public/app/features/explore/Logs.tsx

@@ -1,9 +1,18 @@
+import _ from 'lodash';
 import React, { Fragment, PureComponent } from 'react';
 import Highlighter from 'react-highlight-words';
 
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import { RawTimeRange } from 'app/types/series';
-import { LogsDedupStrategy, LogsModel, dedupLogRows, filterLogLevels, LogLevel } from 'app/core/logs_model';
+import {
+  LogsDedupStrategy,
+  LogsModel,
+  dedupLogRows,
+  filterLogLevels,
+  LogLevel,
+  LogsStreamLabels,
+  LogsMetaKind,
+} from 'app/core/logs_model';
 import { findHighlightChunksInText } from 'app/core/utils/text';
 import { Switch } from 'app/core/components/Switch/Switch';
 
@@ -23,6 +32,51 @@ const graphOptions = {
   },
 };
 
+function renderMetaItem(value: any, kind: LogsMetaKind) {
+  if (kind === LogsMetaKind.LabelsMap) {
+    return (
+      <span className="logs-meta-item__value-labels">
+        <Labels labels={value} />
+      </span>
+    );
+  }
+  return value;
+}
+
+class Label extends PureComponent<{
+  label: string;
+  value: string;
+  onClickLabel?: (label: string, value: string) => void;
+}> {
+  onClickLabel = () => {
+    const { onClickLabel, label, value } = this.props;
+    if (onClickLabel) {
+      onClickLabel(label, value);
+    }
+  };
+
+  render() {
+    const { label, value } = this.props;
+    const tooltip = `${label}: ${value}`;
+    return (
+      <span className="logs-label" title={tooltip} onClick={this.onClickLabel}>
+        {value}
+      </span>
+    );
+  }
+}
+class Labels extends PureComponent<{
+  labels: LogsStreamLabels;
+  onClickLabel?: (label: string, value: string) => void;
+}> {
+  render() {
+    const { labels, onClickLabel } = this.props;
+    return Object.keys(labels).map(key => (
+      <Label key={key} label={key} value={labels[key]} onClickLabel={onClickLabel} />
+    ));
+  }
+}
+
 interface LogsProps {
   className?: string;
   data: LogsModel;
@@ -32,6 +86,7 @@ interface LogsProps {
   scanning?: boolean;
   scanRange?: RawTimeRange;
   onChangeTime?: (range: RawTimeRange) => void;
+  onClickLabel?: (label: string, value: string) => void;
   onStartScanning?: () => void;
   onStopScanning?: () => void;
 }
@@ -39,7 +94,7 @@ interface LogsProps {
 interface LogsState {
   dedup: LogsDedupStrategy;
   hiddenLogLevels: Set<LogLevel>;
-  showLabels: boolean;
+  showLabels: boolean | null; // Tristate: null means auto
   showLocalTime: boolean;
   showUtc: boolean;
 }
@@ -48,7 +103,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
   state = {
     dedup: LogsDedupStrategy.none,
     hiddenLogLevels: new Set(),
-    showLabels: true,
+    showLabels: null,
     showLocalTime: true,
     showUtc: false,
   };
@@ -99,9 +154,12 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
   };
 
   render() {
-    const { className = '', data, loading = false, position, range, scanning, scanRange } = this.props;
-    const { dedup, hiddenLogLevels, showLabels, showLocalTime, showUtc } = this.state;
+    const { className = '', data, loading = false, onClickLabel, position, range, scanning, scanRange } = this.props;
+    const { dedup, hiddenLogLevels, showLocalTime, showUtc } = this.state;
+    let { showLabels } = this.state;
     const hasData = data && data.rows && data.rows.length > 0;
+
+    // Filtering
     const filteredData = filterLogLevels(data, hiddenLogLevels);
     const dedupedData = dedupLogRows(filteredData, dedup);
     const dedupCount = dedupedData.rows.reduce((sum, row) => sum + row.duplicates, 0);
@@ -109,9 +167,17 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
     if (dedup !== LogsDedupStrategy.none) {
       meta.push({
         label: 'Dedup count',
-        value: String(dedupCount),
+        value: dedupCount,
+        kind: LogsMetaKind.Number,
       });
     }
+
+    // Check for labels
+    if (showLabels === null && hasData) {
+      showLabels = data.rows.some(row => _.size(row.uniqueLabels) > 0);
+    }
+
+    // Grid options
     const cssColumnSizes = ['3px']; // Log-level indicator line
     if (showUtc) {
       cssColumnSizes.push('minmax(100px, max-content)');
@@ -177,7 +243,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
                   {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>
+                      <span className="logs-meta-item__value">{renderMetaItem(item.value, item.kind)}</span>
                     </div>
                   ))}
                 </div>
@@ -201,8 +267,8 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
                 {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 className="logs-row-labels">
+                    <Labels labels={row.uniqueLabels} onClickLabel={onClickLabel} />
                   </div>
                 )}
                 <div>

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

@@ -3,9 +3,11 @@ import _ from 'lodash';
 import * as dateMath from 'app/core/utils/datemath';
 import { LogsStream, LogsModel, makeSeriesForLogs } from 'app/core/logs_model';
 import { PluginMeta, DataQuery } from 'app/types';
+import { addLabelToSelector } from 'app/plugins/datasource/prometheus/add_label_to_query';
 
 import LanguageProvider from './language_provider';
 import { mergeStreamsToLogs } from './result_transformer';
+import { formatQuery, parseQuery } from './query_utils';
 
 export const DEFAULT_LIMIT = 1000;
 
@@ -16,20 +18,6 @@ const DEFAULT_QUERY_PARAMS = {
   query: '',
 };
 
-const selectorRegexp = /{[^{]*}/g;
-export function parseQuery(input: string) {
-  const match = input.match(selectorRegexp);
-  let query = '';
-  let regexp = input;
-
-  if (match) {
-    query = match[0];
-    regexp = input.replace(selectorRegexp, '').trim();
-  }
-
-  return { query, regexp };
-}
-
 function serializeParams(data: any) {
   return Object.keys(data)
     .map(k => {
@@ -114,6 +102,21 @@ export default class LoggingDatasource {
     });
   }
 
+  modifyQuery(query: DataQuery, action: any): DataQuery {
+    const parsed = parseQuery(query.expr || '');
+    let selector = parsed.query;
+    switch (action.type) {
+      case 'ADD_FILTER': {
+        selector = addLabelToSelector(selector, action.key, action.value);
+        break;
+      }
+      default:
+        break;
+    }
+    const expression = formatQuery(selector, parsed.regexp);
+    return { ...query, expr: expression };
+  }
+
   getTime(date, roundUp) {
     if (_.isString(date)) {
       date = dateMath.parse(date, roundUp);

+ 1 - 1
public/app/plugins/datasource/logging/datasource.test.ts → public/app/plugins/datasource/logging/query_utils.test.ts

@@ -1,4 +1,4 @@
-import { parseQuery } from './datasource';
+import { parseQuery } from './query_utils';
 
 describe('parseQuery', () => {
   it('returns empty for empty string', () => {

+ 17 - 0
public/app/plugins/datasource/logging/query_utils.ts

@@ -0,0 +1,17 @@
+const selectorRegexp = /{[^{]*}/g;
+export function parseQuery(input: string) {
+  const match = input.match(selectorRegexp);
+  let query = '';
+  let regexp = input;
+
+  if (match) {
+    query = match[0];
+    regexp = input.replace(selectorRegexp, '').trim();
+  }
+
+  return { query, regexp };
+}
+
+export function formatQuery(selector: string, search: string): string {
+  return `${selector || ''} ${search || ''}`.trim();
+}

+ 15 - 15
public/app/plugins/datasource/logging/result_transformer.test.ts

@@ -41,7 +41,7 @@ describe('parseLabels()', () => {
   });
 
   it('returns labels on labels string', () => {
-    expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: '"bar"', baz: '"42"' });
+    expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: 'bar', baz: '42' });
   });
 });
 
@@ -52,7 +52,7 @@ describe('formatLabels()', () => {
   });
 
   it('returns label string on label set', () => {
-    expect(formatLabels({ foo: '"bar"', baz: '"42"' })).toEqual('{baz="42", foo="bar"}');
+    expect(formatLabels({ foo: 'bar', baz: '42' })).toEqual('{baz="42", foo="bar"}');
   });
 });
 
@@ -63,14 +63,14 @@ describe('findCommonLabels()', () => {
   });
 
   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({});
+    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"' });
+    expect(findCommonLabels([{ foo: 'bar' }])).toEqual({ foo: 'bar' });
   });
 });
 
@@ -106,10 +106,10 @@ describe('mergeStreamsToLogs()', () => {
     expect(mergeStreamsToLogs([stream1]).rows).toMatchObject([
       {
         entry: 'WARN boooo',
-        labels: '{foo="bar"}',
+        labels: { foo: 'bar' },
         key: 'EK1970-01-01T00:00:00Z{foo="bar"}',
         logLevel: 'warning',
-        uniqueLabels: '',
+        uniqueLabels: {},
       },
     ]);
   });
@@ -140,21 +140,21 @@ describe('mergeStreamsToLogs()', () => {
     expect(mergeStreamsToLogs([stream1, stream2]).rows).toMatchObject([
       {
         entry: 'INFO 2',
-        labels: '{foo="bar", baz="2"}',
+        labels: { foo: 'bar', baz: '2' },
         logLevel: 'info',
-        uniqueLabels: '{baz="2"}',
+        uniqueLabels: { baz: '2' },
       },
       {
         entry: 'WARN boooo',
-        labels: '{foo="bar", baz="1"}',
+        labels: { foo: 'bar', baz: '1' },
         logLevel: 'warning',
-        uniqueLabels: '{baz="1"}',
+        uniqueLabels: { baz: '1' },
       },
       {
         entry: 'INFO 1',
-        labels: '{foo="bar", baz="2"}',
+        labels: { foo: 'bar', baz: '2' },
         logLevel: 'info',
-        uniqueLabels: '{baz="2"}',
+        uniqueLabels: { baz: '2' },
       },
     ]);
   });

+ 18 - 7
public/app/plugins/datasource/logging/result_transformer.ts

@@ -9,6 +9,7 @@ import {
   LogsStream,
   LogsStreamEntry,
   LogsStreamLabels,
+  LogsMetaKind,
 } from 'app/core/logs_model';
 import { DEFAULT_LIMIT } from './datasource';
 
@@ -40,7 +41,7 @@ export function getLogLevel(line: string): LogLevel {
 /**
  * Regexp to extract Prometheus-style labels
  */
-const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
+const labelRegexp = /\b(\w+)(!?=~?)"([^"\n]*?)"/g;
 
 /**
  * Returns a map of label keys to value from an input selector string.
@@ -104,11 +105,17 @@ export function formatLabels(labels: LogsStreamLabels, defaultValue = ''): strin
     return defaultValue;
   }
   const labelKeys = Object.keys(labels).sort();
-  const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(', ');
+  const cleanSelector = labelKeys.map(key => `${key}="${labels[key]}"`).join(', ');
   return ['{', cleanSelector, '}'].join('');
 }
 
-export function processEntry(entry: LogsStreamEntry, labels: string, uniqueLabels: string, search: string): LogRow {
+export function processEntry(
+  entry: LogsStreamEntry,
+  labels: string,
+  parsedLabels: LogsStreamLabels,
+  uniqueLabels: LogsStreamLabels,
+  search: string
+): LogRow {
   const { line, timestamp } = entry;
   // Assumes unique-ness, needs nanosec precision for timestamp
   const key = `EK${timestamp}${labels}`;
@@ -120,13 +127,13 @@ export function processEntry(entry: LogsStreamEntry, labels: string, uniqueLabel
 
   return {
     key,
-    labels,
     logLevel,
     timeFromNow,
     timeEpochMs,
     timeLocal,
     uniqueLabels,
     entry: line,
+    labels: parsedLabels,
     searchWords: search ? [search] : [],
     timestamp: timestamp,
   };
@@ -141,7 +148,7 @@ export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT)
   const commonLabels = findCommonLabels(streams.map(model => model.parsedLabels));
   streams = streams.map(stream => ({
     ...stream,
-    uniqueLabels: formatLabels(findUniqueLabels(stream.parsedLabels, commonLabels)),
+    uniqueLabels: findUniqueLabels(stream.parsedLabels, commonLabels),
   }));
 
   // Merge stream entries into single list of log rows
@@ -149,7 +156,9 @@ export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT)
     .reduce(
       (acc: LogRow[], stream: LogsStream) => [
         ...acc,
-        ...stream.entries.map(entry => processEntry(entry, stream.labels, stream.uniqueLabels, stream.search)),
+        ...stream.entries.map(entry =>
+          processEntry(entry, stream.labels, stream.parsedLabels, stream.uniqueLabels, stream.search)
+        ),
       ],
       []
     )
@@ -162,13 +171,15 @@ export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT)
   if (_.size(commonLabels) > 0) {
     meta.push({
       label: 'Common labels',
-      value: formatLabels(commonLabels),
+      value: commonLabels,
+      kind: LogsMetaKind.LabelsMap,
     });
   }
   if (limit) {
     meta.push({
       label: 'Limit',
       value: `${limit} (${sortedRows.length} returned)`,
+      kind: LogsMetaKind.String,
     });
   }
 

+ 5 - 3
public/app/plugins/datasource/prometheus/add_label_to_query.ts

@@ -49,7 +49,7 @@ export function addLabelToQuery(query: string, key: string, value: string, opera
     const selectorWithLabel = addLabelToSelector(selector, key, value, operator);
     lastIndex = match.index + match[1].length + 2;
     suffix = query.slice(match.index + match[0].length);
-    parts.push(prefix, '{', selectorWithLabel, '}');
+    parts.push(prefix, selectorWithLabel);
     match = selectorRegexp.exec(query);
   }
 
@@ -59,7 +59,7 @@ export function addLabelToQuery(query: string, key: string, value: string, opera
 
 const labelRegexp = /(\w+)\s*(=|!=|=~|!~)\s*("[^"]*")/g;
 
-function addLabelToSelector(selector: string, labelKey: string, labelValue: string, labelOperator?: string) {
+export function addLabelToSelector(selector: string, labelKey: string, labelValue: string, labelOperator?: string) {
   const parsedLabels = [];
 
   // Split selector into labels
@@ -76,13 +76,15 @@ function addLabelToSelector(selector: string, labelKey: string, labelValue: stri
   parsedLabels.push({ key: labelKey, operator: operatorForLabelKey, value: `"${labelValue}"` });
 
   // Sort labels by key and put them together
-  return _.chain(parsedLabels)
+  const formatted = _.chain(parsedLabels)
     .uniqWith(_.isEqual)
     .compact()
     .sortBy('key')
     .map(({ key, operator, value }) => `${key}${operator}${value}`)
     .value()
     .join(',');
+
+  return `{${formatted}}`;
 }
 
 function isPositionInsideChars(text: string, position: number, openChar: string, closeChar: string) {

+ 14 - 1
public/app/plugins/datasource/prometheus/specs/add_label_to_query.test.ts

@@ -1,4 +1,4 @@
-import addLabelToQuery from '../add_label_to_query';
+import { addLabelToQuery, addLabelToSelector } from '../add_label_to_query';
 
 describe('addLabelToQuery()', () => {
   it('should add label to simple query', () => {
@@ -56,3 +56,16 @@ describe('addLabelToQuery()', () => {
     );
   });
 });
+
+describe('addLabelToSelector()', () => {
+  test('should add a label to an empty selector', () => {
+    expect(addLabelToSelector('{}', 'foo', 'bar')).toBe('{foo="bar"}');
+    expect(addLabelToSelector('', 'foo', 'bar')).toBe('{foo="bar"}');
+  });
+  test('should add a label to a selector', () => {
+    expect(addLabelToSelector('{foo="bar"}', 'baz', '42')).toBe('{baz="42",foo="bar"}');
+  });
+  test('should add a label to a selector with custom operator', () => {
+    expect(addLabelToSelector('{}', 'baz', '42', '!=')).toBe('{baz!="42"}');
+  });
+});

+ 29 - 2
public/sass/pages/_explore.scss

@@ -261,6 +261,8 @@
       border-radius: $border-radius;
       margin: 2*$panel-margin 0;
       border: $panel-border;
+      justify-items: flex-start;
+      align-items: flex-start;
 
       > * {
         margin-right: 1em;
@@ -276,11 +278,11 @@
     .logs-meta {
       flex: 1;
       color: $text-color-weak;
-      padding: 2px 0;
+      // Align first line with controls labels
+      margin-top: -2px;
     }
 
     .logs-meta-item {
-      display: inline-block;
       margin-right: 1em;
     }
 
@@ -294,6 +296,12 @@
       font-family: $font-family-monospace;
     }
 
+    .logs-meta-item__value-labels {
+      // compensate for the labels padding
+      position: relative;
+      top: 4px;
+    }
+
     .logs-row-match-highlight {
       // Undoing mark styling
       background: inherit;
@@ -356,6 +364,25 @@
       background-color: #1f78c1;
       margin: 0 1px 1px 0;
     }
+
+    .logs-label {
+      display: inline-block;
+      padding: 0 2px;
+      background-color: $btn-inverse-bg;
+      border-radius: $border-radius;
+      margin-right: 4px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .logs-row-labels {
+      line-height: 1.2;
+
+      .logs-label {
+        cursor: pointer;
+      }
+    }
   }
 }