Bläddra i källkod

Dashboard: Adds Logs Panel (alpha) as visualization option for Dashboards (#18641)

* WIP: intial commit

* Switch: Adds tooltip

* Refactor: Adds props to LogsPanelEditor

* Refactor: Moves LogRowContextProvider to grafana/ui

* Refactor: Moves LogRowContext and Alert to grafana/ui

* Refactor: Moves LogLabelStats to grafana/ui

* Refactor: Moves LogLabels and LogLabel to grafana/ui

* Refactor: Moves LogMessageAnsi and ansicolor to grafana/ui

* Refactor: Moves calculateFieldStats, LogsParsers and getParser to grafana/data

* Refactor: Moves findHighlightChunksInText to grafana/data

* Refactor: Moves LogRow to grafana/ui

* Refactor: Moving ExploreGraphPanel to grafana/ui

* Refactor: Copies Logs to grafana/ui

* Refactor: Moves ToggleButtonGroup to grafana/ui

* Refactor: Adds Logs to LogsPanel

* Refactor: Moves styles to emotion

* Feature: Adds LogsRows

* Refactor: Introduces render limit

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Refactor: Adds sorting to LogsPanelEditor

* Tests: Adds tests for sorting

* Refactor: Changes according to PR comments

* Refactor: Changes according to PR comments

* Refactor: Moves Logs and ExploreGraphPanel out of grafana/ui

* Fix: Shows the Show context label again
Hugo Häggmark 6 år sedan
förälder
incheckning
e5e7bd3153
55 ändrade filer med 1763 tillägg och 1291 borttagningar
  1. 7 0
      packages/grafana-data/src/types/logs.ts
  2. 1 0
      packages/grafana-data/src/utils/index.ts
  3. 188 1
      packages/grafana-data/src/utils/logs.test.ts
  4. 100 1
      packages/grafana-data/src/utils/logs.ts
  5. 0 0
      packages/grafana-data/src/utils/text.test.ts
  6. 84 0
      packages/grafana-data/src/utils/text.ts
  7. 0 0
      packages/grafana-ui/src/components/Alert/Alert.tsx
  8. 124 0
      packages/grafana-ui/src/components/Collapse/Collapse.tsx
  9. 3 3
      packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx
  10. 126 0
      packages/grafana-ui/src/components/Logs/LogLabel.tsx
  11. 98 0
      packages/grafana-ui/src/components/Logs/LogLabelStats.tsx
  12. 92 0
      packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx
  13. 43 0
      packages/grafana-ui/src/components/Logs/LogLabels.tsx
  14. 0 0
      packages/grafana-ui/src/components/Logs/LogMessageAnsi.test.tsx
  15. 1 1
      packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx
  16. 65 37
      packages/grafana-ui/src/components/Logs/LogRow.tsx
  17. 14 16
      packages/grafana-ui/src/components/Logs/LogRowContext.tsx
  18. 25 15
      packages/grafana-ui/src/components/Logs/LogRowContextProvider.test.ts
  19. 12 9
      packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx
  20. 143 0
      packages/grafana-ui/src/components/Logs/LogRows.tsx
  21. 133 0
      packages/grafana-ui/src/components/Logs/getLogRowStyles.ts
  22. 3 2
      packages/grafana-ui/src/components/Switch/Switch.story.tsx
  23. 2 2
      packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx
  24. 7 0
      packages/grafana-ui/src/components/index.ts
  25. 33 32
      packages/grafana-ui/src/utils/ansicolor.ts
  26. 1 0
      packages/grafana-ui/src/utils/index.ts
  27. 1 101
      public/app/core/logs_model.ts
  28. 1 195
      public/app/core/specs/logs_model.test.ts
  29. 97 1
      public/app/core/utils/explore.test.ts
  30. 10 3
      public/app/core/utils/explore.ts
  31. 0 79
      public/app/core/utils/text.ts
  32. 51 4
      public/app/features/explore/Explore.tsx
  33. 79 69
      public/app/features/explore/ExploreGraphPanel.tsx
  34. 1 2
      public/app/features/explore/ExploreToolbar.tsx
  35. 6 5
      public/app/features/explore/LiveLogs.tsx
  36. 0 75
      public/app/features/explore/LogLabel.tsx
  37. 0 76
      public/app/features/explore/LogLabelStats.tsx
  38. 0 31
      public/app/features/explore/LogLabels.tsx
  39. 60 108
      public/app/features/explore/Logs.tsx
  40. 6 9
      public/app/features/explore/LogsContainer.tsx
  41. 0 43
      public/app/features/explore/Panel.tsx
  42. 4 4
      public/app/features/explore/TableContainer.tsx
  43. 3 1
      public/app/features/explore/state/reducers.ts
  44. 9 4
      public/app/features/explore/utils/ResultProcessor.ts
  45. 2 0
      public/app/features/plugins/built_in_plugins.ts
  46. 1 2
      public/app/plugins/panel/graph2/GraphPanelController.tsx
  47. 39 0
      public/app/plugins/panel/logs/LogsPanel.tsx
  48. 46 0
      public/app/plugins/panel/logs/LogsPanelEditor.tsx
  49. 8 0
      public/app/plugins/panel/logs/img/icn-logs-panel.svg
  50. 6 0
      public/app/plugins/panel/logs/module.tsx
  51. 17 0
      public/app/plugins/panel/logs/plugin.json
  52. 11 0
      public/app/plugins/panel/logs/types.ts
  53. 0 7
      public/app/types/explore.ts
  54. 0 262
      public/sass/components/_panel_logs.scss
  55. 0 91
      public/sass/pages/_explore.scss

+ 7 - 0
packages/grafana-data/src/types/logs.ts

@@ -105,3 +105,10 @@ export interface LogsParser {
    */
   test: (line: string) => any;
 }
+
+export enum LogsDedupDescription {
+  none = 'No de-duplication',
+  exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.',
+  numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.',
+  signature = 'De-duplication of successive lines that have identical punctuation and whitespace.',
+}

+ 1 - 0
packages/grafana-data/src/utils/index.ts

@@ -11,6 +11,7 @@ export * from './labels';
 export * from './object';
 export * from './moment_wrapper';
 export * from './thresholds';
+export * from './text';
 export * from './dataFrameHelper';
 export * from './dataFrameView';
 export * from './vector';

+ 188 - 1
packages/grafana-data/src/utils/logs.test.ts

@@ -1,5 +1,5 @@
 import { LogLevel } from '../types/logs';
-import { getLogLevel } from './logs';
+import { getLogLevel, calculateLogsLabelStats, calculateFieldStats, getParser, LogsParsers } from './logs';
 
 describe('getLoglevel()', () => {
   it('returns no log level on empty line', () => {
@@ -25,3 +25,190 @@ describe('getLoglevel()', () => {
     expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn);
   });
 });
+
+describe('calculateLogsLabelStats()', () => {
+  test('should return no stats for empty rows', () => {
+    expect(calculateLogsLabelStats([], '')).toEqual([]);
+  });
+
+  test('should return no stats of label is not found', () => {
+    const rows = [
+      {
+        entry: 'foo 1',
+        labels: {
+          foo: 'bar',
+        },
+      },
+    ];
+
+    expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]);
+  });
+
+  test('should return stats for found labels', () => {
+    const rows = [
+      {
+        entry: 'foo 1',
+        labels: {
+          foo: 'bar',
+        },
+      },
+      {
+        entry: 'foo 0',
+        labels: {
+          foo: 'xxx',
+        },
+      },
+      {
+        entry: 'foo 2',
+        labels: {
+          foo: 'bar',
+        },
+      },
+    ];
+
+    expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([
+      {
+        value: 'bar',
+        count: 2,
+      },
+      {
+        value: 'xxx',
+        count: 1,
+      },
+    ]);
+  });
+});
+
+describe('LogsParsers', () => {
+  describe('logfmt', () => {
+    const parser = LogsParsers.logfmt;
+
+    test('should detect format', () => {
+      expect(parser.test('foo')).toBeFalsy();
+      expect(parser.test('foo=bar')).toBeTruthy();
+    });
+
+    test('should return parsed fields', () => {
+      expect(parser.getFields('foo=bar baz="42 + 1"')).toEqual(['foo=bar', 'baz="42 + 1"']);
+    });
+
+    test('should return label for field', () => {
+      expect(parser.getLabelFromField('foo=bar')).toBe('foo');
+    });
+
+    test('should return value for field', () => {
+      expect(parser.getValueFromField('foo=bar')).toBe('bar');
+    });
+
+    test('should build a valid value matcher', () => {
+      const matcher = parser.buildMatcher('foo');
+      const match = 'foo=bar'.match(matcher);
+      expect(match).toBeDefined();
+      expect(match![1]).toBe('bar');
+    });
+  });
+
+  describe('JSON', () => {
+    const parser = LogsParsers.JSON;
+
+    test('should detect format', () => {
+      expect(parser.test('foo')).toBeFalsy();
+      expect(parser.test('{"foo":"bar"}')).toBeTruthy();
+    });
+
+    test('should return parsed fields', () => {
+      expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo" : "bar"', '"baz" : 42']);
+    });
+
+    test('should return parsed fields for nested quotes', () => {
+      expect(parser.getFields(`{"foo":"bar: '[value=\\"42\\"]'"}`)).toEqual([`"foo":"bar: '[value=\\"42\\"]'"`]);
+    });
+
+    test('should return label for field', () => {
+      expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo');
+    });
+
+    test('should return value for field', () => {
+      expect(parser.getValueFromField('"foo" : "bar"')).toBe('"bar"');
+      expect(parser.getValueFromField('"foo" : 42')).toBe('42');
+      expect(parser.getValueFromField('"foo" : 42.1')).toBe('42.1');
+    });
+
+    test('should build a valid value matcher for strings', () => {
+      const matcher = parser.buildMatcher('foo');
+      const match = '{"foo":"bar"}'.match(matcher);
+      expect(match).toBeDefined();
+      expect(match![1]).toBe('bar');
+    });
+
+    test('should build a valid value matcher for integers', () => {
+      const matcher = parser.buildMatcher('foo');
+      const match = '{"foo":42.1}'.match(matcher);
+      expect(match).toBeDefined();
+      expect(match![1]).toBe('42.1');
+    });
+  });
+});
+
+describe('calculateFieldStats()', () => {
+  test('should return no stats for empty rows', () => {
+    expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]);
+  });
+
+  test('should return no stats if extractor does not match', () => {
+    const rows = [
+      {
+        entry: 'foo=bar',
+      },
+    ];
+
+    expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]);
+  });
+
+  test('should return stats for found field', () => {
+    const rows = [
+      {
+        entry: 'foo="42 + 1"',
+      },
+      {
+        entry: 'foo=503 baz=foo',
+      },
+      {
+        entry: 'foo="42 + 1"',
+      },
+      {
+        entry: 't=2018-12-05T07:44:59+0000 foo=503',
+      },
+    ];
+
+    expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([
+      {
+        value: '"42 + 1"',
+        count: 2,
+      },
+      {
+        value: '503',
+        count: 2,
+      },
+    ]);
+  });
+});
+
+describe('getParser()', () => {
+  test('should return no parser on empty line', () => {
+    expect(getParser('')).toBeUndefined();
+  });
+
+  test('should return no parser on unknown line pattern', () => {
+    expect(getParser('To Be or not to be')).toBeUndefined();
+  });
+
+  test('should return logfmt parser on key value patterns', () => {
+    expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt);
+  });
+
+  test('should return JSON parser on JSON log lines', () => {
+    // TODO implement other JSON value types than string
+    expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON);
+  });
+});

+ 100 - 1
packages/grafana-data/src/utils/logs.ts

@@ -1,7 +1,11 @@
-import { LogLevel } from '../types/logs';
+import { countBy, chain, map, escapeRegExp } from 'lodash';
+
+import { LogLevel, LogRowModel, LogLabelStatsModel, LogsParser } from '../types/logs';
 import { DataFrame, FieldType } from '../types/index';
 import { ArrayVector } from './vector';
 
