Browse Source

Refactor: Move LogLevel and Labels utils to @grafana/ui (#16285)

* rename Tags to Labels in SeriesData

* move some logs stuff to grafana/ui

* add roundtrip tests
Ryan McKinley 6 years ago
parent
commit
bfba47c6c4

+ 1 - 0
packages/grafana-ui/src/types/index.ts

@@ -7,4 +7,5 @@ export * from './theme';
 export * from './graph';
 export * from './graph';
 export * from './threshold';
 export * from './threshold';
 export * from './input';
 export * from './input';
+export * from './logs';
 export * from './displayValue';
 export * from './displayValue';

+ 21 - 0
packages/grafana-ui/src/types/logs.ts

@@ -0,0 +1,21 @@
+/**
+ * Mapping of log level abbreviation to canonical log level.
+ * Supported levels are reduce to limit color variation.
+ */
+export enum LogLevel {
+  emerg = 'critical',
+  alert = 'critical',
+  crit = 'critical',
+  critical = 'critical',
+  warn = 'warning',
+  warning = 'warning',
+  err = 'error',
+  eror = 'error',
+  error = 'error',
+  info = 'info',
+  notice = 'info',
+  dbug = 'debug',
+  debug = 'debug',
+  trace = 'trace',
+  unknown = 'unknown',
+}

+ 2 - 0
packages/grafana-ui/src/utils/index.ts

@@ -8,5 +8,7 @@ export * from './csv';
 export * from './statsCalculator';
 export * from './statsCalculator';
 export * from './displayValue';
 export * from './displayValue';
 export * from './deprecationWarning';
 export * from './deprecationWarning';
+export * from './logs';
+export * from './labels';
 export { getMappedValue } from './valueMappings';
 export { getMappedValue } from './valueMappings';
 export * from './validate';
 export * from './validate';

+ 55 - 0
packages/grafana-ui/src/utils/labels.test.ts

@@ -0,0 +1,55 @@
+import { parseLabels, formatLabels, findCommonLabels, findUniqueLabels } from './labels';
+
+describe('parseLabels()', () => {
+  it('returns no labels on empty 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 empty 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"' });
+  });
+});

+ 75 - 0
packages/grafana-ui/src/utils/labels.ts

@@ -0,0 +1,75 @@
+import { Labels } from '../types/data';
+
+/**
+ * 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): Labels {
+  const labelsByKey: Labels = {};
+  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: Labels[]): Labels {
+  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 as unknown) as Labels
+  );
+}
+
+/**
+ * Returns a map of labels that are in `labels`, but not in `commonLabels`.
+ */
+export function findUniqueLabels(labels: Labels, commonLabels: Labels): Labels {
+  const uncommonLabels: Labels = { ...labels };
+  Object.keys(commonLabels).forEach(key => {
+    delete uncommonLabels[key];
+  });
+  return uncommonLabels;
+}
+
+/**
+ * Serializes the given labels to a string.
+ */
+export function formatLabels(labels: Labels, 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('');
+}

+ 27 - 0
packages/grafana-ui/src/utils/logs.test.ts

@@ -0,0 +1,27 @@
+import { LogLevel } from '../types/logs';
+import { getLogLevel } from './logs';
+
+describe('getLoglevel()', () => {
+  it('returns no log level on empty line', () => {
+    expect(getLogLevel('')).toBe(LogLevel.unknown);
+  });
+
+  it('returns no log level on when level is part of a word', () => {
+    expect(getLogLevel('this is information')).toBe(LogLevel.unknown);
+  });
+
+  it('returns same log level for long and short version', () => {
+    expect(getLogLevel('[Warn]')).toBe(LogLevel.warning);
+    expect(getLogLevel('[Warning]')).toBe(LogLevel.warning);
+    expect(getLogLevel('[Warn]')).toBe('warning');
+  });
+
+  it('returns log level on line contains a log level', () => {
+    expect(getLogLevel('warn: it is looking bad')).toBe(LogLevel.warn);
+    expect(getLogLevel('2007-12-12 12:12:12 [WARN]: it is looking bad')).toBe(LogLevel.warn);
+  });
+
+  it('returns first log level found', () => {
+    expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn);
+  });
+});

+ 35 - 0
packages/grafana-ui/src/utils/logs.ts

