From e5e7bd315357d7b6ea016e1098a1acbdc3b35d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 26 Aug 2019 08:11:07 +0200 Subject: [PATCH] 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 --- packages/grafana-data/src/types/logs.ts | 7 + packages/grafana-data/src/utils/index.ts | 1 + packages/grafana-data/src/utils/logs.test.ts | 189 ++++++++++++- packages/grafana-data/src/utils/logs.ts | 101 ++++++- .../grafana-data/src}/utils/text.test.ts | 0 packages/grafana-data/src/utils/text.ts | 84 ++++++ .../grafana-ui/src/components/Alert/Alert.tsx | 0 .../src/components/Collapse/Collapse.tsx | 124 +++++++++ .../components/Graph}/GraphSeriesToggler.tsx | 6 +- .../src/components/Logs/LogLabel.tsx | 126 +++++++++ .../src/components/Logs/LogLabelStats.tsx | 98 +++++++ .../src/components/Logs/LogLabelStatsRow.tsx | 92 ++++++ .../src/components/Logs/LogLabels.tsx | 43 +++ .../components/Logs}/LogMessageAnsi.test.tsx | 0 .../src/components/Logs}/LogMessageAnsi.tsx | 2 +- .../src/components/Logs}/LogRow.tsx | 110 +++++--- .../src/components/Logs}/LogRowContext.tsx | 30 +- .../Logs}/LogRowContextProvider.test.ts | 40 ++- .../Logs}/LogRowContextProvider.tsx | 21 +- .../src/components/Logs/LogRows.tsx | 143 ++++++++++ .../src/components/Logs/getLogRowStyles.ts | 133 +++++++++ .../src/components/Switch/Switch.story.tsx | 5 +- .../ToggleButtonGroup/ToggleButtonGroup.tsx | 4 +- packages/grafana-ui/src/components/index.ts | 7 + .../grafana-ui/src/utils}/ansicolor.ts | 63 ++--- packages/grafana-ui/src/utils/index.ts | 1 + public/app/core/logs_model.ts | 102 +------ public/app/core/specs/logs_model.test.ts | 196 +------------ public/app/core/utils/explore.test.ts | 98 ++++++- public/app/core/utils/explore.ts | 13 +- public/app/core/utils/text.ts | 79 ------ public/app/features/explore/Explore.tsx | 55 +++- .../features/explore/ExploreGraphPanel.tsx | 146 +++++----- .../app/features/explore/ExploreToolbar.tsx | 3 +- public/app/features/explore/LiveLogs.tsx | 11 +- public/app/features/explore/LogLabel.tsx | 75 ----- public/app/features/explore/LogLabelStats.tsx | 76 ----- public/app/features/explore/LogLabels.tsx | 31 --- public/app/features/explore/Logs.tsx | 168 ++++------- public/app/features/explore/LogsContainer.tsx | 15 +- public/app/features/explore/Panel.tsx | 43 --- .../app/features/explore/TableContainer.tsx | 8 +- public/app/features/explore/state/reducers.ts | 4 +- .../features/explore/utils/ResultProcessor.ts | 13 +- .../app/features/plugins/built_in_plugins.ts | 2 + .../panel/graph2/GraphPanelController.tsx | 3 +- public/app/plugins/panel/logs/LogsPanel.tsx | 39 +++ .../plugins/panel/logs/LogsPanelEditor.tsx | 46 +++ .../plugins/panel/logs/img/icn-logs-panel.svg | 8 + public/app/plugins/panel/logs/module.tsx | 6 + public/app/plugins/panel/logs/plugin.json | 17 ++ public/app/plugins/panel/logs/types.ts | 11 + public/app/types/explore.ts | 7 - public/sass/components/_panel_logs.scss | 262 ------------------ public/sass/pages/_explore.scss | 91 ------ 55 files changed, 1765 insertions(+), 1293 deletions(-) rename {public/app/core => packages/grafana-data/src}/utils/text.test.ts (100%) create mode 100644 packages/grafana-data/src/utils/text.ts rename public/app/features/explore/Error.tsx => packages/grafana-ui/src/components/Alert/Alert.tsx (100%) create mode 100644 packages/grafana-ui/src/components/Collapse/Collapse.tsx rename {public/app/plugins/panel/graph2 => packages/grafana-ui/src/components/Graph}/GraphSeriesToggler.tsx (95%) create mode 100644 packages/grafana-ui/src/components/Logs/LogLabel.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogLabelStats.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx create mode 100644 packages/grafana-ui/src/components/Logs/LogLabels.tsx rename {public/app/features/explore => packages/grafana-ui/src/components/Logs}/LogMessageAnsi.test.tsx (100%) rename {public/app/features/explore => packages/grafana-ui/src/components/Logs}/LogMessageAnsi.tsx (96%) rename {public/app/features/explore => packages/grafana-ui/src/components/Logs}/LogRow.tsx (79%) rename {public/app/features/explore => packages/grafana-ui/src/components/Logs}/LogRowContext.tsx (86%) rename {public/app/features/explore => packages/grafana-ui/src/components/Logs}/LogRowContextProvider.test.ts (62%) rename {public/app/features/explore => packages/grafana-ui/src/components/Logs}/LogRowContextProvider.tsx (93%) create mode 100644 packages/grafana-ui/src/components/Logs/LogRows.tsx create mode 100644 packages/grafana-ui/src/components/Logs/getLogRowStyles.ts rename {public/app/core => packages/grafana-ui/src}/components/ToggleButtonGroup/ToggleButtonGroup.tsx (91%) rename {public/vendor/ansicolor => packages/grafana-ui/src/utils}/ansicolor.ts (92%) delete mode 100644 public/app/features/explore/LogLabel.tsx delete mode 100644 public/app/features/explore/LogLabelStats.tsx delete mode 100644 public/app/features/explore/LogLabels.tsx delete mode 100644 public/app/features/explore/Panel.tsx create mode 100644 public/app/plugins/panel/logs/LogsPanel.tsx create mode 100644 public/app/plugins/panel/logs/LogsPanelEditor.tsx create mode 100644 public/app/plugins/panel/logs/img/icn-logs-panel.svg create mode 100644 public/app/plugins/panel/logs/module.tsx create mode 100644 public/app/plugins/panel/logs/plugin.json create mode 100644 public/app/plugins/panel/logs/types.ts diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index 641af88c502..524588a5403 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/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.', +} diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index a4cc7df0971..fa926939558 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/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'; diff --git a/packages/grafana-data/src/utils/logs.test.ts b/packages/grafana-data/src/utils/logs.test.ts index 51c526b7d98..28f171e525d 100644 --- a/packages/grafana-data/src/utils/logs.test.ts +++ b/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); + }); +}); diff --git a/packages/grafana-data/src/utils/logs.ts b/packages/grafana-data/src/utils/logs.ts index 43f44578a4e..196a6a28dd7 100644 --- a/packages/grafana-data/src/utils/logs.ts +++ b/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; +} diff --git a/public/app/core/utils/text.test.ts b/packages/grafana-data/src/utils/text.test.ts similarity index 100% rename from public/app/core/utils/text.test.ts rename to packages/grafana-data/src/utils/text.test.ts diff --git a/packages/grafana-data/src/utils/text.ts b/packages/grafana-data/src/utils/text.ts new file mode 100644 index 00000000000..f05a019720f --- /dev/null +++ b/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 = 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(''), + }; +} diff --git a/public/app/features/explore/Error.tsx b/packages/grafana-ui/src/components/Alert/Alert.tsx similarity index 100% rename from public/app/features/explore/Error.tsx rename to packages/grafana-ui/src/components/Alert/Alert.tsx diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx new file mode 100644 index 00000000000..e7ec796e344 --- /dev/null +++ b/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 = ({ 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 ( +
+
+
+ +
+
{label}
+
+ {isOpen && ( +
+
+ {children} +
+ )} +
+ ); +}; + +Collapse.displayName = 'Collapse'; diff --git a/public/app/plugins/panel/graph2/GraphSeriesToggler.tsx b/packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx similarity index 95% rename from public/app/plugins/panel/graph2/GraphSeriesToggler.tsx rename to packages/grafana-ui/src/components/Graph/GraphSeriesToggler.tsx index 564f5b9efe6..797170f095c 100644 --- a/public/app/plugins/panel/graph2/GraphSeriesToggler.tsx +++ b/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) => 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[]; } diff --git a/packages/grafana-ui/src/components/Logs/LogLabel.tsx b/packages/grafana-ui/src/components/Logs/LogLabel.tsx new file mode 100644 index 00000000000..c97e76e76f1 --- /dev/null +++ b/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 { + 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 ( + + + {value} + + {!plain && ( + + )} + {!plain && getRows && ( + + )} + {showStats && ( + + + + )} + + ); + } +} + +export const LogLabel = withTheme(UnThemedLogLabel); +LogLabel.displayName = 'LogLabel'; diff --git a/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx b/packages/grafana-ui/src/components/Logs/LogLabelStats.tsx new file mode 100644 index 00000000000..7f59e231613 --- /dev/null +++ b/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 { + 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 ( +
+
+ + {label}: {total} of {rowCount} rows have that label + + +
+
+ {topRows.map(stat => ( + + ))} + {insertActiveRow && activeRow && } + {otherCount > 0 && ( + + )} +
+
+ ); + } +} + +export const LogLabelStats = withTheme(UnThemedLogLabelStats); +LogLabelStats.displayName = 'LogLabelStats'; diff --git a/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx b/packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx new file mode 100644 index 00000000000..4437ab93b7d --- /dev/null +++ b/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 = ({ 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 ( +
+
+
+ {value} +
+
{count}
+
{percent}
+
+
+
+
+
+ ); +}; + +LogLabelStatsRow.displayName = 'LogLabelStatsRow'; diff --git a/packages/grafana-ui/src/components/Logs/LogLabels.tsx b/packages/grafana-ui/src/components/Logs/LogLabels.tsx new file mode 100644 index 00000000000..22d8aa60027 --- /dev/null +++ b/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 = ({ getRows, labels, onClickLabel, plain }) => { + const theme = useContext(ThemeContext); + const styles = getStyles(theme); + + return ( + + {Object.keys(labels).map(key => ( + + ))} + + ); +}; + +LogLabels.displayName = 'LogLabels'; diff --git a/public/app/features/explore/LogMessageAnsi.test.tsx b/packages/grafana-ui/src/components/Logs/LogMessageAnsi.test.tsx similarity index 100% rename from public/app/features/explore/LogMessageAnsi.test.tsx rename to packages/grafana-ui/src/components/Logs/LogMessageAnsi.test.tsx diff --git a/public/app/features/explore/LogMessageAnsi.tsx b/packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx similarity index 96% rename from public/app/features/explore/LogMessageAnsi.tsx rename to packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx index 552f6202c8c..2d71a276a34 100644 --- a/public/app/features/explore/LogMessageAnsi.tsx +++ b/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; diff --git a/public/app/features/explore/LogRow.tsx b/packages/grafana-ui/src/components/Logs/LogRow.tsx similarity index 79% rename from public/app/features/explore/LogRow.tsx rename to packages/grafana-ui/src/components/Logs/LogRow.tsx index af4d165b732..b61f4619e8d 100644 --- a/public/app/features/explore/LogRow.tsx +++ b/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 { css, cx } from 'emotion'; import { - LogRowContextProvider, - LogRowContextRows, - HasMoreContextRows, - LogRowContextQueryErrors, -} from './LogRowContextProvider'; -import { ThemeContext, selectThemeVariant, GrafanaTheme, DataQueryResponse } from '@grafana/ui'; - -import { LogRowModel, LogLabelStatsModel, LogsParser, TimeZone } from '@grafana/data'; -import { LogRowContext } from './LogRowContext'; + 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 { + LogRowContextRows, + LogRowContextQueryErrors, + HasMoreContextRows, + LogRowContextProvider, +} from './LogRowContextProvider'; +import { LogRowContext } from './LogRowContext'; +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; - className?: string; + getRowContext: (row: LogRowModel, options?: any) => Promise; } 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 => (props: any) => { + const theme = useContext(ThemeContext); + const style = getLogRowStyles(theme); return ( {props.children} - onClick(props.children)} /> + onClick(props.children)} + /> ); }; @@ -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 { - mouseMessageTimer: NodeJS.Timer; +class UnThemedLogRow extends PureComponent { + mouseMessageTimer: NodeJS.Timer | null = null; state: any = { fieldCount: 0, @@ -110,7 +120,7 @@ export class LogRow extends PureComponent { }; componentWillUnmount() { - clearTimeout(this.mouseMessageTimer); + this.clearMouseMessageTimer(); } onClickClose = () => { @@ -148,10 +158,16 @@ export class LogRow extends PureComponent { // 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 { showLabels, timeZone, showTime, + theme, } = this.props; const { fieldCount, @@ -217,13 +234,15 @@ export class LogRow extends PureComponent { 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 { ? cx(logRowStyles, getLogRowWithContextStyles(theme, this.state).row) : logRowStyles; return ( -
+
{showDuplicates && ( -
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
+
+ {row.duplicates && row.duplicates > 0 ? `${row.duplicates + 1}x` : null} +
)} -
+
{showTime && showUtc && ( -
+
{row.timeUtc}
)} {showTime && !showUtc && ( -
+
{row.timeLocal}
)} {showLabels && ( -
- +
+
)}
@@ -285,7 +310,7 @@ export class LogRow extends PureComponent { 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 { {hasAnsi && !parsed && !needsHighlighter && } {!hasAnsi && !parsed && !needsHighlighter && entry} {showFieldStats && ( -
+
{ 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 { return this.renderLogRow(); } } + +export const LogRow = withTheme(UnThemedLogRow); +LogRow.displayName = 'LogRow'; diff --git a/public/app/features/explore/LogRowContext.tsx b/packages/grafana-ui/src/components/Logs/LogRowContext.tsx similarity index 86% rename from public/app/features/explore/LogRowContext.tsx rename to packages/grafana-ui/src/components/Logs/LogRowContext.tsx index e4451d14f52..e5026477ebc 100644 --- a/public/app/features/explore/LogRowContext.tsx +++ b/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 = ({ const theme = useContext(ThemeContext); const { commonStyles, logs } = getLogRowContextStyles(theme); const [scrollTop, setScrollTop] = useState(0); - const listContainerRef = useRef(); + const listContainerRef = useRef() as React.RefObject; useLayoutEffect(() => { if (shouldScrollToBottom && listContainerRef.current) { @@ -211,7 +209,7 @@ export const LogRowContext: React.FunctionComponent = ({ top: -250px; `} shouldScrollToBottom - canLoadMoreRows={hasMoreContextRows.after} + canLoadMoreRows={hasMoreContextRows ? hasMoreContextRows.after : false} onLoadMoreContext={onLoadMoreContext} /> )} @@ -219,7 +217,7 @@ export const LogRowContext: React.FunctionComponent = ({ {context.before && ( { 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 => { + 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 => { + 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'] }); }); diff --git a/public/app/features/explore/LogRowContextProvider.tsx b/packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx similarity index 93% rename from public/app/features/explore/LogRowContextProvider.tsx rename to packages/grafana-ui/src/components/Logs/LogRowContextProvider.tsx index 775934be943..607364f7bb0 100644 --- a/public/app/features/explore/LogRowContextProvider.tsx +++ b/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; @@ -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(null); + const [result, setResult] = useState((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 { if (value) { - setResult(currentResult => { + setResult((currentResult: any) => { let hasMoreLogsBefore = true, hasMoreLogsAfter = true; @@ -158,8 +161,8 @@ export const LogRowContextProvider: React.FunctionComponent setLimit(limit + 10), diff --git a/packages/grafana-ui/src/components/Logs/LogRows.tsx b/packages/grafana-ui/src/components/Logs/LogRows.tsx new file mode 100644 index 00000000000..9743ad1311a --- /dev/null +++ b/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; +} + +interface State { + deferLogs: boolean; + renderAll: boolean; +} + +class UnThemedLogRows extends PureComponent { + 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 ( +
+ {hasData && + !deferLogs && // Only inject highlighterExpression in the first set for performance reasons + firstRows.map((row, index) => ( + + ))} + {hasData && + !deferLogs && + renderAll && + lastRows.map((row, index) => ( + + ))} + {hasData && deferLogs && Rendering {rowCount} rows...} +
+ ); + } +} + +export const LogRows = withTheme(UnThemedLogRows); +LogRows.displayName = 'LogsRows'; diff --git a/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts b/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts new file mode 100644 index 00000000000..ff16bbd4a60 --- /dev/null +++ b/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; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Switch/Switch.story.tsx b/packages/grafana-ui/src/components/Switch/Switch.story.tsx index c3cb6fef76d..5ef9a9e195e 100644 --- a/packages/grafana-ui/src/components/Switch/Switch.story.tsx +++ b/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 setChecked(!checked)} />; + return setChecked(!checked)} tooltip={tooltip} />; }; const story = storiesOf('UI/Switch', module); diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx similarity index 91% rename from public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx rename to packages/grafana-ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx index 07a39cfd109..e9600d9971e 100644 --- a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx +++ b/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 { +export class ToggleButtonGroup extends PureComponent { render() { const { children, label, transparent } = this.props; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 52a1994640f..b8075e10cda 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/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'; diff --git a/public/vendor/ansicolor/ansicolor.ts b/packages/grafana-ui/src/utils/ansicolor.ts similarity index 92% rename from public/vendor/ansicolor/ansicolor.ts rename to packages/grafana-ui/src/utils/ansicolor.ts index 305f0870e5a..574e389ac5c 100644 --- a/public/vendor/ansicolor/ansicolor.ts +++ b/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 + ...colorCodes.map((k, i) => + !k + ? [] + : [ + // color methods - [k, 30 + i, Code.noColor], - [camel('bg', k), 40 + i, Code.noBgColor], - ] + [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)))(); diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 077721ec22e..991b8558857 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/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 diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index fc4729a7fa1..9daf2237d25 100644 --- a/public/app/core/logs_model.ts +++ b/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): LogsModel { if (hiddenLogLevels.size === 0) { return logs; diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts index 10d2e5ea0c7..c6479b2b062 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/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: [], diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 528a464131c..f2dc6068164 100644 --- a/public/app/core/utils/explore.test.ts +++ b/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, + }); + }); + }); +}); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 408eeac4c1d..9a9aa05bd6d 100644 --- a/public/app/core/utils/explore.ts +++ b/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; diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts index 160a8c54045..2cff89f8c7e 100644 --- a/public/app/core/utils/text.ts +++ b/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 = 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']); diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 08d10efa919..137e13e4664 100644 --- a/public/app/features/explore/Explore.tsx +++ b/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; @@ -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 { 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 { 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 { {!showingStartPage && ( <> {mode === ExploreMode.Metrics && ( - + )} {mode === ExploreMode.Metrics && ( @@ -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)( diff --git a/public/app/features/explore/ExploreGraphPanel.tsx b/public/app/features/explore/ExploreGraphPanel.tsx index affd7562fb8..4ff4140e773 100644 --- a/public/app/features/explore/ExploreGraphPanel.tsx +++ b/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 { css, cx } from 'emotion'; +import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone } 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 { + 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 { +class UnThemedExploreGraphPanel extends PureComponent { state: State = { hiddenSeries: [], showAllTimeSeries: false, @@ -45,14 +73,15 @@ export class ExploreGraphPanel extends PureComponent { }; 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 { 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 { 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 ( - {({ onSeriesToggle, toggledSeries }) => { + {({ onSeriesToggle, toggledSeries }: GraphSeriesTogglerAPI) => { return ( { }; 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 && ( -
- +
+ {`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `} - {`Show all ${ + {`Show all ${ series.length }`}
)} - {mode === ExploreMode.Metrics && ( - + {showPanel && ( + {this.renderGraph()} - + )} - {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'; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 2669f203d4c..550f1d8bdc5 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/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 { diff --git a/public/app/features/explore/LiveLogs.tsx b/public/app/features/explore/LiveLogs.tsx index e6184239b56..190aa75716d 100644 --- a/public/app/features/explore/LiveLogs.tsx +++ b/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 { 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 { {rowsToRender.map((row: any, index) => { return (
{showUtc && ( -
+
{row.timeUtc}
)} {!showUtc && ( -
+
{row.timeLocal}
)} -
{row.entry}
+
{row.entry}
); })} diff --git a/public/app/features/explore/LogLabel.tsx b/public/app/features/explore/LogLabel.tsx deleted file mode 100644 index 937a7e3f5bc..00000000000 --- a/public/app/features/explore/LogLabel.tsx +++ /dev/null @@ -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 { - 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 ( - - - {value} - - {!plain && ( - - )} - {!plain && getRows && } - {showStats && ( - - - - )} - - ); - } -} diff --git a/public/app/features/explore/LogLabelStats.tsx b/public/app/features/explore/LogLabelStats.tsx deleted file mode 100644 index b2581ca9634..00000000000 --- a/public/app/features/explore/LogLabelStats.tsx +++ /dev/null @@ -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 ( -
-
-
- {value} -
-
{count}
-
{percent}
-
-
-
-
-
- ); -} - -const STATS_ROW_LIMIT = 5; - -interface Props { - stats: LogLabelStatsModel[]; - label: string; - value: string; - rowCount: number; - onClickClose: () => void; -} - -export class LogLabelStats extends PureComponent { - 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 ( -
-
- - {label}: {total} of {rowCount} rows have that label - - -
-
- {topRows.map(stat => ( - - ))} - {insertActiveRow && activeRow && } - {otherCount > 0 && ( - - )} -
-
- ); - } -} diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx deleted file mode 100644 index 1bac6f035a9..00000000000 --- a/public/app/features/explore/LogLabels.tsx +++ /dev/null @@ -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 { - render() { - const { getRows, labels, onClickLabel, plain } = this.props; - return ( - - {Object.keys(labels).map(key => ( - - ))} - - ); - } -} diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index b491be83882..db5088b3506 100644 --- a/public/app/features/explore/Logs.tsx +++ b/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 ( - + []} /> ); } @@ -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; - 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 { - deferLogsTimer: NodeJS.Timer; - renderAllTimer: NodeJS.Timer; - +export class Logs extends PureComponent { 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 { 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 { 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 { }); } - // 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 (
@@ -220,41 +194,19 @@ export default class Logs extends PureComponent {
)} -
- {hasData && - !deferLogs && // Only inject highlighterExpression in the first set for performance reasons - firstRows.map((row, index) => ( - - ))} - {hasData && - !deferLogs && - renderAll && - lastRows.map((row, index) => ( - - ))} - {hasData && deferLogs && Rendering {dedupedData.rows.length} rows...} -
+ + {!loading && !hasData && !scanning && (
No logs found. diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 3bb2e1373cf..e0f16575530 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/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 { render() { const { - exploreId, loading, logsHighlighterExpressions, logsResult, @@ -108,19 +106,18 @@ export class LogsContainer extends PureComponent { if (isLive) { return ( - + - + ); } return ( - + { hiddenLogLevels={hiddenLogLevels} getRowContext={this.getLogRowContext} /> - + ); } } diff --git a/public/app/features/explore/Panel.tsx b/public/app/features/explore/Panel.tsx deleted file mode 100644 index 841a19dba2a..00000000000 --- a/public/app/features/explore/Panel.tsx +++ /dev/null @@ -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 { - 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 ( -
-
-
- -
-
{this.props.label}
-
- {isOpen && ( -
-
- {this.props.children} -
- )} -
- ); - } -} diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx index 2927e4ea21b..937e830a768 100644 --- a/public/app/features/explore/TableContainer.tsx +++ b/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 { const { loading, onClickCell, showingTable, tableResult } = this.props; return ( - + {tableResult && } - + ); } } diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 4c1c5070e8f..58d5739c691 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/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({} 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, diff --git a/public/app/features/explore/utils/ResultProcessor.ts b/public/app/features/explore/utils/ResultProcessor.ts index 2974c2e7ba0..75c557bcaa7 100644 --- a/public/app/features/explore/utils/ResultProcessor.ts +++ b/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 || []; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 266feb0b8b6..fdd29299ece 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/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, }; diff --git a/public/app/plugins/panel/graph2/GraphPanelController.tsx b/public/app/plugins/panel/graph2/GraphPanelController.tsx index c493fa444ba..f1ae18edce8 100644 --- a/public/app/plugins/panel/graph2/GraphPanelController.tsx +++ b/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[]; diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx new file mode 100644 index 00000000000..d2822f5a755 --- /dev/null +++ b/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 {} + +export const LogsPanel: React.FunctionComponent = ({ + data, + timeZone, + options: { showTime, sortOrder }, + width, +}) => { + if (!data) { + return ( +
+

No data found in response

+
+ ); + } + + const newResults = data ? dataFrameToLogsModel(data.series, data.request.intervalMs) : null; + const sortedNewResults = sortLogsResult(newResults, sortOrder); + + return ( + + + + ); +}; diff --git a/public/app/plugins/panel/logs/LogsPanelEditor.tsx b/public/app/plugins/panel/logs/LogsPanelEditor.tsx new file mode 100644 index 00000000000..ad56cd02eda --- /dev/null +++ b/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> { + onToggleTime = () => { + const { options, onOptionsChange } = this.props; + const { showTime } = options; + + onOptionsChange({ ...options, showTime: !showTime }); + }; + + onShowValuesChange = (item: SelectableValue) => { + 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 ( + <> + + + +
+ Order +