+const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/;
+
 /**
  * Returns the log level of a log line.
  * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`.
@@ -54,3 +58,98 @@ export function addLogLevelToSeries(series: DataFrame, lineIndex: number): DataF
     ],
   };
 }
+
+export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
+  // Consider only rows that have the given label
+  const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
+  const rowCount = rowsWithLabel.length;
+
+  // Get label value counts for eligible rows
+  const countsByValue = countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]);
+  const sortedCounts = chain(countsByValue)
+    .map((count, value) => ({ count, value, proportion: count / rowCount }))
+    .sortBy('count')
+    .reverse()
+    .value();
+
+  return sortedCounts;
+}
+
+export const LogsParsers: { [name: string]: LogsParser } = {
+  JSON: {
+    buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`),
+    getFields: line => {
+      const fields: string[] = [];
+      try {
+        const parsed = JSON.parse(line);
+        map(parsed, (value, key) => {
+          const fieldMatcher = new RegExp(`"${key}"\\s*:\\s*"?${escapeRegExp(JSON.stringify(value))}"?`);
+
+          const match = line.match(fieldMatcher);
+          if (match) {
+            fields.push(match[0]);
+          }
+        });
+      } catch {}
+      return fields;
+    },
+    getLabelFromField: field => (field.match(/^"(\w+)"\s*:/) || [])[1],
+    getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1],
+    test: line => {
+      try {
+        return JSON.parse(line);
+      } catch (error) {}
+    },
+  },
+
+  logfmt: {
+    buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`),
+    getFields: line => {
+      const fields: string[] = [];
+      line.replace(new RegExp(LOGFMT_REGEXP, 'g'), substring => {
+        fields.push(substring.trim());
+        return '';
+      });
+      return fields;
+    },
+    getLabelFromField: field => (field.match(LOGFMT_REGEXP) || [])[1],
+    getValueFromField: field => (field.match(LOGFMT_REGEXP) || [])[2],
+    test: line => LOGFMT_REGEXP.test(line),
+  },
+};
+
+export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] {
+  // Consider only rows that satisfy the matcher
+  const rowsWithField = rows.filter(row => extractor.test(row.entry));
+  const rowCount = rowsWithField.length;
+
+  // Get field value counts for eligible rows
+  const countsByValue = countBy(rowsWithField, r => {
+    const row: LogRowModel = r;
+    const match = row.entry.match(extractor);
+
+    return match ? match[1] : null;
+  });
+  const sortedCounts = chain(countsByValue)
+    .map((count, value) => ({ count, value, proportion: count / rowCount }))
+    .sortBy('count')
+    .reverse()
+    .value();
+
+  return sortedCounts;
+}
+
+export function getParser(line: string): LogsParser | undefined {
+  let parser;
+  try {
+    if (LogsParsers.JSON.test(line)) {
+      parser = LogsParsers.JSON;
+    }
+  } catch (error) {}
+
+  if (!parser && LogsParsers.logfmt.test(line)) {
+    parser = LogsParsers.logfmt;
+  }
+
+  return parser;
+}

+ 0 - 0
public/app/core/utils/text.test.ts → packages/grafana-data/src/utils/text.test.ts


+ 84 - 0
packages/grafana-data/src/utils/text.ts

@@ -0,0 +1,84 @@
+export interface TextMatch {
+  text: string;
+  start: number;
+  length: number;
+  end: number;
+}
+
+/**
+ * Adapt findMatchesInText for react-highlight-words findChunks handler.
+ * See https://github.com/bvaughn/react-highlight-words#props
+ */
+export function findHighlightChunksInText({
+  searchWords,
+  textToHighlight,
+}: {
+  searchWords: string[];
+  textToHighlight: string;
+}) {
+  return searchWords.reduce((acc: any, term: string) => [...acc, ...findMatchesInText(textToHighlight, term)], []);
+}
+
+const cleanNeedle = (needle: string): string => {
+  return needle.replace(/[[{(][\w,.-?:*+]+$/, '');
+};
+
+/**
+ * Returns a list of substring regexp matches.
+ */
+export function findMatchesInText(haystack: string, needle: string): TextMatch[] {
+  // Empty search can send re.exec() into infinite loop, exit early
+  if (!haystack || !needle) {
+    return [];
+  }
+  const matches: TextMatch[] = [];
+  const { cleaned, flags } = parseFlags(cleanNeedle(needle));
+  let regexp: RegExp;
+  try {
+    regexp = new RegExp(`(?:${cleaned})`, flags);
+  } catch (error) {
+    return matches;
+  }
+  haystack.replace(regexp, (substring, ...rest) => {
+    if (substring) {
+      const offset = rest[rest.length - 2];
+      matches.push({
+        text: substring,
+        start: offset,
+        length: substring.length,
+        end: offset + substring.length,
+      });
+    }
+    return '';
+  });
+  return matches;
+}
+
+const CLEAR_FLAG = '-';
+const FLAGS_REGEXP = /\(\?([ims-]+)\)/g;
+
+/**
+ * Converts any mode modifers in the text to the Javascript equivalent flag
+ */
+export function parseFlags(text: string): { cleaned: string; flags: string } {
+  const flags: Set<string> = new Set(['g']);
+
+  const cleaned = text.replace(FLAGS_REGEXP, (str, group) => {
+    const clearAll = group.startsWith(CLEAR_FLAG);
+
+    for (let i = 0; i < group.length; ++i) {
+      const flag = group.charAt(i);
+      if (clearAll || group.charAt(i - 1) === CLEAR_FLAG) {
+        flags.delete(flag);
+      } else if (flag !== CLEAR_FLAG) {
+        flags.add(flag);
+      }
+    }
+    return ''; // Remove flag from text
+  });
+
+  return {
+    cleaned: cleaned,
+    flags: Array.from(flags).join(''),
+  };
+}

+ 0 - 0
public/app/features/explore/Error.tsx → packages/grafana-ui/src/components/Alert/Alert.tsx


+ 124 - 0
packages/grafana-ui/src/components/Collapse/Collapse.tsx

@@ -0,0 +1,124 @@
+import React, { FunctionComponent, useContext } from 'react';
+import { css, cx } from 'emotion';
+
+import { GrafanaTheme } from '../../types/theme';
+import { selectThemeVariant } from '../../themes/selectThemeVariant';
+import { ThemeContext } from '../../themes/index';
+
+const getStyles = (theme: GrafanaTheme) => ({
+  collapse: css`
+    label: collapse;
+    margin-top: ${theme.spacing.sm};
+  `,
+  collapseBody: css`
+    label: collapse__body;
+    padding: ${theme.panelPadding};
+  `,
+  loader: css`
+    label: collapse__loader;
+    height: 2px;
+    position: relative;
+    overflow: hidden;
+    background: none;
+    margin: ${theme.spacing.xs};
+  `,
+  loaderActive: css`
+    label: collapse__loader_active;
+    &:after {
+      content: ' ';
+      display: block;
+      width: 25%;
+      top: 0;
+      top: -50%;
+      height: 250%;
+      position: absolute;
+      animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67) 500ms;
+      animation-iteration-count: 100;
+      left: -25%;
+      background: ${theme.colors.blue};
+    }
+    @keyframes loader {
+      from {
+        left: -25%;
+        opacity: 0.1;
+      }
+      to {
+        left: 100%;
+        opacity: 1;
+      }
+    }
+  `,
+  header: css`
+    label: collapse__header;
+    padding: ${theme.spacing.sm} ${theme.spacing.md} 0 ${theme.spacing.md};
+    display: flex;
+    cursor: inherit;
+    transition: all 0.1s linear;
+    cursor: pointer;
+  `,
+  headerCollapsed: css`
+    label: collapse__header--collapsed;
+    cursor: pointer;
+  `,
+  headerButtons: css`
+    label: collapse__header-buttons;
+    margin-right: ${theme.spacing.sm};
+    font-size: ${theme.typography.size.lg};
+    line-height: ${theme.typography.heading.h6};
+    display: inherit;
+  `,
+  headerButtonsCollapsed: css`
+    label: collapse__header-buttons--collapsed;
+    display: none;
+  `,
+  headerLabel: css`
+    label: collapse__header-label;
+    font-weight: ${theme.typography.weight.semibold};
+    margin-right: ${theme.spacing.sm};
+    font-size: ${theme.typography.heading.h6};
+    box-shadow: ${selectThemeVariant({ light: 'none', dark: '1px 1px 4px rgb(45, 45, 45)' }, theme.type)};
+  `,
+});
+
+interface Props {
+  isOpen: boolean;
+  label: string;
+  loading?: boolean;
+  collapsible?: boolean;
+  onToggle?: (isOpen: boolean) => void;
+}
+
+export const Collapse: FunctionComponent<Props> = ({ isOpen, label, loading, collapsible, onToggle, children }) => {
+  const theme = useContext(ThemeContext);
+  const style = getStyles(theme);
+  const onClickToggle = () => {
+    if (onToggle) {
+      onToggle(!isOpen);
+    }
+  };
+
+  const panelClass = cx([style.collapse, 'panel-container']);
+  const iconClass = isOpen ? 'fa fa-caret-up' : 'fa fa-caret-down';
+  const loaderClass = loading ? cx([style.loader, style.loaderActive]) : cx([style.loader]);
+  const headerClass = collapsible ? cx([style.header]) : cx([style.headerCollapsed]);
+  const headerButtonsClass = collapsible ? cx([style.headerButtons]) : cx([style.headerButtonsCollapsed]);
+
+  return (
+    <div className={panelClass}>
+      <div className={headerClass} onClick={onClickToggle}>
+        <div className={headerButtonsClass}>
+          <span className={iconClass} />
+        </div>
+        <div className={cx([style.headerLabel])}>{label}</div>
+      </div>
+      {isOpen && (
+        <div className={cx([style.collapseBody])}>
+          <div className={loaderClass} />
+          {children}
+        </div>
+      )}
+    </div>
+  );
+};
+
+Collapse.displayName = 'Collapse';

+ 3 - 3
public/app/plugins/panel/graph2/GraphSeriesToggler.tsx → packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx

@@ -3,18 +3,18 @@ import { GraphSeriesXY } from '@grafana/data';
 import difference from 'lodash/difference';
 import isEqual from 'lodash/isEqual';
 
-interface GraphSeriesTogglerAPI {
+export interface GraphSeriesTogglerAPI {
   onSeriesToggle: (label: string, event: React.MouseEvent<HTMLElement>) => void;
   toggledSeries: GraphSeriesXY[];
 }
 
-interface GraphSeriesTogglerProps {
+export interface GraphSeriesTogglerProps {
   children: (api: GraphSeriesTogglerAPI) => JSX.Element;
   series: GraphSeriesXY[];
   onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
 }
 
-interface GraphSeriesTogglerState {
+export interface GraphSeriesTogglerState {
   hiddenSeries: string[];
   toggledSeries: GraphSeriesXY[];
 }

+ 126 - 0
packages/grafana-ui/src/components/Logs/LogLabel.tsx

@@ -0,0 +1,126 @@
+import React, { PureComponent } from 'react';
+import { css, cx } from 'emotion';
+import { LogRowModel, LogLabelStatsModel, calculateLogsLabelStats } from '@grafana/data';
+
+import { LogLabelStats } from './LogLabelStats';
+import { GrafanaTheme, Themeable } from '../../types/theme';
+import { selectThemeVariant } from '../../themes/selectThemeVariant';
+import { withTheme } from '../../themes/ThemeContext';
+
+const getStyles = (theme: GrafanaTheme) => {
+  return {
+    logsLabel: css`
+      label: logs-label;
+      display: flex;
+      padding: 0 2px;
+      background-color: ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark6 }, theme.type)};
+      border-radius: ${theme.border.radius};
+      margin: 0 4px 2px 0;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      overflow: hidden;
+    `,
+    logsLabelValue: css`
+      label: logs-label__value;
+      display: inline-block;
+      max-width: 20em;
+      text-overflow: ellipsis;
+      overflow: hidden;
+    `,
+    logsLabelIcon: css`
+      label: logs-label__icon;
+      border-left: solid 1px ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark1 }, theme.type)};
+      padding: 0 2px;
+      cursor: pointer;
+      margin-left: 2px;
+    `,
+    logsLabelStats: css`
+      position: absolute;
+      top: 1.25em;
+      left: -10px;
+      z-index: 100;
+      justify-content: space-between;
+      box-shadow: 0 0 20px ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.black }, theme.type)};
+    `,
+  };
+};
+
+interface Props extends Themeable {
+  value: string;
+  label: string;
+  getRows: () => LogRowModel[];
+  plain?: boolean;
+  onClickLabel?: (label: string, value: string) => void;
+}
+
+interface State {
+  showStats: boolean;
+  stats: LogLabelStatsModel[];
+}
+
+class UnThemedLogLabel extends PureComponent<Props, State> {
+  state: State = {
+    stats: [],
+    showStats: false,
+  };
+
+  onClickClose = () => {
+    this.setState({ showStats: false });
+  };
+
+  onClickLabel = () => {
+    const { onClickLabel, label, value } = this.props;
+    if (onClickLabel) {
+      onClickLabel(label, value);
+    }
+  };
+
+  onClickStats = () => {
+    this.setState(state => {
+      if (state.showStats) {
+        return { showStats: false, stats: [] };
+      }
+      const allRows = this.props.getRows();
+      const stats = calculateLogsLabelStats(allRows, this.props.label);
+      return { showStats: true, stats };
+    });
+  };
+
+  render() {
+    const { getRows, label, plain, value, theme } = this.props;
+    const styles = getStyles(theme);
+    const { showStats, stats } = this.state;
+    const tooltip = `${label}: ${value}`;
+    return (
+      <span className={cx([styles.logsLabel])}>
+        <span className={cx([styles.logsLabelValue])} title={tooltip}>
+          {value}
+        </span>
+        {!plain && (
+          <span
+            title="Filter for label"
+            onClick={this.onClickLabel}
+            className={cx([styles.logsLabelIcon, 'fa fa-search-plus'])}
+          />
+        )}
+        {!plain && getRows && (
+          <span onClick={this.onClickStats} className={cx([styles.logsLabelIcon, 'fa fa-signal'])} />
+        )}
+        {showStats && (
+          <span className={cx([styles.logsLabelStats])}>
+            <LogLabelStats
+              stats={stats}
+              rowCount={getRows().length}
+              label={label}
+              value={value}
+              onClickClose={this.onClickClose}
+            />
+          </span>
+        )}
+      </span>
+    );
+  }
+}
+
+export const LogLabel = withTheme(UnThemedLogLabel);
+LogLabel.displayName = 'LogLabel';

+ 98 - 0
packages/grafana-ui/src/components/Logs/LogLabelStats.tsx

@@ -0,0 +1,98 @@
+import React, { PureComponent } from 'react';
+import { css, cx } from 'emotion';
+import { LogLabelStatsModel } from '@grafana/data';
+
+import { LogLabelStatsRow } from './LogLabelStatsRow';
+import { Themeable, GrafanaTheme } from '../../types/theme';
+import { selectThemeVariant } from '../../themes/selectThemeVariant';
+import { withTheme } from '../../themes/index';
+
+const STATS_ROW_LIMIT = 5;
+
+const getStyles = (theme: GrafanaTheme) => ({
+  logsStats: css`
+    label: logs-stats;
+    background-color: ${selectThemeVariant({ light: theme.colors.pageBg, dark: theme.colors.dark2 }, theme.type)};
+    color: ${theme.colors.text};
+    border: 1px solid ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)};
+    border-radius: ${theme.border.radius.md};
+    max-width: 500px;
+  `,
+  logsStatsHeader: css`
+    label: logs-stats__header;
+    background: ${selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark9 }, theme.type)};
+    padding: 6px 10px;
+    display: flex;
+  `,
+  logsStatsTitle: css`
+    label: logs-stats__title;
+    font-weight: ${theme.typography.weight.semibold};
+    padding-right: ${theme.spacing.d};
+    overflow: hidden;
+    display: inline-block;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    flex-grow: 1;
+  `,
+  logsStatsClose: css`
+    label: logs-stats__close;
+    cursor: pointer;
+  `,
+  logsStatsBody: css`
+    label: logs-stats__body;
+    padding: 20px 10px 10px 10px;
+  `,
+});
+
+interface Props extends Themeable {
+  stats: LogLabelStatsModel[];
+  label: string;
+  value: string;
+  rowCount: number;
+  onClickClose: () => void;
+}
+
+class UnThemedLogLabelStats extends PureComponent<Props> {
+  render() {
+    const { label, rowCount, stats, value, onClickClose, theme } = this.props;
+    const style = getStyles(theme);
+    const topRows = stats.slice(0, STATS_ROW_LIMIT);
+    let activeRow = topRows.find(row => row.value === value);
+    let otherRows = stats.slice(STATS_ROW_LIMIT);
+    const insertActiveRow = !activeRow;
+
+    // Remove active row from other to show extra
+    if (insertActiveRow) {
+      activeRow = otherRows.find(row => row.value === value);
+      otherRows = otherRows.filter(row => row.value !== value);
+    }
+
+    const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
+    const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
+    const total = topCount + otherCount;
+    const otherProportion = otherCount / total;
+
+    return (
+      <div className={cx([style.logsStats])}>
+        <div className={cx([style.logsStatsHeader])}>
+          <span className={cx([style.logsStatsTitle])}>
+            {label}: {total} of {rowCount} rows have that label
+          </span>
+          <span className={cx([style.logsStatsClose, 'fa fa-remove'])} onClick={onClickClose} />
+        </div>
+        <div className={cx([style.logsStatsBody])}>
+          {topRows.map(stat => (
+            <LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
+          ))}
+          {insertActiveRow && activeRow && <LogLabelStatsRow key={activeRow.value} {...activeRow} active />}
+          {otherCount > 0 && (
+            <LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
+          )}
+        </div>
+      </div>
+    );
+  }
+}
+
+export const LogLabelStats = withTheme(UnThemedLogLabelStats);
+LogLabelStats.displayName = 'LogLabelStats';

+ 92 - 0
packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx

@@ -0,0 +1,92 @@
+import React, { FunctionComponent, useContext } from 'react';
+import { css, cx } from 'emotion';
+
+import { ThemeContext } from '../../themes/ThemeContext';
+import { GrafanaTheme } from '../../types/theme';
+
+const getStyles = (theme: GrafanaTheme) => ({
+  logsStatsRow: css`
+    label: logs-stats-row;
+    margin: ${parseInt(theme.spacing.d, 10) / 1.75}px 0;
+  `,
+  logsStatsRowActive: css`
+    label: logs-stats-row--active;
+    color: ${theme.colors.blue};
+    position: relative;
+
+    ::after {
+      display: inline;
+      content: '*';
+      position: absolute;
+      top: 0;
+      left: -8px;
+    }
+  `,
+  logsStatsRowLabel: css`
+    label: logs-stats-row__label;
+    display: flex;
+    margin-bottom: 1px;
+  `,
+  logsStatsRowValue: css`
+    label: logs-stats-row__value;
+    flex: 1;
+    text-overflow: ellipsis;
+    overflow: hidden;
+  `,
+  logsStatsRowCount: css`
+    label: logs-stats-row__count;
+    text-align: right;
+    margin-left: 0.5em;
+  `,
+  logsStatsRowPercent: css`
+    label: logs-stats-row__percent;
+    text-align: right;
+    margin-left: 0.5em;
+    width: 3em;
+  `,
+  logsStatsRowBar: css`
+    label: logs-stats-row__bar;
+    height: 4px;
+    overflow: hidden;
+    background: ${theme.colors.textFaint};
+  `,
+  logsStatsRowInnerBar: css`
+    label: logs-stats-row__innerbar;
+    height: 4px;
+    overflow: hidden;
+    background: ${theme.colors.textFaint};
+    background: ${theme.colors.blue};
+  `,
+});
+
+export interface Props {
+  active?: boolean;
+  count: number;
+  proportion: number;
+  value?: string;
+}
+
+export const LogLabelStatsRow: FunctionComponent<Props> = ({ active, count, proportion, value }) => {
+  const theme = useContext(ThemeContext);
+  const style = getStyles(theme);
+  const percent = `${Math.round(proportion * 100)}%`;
+  const barStyle = { width: percent };
+  const className = active ? cx([style.logsStatsRow, style.logsStatsRowActive]) : cx([style.logsStatsRow]);
+
+  return (
+    <div className={className}>
+      <div className={cx([style.logsStatsRowLabel])}>
+        <div className={cx([style.logsStatsRowValue])} title={value}>
+          {value}
+        </div>
+        <div className={cx([style.logsStatsRowCount])}>{count}</div>
+        <div className={cx([style.logsStatsRowPercent])}>{percent}</div>
+      </div>
+      <div className={cx([style.logsStatsRowBar])}>
+        <div className={cx([style.logsStatsRowInnerBar])} style={barStyle} />
+      </div>
+    </div>
+  );
+};
+
+LogLabelStatsRow.displayName = 'LogLabelStatsRow';

+ 43 - 0
packages/grafana-ui/src/components/Logs/LogLabels.tsx

@@ -0,0 +1,43 @@
+import React, { FunctionComponent, useContext } from 'react';
+import { css, cx } from 'emotion';
+import { Labels, LogRowModel } from '@grafana/data';
+
+import { LogLabel } from './LogLabel';
+import { GrafanaTheme } from '../../types/theme';
+import { ThemeContext } from '../../themes/ThemeContext';
+
+const getStyles = (theme: GrafanaTheme) => ({
+  logsLabels: css`
+    display: flex;
+    flex-wrap: wrap;
+  `,
+});
+
+interface Props {
+  labels: Labels;
+  getRows: () => LogRowModel[];
+  plain?: boolean;
+  onClickLabel?: (label: string, value: string) => void;
+}
+
+export const LogLabels: FunctionComponent<Props> = ({ getRows, labels, onClickLabel, plain }) => {
+  const theme = useContext(ThemeContext);
+  const styles = getStyles(theme);
+
+  return (
+    <span className={cx([styles.logsLabels])}>
+      {Object.keys(labels).map(key => (
+        <LogLabel
+          key={key}
+          getRows={getRows}
+          label={key}
+          value={labels[key]}
+          plain={plain}
+          onClickLabel={onClickLabel}
+        />
+      ))}
+    </span>
+  );
+};
+
+LogLabels.displayName = 'LogLabels';

+ 0 - 0
public/app/features/explore/LogMessageAnsi.test.tsx → packages/grafana-ui/src/components/Logs/LogMessageAnsi.test.tsx


+ 1 - 1
public/app/features/explore/LogMessageAnsi.tsx → packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx

@@ -1,5 +1,5 @@
 import React, { PureComponent } from 'react';
-import ansicolor from 'vendor/ansicolor/ansicolor';
+import ansicolor from '../../utils/ansicolor';
 
 interface Style {
   [key: string]: string;

+ 65 - 37
public/app/features/explore/LogRow.tsx → packages/grafana-ui/src/components/Logs/LogRow.tsx

@@ -1,28 +1,34 @@
-import React, { PureComponent } from 'react';
+import React, { PureComponent, FunctionComponent, useContext } from 'react';
 import _ from 'lodash';
 // @ts-ignore
 import Highlighter from 'react-highlight-words';
-import classnames from 'classnames';
-
-import { calculateFieldStats, getParser } from 'app/core/logs_model';
-import { LogLabels } from './LogLabels';
-import { findHighlightChunksInText } from 'app/core/utils/text';
-import { LogLabelStats } from './LogLabelStats';
-import { LogMessageAnsi } from './LogMessageAnsi';
+import {
+  LogRowModel,
+  LogLabelStatsModel,
+  LogsParser,
+  TimeZone,
+  calculateFieldStats,
+  getParser,
+  findHighlightChunksInText,
+} from '@grafana/data';
+import tinycolor from 'tinycolor2';
 import { css, cx } from 'emotion';
+import { DataQueryResponse, GrafanaTheme, selectThemeVariant, ThemeContext } from '../../index';
 import {
-  LogRowContextProvider,
   LogRowContextRows,
-  HasMoreContextRows,
   LogRowContextQueryErrors,
+  HasMoreContextRows,
+  LogRowContextProvider,
 } from './LogRowContextProvider';
-import { ThemeContext, selectThemeVariant, GrafanaTheme, DataQueryResponse } from '@grafana/ui';
-
-import { LogRowModel, LogLabelStatsModel, LogsParser, TimeZone } from '@grafana/data';
 import { LogRowContext } from './LogRowContext';
-import tinycolor from 'tinycolor2';
+import { LogLabels } from './LogLabels';
+import { LogMessageAnsi } from './LogMessageAnsi';
+import { LogLabelStats } from './LogLabelStats';
+import { Themeable } from '../../types/theme';
+import { withTheme } from '../../themes/index';
+import { getLogRowStyles } from './getLogRowStyles';
 
-interface Props {
+interface Props extends Themeable {
   highlighterExpressions?: string[];
   row: LogRowModel;
   showDuplicates: boolean;
@@ -32,8 +38,7 @@ interface Props {
   getRows: () => LogRowModel[];
   onClickLabel?: (label: string, value: string) => void;
   onContextClick?: () => void;
-  getRowContext?: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
-  className?: string;
+  getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
 }
 
 interface State {
@@ -52,11 +57,16 @@ interface State {
  * Renders a highlighted field.
  * When hovering, a stats icon is shown.
  */
-const FieldHighlight = (onClick: any) => (props: any) => {
+const FieldHighlight = (onClick: any): FunctionComponent<any> => (props: any) => {
+  const theme = useContext(ThemeContext);
+  const style = getLogRowStyles(theme);
   return (
     <span className={props.className} style={props.style}>
       {props.children}
-      <span className="logs-row__field-highlight--icon fa fa-signal" onClick={() => onClick(props.children)} />
+      <span
+        className={cx([style, 'logs-row__field-highlight--icon', 'fa fa-signal'])}
+        onClick={() => onClick(props.children)}
+      />
     </span>
   );
 };
@@ -94,8 +104,8 @@ const getLogRowWithContextStyles = (theme: GrafanaTheme, state: State) => {
  * Once a parser is found, it will determine fields, that will be highlighted.
  * When the user requests stats for a field, they will be calculated and rendered below the row.
  */
-export class LogRow extends PureComponent<Props, State> {
-  mouseMessageTimer: NodeJS.Timer;
+class UnThemedLogRow extends PureComponent<Props, State> {
+  mouseMessageTimer: NodeJS.Timer | null = null;
 
   state: any = {
     fieldCount: 0,
@@ -110,7 +120,7 @@ export class LogRow extends PureComponent<Props, State> {
   };
 
   componentWillUnmount() {
-    clearTimeout(this.mouseMessageTimer);
+    this.clearMouseMessageTimer();
   }
 
   onClickClose = () => {
@@ -148,10 +158,16 @@ export class LogRow extends PureComponent<Props, State> {
       // See comment in onMouseOverMessage method
       return;
     }
-    clearTimeout(this.mouseMessageTimer);
+    this.clearMouseMessageTimer();
     this.setState({ parsed: false });
   };
 
+  clearMouseMessageTimer = () => {
+    if (this.mouseMessageTimer) {
+      clearTimeout(this.mouseMessageTimer);
+    }
+  };
+
   parseMessage = () => {
     if (!this.state.parsed) {
       const { row } = this.props;
@@ -206,6 +222,7 @@ export class LogRow extends PureComponent<Props, State> {
       showLabels,
       timeZone,
       showTime,
+      theme,
     } = this.props;
     const {
       fieldCount,
@@ -217,13 +234,15 @@ export class LogRow extends PureComponent<Props, State> {
       showFieldStats,
       showContext,
     } = this.state;
+    const style = getLogRowStyles(theme, row.logLevel);
     const { entry, hasAnsi, raw } = row;
     const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords);
     const highlights = previewHighlights ? highlighterExpressions : row.searchWords;
     const needsHighlighter = highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0;
-    const highlightClassName = classnames('logs-row__match-highlight', {
-      'logs-row__match-highlight--preview': previewHighlights,
-    });
+    const highlightClassName = previewHighlights
+      ? cx([style.logsRowMatchHighLight, style.logsRowMatchHighLightPreview])
+      : cx([style.logsRowMatchHighLight]);
+
     const showUtc = timeZone === 'utc';
 
     return (
@@ -233,28 +252,34 @@ export class LogRow extends PureComponent<Props, State> {
             ? cx(logRowStyles, getLogRowWithContextStyles(theme, this.state).row)
             : logRowStyles;
           return (
-            <div className={`logs-row ${this.props.className}`}>
+            <div className={cx([style.logsRow])}>
               {showDuplicates && (
-                <div className="logs-row__duplicates">{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}</div>
+                <div className={cx([style.logsRowDuplicates])}>
+                  {row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
+                </div>
               )}
-              <div className={row.logLevel ? `logs-row__level logs-row__level--${row.logLevel}` : ''} />
+              <div className={cx([style.logsRowLevel])} />
               {showTime && showUtc && (
-                <div className="logs-row__localtime" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
+                <div className={cx([style.logsRowLocalTime])} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
                   {row.timeUtc}
                 </div>
               )}
               {showTime && !showUtc && (
-                <div className="logs-row__localtime" title={`${row.timeUtc} (${row.timeFromNow})`}>
+                <div className={cx([style.logsRowLocalTime])} title={`${row.timeUtc} (${row.timeFromNow})`}>
                   {row.timeLocal}
                 </div>
               )}
               {showLabels && (
-                <div className="logs-row__labels">
-                  <LogLabels getRows={getRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
+                <div className={cx([style.logsRowLabels])}>
+                  <LogLabels
+                    getRows={getRows}
+                    labels={row.uniqueLabels ? row.uniqueLabels : {}}
+                    onClickLabel={onClickLabel}
+                  />
                 </div>
               )}
               <div
-                className="logs-row__message"
+                className={cx([style.logsRowMessage])}
                 onMouseEnter={this.onMouseOverMessage}
                 onMouseLeave={this.onMouseOutMessage}
               >
@@ -285,7 +310,7 @@ export class LogRow extends PureComponent<Props, State> {
                         highlightTag={FieldHighlight(this.onClickHighlight)}
                         textToHighlight={entry}
                         searchWords={parsedFieldHighlights}
-                        highlightClassName="logs-row__field-highlight"
+                        highlightClassName={cx([style.logsRowFieldHighLight])}
                       />
                     )}
                     {!parsed && needsHighlighter && (
@@ -300,7 +325,7 @@ export class LogRow extends PureComponent<Props, State> {
                     {hasAnsi && !parsed && !needsHighlighter && <LogMessageAnsi value={raw} />}
                     {!hasAnsi && !parsed && !needsHighlighter && entry}
                     {showFieldStats && (
-                      <div className="logs-row__stats">
+                      <div className={cx([style.logsRowStats])}>
                         <LogLabelStats
                           stats={fieldStats}
                           label={fieldLabel}
@@ -320,7 +345,7 @@ export class LogRow extends PureComponent<Props, State> {
                         position: relative;
                         z-index: ${showContext ? 1 : 0};
                         cursor: pointer;
-                        .logs-row:hover & {
+                        .${style.logsRow}:hover & {
                           visibility: visible;
                           margin-left: 10px;
                           text-decoration: underline;
@@ -357,3 +382,6 @@ export class LogRow extends PureComponent<Props, State> {
     return this.renderLogRow();
   }
 }
+
+export const LogRow = withTheme(UnThemedLogRow);
+LogRow.displayName = 'LogRow';

+ 14 - 16
public/app/features/explore/LogRowContext.tsx → packages/grafana-ui/src/components/Logs/LogRowContext.tsx

@@ -1,24 +1,22 @@
 import React, { useContext, useRef, useState, useLayoutEffect } from 'react';
-import {
-  ThemeContext,
-  List,
-  GrafanaTheme,
-  selectThemeVariant,
-  ClickOutsideWrapper,
-  CustomScrollbar,
-  DataQueryError,
-} from '@grafana/ui';
-
 import { LogRowModel } from '@grafana/data';
 import { css, cx } from 'emotion';
-import { LogRowContextRows, HasMoreContextRows, LogRowContextQueryErrors } from './LogRowContextProvider';
-import { Alert } from './Error';
+
+import { Alert } from '../Alert/Alert';
+import { LogRowContextRows, LogRowContextQueryErrors, HasMoreContextRows } from './LogRowContextProvider';
+import { GrafanaTheme } from '../../types/theme';
+import { selectThemeVariant } from '../../themes/selectThemeVariant';
+import { DataQueryError } from '../../types/datasource';
+import { ThemeContext } from '../../themes/ThemeContext';
+import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
+import { List } from '../List/List';
+import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
 
 interface LogRowContextProps {
   row: LogRowModel;
   context: LogRowContextRows;
   errors?: LogRowContextQueryErrors;
-  hasMoreContextRows: HasMoreContextRows;
+  hasMoreContextRows?: HasMoreContextRows;
   onOutsideClick: () => void;
   onLoadMoreContext: () => void;
 }
@@ -143,7 +141,7 @@ const LogRowContextGroup: React.FunctionComponent<LogRowContextGroupProps> = ({
   const theme = useContext(ThemeContext);
   const { commonStyles, logs } = getLogRowContextStyles(theme);
   const [scrollTop, setScrollTop] = useState(0);
-  const listContainerRef = useRef<HTMLDivElement>();
+  const listContainerRef = useRef<HTMLDivElement>() as React.RefObject<HTMLDivElement>;
 
   useLayoutEffect(() => {
     if (shouldScrollToBottom && listContainerRef.current) {
@@ -211,7 +209,7 @@ export const LogRowContext: React.FunctionComponent<LogRowContextProps> = ({
               top: -250px;
             `}
             shouldScrollToBottom
-            canLoadMoreRows={hasMoreContextRows.after}
+            canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.after : false}
             onLoadMoreContext={onLoadMoreContext}
           />
         )}
@@ -219,7 +217,7 @@ export const LogRowContext: React.FunctionComponent<LogRowContextProps> = ({
         {context.before && (
           <LogRowContextGroup
             onLoadMoreContext={onLoadMoreContext}
-            canLoadMoreRows={hasMoreContextRows.before}
+            canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.before : false}
             row={row}
             rows={context.before}
             error={errors && errors.before}

+ 25 - 15
public/app/features/explore/LogRowContextProvider.test.ts → packages/grafana-ui/src/components/Logs/LogRowContextProvider.test.ts

@@ -1,5 +1,7 @@
 import { DataFrameHelper, FieldType, LogRowModel } from '@grafana/data';
 import { getRowContexts } from './LogRowContextProvider';
+import { Labels, LogLevel } from '@grafana/data/src';
+import { DataQueryResponse } from '../../types';
 
 describe('getRowContexts', () => {
   describe('when called with a DataFrame and results are returned', () => {
@@ -22,10 +24,10 @@ describe('getRowContexts', () => {
       });
       const row: LogRowModel = {
         entry: '4',
-        labels: null,
+        labels: (null as any) as Labels,
         hasAnsi: false,
         raw: '4',
-        logLevel: null,
+        logLevel: LogLevel.info,
         timeEpochMs: 4,
         timeFromNow: '',
         timeLocal: '',
@@ -33,14 +35,18 @@ describe('getRowContexts', () => {
         timestamp: '4',
       };
 
-      const getRowContext = jest
-        .fn()
-        .mockResolvedValueOnce({ data: [firstResult] })
-        .mockResolvedValueOnce({ data: [secondResult] });
+      let called = false;
+      const getRowContextMock = (row: LogRowModel, options?: any): Promise<DataQueryResponse> => {
+        if (!called) {
+          called = true;
+          return Promise.resolve({ data: [firstResult] });
+        }
+        return Promise.resolve({ data: [secondResult] });
+      };
 
-      const result = await getRowContexts(getRowContext, row, 10);
+      const result = await getRowContexts(getRowContextMock, row, 10);
 
-      expect(result).toEqual({ data: [[['3', '2', '1']], [['6', '5', '4']]], errors: [null, null] });
+      expect(result).toEqual({ data: [[['3', '2', '1']], [['6', '5', '4']]], errors: ['', ''] });
     });
   });
 
@@ -50,10 +56,10 @@ describe('getRowContexts', () => {
       const secondError = new Error('Error 2');
       const row: LogRowModel = {
         entry: '4',
-        labels: null,
+        labels: (null as any) as Labels,
         hasAnsi: false,
         raw: '4',
-        logLevel: null,
+        logLevel: LogLevel.info,
         timeEpochMs: 4,
         timeFromNow: '',
         timeLocal: '',
@@ -61,12 +67,16 @@ describe('getRowContexts', () => {
         timestamp: '4',
       };
 
-      const getRowContext = jest
-        .fn()
-        .mockRejectedValueOnce(firstError)
-        .mockRejectedValueOnce(secondError);
+      let called = false;
+      const getRowContextMock = (row: LogRowModel, options?: any): Promise<DataQueryResponse> => {
+        if (!called) {
+          called = true;
+          return Promise.reject(firstError);
+        }
+        return Promise.reject(secondError);
+      };
 
-      const result = await getRowContexts(getRowContext, row, 10);
+      const result = await getRowContexts(getRowContextMock, row, 10);
 
       expect(result).toEqual({ data: [[], []], errors: ['Error 1', 'Error 2'] });
     });

+ 12 - 9
public/app/features/explore/LogRowContextProvider.tsx → packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx

@@ -1,9 +1,10 @@
-import { DataQueryResponse, DataQueryError } from '@grafana/ui';
 import { LogRowModel, toDataFrame, Field } from '@grafana/data';
 import { useState, useEffect } from 'react';
 import flatten from 'lodash/flatten';
 import useAsync from 'react-use/lib/useAsync';
 
+import { DataQueryResponse, DataQueryError } from '../../types/datasource';
+
 export interface LogRowContextRows {
   before?: string[];
   after?: string[];
@@ -18,6 +19,11 @@ export interface HasMoreContextRows {
   after: boolean;
 }
 
+interface ResultType {
+  data: string[][];
+  errors: string[];
+}
+
 interface LogRowContextProviderProps {
   row: LogRowModel;
   getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
@@ -84,7 +90,7 @@ export const getRowContexts = async (
     errors: results.map(result => {
       const errorResult: DataQueryError = result as DataQueryError;
       if (!errorResult.message) {
-        return null;
+        return '';
       }
 
       return errorResult.message;
@@ -105,10 +111,7 @@ export const LogRowContextProvider: React.FunctionComponent<LogRowContextProvide
   // React Hook that creates an object state value called result to component state and a setter function called setResult
   // The intial value for result is null
   // Used for sorting the response from backend
-  const [result, setResult] = useState<{
-    data: string[][];
-    errors: string[];
-  }>(null);
+  const [result, setResult] = useState<ResultType>((null as any) as ResultType);
 
   // React Hook that creates an object state value called hasMoreContextRows to component state and a setter function called setHasMoreContextRows
   // The intial value for hasMoreContextRows is {before: true, after: true}
@@ -130,7 +133,7 @@ export const LogRowContextProvider: React.FunctionComponent<LogRowContextProvide
   // The side effect changes the hasMoreContextRows state if there are more context rows before or after the current result
   useEffect(() => {
     if (value) {
-      setResult(currentResult => {
+      setResult((currentResult: any) => {
         let hasMoreLogsBefore = true,
           hasMoreLogsAfter = true;
 
@@ -158,8 +161,8 @@ export const LogRowContextProvider: React.FunctionComponent<LogRowContextProvide
       after: result ? flatten(result.data[1]) : [],
     },
     errors: {
-      before: result ? result.errors[0] : null,
-      after: result ? result.errors[1] : null,
+      before: result ? result.errors[0] : undefined,
+      after: result ? result.errors[1] : undefined,
     },
     hasMoreContextRows,
     updateLimit: () => setLimit(limit + 10),

+ 143 - 0
packages/grafana-ui/src/components/Logs/LogRows.tsx

@@ -0,0 +1,143 @@
+import React, { PureComponent } from 'react';
+import { cx } from 'emotion';
+import { LogsModel, TimeZone, LogsDedupStrategy, LogRowModel } from '@grafana/data';
+
+import { LogRow } from './LogRow';
+import { Themeable } from '../../types/theme';
+import { withTheme } from '../../themes/index';
+import { getLogRowStyles } from './getLogRowStyles';
+
+const PREVIEW_LIMIT = 100;
+const RENDER_LIMIT = 500;
+
+export interface Props extends Themeable {
+  data: LogsModel;
+  dedupStrategy: LogsDedupStrategy;
+  highlighterExpressions: string[];
+  showTime: boolean;
+  showLabels: boolean;
+  timeZone: TimeZone;
+  deduplicatedData?: LogsModel;
+  rowLimit?: number;
+  onClickLabel?: (label: string, value: string) => void;
+  getRowContext?: (row: LogRowModel, options?: any) => Promise<any>;
+}
+
+interface State {
+  deferLogs: boolean;
+  renderAll: boolean;
+}
+
+class UnThemedLogRows extends PureComponent<Props, State> {
+  deferLogsTimer: NodeJS.Timer | null = null;
+  renderAllTimer: NodeJS.Timer | null = null;
+
+  state: State = {
+    deferLogs: true,
+    renderAll: false,
+  };
+
+  componentDidMount() {
+    // Staged rendering
+    if (this.state.deferLogs) {
+      const { data } = this.props;
+      const rowCount = data && data.rows ? data.rows.length : 0;
+      // Render all right away if not too far over the limit
+      const renderAll = rowCount <= PREVIEW_LIMIT * 2;
+      this.deferLogsTimer = setTimeout(() => this.setState({ deferLogs: false, renderAll }), rowCount);
+    }
+  }
+
+  componentDidUpdate(prevProps: Props, prevState: State) {
+    // Staged rendering
+    if (prevState.deferLogs && !this.state.deferLogs && !this.state.renderAll) {
+      this.renderAllTimer = setTimeout(() => this.setState({ renderAll: true }), 2000);
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.deferLogsTimer) {
+      clearTimeout(this.deferLogsTimer);
+    }
+
+    if (this.renderAllTimer) {
+      clearTimeout(this.renderAllTimer);
+    }
+  }
+
+  render() {
+    const {
+      dedupStrategy,
+      showTime,
+      data,
+      deduplicatedData,
+      highlighterExpressions,
+      showLabels,
+      timeZone,
+      onClickLabel,
+      rowLimit,
+      theme,
+    } = this.props;
+    const { deferLogs, renderAll } = this.state;
+    const dedupedData = deduplicatedData ? deduplicatedData : data;
+    const hasData = data && data.rows && data.rows.length > 0;
+    const hasLabel = hasData && dedupedData && dedupedData.hasUniqueLabels ? true : false;
+    const dedupCount = dedupedData
+      ? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0)
+      : 0;
+    const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0;
+
+    // Staged rendering
+    const processedRows = dedupedData ? dedupedData.rows : [];
+    const firstRows = processedRows.slice(0, PREVIEW_LIMIT);
+    const renderLimit = rowLimit || RENDER_LIMIT;
+    const rowCount = Math.min(processedRows.length, renderLimit);
+    const lastRows = processedRows.slice(PREVIEW_LIMIT, rowCount);
+
+    // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
+    const getRows = () => processedRows;
+    const getRowContext = this.props.getRowContext ? this.props.getRowContext : () => Promise.resolve([]);
+    const { logsRows } = getLogRowStyles(theme);
+
+    return (
+      <div className={cx([logsRows])}>
+        {hasData &&
+        !deferLogs && // Only inject highlighterExpression in the first set for performance reasons
+          firstRows.map((row, index) => (
+            <LogRow
+              key={index}
+              getRows={getRows}
+              getRowContext={getRowContext}
+              highlighterExpressions={highlighterExpressions}
+              row={row}
+              showDuplicates={showDuplicates}
+              showLabels={showLabels && hasLabel}
+              showTime={showTime}
+              timeZone={timeZone}
+              onClickLabel={onClickLabel}
+            />
+          ))}
+        {hasData &&
+          !deferLogs &&
+          renderAll &&
+          lastRows.map((row, index) => (
+            <LogRow
+              key={PREVIEW_LIMIT + index}
+              getRows={getRows}
+              getRowContext={getRowContext}
+              row={row}
+              showDuplicates={showDuplicates}
+              showLabels={showLabels && hasLabel}
+              showTime={showTime}
+              timeZone={timeZone}
+              onClickLabel={onClickLabel}
+            />
+          ))}
+        {hasData && deferLogs && <span>Rendering {rowCount} rows...</span>}
+      </div>
+    );
+  }
+}
+
+export const LogRows = withTheme(UnThemedLogRows);
+LogRows.displayName = 'LogsRows';

+ 133 - 0
packages/grafana-ui/src/components/Logs/getLogRowStyles.ts

@@ -0,0 +1,133 @@
+import { css } from 'emotion';
+import { LogLevel } from '@grafana/data';
+
+import { GrafanaTheme } from '../../types/theme';
+import { selectThemeVariant } from '../../themes/selectThemeVariant';
+
+export const getLogRowStyles = (theme: GrafanaTheme, logLevel?: LogLevel) => {
+  let logColor = selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.gray2 }, theme.type);
+  switch (logLevel) {
+    case LogLevel.crit:
+    case LogLevel.critical:
+      logColor = '#705da0';
+      break;
+    case LogLevel.error:
+    case LogLevel.err:
+      logColor = '#e24d42';
+      break;
+    case LogLevel.warning:
+    case LogLevel.warn:
+      logColor = theme.colors.yellow;
+      break;
+    case LogLevel.info:
+      logColor = '#7eb26d';
+      break;
+    case LogLevel.debug:
+      logColor = '#1f78c1';
+      break;
+    case LogLevel.trace:
+      logColor = '#6ed0e0';
+      break;
+  }
+
+  return {
+    logsRowFieldHighLight: css`
+      label: logs-row__field-highlight;
+      background: inherit;
+      padding: inherit;
+      border-bottom: 1px dotted ${theme.colors.yellow};
+
+      .logs-row__field-highlight--icon {
+        margin-left: 0.5em;
+        cursor: pointer;
+        display: none;
+      }
+
+      &:hover {
+        color: ${theme.colors.yellow};
+        border-bottom-style: solid;
+
+        .logs-row__field-highlight--icon {
+          display: inline;
+        }
+      }
+    `,
+    logsRowMatchHighLight: css`
+      label: logs-row__match-highlight;
+      background: inherit;
+      padding: inherit;
+
+      color: ${theme.colors.yellow};
+      border-bottom: 1px solid ${theme.colors.yellow};
+      background-color: rgba(${theme.colors.yellow}, 0.1);
+    `,
+    logsRowMatchHighLightPreview: css`
+      label: logs-row__match-highlight--preview;
+      background-color: rgba(${theme.colors.yellow}, 0.2);
+      border-bottom-style: dotted;
+    `,
+    logsRows: css`
+      label: logs-rows;
+      font-family: ${theme.typography.fontFamily.monospace};
+      font-size: ${theme.typography.size.sm};
+      display: table;
+      table-layout: fixed;
+      width: 100%;
+    `,
+    logsRow: css`
+      label: logs-row;
+      display: table-row;
+
+      > div {
+        display: table-cell;
+        padding-right: 10px;
+        border-top: 1px solid transparent;
+        border-bottom: 1px solid transparent;
+        height: 100%;
+      }
+
+      &:hover {
+        background: ${theme.colors.pageBg};
+      }
+    `,
+    logsRowDuplicates: css`
+      label: logs-row__duplicates;
+      text-align: right;
+      width: 4em;
+    `,
+    logsRowLevel: css`
+      label: logs-row__level;
+      position: relative;
+      width: 10px;
+
+      &::after {
+        content: '';
+        display: block;
+        position: absolute;
+        top: 1px;
+        bottom: 1px;
+        width: 3px;
+        background-color: ${logColor};
+      }
+    `,
+    logsRowLocalTime: css`
+      label: logs-row__localtime;
+      white-space: nowrap;
+      width: 12.5em;
+    `,
+    logsRowLabels: css`
+      label: logs-row__labels;
+      width: 20%;
+      line-height: 1.2;
+      position: relative;
+    `,
+    logsRowMessage: css`
+      label: logs-row__message;
+      word-break: break-all;
+    `,
+    logsRowStats: css`
+      label: logs-row__stats;
+      margin: 5px 0;
+    `,
+  };
+};

+ 3 - 2
packages/grafana-ui/src/components/Switch/Switch.story.tsx

@@ -8,13 +8,14 @@ import { text } from '@storybook/addon-knobs';
 const getKnobs = () => {
   return {
     label: text('Label Text', 'Label'),
+    tooltip: text('Tooltip', null),
   };
 };
 
 const SwitchWrapper = () => {
-  const { label } = getKnobs();
+  const { label, tooltip } = getKnobs();
   const [checked, setChecked] = useState(false);
-  return <Switch label={label} checked={checked} onChange={() => setChecked(!checked)} />;
+  return <Switch label={label} checked={checked} onChange={() => setChecked(!checked)} tooltip={tooltip} />;
 };
 
 const story = storiesOf('UI/Switch', module);

+ 2 - 2
public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx → packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx

@@ -1,5 +1,5 @@
 import React, { FC, ReactNode, PureComponent } from 'react';
-import { Tooltip } from '@grafana/ui';
+import { Tooltip } from '../Tooltip/Tooltip';
 
 interface ToggleButtonGroupProps {
   label?: string;
@@ -7,7 +7,7 @@ interface ToggleButtonGroupProps {
   transparent?: boolean;
 }
 
-export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
+export class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
   render() {
     const { children, label, transparent } = this.props;
 

+ 7 - 0
packages/grafana-ui/src/components/index.ts

@@ -61,6 +61,13 @@ export {
   LegendPlacement,
   LegendDisplayMode,
 } from './Legend/Legend';
+export { Alert } from './Alert/Alert';
+export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler';
+export { Collapse } from './Collapse/Collapse';
+export { LogLabels } from './Logs/LogLabels';
+export { LogRows } from './Logs/LogRows';
+export { getLogRowStyles } from './Logs/getLogRowStyles';
+export { ToggleButtonGroup, ToggleButton } from './ToggleButtonGroup/ToggleButtonGroup';
 // Panel editors
 export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
 export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';

+ 33 - 32
public/vendor/ansicolor/ansicolor.ts → packages/grafana-ui/src/utils/ansicolor.ts

@@ -100,7 +100,9 @@ class Color {
 
     return rgb
       ? prop + 'rgba(' + [...rgb, alpha].join(',') + ');'
-      : !color.background && alpha < 1 ? 'color:rgba(0,0,0,0.5);' : ''; // Chrome does not support 'opacity' property...
+      : !color.background && alpha < 1
+      ? 'color:rgba(0,0,0,0.5);'
+      : ''; // Chrome does not support 'opacity' property...
   }
 }
 
@@ -118,11 +120,13 @@ class Code {
   static noColor = 39;
   static noBgColor = 49;
 
-  value: number;
+  value: number | undefined;
 
   constructor(n?: string | number) {
     if (n !== undefined) {
       this.value = Number(n);
+    } else {
+      this.value = undefined;
     }
   }
 
@@ -178,45 +182,42 @@ const camel = (a: string, b: string) => a + b.charAt(0).toUpperCase() + b.slice(
 
 const stringWrappingMethods = (() =>
   [
-    ...colorCodes.map(
-      (k, i) =>
-        !k
-          ? []
-          : [
-              // color methods
-
-              [k, 30 + i, Code.noColor],
-              [camel('bg', k), 40 + i, Code.noBgColor],
-            ]
+    ...colorCodes.map((k, i) =>
+      !k
+        ? []
+        : [
+            // color methods
+
+            [k, 30 + i, Code.noColor],
+            [camel('bg', k), 40 + i, Code.noBgColor],
+          ]
     ),
 
-    ...colorCodesLight.map(
-      (k, i) =>
-        !k
-          ? []
-          : [
-              // light color methods
+    ...colorCodesLight.map((k, i) =>
+      !k
+        ? []
+        : [
+            // light color methods
 
-              [k, 90 + i, Code.noColor],
-              [camel('bg', k), 100 + i, Code.noBgColor],
-            ]
+            [k, 90 + i, Code.noColor],
+            [camel('bg', k), 100 + i, Code.noBgColor],
+          ]
     ),
 
     /* THIS ONE IS FOR BACKWARDS COMPATIBILITY WITH PREVIOUS VERSIONS (had 'bright' instead of 'light' for backgrounds)
-         */
-    ...['', 'BrightRed', 'BrightGreen', 'BrightYellow', 'BrightBlue', 'BrightMagenta', 'BrightCyan'].map(
-      (k, i) => (!k ? [] : [['bg' + k, 100 + i, Code.noBgColor]])
+     */
+    ...['', 'BrightRed', 'BrightGreen', 'BrightYellow', 'BrightBlue', 'BrightMagenta', 'BrightCyan'].map((k, i) =>
+      !k ? [] : [['bg' + k, 100 + i, Code.noBgColor]]
     ),
 
-    ...styleCodes.map(
-      (k, i) =>
-        !k
-          ? []
-          : [
-              // style methods
+    ...styleCodes.map((k, i) =>
+      !k
+        ? []
+        : [
+            // style methods
 
-              [k, i, k === 'bright' || k === 'dim' ? Code.noBrightness : 20 + i],
-            ]
+            [k, i, k === 'bright' || k === 'dim' ? Code.noBrightness : 20 + i],
+          ]
     ),
   ].reduce((a, b) => a.concat(b)))();
 

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

@@ -6,6 +6,7 @@ export * from './fieldDisplay';
 export * from './validate';
 export { getFlotPairs } from './flotPairs';
 export * from './slate';
+export { default as ansicolor } from './ansicolor';
 
 // Export with a namespace
 import * as DOMUtil from './dom'; // includes Element.closest polyfil

+ 1 - 101
public/app/core/logs_model.ts

@@ -1,7 +1,5 @@
 import _ from 'lodash';
-import ansicolor from 'vendor/ansicolor/ansicolor';
-
-import { colors, getFlotPairs } from '@grafana/ui';
+import { colors, getFlotPairs, ansicolor } from '@grafana/ui';
 
 import {
   Labels,
@@ -16,8 +14,6 @@ import {
   LogsModel,
   LogsMetaItem,
   LogsMetaKind,
-  LogsParser,
-  LogLabelStatsModel,
   LogsDedupStrategy,
   DataFrameHelper,
   GraphSeriesXY,
@@ -41,89 +37,6 @@ export const LogLevelColor = {
   [LogLevel.unknown]: getThemeColor('#8e8e8e', '#dde4ed'),
 };
 
-export enum LogsDedupDescription {
-  none = 'No de-duplication',
-  exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.',
-  numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.',
-  signature = 'De-duplication of successive lines that have identical punctuation and whitespace.',
-}
-const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/;
-
-export const LogsParsers: { [name: string]: LogsParser } = {
-  JSON: {
-    buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`),
-    getFields: line => {
-      const fields: string[] = [];
-      try {
-        const parsed = JSON.parse(line);
-        _.map(parsed, (value, key) => {
-          const fieldMatcher = new RegExp(`"${key}"\\s*:\\s*"?${_.escapeRegExp(JSON.stringify(value))}"?`);
-
-          const match = line.match(fieldMatcher);
-          if (match) {
-            fields.push(match[0]);
-          }
-        });
-      } catch {}
-      return fields;
-    },
-    getLabelFromField: field => (field.match(/^"(\w+)"\s*:/) || [])[1],
-    getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1],
-    test: line => {
-      try {
-        return JSON.parse(line);
-      } catch (error) {}
-    },
-  },
-
-  logfmt: {
-    buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`),
-    getFields: line => {
-      const fields: string[] = [];
-      line.replace(new RegExp(LOGFMT_REGEXP, 'g'), substring => {
-        fields.push(substring.trim());
-        return '';
-      });
-      return fields;
-    },
-    getLabelFromField: field => (field.match(LOGFMT_REGEXP) || [])[1],
-    getValueFromField: field => (field.match(LOGFMT_REGEXP) || [])[2],
-    test: line => LOGFMT_REGEXP.test(line),
-  },
-};
-
-export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] {
-  // Consider only rows that satisfy the matcher
-  const rowsWithField = rows.filter(row => extractor.test(row.entry));
-  const rowCount = rowsWithField.length;
-
-  // Get field value counts for eligible rows
-  const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]);
-  const sortedCounts = _.chain(countsByValue)
-    .map((count, value) => ({ count, value, proportion: count / rowCount }))
-    .sortBy('count')
-    .reverse()
-    .value();
-
-  return sortedCounts;
-}
-
-export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
-  // Consider only rows that have the given label
-  const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
-  const rowCount = rowsWithLabel.length;
-
-  // Get label value counts for eligible rows
-  const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]);
-  const sortedCounts = _.chain(countsByValue)
-    .map((count, value) => ({ count, value, proportion: count / rowCount }))
-    .sortBy('count')
-    .reverse()
-    .value();
-
-  return sortedCounts;
-}
-
 const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
 function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean {
   switch (strategy) {
@@ -165,19 +78,6 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
   };
 }
 
-export function getParser(line: string): LogsParser {
-  let parser;
-  try {
-    if (LogsParsers.JSON.test(line)) {
-      parser = LogsParsers.JSON;
-    }
-  } catch (error) {}
-  if (!parser && LogsParsers.logfmt.test(line)) {
-    parser = LogsParsers.logfmt;
-  }
-  return parser;
-}
-
 export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>): LogsModel {
   if (hiddenLogLevels.size === 0) {
     return logs;

+ 1 - 195
public/app/core/specs/logs_model.test.ts

@@ -8,14 +8,7 @@ import {
   DataFrameHelper,
   toDataFrame,
 } from '@grafana/data';
-import {
-  dedupLogRows,
-  calculateFieldStats,
-  calculateLogsLabelStats,
-  getParser,
-  LogsParsers,
-  dataFrameToLogsModel,
-} from '../logs_model';
+import { dedupLogRows, dataFrameToLogsModel } from '../logs_model';
 
 describe('dedupLogRows()', () => {
   test('should return rows as is when dedup is set to none', () => {
@@ -152,193 +145,6 @@ describe('dedupLogRows()', () => {
   });
 });
 
-describe('calculateFieldStats()', () => {
-  test('should return no stats for empty rows', () => {
-    expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]);
-  });
-
-  test('should return no stats if extractor does not match', () => {
-    const rows = [
-      {
-        entry: 'foo=bar',
-      },
-    ];
-
-    expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]);
-  });
-
-  test('should return stats for found field', () => {
-    const rows = [
-      {
-        entry: 'foo="42 + 1"',
-      },
-      {
-        entry: 'foo=503 baz=foo',
-      },
-      {
-        entry: 'foo="42 + 1"',
-      },
-      {
-        entry: 't=2018-12-05T07:44:59+0000 foo=503',
-      },
-    ];
-
-    expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([
-      {
-        value: '"42 + 1"',
-        count: 2,
-      },
-      {
-        value: '503',
-        count: 2,
-      },
-    ]);
-  });
-});
-
-describe('calculateLogsLabelStats()', () => {
-  test('should return no stats for empty rows', () => {
-    expect(calculateLogsLabelStats([], '')).toEqual([]);
-  });
-
-  test('should return no stats of label is not found', () => {
-    const rows = [
-      {
-        entry: 'foo 1',
-        labels: {
-          foo: 'bar',
-        },
-      },
-    ];
-
-    expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]);
-  });
-
-  test('should return stats for found labels', () => {
-    const rows = [
-      {
-        entry: 'foo 1',
-        labels: {
-          foo: 'bar',
-        },
-      },
-      {
-        entry: 'foo 0',
-        labels: {
-          foo: 'xxx',
-        },
-      },
-      {
-        entry: 'foo 2',
-        labels: {
-          foo: 'bar',
-        },
-      },
-    ];
-
-    expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([
-      {
-        value: 'bar',
-        count: 2,
-      },
-      {
-        value: 'xxx',
-        count: 1,
-      },
-    ]);
-  });
-});
-
-describe('getParser()', () => {
-  test('should return no parser on empty line', () => {
-    expect(getParser('')).toBeUndefined();
-  });
-
-  test('should return no parser on unknown line pattern', () => {
-    expect(getParser('To Be or not to be')).toBeUndefined();
-  });
-
-  test('should return logfmt parser on key value patterns', () => {
-    expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt);
-  });
-
-  test('should return JSON parser on JSON log lines', () => {
-    // TODO implement other JSON value types than string
-    expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON);
-  });
-});
-
-describe('LogsParsers', () => {
-  describe('logfmt', () => {
-    const parser = LogsParsers.logfmt;
-
-    test('should detect format', () => {
-      expect(parser.test('foo')).toBeFalsy();
-      expect(parser.test('foo=bar')).toBeTruthy();
-    });
-
-    test('should return parsed fields', () => {
-      expect(parser.getFields('foo=bar baz="42 + 1"')).toEqual(['foo=bar', 'baz="42 + 1"']);
-    });
-
-    test('should return label for field', () => {
-      expect(parser.getLabelFromField('foo=bar')).toBe('foo');
-    });
-
-    test('should return value for field', () => {
-      expect(parser.getValueFromField('foo=bar')).toBe('bar');
-    });
-
-    test('should build a valid value matcher', () => {
-      const matcher = parser.buildMatcher('foo');
-      const match = 'foo=bar'.match(matcher);
-      expect(match).toBeDefined();
-      expect(match[1]).toBe('bar');
-    });
-  });
-
-  describe('JSON', () => {
-    const parser = LogsParsers.JSON;
-
-    test('should detect format', () => {
-      expect(parser.test('foo')).toBeFalsy();
-      expect(parser.test('{"foo":"bar"}')).toBeTruthy();
-    });
-
-    test('should return parsed fields', () => {
-      expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo" : "bar"', '"baz" : 42']);
-    });
-
-    test('should return parsed fields for nested quotes', () => {
-      expect(parser.getFields(`{"foo":"bar: '[value=\\"42\\"]'"}`)).toEqual([`"foo":"bar: '[value=\\"42\\"]'"`]);
-    });
-
-    test('should return label for field', () => {
-      expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo');
-    });
-
-    test('should return value for field', () => {
-      expect(parser.getValueFromField('"foo" : "bar"')).toBe('"bar"');
-      expect(parser.getValueFromField('"foo" : 42')).toBe('42');
-      expect(parser.getValueFromField('"foo" : 42.1')).toBe('42.1');
-    });
-
-    test('should build a valid value matcher for strings', () => {
-      const matcher = parser.buildMatcher('foo');
-      const match = '{"foo":"bar"}'.match(matcher);
-      expect(match).toBeDefined();
-      expect(match[1]).toBe('bar');
-    });
-
-    test('should build a valid value matcher for integers', () => {
-      const matcher = parser.buildMatcher('foo');
-      const match = '{"foo":42.1}'.match(matcher);
-      expect(match).toBeDefined();
-      expect(match[1]).toBe('42.1');
-    });
-  });
-});
-
 const emptyLogsModel: any = {
   hasUniqueLabels: false,
   rows: [],

+ 97 - 1
public/app/core/utils/explore.test.ts

@@ -9,11 +9,15 @@ import {
   getValueWithRefId,
   getFirstQueryErrorWithoutRefId,
   getRefIds,
+  refreshIntervalToSortOrder,
+  SortOrder,
+  sortLogsResult,
 } from './explore';
 import { ExploreUrlState, ExploreMode } from 'app/types/explore';
 import store from 'app/core/store';
-import { LogsDedupStrategy } from '@grafana/data';
+import { LogsDedupStrategy, LogsModel, LogLevel } from '@grafana/data';
 import { DataQueryError } from '@grafana/ui';
+import { liveOption, offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
 
 const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
   datasource: null,
@@ -356,3 +360,95 @@ describe('getRefIds', () => {
     });
   });
 });
+
+describe('refreshIntervalToSortOrder', () => {
+  describe('when called with live option', () => {
+    it('then it should return ascending', () => {
+      const result = refreshIntervalToSortOrder(liveOption.value);
+
+      expect(result).toBe(SortOrder.Ascending);
+    });
+  });
+
+  describe('when called with off option', () => {
+    it('then it should return descending', () => {
+      const result = refreshIntervalToSortOrder(offOption.value);
+
+      expect(result).toBe(SortOrder.Descending);
+    });
+  });
+
+  describe('when called with 5s option', () => {
+    it('then it should return descending', () => {
+      const result = refreshIntervalToSortOrder('5s');
+
+      expect(result).toBe(SortOrder.Descending);
+    });
+  });
+
+  describe('when called with undefined', () => {
+    it('then it should return descending', () => {
+      const result = refreshIntervalToSortOrder(undefined);
+
+      expect(result).toBe(SortOrder.Descending);
+    });
+  });
+});
+
+describe('sortLogsResult', () => {
+  const firstRow = {
+    timestamp: '2019-01-01T21:00:0.0000000Z',
+    entry: '',
+    hasAnsi: false,
+    labels: {},
+    logLevel: LogLevel.info,
+    raw: '',
+    timeEpochMs: 0,
+    timeFromNow: '',
+    timeLocal: '',
+    timeUtc: '',
+  };
+  const sameAsFirstRow = firstRow;
+  const secondRow = {
+    timestamp: '2019-01-01T22:00:0.0000000Z',
+    entry: '',
+    hasAnsi: false,
+    labels: {},
+    logLevel: LogLevel.info,
+    raw: '',
+    timeEpochMs: 0,
+    timeFromNow: '',
+    timeLocal: '',
+    timeUtc: '',
+  };
+
+  describe('when called with SortOrder.Descending', () => {
+    it('then it should sort descending', () => {
+      const logsResult: LogsModel = {
+        rows: [firstRow, sameAsFirstRow, secondRow],
+        hasUniqueLabels: false,
+      };
+      const result = sortLogsResult(logsResult, SortOrder.Descending);
+
+      expect(result).toEqual({
+        rows: [secondRow, firstRow, sameAsFirstRow],
+        hasUniqueLabels: false,
+      });
+    });
+  });
+
+  describe('when called with SortOrder.Ascending', () => {
+    it('then it should sort ascending', () => {
+      const logsResult: LogsModel = {
+        rows: [secondRow, firstRow, sameAsFirstRow],
+        hasUniqueLabels: false,
+      };
+      const result = sortLogsResult(logsResult, SortOrder.Ascending);
+
+      expect(result).toEqual({
+        rows: [firstRow, sameAsFirstRow, secondRow],
+        hasUniqueLabels: false,
+      });
+    });
+  });
+});

+ 10 - 3
public/app/core/utils/explore.ts

@@ -510,10 +510,17 @@ const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => {
   return 0;
 };
 
-export const sortLogsResult = (logsResult: LogsModel, refreshInterval: string) => {
+export enum SortOrder {
+  Descending = 'Descending',
+  Ascending = 'Ascending',
+}
+
+export const refreshIntervalToSortOrder = (refreshInterval: string) =>
+  isLive(refreshInterval) ? SortOrder.Ascending : SortOrder.Descending;
+
+export const sortLogsResult = (logsResult: LogsModel, sortOrder: SortOrder) => {
   const rows = logsResult ? logsResult.rows : [];
-  const live = isLive(refreshInterval);
-  live ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder);
+  sortOrder === SortOrder.Ascending ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder);
   const result: LogsModel = logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows };
 
   return result;

+ 0 - 79
public/app/core/utils/text.ts

@@ -1,84 +1,5 @@
-import { TextMatch } from 'app/types/explore';
 import xss from 'xss';
 
-/**
- * Adapt findMatchesInText for react-highlight-words findChunks handler.
- * See https://github.com/bvaughn/react-highlight-words#props
- */
-export function findHighlightChunksInText({
-  searchWords,
-  textToHighlight,
-}: {
-  searchWords: string[];
-  textToHighlight: string;
-}) {
-  return searchWords.reduce((acc: any, term: string) => [...acc, ...findMatchesInText(textToHighlight, term)], []);
-}
-
-const cleanNeedle = (needle: string): string => {
-  return needle.replace(/[[{(][\w,.-?:*+]+$/, '');
-};
-
-/**
- * Returns a list of substring regexp matches.
- */
-export function findMatchesInText(haystack: string, needle: string): TextMatch[] {
-  // Empty search can send re.exec() into infinite loop, exit early
-  if (!haystack || !needle) {
-    return [];
-  }
-  const matches: TextMatch[] = [];
-  const { cleaned, flags } = parseFlags(cleanNeedle(needle));
-  let regexp: RegExp;
-  try {
-    regexp = new RegExp(`(?:${cleaned})`, flags);
-  } catch (error) {
-    return matches;
-  }
-  haystack.replace(regexp, (substring, ...rest) => {
-    if (substring) {
-      const offset = rest[rest.length - 2];
-      matches.push({
-        text: substring,
-        start: offset,
-        length: substring.length,
-        end: offset + substring.length,
-      });
-    }
-    return '';
-  });
-  return matches;
-}
-
-const CLEAR_FLAG = '-';
-const FLAGS_REGEXP = /\(\?([ims-]+)\)/g;
-
-/**
- * Converts any mode modifers in the text to the Javascript equivalent flag
- */
-export function parseFlags(text: string): { cleaned: string; flags: string } {
-  const flags: Set<string> = new Set(['g']);
-
-  const cleaned = text.replace(FLAGS_REGEXP, (str, group) => {
-    const clearAll = group.startsWith(CLEAR_FLAG);
-
-    for (let i = 0; i < group.length; ++i) {
-      const flag = group.charAt(i);
-      if (clearAll || group.charAt(i - 1) === CLEAR_FLAG) {
-        flags.delete(flag);
-      } else if (flag !== CLEAR_FLAG) {
-        flags.add(flag);
-      }
-    }
-    return ''; // Remove flag from text
-  });
-
-  return {
-    cleaned: cleaned,
-    flags: Array.from(flags).join(''),
-  };
-}
-
 const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => {
   // @ts-ignore
   acc[element] = xss.whiteList[element].concat(['class', 'style']);

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

@@ -10,7 +10,7 @@ import { AutoSizer } from 'react-virtualized';
 import store from 'app/core/store';
 
 // Components
-import { Alert } from './Error';
+import { Alert } from '@grafana/ui';
 import ErrorBoundary from './ErrorBoundary';
 import LogsContainer from './LogsContainer';
 import QueryRows from './QueryRows';
@@ -26,10 +26,11 @@ import {
   refreshExplore,
   reconnectDatasource,
   updateTimeRange,
+  toggleGraph,
 } from './state/actions';
 
 // Types
-import { RawTimeRange, GraphSeriesXY } from '@grafana/data';
+import { RawTimeRange, GraphSeriesXY, LoadingState, TimeZone, AbsoluteTimeRange } from '@grafana/data';
 
 import { DataQuery, ExploreStartPageProps, DataSourceApi, DataQueryError } from '@grafana/ui';
 import {
@@ -55,7 +56,7 @@ import { FadeIn } from 'app/core/components/Animations/FadeIn';
 import { getTimeZone } from '../profile/state/selectors';
 import { ErrorContainer } from './ErrorContainer';
 import { scanStopAction } from './state/actionTypes';
-import ExploreGraphPanel from './ExploreGraphPanel';
+import { ExploreGraphPanel } from './ExploreGraphPanel';
 
 interface ExploreProps {
   StartPage?: ComponentClass<ExploreStartPageProps>;
@@ -88,6 +89,13 @@ interface ExploreProps {
   isLive: boolean;
   updateTimeRange: typeof updateTimeRange;
   graphResult?: GraphSeriesXY[];
+  loading?: boolean;
+  absoluteRange: AbsoluteTimeRange;
+  showingGraph?: boolean;
+  showingTable?: boolean;
+  timeZone?: TimeZone;
+  onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
+  toggleGraph: typeof toggleGraph;
 }
 
 /**
@@ -190,6 +198,16 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.props.scanStopAction({ exploreId: this.props.exploreId });
   };
 
+  onToggleGraph = (showingGraph: boolean) => {
+    const { toggleGraph, exploreId } = this.props;
+    toggleGraph(exploreId, showingGraph);
+  };
+
+  onUpdateTimeRange = (absoluteRange: AbsoluteTimeRange) => {
+    const { updateTimeRange, exploreId } = this.props;
+    updateTimeRange({ exploreId, absoluteRange });
+  };
+
   refreshExplore = () => {
     const { exploreId, update } = this.props;
 
@@ -227,6 +245,11 @@ export class Explore extends React.PureComponent<ExploreProps> {
       queryErrors,
       mode,
       graphResult,
+      loading,
+      absoluteRange,
+      showingGraph,
+      showingTable,
+      timeZone,
     } = this.props;
     const exploreClass = split ? 'explore explore-split' : 'explore';
 
@@ -262,7 +285,21 @@ export class Explore extends React.PureComponent<ExploreProps> {
                       {!showingStartPage && (
                         <>
                           {mode === ExploreMode.Metrics && (
-                            <ExploreGraphPanel exploreId={exploreId} series={graphResult} width={width} />
+                            <ExploreGraphPanel
+                              series={graphResult}
+                              width={width}
+                              loading={loading}
+                              absoluteRange={absoluteRange}
+                              isStacked={false}
+                              showPanel={true}
+                              showingGraph={showingGraph}
+                              showingTable={showingTable}
+                              timeZone={timeZone}
+                              onToggleGraph={this.onToggleGraph}
+                              onUpdateTimeRange={this.onUpdateTimeRange}
+                              showBars={false}
+                              showLines={true}
+                            />
                           )}
                           {mode === ExploreMode.Metrics && (
                             <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />
@@ -311,6 +348,10 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
     supportedModes,
     mode,
     graphResult,
+    loadingState,
+    showingGraph,
+    showingTable,
+    absoluteRange,
   } = item;
 
   const { datasource, queries, range: urlRange, mode: urlMode, ui } = (urlState || {}) as ExploreUrlState;
@@ -335,6 +376,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
   }
 
   const initialUI = ui || DEFAULT_UI_STATE;
+  const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
 
   return {
     StartPage,
@@ -355,6 +397,10 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
     queryErrors,
     isLive,
     graphResult,
+    loading,
+    showingGraph,
+    showingTable,
+    absoluteRange,
   };
 }
 
@@ -368,6 +414,7 @@ const mapDispatchToProps = {
   scanStopAction,
   setQueries,
   updateTimeRange,
+  toggleGraph,
 };
 
 export default hot(module)(

+ 79 - 69
public/app/features/explore/ExploreGraphPanel.tsx

@@ -1,30 +1,58 @@
 import React, { PureComponent } from 'react';
-import { hot } from 'react-hot-loader';
-import { connect } from 'react-redux';
-import { LegendDisplayMode, GraphWithLegend } from '@grafana/ui';
-import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone, LoadingState } from '@grafana/data';
-
-import { GraphSeriesToggler } from 'app/plugins/panel/graph2/GraphSeriesToggler';
-import Panel from './Panel';
-import { StoreState, ExploreId, ExploreMode } from 'app/types';
-import { getTimeZone } from '../profile/state/selectors';
-import { toggleGraph, updateTimeRange } from './state/actions';
+import { css, cx } from 'emotion';
+import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone } from '@grafana/data';
+
+import {
+  GrafanaTheme,
+  selectThemeVariant,
+  Themeable,
+  GraphWithLegend,
+  LegendDisplayMode,
+  withTheme,
+  Collapse,
+  GraphSeriesToggler,
+  GraphSeriesTogglerAPI,
+} from '@grafana/ui';
 
 const MAX_NUMBER_OF_TIME_SERIES = 20;
 
-interface Props {
-  exploreId: ExploreId;
+const getStyles = (theme: GrafanaTheme) => ({
+  timeSeriesDisclaimer: css`
+    label: time-series-disclaimer;
+    width: 300px;
+    margin: ${theme.spacing.sm} auto;
+    padding: 10px 0;
+    border-radius: ${theme.border.radius.md};
+    text-align: center;
+    background-color: ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.dark4 }, theme.type)};
+  `,
+  disclaimerIcon: css`
+    label: disclaimer-icon;
+    color: ${theme.colors.yellow};
+    margin-right: ${theme.spacing.xs};
+  `,
+  showAllTimeSeries: css`
+    label: show-all-time-series;
+    cursor: pointer;
+    color: ${theme.colors.linkExternal};
+  `,
+});
+
+interface Props extends Themeable {
   series: GraphSeriesXY[];
   width: number;
-  absoluteRange?: AbsoluteTimeRange;
-  loading?: boolean;
-  mode?: ExploreMode;
-  showingGraph?: boolean;
-  showingTable?: boolean;
-  timeZone?: TimeZone;
+  absoluteRange: AbsoluteTimeRange;
+  loading: boolean;
+  showPanel: boolean;
+  showBars: boolean;
+  showLines: boolean;
+  isStacked: boolean;
+  showingGraph: boolean;
+  showingTable: boolean;
+  timeZone: TimeZone;
+  onUpdateTimeRange: (absoluteRange: AbsoluteTimeRange) => void;
+  onToggleGraph?: (showingGraph: boolean) => void;
   onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
-  toggleGraph: typeof toggleGraph;
-  updateTimeRange: typeof updateTimeRange;
 }
 
 interface State {
@@ -32,7 +60,7 @@ interface State {
   showAllTimeSeries: boolean;
 }
 
-export class ExploreGraphPanel extends PureComponent<Props, State> {
+class UnThemedExploreGraphPanel extends PureComponent<Props, State> {
   state: State = {
     hiddenSeries: [],
     showAllTimeSeries: false,
@@ -45,14 +73,15 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
   };
 
   onClickGraphButton = () => {
-    const { toggleGraph, exploreId, showingGraph } = this.props;
-    toggleGraph(exploreId, showingGraph);
+    const { onToggleGraph, showingGraph } = this.props;
+    if (onToggleGraph) {
+      onToggleGraph(showingGraph);
+    }
   };
 
   onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
-    const { exploreId, updateTimeRange } = this.props;
-
-    updateTimeRange({ exploreId, absoluteRange });
+    const { onUpdateTimeRange } = this.props;
+    onUpdateTimeRange(absoluteRange);
   };
 
   renderGraph = () => {
@@ -62,9 +91,12 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
       onHiddenSeriesChanged,
       timeZone,
       absoluteRange,
-      mode,
+      showPanel,
       showingGraph,
       showingTable,
+      showBars,
+      showLines,
+      isStacked,
     } = this.props;
     const { showAllTimeSeries } = this.state;
 
@@ -80,16 +112,13 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
         to: dateTimeForTimeZone(timeZone, absoluteRange.to),
       },
     };
-    const height = mode === ExploreMode.Logs ? 100 : showingGraph && showingTable ? 200 : 400;
-    const showBars = mode === ExploreMode.Logs ? true : false;
-    const showLines = mode === ExploreMode.Metrics ? true : false;
-    const isStacked = mode === ExploreMode.Logs ? true : false;
-    const lineWidth = mode === ExploreMode.Metrics ? 1 : 5;
+    const height = showPanel === false ? 100 : showingGraph && showingTable ? 200 : 400;
+    const lineWidth = showLines ? 1 : 5;
     const seriesToShow = showAllTimeSeries ? series : series.slice(0, MAX_NUMBER_OF_TIME_SERIES);
 
     return (
       <GraphSeriesToggler series={seriesToShow} onHiddenSeriesChanged={onHiddenSeriesChanged}>
-        {({ onSeriesToggle, toggledSeries }) => {
+        {({ onSeriesToggle, toggledSeries }: GraphSeriesTogglerAPI) => {
           return (
             <GraphWithLegend
               displayMode={LegendDisplayMode.List}
@@ -116,58 +145,39 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
   };
 
   render() {
-    const { series, mode, showingGraph, loading } = this.props;
+    const { series, showPanel, showingGraph, loading, theme } = this.props;
     const { showAllTimeSeries } = this.state;
+    const style = getStyles(theme);
 
     return (
       <>
         {series && series.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
-          <div className="time-series-disclaimer">
-            <i className="fa fa-fw fa-warning disclaimer-icon" />
+          <div className={cx([style.timeSeriesDisclaimer])}>
+            <i className={cx(['fa fa-fw fa-warning', style.disclaimerIcon])} />
             {`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
-            <span className="show-all-time-series" onClick={this.onShowAllTimeSeries}>{`Show all ${
+            <span className={cx([style.showAllTimeSeries])} onClick={this.onShowAllTimeSeries}>{`Show all ${
               series.length
             }`}</span>
           </div>
         )}
 
-        {mode === ExploreMode.Metrics && (
-          <Panel label="Graph" collapsible isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
+        {showPanel && (
+          <Collapse
+            label="Graph"
+            collapsible
+            isOpen={showingGraph}
+            loading={loading}
+            onToggle={this.onClickGraphButton}
+          >
             {this.renderGraph()}
-          </Panel>
+          </Collapse>
         )}
 
-        {mode === ExploreMode.Logs && this.renderGraph()}
+        {!showPanel && this.renderGraph()}
       </>
     );
   }
 }
 
-function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
-  const explore = state.explore;
-  // @ts-ignore
-  const item: ExploreItemState = explore[exploreId];
-  const { loadingState, showingGraph, showingTable, absoluteRange, mode } = item;
-  const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
-
-  return {
-    loading,
-    showingGraph,
-    showingTable,
-    timeZone: getTimeZone(state.user),
-    absoluteRange,
-    mode,
-  };
-}
-
-const mapDispatchToProps = {
-  toggleGraph,
-  updateTimeRange,
-};
-
-export default hot(module)(
-  connect(
-    mapStateToProps,
-    mapDispatchToProps
-  )(ExploreGraphPanel)
-);
+export const ExploreGraphPanel = withTheme(UnThemedExploreGraphPanel);
+ExploreGraphPanel.displayName = 'ExploreGraphPanel';

+ 1 - 2
public/app/features/explore/ExploreToolbar.tsx

@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 
 import { ExploreId, ExploreMode } from 'app/types/explore';
-import { DataSourceSelectItem } from '@grafana/ui';
+import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton } from '@grafana/ui';
 import { RawTimeRange, TimeZone, TimeRange, LoadingState, SelectableValue } from '@grafana/data';
 import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { StoreState } from 'app/types/store';
@@ -17,7 +17,6 @@ import {
   changeMode,
 } from './state/actions';
 import { getTimeZone } from '../profile/state/selectors';
-import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
 import { ExploreTimeControls } from './ExploreTimeControls';
 
 enum IconSide {

+ 6 - 5
public/app/features/explore/LiveLogs.tsx

@@ -1,6 +1,6 @@
 import React, { PureComponent } from 'react';
 import { css, cx } from 'emotion';
-import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton } from '@grafana/ui';
+import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton, getLogRowStyles } from '@grafana/ui';
 
 import { LogsModel, LogRowModel, TimeZone } from '@grafana/data';
 
@@ -73,6 +73,7 @@ class LiveLogs extends PureComponent<Props, State> {
     const styles = getStyles(theme);
     const rowsToRender: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : [];
     const showUtc = timeZone === 'utc';
+    const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
 
     return (
       <>
@@ -80,20 +81,20 @@ class LiveLogs extends PureComponent<Props, State> {
           {rowsToRender.map((row: any, index) => {
             return (
               <div
-                className={row.fresh ? cx(['logs-row', styles.logsRowFresh]) : cx(['logs-row', styles.logsRowOld])}
+                className={row.fresh ? cx([logsRow, styles.logsRowFresh]) : cx([logsRow, styles.logsRowOld])}
                 key={`${row.timeEpochMs}-${index}`}
               >
                 {showUtc && (
-                  <div className="logs-row__localtime" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
+                  <div className={cx([logsRowLocalTime])} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
                     {row.timeUtc}
                   </div>
                 )}
                 {!showUtc && (
-                  <div className="logs-row__localtime" title={`${row.timeUtc} (${row.timeFromNow})`}>
+                  <div className={cx([logsRowLocalTime])} title={`${row.timeUtc} (${row.timeFromNow})`}>
                     {row.timeLocal}
                   </div>
                 )}
-                <div className="logs-row__message">{row.entry}</div>
+                <div className={cx([logsRowMessage])}>{row.entry}</div>
               </div>
             );
           })}

+ 0 - 75
public/app/features/explore/LogLabel.tsx

@@ -1,75 +0,0 @@
-import React, { PureComponent } from 'react';
-
-import { LogLabelStats } from './LogLabelStats';
-import { LogRowModel, LogLabelStatsModel } from '@grafana/data';
-import { calculateLogsLabelStats } from 'app/core/logs_model';
-
-interface Props {
-  getRows?: () => LogRowModel[];
-  label: string;
-  plain?: boolean;
-  value: string;
-  onClickLabel?: (label: string, value: string) => void;
-}
-
-interface State {
-  showStats: boolean;
-  stats: LogLabelStatsModel[];
-}
-
-export class LogLabel extends PureComponent<Props, State> {
-  state: State = {
-    stats: null,
-    showStats: false,
-  };
-
-  onClickClose = () => {
-    this.setState({ showStats: false });
-  };
-
-  onClickLabel = () => {
-    const { onClickLabel, label, value } = this.props;
-    if (onClickLabel) {
-      onClickLabel(label, value);
-    }
-  };
-
-  onClickStats = () => {
-    this.setState(state => {
-      if (state.showStats) {
-        return { showStats: false, stats: null };
-      }
-      const allRows = this.props.getRows();
-      const stats = calculateLogsLabelStats(allRows, this.props.label);
-      return { showStats: true, stats };
-    });
-  };
-
-  render() {
-    const { getRows, label, plain, value } = this.props;
-    const { showStats, stats } = this.state;
-    const tooltip = `${label}: ${value}`;
-    return (
-      <span className="logs-label">
-        <span className="logs-label__value" title={tooltip}>
-          {value}
-        </span>
-        {!plain && (
-          <span title="Filter for label" onClick={this.onClickLabel} className="logs-label__icon fa fa-search-plus" />
-        )}
-        {!plain && getRows && <span onClick={this.onClickStats} className="logs-label__icon fa fa-signal" />}
-        {showStats && (
-          <span className="logs-label__stats">
-            <LogLabelStats
-              stats={stats}
-              rowCount={getRows().length}
-              label={label}
-              value={value}
-              onClickClose={this.onClickClose}
-            />
-          </span>
-        )}
-      </span>
-    );
-  }
-}

+ 0 - 76
public/app/features/explore/LogLabelStats.tsx

@@ -1,76 +0,0 @@
-import React, { PureComponent } from 'react';
-import classnames from 'classnames';
-import { LogLabelStatsModel } from '@grafana/data';
-
-function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) {
-  const { active, count, proportion, value } = logLabelStatsModel;
-  const percent = `${Math.round(proportion * 100)}%`;
-  const barStyle = { width: percent };
-  const className = classnames('logs-stats-row', { 'logs-stats-row--active': active });
-
-  return (
-    <div className={className}>
-      <div className="logs-stats-row__label">
-        <div className="logs-stats-row__value" title={value}>
-          {value}
-        </div>
-        <div className="logs-stats-row__count">{count}</div>
-        <div className="logs-stats-row__percent">{percent}</div>
-      </div>
-      <div className="logs-stats-row__bar">
-        <div className="logs-stats-row__innerbar" style={barStyle} />
-      </div>
-    </div>
-  );
-}
-
-const STATS_ROW_LIMIT = 5;
-
-interface Props {
-  stats: LogLabelStatsModel[];
-  label: string;
-  value: string;
-  rowCount: number;
-  onClickClose: () => void;
-}
-
-export class LogLabelStats extends PureComponent<Props> {
-  render() {
-    const { label, rowCount, stats, value, onClickClose } = this.props;
-    const topRows = stats.slice(0, STATS_ROW_LIMIT);
-    let activeRow = topRows.find(row => row.value === value);
-    let otherRows = stats.slice(STATS_ROW_LIMIT);
-    const insertActiveRow = !activeRow;
-
-    // Remove active row from other to show extra
-    if (insertActiveRow) {
-      activeRow = otherRows.find(row => row.value === value);
-      otherRows = otherRows.filter(row => row.value !== value);
-    }
-
-    const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
-    const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
-    const total = topCount + otherCount;
-    const otherProportion = otherCount / total;
-
-    return (
-      <div className="logs-stats">
-        <div className="logs-stats__header">
-          <span className="logs-stats__title">
-            {label}: {total} of {rowCount} rows have that label
-          </span>
-          <span className="logs-stats__close fa fa-remove" onClick={onClickClose} />
-        </div>
-        <div className="logs-stats__body">
-          {topRows.map(stat => (
-            <LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
-          ))}
-          {insertActiveRow && activeRow && <LogLabelStatsRow key={activeRow.value} {...activeRow} active />}
-          {otherCount > 0 && (
-            <LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
-          )}
-        </div>
-      </div>
-    );
-  }
-}

+ 0 - 31
public/app/features/explore/LogLabels.tsx

@@ -1,31 +0,0 @@
-import React, { PureComponent } from 'react';
-
-import { LogLabel } from './LogLabel';
-import { Labels, LogRowModel } from '@grafana/data';
-
-interface Props {
-  getRows?: () => LogRowModel[];
-  labels: Labels;
-  plain?: boolean;
-  onClickLabel?: (label: string, value: string) => void;
-}
-
-export class LogLabels extends PureComponent<Props> {
-  render() {
-    const { getRows, labels, onClickLabel, plain } = this.props;
-    return (
-      <span className="logs-labels">
-        {Object.keys(labels).map(key => (
-          <LogLabel
-            key={key}
-            getRows={getRows}
-            label={key}
-            value={labels[key]}
-            plain={plain}
-            onClickLabel={onClickLabel}
-          />
-        ))}
-      </span>
-    );
-  }
-}

+ 60 - 108
public/app/features/explore/Logs.tsx

@@ -1,9 +1,8 @@
 import _ from 'lodash';
 import React, { PureComponent } from 'react';
 
-import { rangeUtil } from '@grafana/data';
-import { Switch } from '@grafana/ui';
 import {
+  rangeUtil,
   RawTimeRange,
   LogLevel,
   TimeZone,
@@ -12,23 +11,17 @@ import {
   LogsModel,
   LogsDedupStrategy,
   LogRowModel,
+  LogsDedupDescription,
 } from '@grafana/data';
+import { Switch, LogLabels, ToggleButtonGroup, ToggleButton, LogRows } from '@grafana/ui';
 
-import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
-
-import { LogLabels } from './LogLabels';
-import { LogRow } from './LogRow';
-import { LogsDedupDescription } from 'app/core/logs_model';
-import ExploreGraphPanel from './ExploreGraphPanel';
-import { ExploreId } from 'app/types';
-
-const PREVIEW_LIMIT = 100;
+import { ExploreGraphPanel } from './ExploreGraphPanel';
 
 function renderMetaItem(value: any, kind: LogsMetaKind) {
   if (kind === LogsMetaKind.LabelsMap) {
     return (
       <span className="logs-meta-item__labels">
-        <LogLabels labels={value} plain />
+        <LogLabels labels={value} plain getRows={() => []} />
       </span>
     );
   }
@@ -39,7 +32,6 @@ interface Props {
   data?: LogsModel;
   dedupedData?: LogsModel;
   width: number;
-  exploreId: ExploreId;
   highlighterExpressions: string[];
   loading: boolean;
   absoluteRange: AbsoluteTimeRange;
@@ -48,7 +40,7 @@ interface Props {
   scanRange?: RawTimeRange;
   dedupStrategy: LogsDedupStrategy;
   hiddenLogLevels: Set<LogLevel>;
-  onChangeTime?: (range: AbsoluteTimeRange) => void;
+  onChangeTime: (range: AbsoluteTimeRange) => void;
   onClickLabel?: (label: string, value: string) => void;
   onStartScanning?: () => void;
   onStopScanning?: () => void;
@@ -58,46 +50,16 @@ interface Props {
 }
 
 interface State {
-  deferLogs: boolean;
-  renderAll: boolean;
   showLabels: boolean;
   showTime: boolean;
 }
 
-export default class Logs extends PureComponent<Props, State> {
-  deferLogsTimer: NodeJS.Timer;
-  renderAllTimer: NodeJS.Timer;
-
+export class Logs extends PureComponent<Props, State> {
   state = {
-    deferLogs: true,
-    renderAll: false,
     showLabels: false,
     showTime: true,
   };
 
-  componentDidMount() {
-    // Staged rendering
-    if (this.state.deferLogs) {
-      const { data } = this.props;
-      const rowCount = data && data.rows ? data.rows.length : 0;
-      // Render all right away if not too far over the limit
-      const renderAll = rowCount <= PREVIEW_LIMIT * 2;
-      this.deferLogsTimer = setTimeout(() => this.setState({ deferLogs: false, renderAll }), rowCount);
-    }
-  }
-
-  componentDidUpdate(prevProps: Props, prevState: State) {
-    // Staged rendering
-    if (prevState.deferLogs && !this.state.deferLogs && !this.state.renderAll) {
-      this.renderAllTimer = setTimeout(() => this.setState({ renderAll: true }), 2000);
-    }
-  }
-
-  componentWillUnmount() {
-    clearTimeout(this.deferLogsTimer);
-    clearTimeout(this.renderAllTimer);
-  }
-
   onChangeDedup = (dedup: LogsDedupStrategy) => {
     const { onDedupStrategyChange } = this.props;
     if (this.props.dedupStrategy === dedup) {
@@ -106,39 +68,46 @@ export default class Logs extends PureComponent<Props, State> {
     return onDedupStrategyChange(dedup);
   };
 
-  onChangeLabels = (event: React.SyntheticEvent) => {
-    const target = event.target as HTMLInputElement;
-    this.setState({
-      showLabels: target.checked,
-    });
+  onChangeLabels = (event?: React.SyntheticEvent) => {
+    const target = event && (event.target as HTMLInputElement);
+    if (target) {
+      this.setState({
+        showLabels: target.checked,
+      });
+    }
   };
 
-  onChangeTime = (event: React.SyntheticEvent) => {
-    const target = event.target as HTMLInputElement;
-    this.setState({
-      showTime: target.checked,
-    });
+  onChangeTime = (event?: React.SyntheticEvent) => {
+    const target = event && (event.target as HTMLInputElement);
+    if (target) {
+      this.setState({
+        showTime: target.checked,
+      });
+    }
   };
 
   onToggleLogLevel = (hiddenRawLevels: string[]) => {
-    const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map((level: LogLevel) => LogLevel[level]);
+    const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map(level => LogLevel[level as LogLevel]);
     this.props.onToggleLogLevel(hiddenLogLevels);
   };
 
   onClickScan = (event: React.SyntheticEvent) => {
     event.preventDefault();
-    this.props.onStartScanning();
+    if (this.props.onStartScanning) {
+      this.props.onStartScanning();
+    }
   };
 
   onClickStopScan = (event: React.SyntheticEvent) => {
     event.preventDefault();
-    this.props.onStopScanning();
+    if (this.props.onStopScanning) {
+      this.props.onStopScanning();
+    }
   };
 
   render() {
     const {
       data,
-      exploreId,
       highlighterExpressions,
       loading = false,
       onClickLabel,
@@ -147,19 +116,21 @@ export default class Logs extends PureComponent<Props, State> {
       scanRange,
       width,
       dedupedData,
+      absoluteRange,
+      onChangeTime,
     } = this.props;
 
     if (!data) {
       return null;
     }
 
-    const { deferLogs, renderAll, showLabels, showTime } = this.state;
+    const { showLabels, showTime } = this.state;
     const { dedupStrategy } = this.props;
     const hasData = data && data.rows && data.rows.length > 0;
-    const hasLabel = hasData && dedupedData.hasUniqueLabels;
-    const dedupCount = dedupedData.rows.reduce((sum, row) => sum + row.duplicates, 0);
-    const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0;
-    const meta = data.meta ? [...data.meta] : [];
+    const dedupCount = dedupedData
+      ? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0)
+      : 0;
+    const meta = data && data.meta ? [...data.meta] : [];
 
     if (dedupStrategy !== LogsDedupStrategy.none) {
       meta.push({
@@ -169,23 +140,26 @@ export default class Logs extends PureComponent<Props, State> {
       });
     }
 
-    // Staged rendering
-    const processedRows = dedupedData.rows;
-    const firstRows = processedRows.slice(0, PREVIEW_LIMIT);
-    const lastRows = processedRows.slice(PREVIEW_LIMIT);
     const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...';
-
-    // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
-    const getRows = () => processedRows;
+    const series = data && data.series ? data.series : [];
 
     return (
       <div className="logs-panel">
         <div className="logs-panel-graph">
           <ExploreGraphPanel
-            exploreId={exploreId}
-            series={data.series}
+            series={series}
             width={width}
             onHiddenSeriesChanged={this.onToggleLogLevel}
+            loading={loading}
+            absoluteRange={absoluteRange}
+            isStacked={true}
+            showPanel={false}
+            showingGraph={true}
+            showingTable={true}
+            timeZone={timeZone}
+            showBars={true}
+            showLines={false}
+            onUpdateTimeRange={onChangeTime}
           />
         </div>
         <div className="logs-panel-options">
@@ -220,41 +194,19 @@ export default class Logs extends PureComponent<Props, State> {
           </div>
         )}
 
-        <div className="logs-rows">
-          {hasData &&
-          !deferLogs && // Only inject highlighterExpression in the first set for performance reasons
-            firstRows.map((row, index) => (
-              <LogRow
-                key={index}
-                getRows={getRows}
-                getRowContext={this.props.getRowContext}
-                highlighterExpressions={highlighterExpressions}
-                row={row}
-                showDuplicates={showDuplicates}
-                showLabels={showLabels && hasLabel}
-                showTime={showTime}
-                timeZone={timeZone}
-                onClickLabel={onClickLabel}
-              />
-            ))}
-          {hasData &&
-            !deferLogs &&
-            renderAll &&
-            lastRows.map((row, index) => (
-              <LogRow
-                key={PREVIEW_LIMIT + index}
-                getRows={getRows}
-                getRowContext={this.props.getRowContext}
-                row={row}
-                showDuplicates={showDuplicates}
-                showLabels={showLabels && hasLabel}
-                showTime={showTime}
-                timeZone={timeZone}
-                onClickLabel={onClickLabel}
-              />
-            ))}
-          {hasData && deferLogs && <span>Rendering {dedupedData.rows.length} rows...</span>}
-        </div>
+        <LogRows
+          data={data}
+          deduplicatedData={dedupedData}
+          dedupStrategy={dedupStrategy}
+          getRowContext={this.props.getRowContext}
+          highlighterExpressions={highlighterExpressions}
+          onClickLabel={onClickLabel}
+          rowLimit={data ? data.rows.length : undefined}
+          showLabels={showLabels}
+          showTime={showTime}
+          timeZone={timeZone}
+        />
+
         {!loading && !hasData && !scanning && (
           <div className="logs-panel-nodata">
             No logs found.

+ 6 - 9
public/app/features/explore/LogsContainer.tsx

@@ -1,7 +1,7 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
-import { DataSourceApi } from '@grafana/ui';
+import { DataSourceApi, Collapse } from '@grafana/ui';
 
 import {
   RawTimeRange,
@@ -19,13 +19,12 @@ import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { StoreState } from 'app/types';
 
 import { changeDedupStrategy, updateTimeRange } from './state/actions';
-import Logs from './Logs';
-import Panel from './Panel';
 import { toggleLogLevelAction, changeRefreshIntervalAction } from 'app/features/explore/state/actionTypes';
 import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
 import { getTimeZone } from '../profile/state/selectors';
 import { LiveLogsWithTheme } from './LiveLogs';
 import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
+import { Logs } from './Logs';
 
 interface LogsContainerProps {
   datasourceInstance: DataSourceApi | null;
@@ -89,7 +88,6 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
 
   render() {
     const {
-      exploreId,
       loading,
       logsHighlighterExpressions,
       logsResult,
@@ -108,19 +106,18 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
 
     if (isLive) {
       return (
-        <Panel label="Logs" loading={false} isOpen>
+        <Collapse label="Logs" loading={false} isOpen>
           <LiveLogsWithTheme logsResult={logsResult} timeZone={timeZone} stopLive={this.onStopLive} />
-        </Panel>
+        </Collapse>
       );
     }
 
     return (
-      <Panel label="Logs" loading={loading} isOpen>
+      <Collapse label="Logs" loading={loading} isOpen>
         <Logs
           dedupStrategy={this.props.dedupStrategy || LogsDedupStrategy.none}
           data={logsResult}
           dedupedData={dedupedResult}
-          exploreId={exploreId}
           highlighterExpressions={logsHighlighterExpressions}
           loading={loading}
           onChangeTime={this.onChangeTime}
@@ -137,7 +134,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
           hiddenLogLevels={hiddenLogLevels}
           getRowContext={this.getLogRowContext}
         />
-      </Panel>
+      </Collapse>
     );
   }
 }

+ 0 - 43
public/app/features/explore/Panel.tsx

@@ -1,43 +0,0 @@
-import React, { PureComponent } from 'react';
-
-interface Props {
-  isOpen: boolean;
-  label: string;
-  loading?: boolean;
-  collapsible?: boolean;
-  onToggle?: (isOpen: boolean) => void;
-}
-
-export default class Panel extends PureComponent<Props> {
-  onClickToggle = () => {
-    const { onToggle, isOpen } = this.props;
-    if (onToggle) {
-      onToggle(!isOpen);
-    }
-  };
-
-  render() {
-    const { isOpen, loading, collapsible } = this.props;
-    const panelClass = collapsible
-      ? 'explore-panel explore-panel--collapsible panel-container'
-      : 'explore-panel panel-container';
-    const iconClass = isOpen ? 'fa fa-caret-up' : 'fa fa-caret-down';
-    const loaderClass = loading ? 'explore-panel__loader explore-panel__loader--active' : 'explore-panel__loader';
-    return (
-      <div className={panelClass}>
-        <div className="explore-panel__header" onClick={this.onClickToggle}>
-          <div className="explore-panel__header-buttons">
-            <span className={iconClass} />
-          </div>
-          <div className="explore-panel__header-label">{this.props.label}</div>
-        </div>
-        {isOpen && (
-          <div className="explore-panel__body">
-            <div className={loaderClass} />
-            {this.props.children}
-          </div>
-        )}
-      </div>
-    );
-  }
-}

+ 4 - 4
public/app/features/explore/TableContainer.tsx

@@ -1,15 +1,15 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
+import { LoadingState } from '@grafana/data';
+import { Collapse } from '@grafana/ui';
 
 import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { StoreState } from 'app/types';
 
 import { toggleTable } from './state/actions';
 import Table from './Table';
-import Panel from './Panel';
 import TableModel from 'app/core/table_model';
-import { LoadingState } from '@grafana/data';
 
 interface TableContainerProps {
   exploreId: ExploreId;
@@ -29,9 +29,9 @@ export class TableContainer extends PureComponent<TableContainerProps> {
     const { loading, onClickCell, showingTable, tableResult } = this.props;
 
     return (
-      <Panel label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
+      <Collapse label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
         {tableResult && <Table data={tableResult} loading={loading} onClickCell={onClickCell} />}
-      </Panel>
+      </Collapse>
     );
   }
 }

+ 3 - 1
public/app/features/explore/state/reducers.ts

@@ -7,6 +7,7 @@ import {
   DEFAULT_UI_STATE,
   generateNewKeyAndAddRefIdIfMissing,
   sortLogsResult,
+  refreshIntervalToSortOrder,
 } from 'app/core/utils/explore';
 import { ExploreItemState, ExploreState, ExploreId, ExploreUpdateState, ExploreMode } from 'app/types/explore';
 import { LoadingState } from '@grafana/data';
@@ -183,7 +184,8 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
     mapper: (state, action): ExploreItemState => {
       const { refreshInterval } = action.payload;
       const live = isLive(refreshInterval);
-      const logsResult = sortLogsResult(state.logsResult, refreshInterval);
+      const sortOrder = refreshIntervalToSortOrder(refreshInterval);
+      const logsResult = sortLogsResult(state.logsResult, sortOrder);
 
       return {
         ...state,

+ 9 - 4
public/app/features/explore/utils/ResultProcessor.ts

@@ -14,7 +14,7 @@ import {
 import { ExploreItemState, ExploreMode } from 'app/types/explore';
 import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState';
 import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
-import { sortLogsResult } from 'app/core/utils/explore';
+import { sortLogsResult, refreshIntervalToSortOrder } from 'app/core/utils/explore';
 import { dataFrameToLogsModel } from 'app/core/logs_model';
 import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel';
 
@@ -80,14 +80,19 @@ export class ResultProcessor {
     const graphInterval = this.state.queryIntervals.intervalMs;
     const dataFrame = this.rawData.map(result => guessFieldTypes(toDataFrame(result)));
     const newResults = this.rawData ? dataFrameToLogsModel(dataFrame, graphInterval) : null;
-    const sortedNewResults = sortLogsResult(newResults, this.state.refreshInterval);
+    const sortOrder = refreshIntervalToSortOrder(this.state.refreshInterval);
+    const sortedNewResults = sortLogsResult(newResults, sortOrder);
 
     if (this.replacePreviousResults) {
-      return sortedNewResults;
+      const slice = 1000;
+      const rows = sortedNewResults.rows.slice(0, slice);
+      const series = sortedNewResults.series;
+
+      return { ...sortedNewResults, rows, series };
     }
 
     const prevLogsResult: LogsModel = this.state.logsResult || { hasUniqueLabels: false, rows: [] };
-    const sortedLogResult = sortLogsResult(prevLogsResult, this.state.refreshInterval);
+    const sortedLogResult = sortLogsResult(prevLogsResult, sortOrder);
     const rowsInState = sortedLogResult.rows;
     const seriesInState = sortedLogResult.series || [];
 

+ 2 - 0
public/app/features/plugins/built_in_plugins.ts

@@ -32,6 +32,7 @@ import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
 import * as gaugePanel from 'app/plugins/panel/gauge/module';
 import * as pieChartPanel from 'app/plugins/panel/piechart/module';
 import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
+import * as logsPanel from 'app/plugins/panel/logs/module';
 
 import * as exampleApp from 'app/plugins/app/example-app/module';
 
@@ -70,6 +71,7 @@ const builtInPlugins: any = {
   'app/plugins/panel/gauge/module': gaugePanel,
   'app/plugins/panel/piechart/module': pieChartPanel,
   'app/plugins/panel/bargauge/module': barGaugePanel,
+  'app/plugins/panel/logs/module': logsPanel,
 
   'app/plugins/app/example-app/module': exampleApp,
 };

+ 1 - 2
public/app/plugins/panel/graph2/GraphPanelController.tsx

@@ -1,11 +1,10 @@
 import React from 'react';
-import { PanelData } from '@grafana/ui';
+import { PanelData, GraphSeriesToggler } from '@grafana/ui';
 import { GraphSeriesXY } from '@grafana/data';
 
 import { getGraphSeriesModel } from './getGraphSeriesModel';
 import { Options, SeriesOptions } from './types';
 import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from '@grafana/ui/src/components/Graph/GraphWithLegend';
-import { GraphSeriesToggler } from './GraphSeriesToggler';
 
 interface GraphPanelControllerAPI {
   series: GraphSeriesXY[];

+ 39 - 0
public/app/plugins/panel/logs/LogsPanel.tsx

@@ -0,0 +1,39 @@
+import React from 'react';
+import { PanelProps, LogRows, CustomScrollbar } from '@grafana/ui';
+import { Options } from './types';
+import { LogsDedupStrategy } from '@grafana/data';
+import { dataFrameToLogsModel } from 'app/core/logs_model';
+import { sortLogsResult } from 'app/core/utils/explore';
+
+interface LogsPanelProps extends PanelProps<Options> {}
+
+export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
+  data,
+  timeZone,
+  options: { showTime, sortOrder },
+  width,
+}) => {
+  if (!data) {
+    return (
+      <div className="panel-empty">
+        <p>No data found in response</p>
+      </div>
+    );
+  }
+
+  const newResults = data ? dataFrameToLogsModel(data.series, data.request.intervalMs) : null;
+  const sortedNewResults = sortLogsResult(newResults, sortOrder);
+
+  return (
+    <CustomScrollbar autoHide>
+      <LogRows
+        data={sortedNewResults}
+        dedupStrategy={LogsDedupStrategy.none}
+        highlighterExpressions={[]}
+        showTime={showTime}
+        showLabels={false}
+        timeZone={timeZone}
+      />
+    </CustomScrollbar>
+  );
+};

+ 46 - 0
public/app/plugins/panel/logs/LogsPanelEditor.tsx

@@ -0,0 +1,46 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import { PanelEditorProps, Switch, PanelOptionsGrid, PanelOptionsGroup, FormLabel, Select } from '@grafana/ui';
+
+// Types
+import { Options } from './types';
+import { SortOrder } from 'app/core/utils/explore';
+import { SelectableValue } from '@grafana/data';
+
+const sortOrderOptions = [
+  { value: SortOrder.Descending, label: 'Descending' },
+  { value: SortOrder.Ascending, label: 'Ascending' },
+];
+
+export class LogsPanelEditor extends PureComponent<PanelEditorProps<Options>> {
+  onToggleTime = () => {
+    const { options, onOptionsChange } = this.props;
+    const { showTime } = options;
+
+    onOptionsChange({ ...options, showTime: !showTime });
+  };
+
+  onShowValuesChange = (item: SelectableValue<SortOrder>) => {
+    const { options, onOptionsChange } = this.props;
+    onOptionsChange({ ...options, sortOrder: item.value });
+  };
+
+  render() {
+    const { showTime, sortOrder } = this.props.options;
+    const value = sortOrderOptions.filter(option => option.value === sortOrder)[0];
+
+    return (
+      <>
+        <PanelOptionsGrid>
+          <PanelOptionsGroup title="Columns">
+            <Switch label="Time" labelClass="width-10" checked={showTime} onChange={this.onToggleTime} />
+            <div className="gf-form">
+              <FormLabel>Order</FormLabel>
+              <Select options={sortOrderOptions} value={value} onChange={this.onShowValuesChange} />
+            </div>
+          </PanelOptionsGroup>
+        </PanelOptionsGrid>
+      </>
+    );
+  }
+}

+ 8 - 0
public/app/plugins/panel/logs/img/icn-logs-panel.svg

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="144px" height="144px" viewBox="0 0 144 144" version="1.1">
+<g id="surface736507">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(90.196078%,49.411765%,13.333333%);fill-opacity:1;" d="M 93 42 L 132 42 L 132 30 C 132 23.371094 126.628906 18 120 18 L 93 18 Z M 93 42 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(92.54902%,94.117647%,94.509804%);fill-opacity:1;" d="M 120 18 L 42 18 C 35.371094 18 30 23.371094 30 30 L 30 114 C 30 120.628906 35.371094 126 42 126 L 96 126 C 102.628906 126 108 120.628906 108 114 L 108 30 C 108 23.371094 113.371094 18 120 18 Z M 120 18 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(90.196078%,49.411765%,13.333333%);fill-opacity:1;" d="M 96 126 L 24 126 C 17.371094 126 12 120.628906 12 114 L 12 105 L 84 105 L 84 114 C 84 120.628906 89.371094 126 96 126 Z M 48 42 L 90 42 L 90 48 L 48 48 Z M 48 57 L 78 57 L 78 63 L 48 63 Z M 48 72 L 90 72 L 90 78 L 48 78 Z M 48 87 L 78 87 L 78 93 L 48 93 Z M 48 87 "/>
+</g>
+</svg>

+ 6 - 0
public/app/plugins/panel/logs/module.tsx

@@ -0,0 +1,6 @@
+import { PanelPlugin } from '@grafana/ui';
+import { Options, defaults } from './types';
+import { LogsPanel } from './LogsPanel';
+import { LogsPanelEditor } from './LogsPanelEditor';
+
+export const plugin = new PanelPlugin<Options>(LogsPanel).setDefaults(defaults).setEditor(LogsPanelEditor);

+ 17 - 0
public/app/plugins/panel/logs/plugin.json

@@ -0,0 +1,17 @@
+{
+  "type": "panel",
+  "name": "Logs",
+  "id": "logs",
+  "state": "alpha",
+
+  "info": {
+    "author": {
+      "name": "Grafana Project",
+      "url": "https://grafana.com"
+    },
+    "logos": {
+      "small": "img/icn-logs-panel.svg",
+      "large": "img/icn-logs-panel.svg"
+    }
+  }
+}

+ 11 - 0
public/app/plugins/panel/logs/types.ts

@@ -0,0 +1,11 @@
+import { SortOrder } from 'app/core/utils/explore';
+
+export interface Options {
+  showTime: boolean;
+  sortOrder: SortOrder;
+}
+
+export const defaults: Options = {
+  showTime: true,
+  sortOrder: SortOrder.Descending,
+};

+ 0 - 7
public/app/types/explore.ts

@@ -333,10 +333,3 @@ export interface QueryTransaction {
   result?: any; // Table model / Timeseries[] / Logs
   scanning?: boolean;
 }
-
-export interface TextMatch {
-  text: string;
-  start: number;
-  length: number;
-  end: number;
-}

+ 0 - 262
public/sass/components/_panel_logs.scss

@@ -56,265 +56,3 @@ $column-horizontal-spacing: 10px;
   position: relative;
   top: 4px;
 }
-
-.logs-rows {
-  font-family: $font-family-monospace;
-  font-size: $font-size-sm;
-  display: table;
-  table-layout: fixed;
-  width: 100%;
-}
-
-.logs-row {
-  display: table-row;
-
-  > div {
-    display: table-cell;
-    padding-right: $column-horizontal-spacing;
-    border-top: 1px solid transparent;
-    border-bottom: 1px solid transparent;
-    height: 100%;
-  }
-
-  &:hover {
-    background: $page-bg;
-  }
-}
-
-.logs-row__localtime {
-  white-space: nowrap;
-  width: 12.5em;
-}
-
-.logs-row__labels {
-  width: 20%;
-  line-height: 1.2;
-  position: relative;
-}
-
-.logs-row__message {
-  word-break: break-all;
-}
-
-.logs-row__match-highlight {
-  // Undoing mark styling
-  background: inherit;
-  padding: inherit;
-
-  color: $typeahead-selected-color;
-  border-bottom: 1px solid $typeahead-selected-color;
-  background-color: rgba($typeahead-selected-color, 0.1);
-
-  &--preview {
-    background-color: rgba($typeahead-selected-color, 0.2);
-    border-bottom-style: dotted;
-  }
-}
-
-.logs-row__level {
-  position: relative;
-  width: 10px;
-
-  &::after {
-    content: '';
-    display: block;
-    position: absolute;
-    top: 1px;
-    bottom: 1px;
-    width: 3px;
-    background-color: $logs-color-unkown;
-  }
-
-  &--critical,
-  &--crit {
-    &::after {
-      background-color: #705da0;
-    }
-  }
-
-  &--error,
-  &--err {
-    &::after {
-      background-color: #e24d42;
-    }
-  }
-
-  &--warning,
-  &--warn {
-    &::after {
-      background-color: $yellow;
-    }
-  }
-
-  &--info {
-    &::after {
-      background-color: #7eb26d;
-    }
-  }
-
-  &--debug {
-    &::after {
-      background-color: #1f78c1;
-    }
-  }
-
-  &--trace {
-    &::after {
-      background-color: #6ed0e0;
-    }
-  }
-}
-
-.logs-row__duplicates {
-  text-align: right;
-  width: 4em;
-}
-
-.logs-row__field-highlight {
-  // Undoing mark styling
-  background: inherit;
-  padding: inherit;
-  border-bottom: 1px dotted $typeahead-selected-color;
-
-  .logs-row__field-highlight--icon {
-    margin-left: 0.5em;
-    cursor: pointer;
-    display: none;
-  }
-}
-
-.logs-row__stats {
-  margin: 5px 0;
-}
-
-.logs-row__field-highlight:hover {
-  color: $typeahead-selected-color;
-  border-bottom-style: solid;
-
-  .logs-row__field-highlight--icon {
-    display: inline;
-  }
-}
-
-.logs-labels {
-  display: flex;
-  flex-wrap: wrap;
-}
-
-.logs-label {
-  display: flex;
-  padding: 0 2px;
-  background-color: $btn-inverse-bg;
-  border-radius: $border-radius;
-  margin: 0 4px 2px 0;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  overflow: hidden;
-}
-
-.logs-label__icon {
-  border-left: $panel-border;
-  padding: 0 2px;
-  cursor: pointer;
-  margin-left: 2px;
-}
-
-.logs-label__value {
-  display: inline-block;
-  max-width: 20em;
-  text-overflow: ellipsis;
-  overflow: hidden;
-}
-
-.logs-label__stats {
-  position: absolute;
-  top: 1.25em;
-  left: -10px;
-  z-index: 100;
-  justify-content: space-between;
-  box-shadow: $popover-shadow;
-}
-
-/*
-* Stats popover & message stats box
-*/
-.logs-stats {
-  background-color: $popover-bg;
-  color: $popover-color;
-  border: 1px solid $popover-border-color;
-  border-radius: $border-radius;
-  max-width: 500px;
-}
-
-.logs-stats__header {
-  background: $popover-header-bg;
-  padding: 6px 10px;
-  display: flex;
-}
-
-.logs-stats__title {
-  font-weight: $font-weight-semi-bold;
-  padding-right: $spacer;
-  overflow: hidden;
-  display: inline-block;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  flex-grow: 1;
-}
-
-.logs-stats__body {
-  padding: 20px 10px 10px 10px;
-}
-
-.logs-stats__close {
-  cursor: pointer;
-}
-
-.logs-stats-row {
-  margin: $spacer/1.75 0;
-
-  &--active {
-    color: $blue;
-    position: relative;
-  }
-
-  &--active::after {
-    display: inline;
-    content: '*';
-    position: absolute;
-    top: 0;
-    left: -8px;
-  }
-
-  &__label {
-    display: flex;
-    margin-bottom: 1px;
-  }
-
-  &__value {
-    flex: 1;
-    text-overflow: ellipsis;
-    overflow: hidden;
-  }
-
-  &__count,
-  &__percent {
-    text-align: right;
-    margin-left: 0.5em;
-  }
-
-  &__percent {
-    width: 3em;
-  }
-
-  &__bar,
-  &__innerbar {
-    height: 4px;
-    overflow: hidden;
-    background: $text-color-faint;
-  }
-
-  &__innerbar {
-    background: $blue;
-  }
-}

+ 0 - 91
public/sass/pages/_explore.scss

@@ -163,64 +163,6 @@
   }
 }
 
-.explore-panel {
-  margin-top: $space-sm;
-}
-
-.explore-panel__body {
-  padding: $panel-padding;
-}
-
-.explore-panel__header {
-  padding: $space-sm $space-md 0 $space-md;
-  display: flex;
-  cursor: inherit;
-  transition: all 0.1s linear;
-}
-
-.explore-panel__header-label {
-  font-weight: $font-weight-semi-bold;
-  margin-right: $space-sm;
-  font-size: $font-size-h6;
-  box-shadow: $text-shadow-faint;
-}
-
-.explore-panel__header-buttons {
-  display: none;
-}
-
-.explore-panel--collapsible {
-  .explore-panel__header {
-    cursor: pointer;
-  }
-
-  .explore-panel__header-buttons {
-    margin-right: $space-sm;
-    font-size: $font-size-lg;
-    line-height: $font-size-h6;
-    display: inherit;
-  }
-}
-
-.time-series-disclaimer {
-  width: 300px;
-  margin: $space-sm auto;
-  padding: 10px 0;
-  border-radius: $border-radius;
-  text-align: center;
-  background-color: $panel-bg;
-
-  .disclaimer-icon {
-    color: $yellow;
-    margin-right: $space-xs;
-  }
-
-  .show-all-time-series {
-    cursor: pointer;
-    color: $external-link-color;
-  }
-}
-
 .navbar .elapsed-time {
   position: absolute;
   left: 0;
@@ -234,39 +176,6 @@
   flex-wrap: wrap;
 }
 
-.explore-panel__loader {
-  height: 2px;
-  position: relative;
-  overflow: hidden;
-  background: none;
-  margin: $space-xs;
-}
-
-.explore-panel__loader--active:after {
-  content: ' ';
-  display: block;
-  width: 25%;
-  top: 0;
-  top: -50%;
-  height: 250%;
-  position: absolute;
-  animation: loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67) 500ms;
-  animation-iteration-count: 100;
-  left: -25%;
-  background: $blue;
-}
-
-@keyframes loader {
-  from {
-    left: -25%;
-    opacity: 0.1;
-  }
-  to {
-    left: 100%;
-    opacity: 1;
-  }
-}
-
 .query-row {
   display: flex;
   position: relative;