@@ -0,0 +1,35 @@
+import { LogLevel } from '../types/logs';
+import { SeriesData, FieldType } from '../types/data';
+
+/**
+ * Returns the log level of a log line.
+ * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`.
+ *
+ * Example: `getLogLevel('WARN 1999-12-31 this is great') // LogLevel.warn`
+ */
+export function getLogLevel(line: string): LogLevel {
+  if (!line) {
+    return LogLevel.unknown;
+  }
+  for (const key of Object.keys(LogLevel)) {
+    const regexp = new RegExp(`\\b${key}\\b`, 'i');
+    if (regexp.test(line)) {
+      const level = (LogLevel as any)[key];
+      if (level) {
+        return level;
+      }
+    }
+  }
+  return LogLevel.unknown;
+}
+
+export function addLogLevelToSeries(series: SeriesData, lineIndex: number): SeriesData {
+  return {
+    ...series, // Keeps Tags, RefID etc
+    fields: [...series.fields, { name: 'LogLevel', type: FieldType.string }],
+    rows: series.rows.map(row => {
+      const line = row[lineIndex];
+      return [...row, getLogLevel(line)];
+    }),
+  };
+}

+ 3 - 3
packages/grafana-ui/src/utils/processSeriesData.ts

@@ -89,7 +89,7 @@ export function guessFieldTypeFromValue(v: any): FieldType {
 /**
 /**
  * Looks at the data to guess the column type.  This ignores any existing setting
  * Looks at the data to guess the column type.  This ignores any existing setting
  */
  */
-function guessFieldTypeFromTable(series: SeriesData, index: number): FieldType | undefined {
+export function guessFieldTypeFromSeries(series: SeriesData, index: number): FieldType | undefined {
   const column = series.fields[index];
   const column = series.fields[index];
 
 
   // 1. Use the column name to guess
   // 1. Use the column name to guess
@@ -129,7 +129,7 @@ export const guessFieldTypes = (series: SeriesData): SeriesData => {
           // Replace it with a calculated version
           // Replace it with a calculated version
           return {
           return {
             ...field,
             ...field,
-            type: guessFieldTypeFromTable(series, index),
+            type: guessFieldTypeFromSeries(series, index),
           };
           };
         }),
         }),
       };
       };
@@ -162,7 +162,7 @@ export const toLegacyResponseData = (series: SeriesData): TimeSeries | TableData
   const { fields, rows } = series;
   const { fields, rows } = series;
 
 
   if (fields.length === 2) {
   if (fields.length === 2) {
-    const type = guessFieldTypeFromTable(series, 1);
+    const type = guessFieldTypeFromSeries(series, 1);
     if (type === FieldType.time) {
     if (type === FieldType.time) {
       return {
       return {
         target: fields[0].name || series.name,
         target: fields[0].name || series.name,

+ 6 - 32
public/app/core/logs_model.ts

@@ -1,30 +1,8 @@
 import _ from 'lodash';
 import _ from 'lodash';
 
 
-import { colors, TimeSeries } from '@grafana/ui';
+import { colors, TimeSeries, Labels, LogLevel } from '@grafana/ui';
 import { getThemeColor } from 'app/core/utils/colors';
 import { getThemeColor } from 'app/core/utils/colors';
 
 
-/**
- * Mapping of log level abbreviation to canonical log level.
- * Supported levels are reduce to limit color variation.
- */
-export enum LogLevel {
-  emerg = 'critical',
-  alert = 'critical',
-  crit = 'critical',
-  critical = 'critical',
-  warn = 'warning',
-  warning = 'warning',
-  err = 'error',
-  eror = 'error',
-  error = 'error',
-  info = 'info',
-  notice = 'info',
-  dbug = 'debug',
-  debug = 'debug',
-  trace = 'trace',
-  unknown = 'unknown',
-}
-
 export const LogLevelColor = {
 export const LogLevelColor = {
   [LogLevel.critical]: colors[7],
   [LogLevel.critical]: colors[7],
   [LogLevel.warning]: colors[1],
   [LogLevel.warning]: colors[1],
@@ -46,7 +24,7 @@ export interface LogRowModel {
   entry: string;
   entry: string;
   hasAnsi: boolean;
   hasAnsi: boolean;
   key: string; // timestamp + labels
   key: string; // timestamp + labels
-  labels: LogsStreamLabels;
+  labels: Labels;
   logLevel: LogLevel;
   logLevel: LogLevel;
   raw: string;
   raw: string;
   searchWords?: string[];
   searchWords?: string[];
@@ -54,7 +32,7 @@ export interface LogRowModel {
   timeFromNow: string;
   timeFromNow: string;
   timeEpochMs: number;
   timeEpochMs: number;
   timeLocal: string;
   timeLocal: string;
-  uniqueLabels?: LogsStreamLabels;
+  uniqueLabels?: Labels;
 }
 }
 
 
 export interface LogLabelStatsModel {
 export interface LogLabelStatsModel {
@@ -72,7 +50,7 @@ export enum LogsMetaKind {
 
 
 export interface LogsMetaItem {
 export interface LogsMetaItem {
   label: string;
   label: string;
-  value: string | number | LogsStreamLabels;
+  value: string | number | Labels;
   kind: LogsMetaKind;
   kind: LogsMetaKind;
 }
 }
 
 
@@ -88,8 +66,8 @@ export interface LogsStream {
   labels: string;
   labels: string;
   entries: LogsStreamEntry[];
   entries: LogsStreamEntry[];
   search?: string;
   search?: string;
-  parsedLabels?: LogsStreamLabels;
-  uniqueLabels?: LogsStreamLabels;
+  parsedLabels?: Labels;
+  uniqueLabels?: Labels;
 }
 }
 
 
 export interface LogsStreamEntry {
 export interface LogsStreamEntry {
@@ -99,10 +77,6 @@ export interface LogsStreamEntry {
   timestamp?: string;
   timestamp?: string;
 }
 }
 
 
-export interface LogsStreamLabels {
-  [key: string]: string;
-}
-
 export enum LogsDedupDescription {
 export enum LogsDedupDescription {
   none = 'No de-duplication',
   none = 'No de-duplication',
   exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.',
   exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.',

+ 3 - 2
public/app/features/explore/LogLabels.tsx

@@ -1,11 +1,12 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 
 
-import { LogsStreamLabels, LogRowModel } from 'app/core/logs_model';
+import { LogRowModel } from 'app/core/logs_model';
 import { LogLabel } from './LogLabel';
 import { LogLabel } from './LogLabel';
+import { Labels } from '@grafana/ui';
 
 
 interface Props {
 interface Props {
   getRows?: () => LogRowModel[];
   getRows?: () => LogRowModel[];
-  labels: LogsStreamLabels;
+  labels: Labels;
   plain?: boolean;
   plain?: boolean;
   onClickLabel?: (label: string, value: string) => void;
   onClickLabel?: (label: string, value: string) => void;
 }
 }

+ 2 - 2
public/app/features/explore/Logs.tsx

@@ -2,10 +2,10 @@ import _ from 'lodash';
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 
 
 import * as rangeUtil from 'app/core/utils/rangeutil';
 import * as rangeUtil from 'app/core/utils/rangeutil';
-import { RawTimeRange, Switch } from '@grafana/ui';
+import { RawTimeRange, Switch, LogLevel } from '@grafana/ui';
 import TimeSeries from 'app/core/time_series2';
 import TimeSeries from 'app/core/time_series2';
 
 
-import { LogsDedupDescription, LogsDedupStrategy, LogsModel, LogLevel, LogsMetaKind } from 'app/core/logs_model';
+import { LogsDedupDescription, LogsDedupStrategy, LogsModel, LogsMetaKind } from 'app/core/logs_model';
 
 
 import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
 import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
 
 

+ 2 - 2
public/app/features/explore/LogsContainer.tsx

@@ -1,10 +1,10 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 import { connect } from 'react-redux';
-import { RawTimeRange, TimeRange } from '@grafana/ui';
+import { RawTimeRange, TimeRange, LogLevel } from '@grafana/ui';
 
 
 import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { ExploreId, ExploreItemState } from 'app/types/explore';
-import { LogsModel, LogsDedupStrategy, LogLevel } from 'app/core/logs_model';
+import { LogsModel, LogsDedupStrategy } from 'app/core/logs_model';
 import { StoreState } from 'app/types';
 import { StoreState } from 'app/types';
 
 
 import { toggleLogs, changeDedupStrategy } from './state/actions';
 import { toggleLogs, changeDedupStrategy } from './state/actions';

+ 1 - 1
public/app/features/explore/state/actionTypes.ts

@@ -7,6 +7,7 @@ import {
   DataSourceSelectItem,
   DataSourceSelectItem,
   DataSourceApi,
   DataSourceApi,
   QueryFixAction,
   QueryFixAction,
+  LogLevel,
 } from '@grafana/ui/src/types';
 } from '@grafana/ui/src/types';
 import {
 import {
   ExploreId,
   ExploreId,
@@ -18,7 +19,6 @@ import {
   ExploreUIState,
   ExploreUIState,
 } from 'app/types/explore';
 } from 'app/types/explore';
 import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
 import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
-import { LogLevel } from 'app/core/logs_model';
 
 
 /**  Higher order actions
 /**  Higher order actions
  *
  *

+ 36 - 88
public/app/plugins/datasource/loki/result_transformer.test.ts

@@ -1,92 +1,6 @@
-import { LogLevel, LogsStream } from 'app/core/logs_model';
+import { LogsStream } from 'app/core/logs_model';
 
 
-import {
-  findCommonLabels,
-  findUniqueLabels,
-  formatLabels,
-  getLogLevel,
-  mergeStreamsToLogs,
-  parseLabels,
-} from './result_transformer';
-
-describe('getLoglevel()', () => {
-  it('returns no log level on empty line', () => {
-    expect(getLogLevel('')).toBe(LogLevel.unknown);
-  });
-
-  it('returns no log level on when level is part of a word', () => {
-    expect(getLogLevel('this is information')).toBe(LogLevel.unknown);
-  });
-
-  it('returns same log level for long and short version', () => {
-    expect(getLogLevel('[Warn]')).toBe(LogLevel.warning);
-    expect(getLogLevel('[Warning]')).toBe(LogLevel.warning);
-    expect(getLogLevel('[Warn]')).toBe('warning');
-  });
-
-  it('returns log level on line contains a log level', () => {
-    expect(getLogLevel('warn: it is looking bad')).toBe(LogLevel.warn);
-    expect(getLogLevel('2007-12-12 12:12:12 [WARN]: it is looking bad')).toBe(LogLevel.warn);
-  });
-
-  it('returns first log level found', () => {
-    expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn);
-  });
-});
-
-describe('parseLabels()', () => {
-  it('returns no labels on empty 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 empty 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"' });
-  });
-});
+import { mergeStreamsToLogs, logStreamToSeriesData, seriesDataToLogStream } from './result_transformer';
 
 
 describe('mergeStreamsToLogs()', () => {
 describe('mergeStreamsToLogs()', () => {
   it('returns empty logs given no streams', () => {
   it('returns empty logs given no streams', () => {
@@ -201,3 +115,37 @@ describe('mergeStreamsToLogs()', () => {
     ]);
     ]);
   });
   });
 });
 });
+
+describe('convert SeriesData to/from LogStream', () => {
+  const streams = [
+    {
+      labels: '{foo="bar"}',
+      entries: [
+        {
+          line: "foo: 'bar'",
+          ts: '1970-01-01T00:00:00Z',
+        },
+      ],
+    },
+    {
+      labels: '{bar="foo"}',
+      entries: [
+        {
+          line: "bar: 'foo'",
+          ts: '1970-01-01T00:00:00Z',
+        },
+      ],
+    },
+  ];
+  it('converts streams to series', () => {
+    const data = streams.map(stream => logStreamToSeriesData(stream));
+
+    expect(data.length).toBe(2);
+    expect(data[0].labels['foo']).toEqual('bar');
+    expect(data[0].rows[0][0]).toEqual(streams[0].entries[0].ts);
+
+    const roundtrip = data.map(series => seriesDataToLogStream(series));
+    expect(roundtrip.length).toBe(2);
+    expect(roundtrip[0].labels).toEqual(streams[0].labels);
+  });
+});

+ 59 - 107
public/app/plugins/datasource/loki/result_transformer.ts

@@ -2,120 +2,27 @@ import ansicolor from 'vendor/ansicolor/ansicolor';
 import _ from 'lodash';
 import _ from 'lodash';
 import moment from 'moment';
 import moment from 'moment';
 
 
-import {
-  LogLevel,
-  LogsMetaItem,
-  LogsModel,
-  LogRowModel,
-  LogsStream,
-  LogsStreamEntry,
-  LogsStreamLabels,
-  LogsMetaKind,
-} from 'app/core/logs_model';
+import { LogsMetaItem, LogsModel, LogRowModel, LogsStream, LogsStreamEntry, LogsMetaKind } from 'app/core/logs_model';
 import { hasAnsiCodes } from 'app/core/utils/text';
 import { hasAnsiCodes } from 'app/core/utils/text';
 import { DEFAULT_MAX_LINES } from './datasource';
 import { DEFAULT_MAX_LINES } from './datasource';
 
 
-/**
- * Returns the log level of a log line.
- * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`.
- *
- * Example: `getLogLevel('WARN 1999-12-31 this is great') // LogLevel.warn`
- */
-export function getLogLevel(line: string): LogLevel {
-  if (!line) {
-    return LogLevel.unknown;
-  }
-  let level: LogLevel;
-  Object.keys(LogLevel).forEach(key => {
-    if (!level) {
-      const regexp = new RegExp(`\\b${key}\\b`, 'i');
-      if (regexp.test(line)) {
-        level = LogLevel[key];
-      }
-    }
-  });
-  if (!level) {
-    level = LogLevel.unknown;
-  }
-  return level;
-}
-
-/**
- * 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('');
-}
+import {
+  parseLabels,
+  SeriesData,
+  findUniqueLabels,
+  Labels,
+  findCommonLabels,
+  getLogLevel,
+  FieldType,
+  formatLabels,
+  guessFieldTypeFromSeries,
+} from '@grafana/ui';
 
 
 export function processEntry(
 export function processEntry(
   entry: LogsStreamEntry,
   entry: LogsStreamEntry,
   labels: string,
   labels: string,
-  parsedLabels: LogsStreamLabels,
-  uniqueLabels: LogsStreamLabels,
+  parsedLabels: Labels,
+  uniqueLabels: Labels,
   search: string
   search: string
 ): LogRowModel {
 ): LogRowModel {
   const { line } = entry;
   const { line } = entry;
@@ -201,3 +108,48 @@ export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_MAX_LI
     rows: sortedRows,
     rows: sortedRows,
   };
   };
 }
 }
+
+export function logStreamToSeriesData(stream: LogsStream): SeriesData {
+  let labels: Labels = stream.parsedLabels;
+  if (!labels && stream.labels) {
+    labels = parseLabels(stream.labels);
+  }
+  return {
+    labels,
+    fields: [{ name: 'ts', type: FieldType.time }, { name: 'line', type: FieldType.string }],
+    rows: stream.entries.map(entry => {
+      return [entry.ts || entry.timestamp, entry.line];
+    }),
+  };
+}
+
+export function seriesDataToLogStream(series: SeriesData): LogsStream {
+  let timeIndex = -1;
+  let lineIndex = -1;
+  for (let i = 0; i < series.fields.length; i++) {
+    const field = series.fields[i];
+    const type = field.type || guessFieldTypeFromSeries(series, i);
+    if (timeIndex < 0 && type === FieldType.time) {
+      timeIndex = i;
+    }
+    if (lineIndex < 0 && type === FieldType.string) {
+      lineIndex = i;
+    }
+  }
+  if (timeIndex < 0) {
+    throw new Error('Series does not have a time field');
+  }
+  if (lineIndex < 0) {
+    throw new Error('Series does not have a line field');
+  }
+  return {
+    labels: formatLabels(series.labels),
+    parsedLabels: series.labels,
+    entries: series.rows.map(row => {
+      return {
+        line: row[lineIndex],
+        ts: row[timeIndex],
+      };
+    }),
+  };
+}

+ 2 - 1
public/app/types/explore.ts

@@ -9,10 +9,11 @@ import {
   DataSourceApi,
   DataSourceApi,
   QueryHint,
   QueryHint,
   ExploreStartPageProps,
   ExploreStartPageProps,
+  LogLevel,
 } from '@grafana/ui';
 } from '@grafana/ui';
 
 
 import { Emitter, TimeSeries } from 'app/core/core';
 import { Emitter, TimeSeries } from 'app/core/core';
-import { LogsModel, LogsDedupStrategy, LogLevel } from 'app/core/logs_model';
+import { LogsModel, LogsDedupStrategy } from 'app/core/logs_model';
 import TableModel from 'app/core/table_model';
 import TableModel from 'app/core/table_model';
 
 
 export interface CompletionItem {
 export interface CompletionItem {