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
This commit is contained in:
Hugo Häggmark
2019-08-26 08:11:07 +02:00
committed by GitHub
parent 98a512a3c7
commit e5e7bd3153
55 changed files with 1765 additions and 1293 deletions

View File

@@ -1,63 +0,0 @@
import React, { FC, ReactNode, PureComponent } from 'react';
import { Tooltip } from '@grafana/ui';
interface ToggleButtonGroupProps {
label?: string;
children: JSX.Element[];
transparent?: boolean;
}
export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
render() {
const { children, label, transparent } = this.props;
return (
<div className="gf-form">
{label && <label className={`gf-form-label ${transparent ? 'gf-form-label--transparent' : ''}`}>{label}</label>}
<div className={`toggle-button-group ${transparent ? 'toggle-button-group--transparent' : ''}`}>{children}</div>
</div>
);
}
}
interface ToggleButtonProps {
onChange?: (value: any) => void;
selected?: boolean;
value: any;
className?: string;
children: ReactNode;
tooltip?: string;
}
export const ToggleButton: FC<ToggleButtonProps> = ({
children,
selected,
className = '',
value = null,
tooltip,
onChange,
}) => {
const onClick = (event: React.SyntheticEvent) => {
event.stopPropagation();
if (!selected && onChange) {
onChange(value);
}
};
const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
const button = (
<button className={btnClassName} onClick={onClick}>
<span>{children}</span>
</button>
);
if (tooltip) {
return (
<Tooltip content={tooltip} placement="bottom">
{button}
</Tooltip>
);
} else {
return button;
}
};

View File

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

View File

@@ -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: [],

View File

@@ -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,
});
});
});
});

View File

@@ -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;

View File

@@ -1,68 +0,0 @@
import { findMatchesInText, parseFlags } from './text';
describe('findMatchesInText()', () => {
it('gets no matches for when search and or line are empty', () => {
expect(findMatchesInText('', '')).toEqual([]);
expect(findMatchesInText('foo', '')).toEqual([]);
expect(findMatchesInText('', 'foo')).toEqual([]);
});
it('gets no matches for unmatched search string', () => {
expect(findMatchesInText('foo', 'bar')).toEqual([]);
});
it('gets matches for matched search string', () => {
expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]);
expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
});
test('should find all matches for a complete regex', () => {
expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
{ length: 3, start: 1, text: 'foo', end: 4 },
{ length: 3, start: 5, text: 'foo', end: 8 },
{ length: 3, start: 9, text: 'bar', end: 12 },
]);
});
test('not fail on incomplete regex', () => {
expect(findMatchesInText(' foo foo bar ', 'foo|')).toEqual([
{ length: 3, start: 1, text: 'foo', end: 4 },
{ length: 3, start: 5, text: 'foo', end: 8 },
]);
expect(findMatchesInText('foo foo bar', '(')).toEqual([]);
expect(findMatchesInText('foo foo bar', '(foo|')).toEqual([]);
});
test('should parse and use flags', () => {
expect(findMatchesInText(' foo FOO bar ', '(?i)foo')).toEqual([
{ length: 3, start: 1, text: 'foo', end: 4 },
{ length: 3, start: 5, text: 'FOO', end: 8 },
]);
expect(findMatchesInText(' foo FOO bar ', '(?i)(?-i)foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
expect(findMatchesInText('FOO\nfoobar\nbar', '(?ims)^foo.')).toEqual([
{ length: 4, start: 0, text: 'FOO\n', end: 4 },
{ length: 4, start: 4, text: 'foob', end: 8 },
]);
expect(findMatchesInText('FOO\nfoobar\nbar', '(?ims)(?-smi)^foo.')).toEqual([]);
});
});
describe('parseFlags()', () => {
it('when no flags or text', () => {
expect(parseFlags('')).toEqual({ cleaned: '', flags: 'g' });
expect(parseFlags('(?is)')).toEqual({ cleaned: '', flags: 'gis' });
expect(parseFlags('foo')).toEqual({ cleaned: 'foo', flags: 'g' });
});
it('when flags present', () => {
expect(parseFlags('(?i)foo')).toEqual({ cleaned: 'foo', flags: 'gi' });
expect(parseFlags('(?ims)foo')).toEqual({ cleaned: 'foo', flags: 'gims' });
});
it('when flags cancel each other', () => {
expect(parseFlags('(?i)(?-i)foo')).toEqual({ cleaned: 'foo', flags: 'g' });
expect(parseFlags('(?i-i)foo')).toEqual({ cleaned: 'foo', flags: 'g' });
expect(parseFlags('(?is)(?-ims)foo')).toEqual({ cleaned: 'foo', flags: 'g' });
expect(parseFlags('(?i)(?-i)(?i)foo')).toEqual({ cleaned: 'foo', flags: 'gi' });
});
});

View File

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

View File

@@ -1,32 +0,0 @@
import React, { FC } from 'react';
interface Props {
message: any;
button?: {
text: string;
onClick: (event: React.MouseEvent) => void;
};
}
export const Alert: FC<Props> = props => {
const { message, button } = props;
return (
<div className="alert-container">
<div className="alert-error alert">
<div className="alert-icon">
<i className="fa fa-exclamation-triangle" />
</div>
<div className="alert-body">
<div className="alert-title">{message}</div>
</div>
{button && (
<div className="alert-button">
<button className="btn btn-outline-danger" onClick={button.onClick}>
{button.text}
</button>
</div>
)}
</div>
</div>
);
};

View File

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

View File

@@ -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<Props, State> {
class UnThemedExploreGraphPanel extends PureComponent<Props, State> {
state: State = {
hiddenSeries: [],
showAllTimeSeries: false,
@@ -45,14 +73,15 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
};
onClickGraphButton = () => {
const { toggleGraph, exploreId, showingGraph } = this.props;
toggleGraph(exploreId, showingGraph);
const { onToggleGraph, showingGraph } = this.props;
if (onToggleGraph) {
onToggleGraph(showingGraph);
}
};
onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
const { exploreId, updateTimeRange } = this.props;
updateTimeRange({ exploreId, absoluteRange });
const { onUpdateTimeRange } = this.props;
onUpdateTimeRange(absoluteRange);
};
renderGraph = () => {
@@ -62,9 +91,12 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
onHiddenSeriesChanged,
timeZone,
absoluteRange,
mode,
showPanel,
showingGraph,
showingTable,
showBars,
showLines,
isStacked,
} = this.props;
const { showAllTimeSeries } = this.state;
@@ -80,16 +112,13 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
to: dateTimeForTimeZone(timeZone, absoluteRange.to),
},
};
const height = mode === ExploreMode.Logs ? 100 : showingGraph && showingTable ? 200 : 400;
const showBars = mode === ExploreMode.Logs ? true : false;
const showLines = mode === ExploreMode.Metrics ? true : false;
const isStacked = mode === ExploreMode.Logs ? true : false;
const lineWidth = mode === ExploreMode.Metrics ? 1 : 5;
const height = showPanel === false ? 100 : showingGraph && showingTable ? 200 : 400;
const lineWidth = showLines ? 1 : 5;
const seriesToShow = showAllTimeSeries ? series : series.slice(0, MAX_NUMBER_OF_TIME_SERIES);
return (
<GraphSeriesToggler series={seriesToShow} onHiddenSeriesChanged={onHiddenSeriesChanged}>
{({ onSeriesToggle, toggledSeries }) => {
{({ onSeriesToggle, toggledSeries }: GraphSeriesTogglerAPI) => {
return (
<GraphWithLegend
displayMode={LegendDisplayMode.List}
@@ -116,58 +145,39 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
};
render() {
const { series, mode, showingGraph, loading } = this.props;
const { series, showPanel, showingGraph, loading, theme } = this.props;
const { showAllTimeSeries } = this.state;
const style = getStyles(theme);
return (
<>
{series && series.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
<div className="time-series-disclaimer">
<i className="fa fa-fw fa-warning disclaimer-icon" />
<div className={cx([style.timeSeriesDisclaimer])}>
<i className={cx(['fa fa-fw fa-warning', style.disclaimerIcon])} />
{`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
<span className="show-all-time-series" onClick={this.onShowAllTimeSeries}>{`Show all ${
<span className={cx([style.showAllTimeSeries])} onClick={this.onShowAllTimeSeries}>{`Show all ${
series.length
}`}</span>
</div>
)}
{mode === ExploreMode.Metrics && (
<Panel label="Graph" collapsible isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
{showPanel && (
<Collapse
label="Graph"
collapsible
isOpen={showingGraph}
loading={loading}
onToggle={this.onClickGraphButton}
>
{this.renderGraph()}
</Panel>
</Collapse>
)}
{mode === ExploreMode.Logs && this.renderGraph()}
{!showPanel && this.renderGraph()}
</>
);
}
}
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
const explore = state.explore;
// @ts-ignore
const item: ExploreItemState = explore[exploreId];
const { loadingState, showingGraph, showingTable, absoluteRange, mode } = item;
const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
return {
loading,
showingGraph,
showingTable,
timeZone: getTimeZone(state.user),
absoluteRange,
mode,
};
}
const mapDispatchToProps = {
toggleGraph,
updateTimeRange,
};
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(ExploreGraphPanel)
);
export const ExploreGraphPanel = withTheme(UnThemedExploreGraphPanel);
ExploreGraphPanel.displayName = 'ExploreGraphPanel';

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { LogMessageAnsi } from './LogMessageAnsi';
describe('<LogMessageAnsi />', () => {
it('renders string without ANSI codes', () => {
const wrapper = shallow(<LogMessageAnsi value="Lorem ipsum" />);
expect(wrapper.find('span').exists()).toBe(false);
expect(wrapper.text()).toBe('Lorem ipsum');
});
it('renders string with ANSI codes', () => {
const value = 'Lorem \u001B[31mipsum\u001B[0m et dolor';
const wrapper = shallow(<LogMessageAnsi value={value} />);
expect(wrapper.find('span')).toHaveLength(1);
expect(
wrapper
.find('span')
.first()
.prop('style')
).toMatchObject(
expect.objectContaining({
color: expect.any(String),
})
);
expect(
wrapper
.find('span')
.first()
.text()
).toBe('ipsum');
});
});

View File

@@ -1,75 +0,0 @@
import React, { PureComponent } from 'react';
import ansicolor from 'vendor/ansicolor/ansicolor';
interface Style {
[key: string]: string;
}
interface ParsedChunk {
style: Style;
text: string;
}
function convertCSSToStyle(css: string): Style {
return css.split(/;\s*/).reduce((accumulated, line) => {
const match = line.match(/([^:\s]+)\s*:\s*(.+)/);
if (match && match[1] && match[2]) {
const key = match[1].replace(/-(a-z)/g, (_, character) => character.toUpperCase());
// @ts-ignore
accumulated[key] = match[2];
}
return accumulated;
}, {});
}
interface Props {
value: string;
}
interface State {
chunks: ParsedChunk[];
prevValue: string;
}
export class LogMessageAnsi extends PureComponent<Props, State> {
state: State = {
chunks: [],
prevValue: '',
};
static getDerivedStateFromProps(props: Props, state: State) {
if (props.value === state.prevValue) {
return null;
}
const parsed = ansicolor.parse(props.value);
return {
chunks: parsed.spans.map(span => {
return span.css
? {
style: convertCSSToStyle(span.css),
text: span.text,
}
: { text: span.text };
}),
prevValue: props.value,
};
}
render() {
const { chunks } = this.state;
return chunks.map((chunk, index) =>
chunk.style ? (
<span key={index} style={chunk.style}>
{chunk.text}
</span>
) : (
chunk.text
)
);
}
}

View File

@@ -1,359 +0,0 @@
import React, { PureComponent } 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';
import tinycolor from 'tinycolor2';
interface Props {
highlighterExpressions?: string[];
row: LogRowModel;
showDuplicates: boolean;
showLabels: boolean;
showTime: boolean;
timeZone: TimeZone;
getRows: () => LogRowModel[];
onClickLabel?: (label: string, value: string) => void;
onContextClick?: () => void;
getRowContext?: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
className?: string;
}
interface State {
fieldCount: number;
fieldLabel: string;
fieldStats: LogLabelStatsModel[];
fieldValue: string;
parsed: boolean;
parser?: LogsParser;
parsedFieldHighlights: string[];
showFieldStats: boolean;
showContext: boolean;
}
/**
* Renders a highlighted field.
* When hovering, a stats icon is shown.
*/
const FieldHighlight = (onClick: any) => (props: any) => {
return (
<span className={props.className} style={props.style}>
{props.children}
<span className="logs-row__field-highlight--icon fa fa-signal" onClick={() => onClick(props.children)} />
</span>
);
};
const logRowStyles = css`
position: relative;
/* z-index: 0; */
/* outline: none; */
`;
const getLogRowWithContextStyles = (theme: GrafanaTheme, state: State) => {
const outlineColor = selectThemeVariant(
{
light: theme.colors.white,
dark: theme.colors.black,
},
theme.type
);
return {
row: css`
z-index: 1;
outline: 9999px solid
${tinycolor(outlineColor as tinycolor.ColorInput)
.setAlpha(0.7)
.toRgbString()};
`,
};
};
/**
* Renders a log line.
*
* When user hovers over it for a certain time, it lazily parses the log line.
* Once a parser is found, it will determine fields, that will be highlighted.
* When the user requests stats for a field, they will be calculated and rendered below the row.
*/
export class LogRow extends PureComponent<Props, State> {
mouseMessageTimer: NodeJS.Timer;
state: any = {
fieldCount: 0,
fieldLabel: null,
fieldStats: null,
fieldValue: null,
parsed: false,
parser: undefined,
parsedFieldHighlights: [],
showFieldStats: false,
showContext: false,
};
componentWillUnmount() {
clearTimeout(this.mouseMessageTimer);
}
onClickClose = () => {
this.setState({ showFieldStats: false });
};
onClickHighlight = (fieldText: string) => {
const { getRows } = this.props;
const { parser } = this.state;
const allRows = getRows();
// Build value-agnostic row matcher based on the field label
const fieldLabel = parser.getLabelFromField(fieldText);
const fieldValue = parser.getValueFromField(fieldText);
const matcher = parser.buildMatcher(fieldLabel);
const fieldStats = calculateFieldStats(allRows, matcher);
const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0);
this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true });
};
onMouseOverMessage = () => {
if (this.state.showContext || this.isTextSelected()) {
// When showing context we don't want to the LogRow rerender as it will mess up state of context block
// making the "after" context to be scrolled to the top, what is desired only on open
// The log row message needs to be refactored to separate component that encapsulates parsing and parsed message state
return;
}
// Don't parse right away, user might move along
this.mouseMessageTimer = setTimeout(this.parseMessage, 500);
};
onMouseOutMessage = () => {
if (this.state.showContext) {
// See comment in onMouseOverMessage method
return;
}
clearTimeout(this.mouseMessageTimer);
this.setState({ parsed: false });
};
parseMessage = () => {
if (!this.state.parsed) {
const { row } = this.props;
const parser = getParser(row.entry);
if (parser) {
// Use parser to highlight detected fields
const parsedFieldHighlights = parser.getFields(this.props.row.entry);
this.setState({ parsedFieldHighlights, parsed: true, parser });
}
}
};
isTextSelected() {
if (!window.getSelection) {
return false;
}
const selection = window.getSelection();
if (!selection) {
return false;
}
return selection.anchorNode !== null && selection.isCollapsed === false;
}
toggleContext = () => {
this.setState(state => {
return {
showContext: !state.showContext,
};
});
};
onContextToggle = (e: React.SyntheticEvent<HTMLElement>) => {
e.stopPropagation();
this.toggleContext();
};
renderLogRow(
context?: LogRowContextRows,
errors?: LogRowContextQueryErrors,
hasMoreContextRows?: HasMoreContextRows,
updateLimit?: () => void
) {
const {
getRows,
highlighterExpressions,
onClickLabel,
row,
showDuplicates,
showLabels,
timeZone,
showTime,
} = this.props;
const {
fieldCount,
fieldLabel,
fieldStats,
fieldValue,
parsed,
parsedFieldHighlights,
showFieldStats,
showContext,
} = this.state;
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 showUtc = timeZone === 'utc';
return (
<ThemeContext.Consumer>
{theme => {
const styles = this.state.showContext
? cx(logRowStyles, getLogRowWithContextStyles(theme, this.state).row)
: logRowStyles;
return (
<div className={`logs-row ${this.props.className}`}>
{showDuplicates && (
<div className="logs-row__duplicates">{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}</div>
)}
<div className={row.logLevel ? `logs-row__level logs-row__level--${row.logLevel}` : ''} />
{showTime && showUtc && (
<div className="logs-row__localtime" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
{row.timeUtc}
</div>
)}
{showTime && !showUtc && (
<div className="logs-row__localtime" title={`${row.timeUtc} (${row.timeFromNow})`}>
{row.timeLocal}
</div>
)}
{showLabels && (
<div className="logs-row__labels">
<LogLabels getRows={getRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
</div>
)}
<div
className="logs-row__message"
onMouseEnter={this.onMouseOverMessage}
onMouseLeave={this.onMouseOutMessage}
>
<div
className={css`
position: relative;
`}
>
{showContext && context && (
<LogRowContext
row={row}
context={context}
errors={errors}
hasMoreContextRows={hasMoreContextRows}
onOutsideClick={this.toggleContext}
onLoadMoreContext={() => {
if (updateLimit) {
updateLimit();
}
}}
/>
)}
<span className={styles}>
{parsed && (
<Highlighter
style={{ whiteSpace: 'pre-wrap' }}
autoEscape
highlightTag={FieldHighlight(this.onClickHighlight)}
textToHighlight={entry}
searchWords={parsedFieldHighlights}
highlightClassName="logs-row__field-highlight"
/>
)}
{!parsed && needsHighlighter && (
<Highlighter
style={{ whiteSpace: 'pre-wrap' }}
textToHighlight={entry}
searchWords={highlights}
findChunks={findHighlightChunksInText}
highlightClassName={highlightClassName}
/>
)}
{hasAnsi && !parsed && !needsHighlighter && <LogMessageAnsi value={raw} />}
{!hasAnsi && !parsed && !needsHighlighter && entry}
{showFieldStats && (
<div className="logs-row__stats">
<LogLabelStats
stats={fieldStats}
label={fieldLabel}
value={fieldValue}
onClickClose={this.onClickClose}
rowCount={fieldCount}
/>
</div>
)}
</span>
{row.searchWords && row.searchWords.length > 0 && (
<span
onClick={this.onContextToggle}
className={css`
visibility: hidden;
white-space: nowrap;
position: relative;
z-index: ${showContext ? 1 : 0};
cursor: pointer;
.logs-row:hover & {
visibility: visible;
margin-left: 10px;
text-decoration: underline;
}
`}
>
{showContext ? 'Hide' : 'Show'} context
</span>
)}
</div>
</div>
</div>
);
}}
</ThemeContext.Consumer>
);
}
render() {
const { showContext } = this.state;
if (showContext) {
return (
<>
<LogRowContextProvider row={this.props.row} getRowContext={this.props.getRowContext}>
{({ result, errors, hasMoreContextRows, updateLimit }) => {
return <>{this.renderLogRow(result, errors, hasMoreContextRows, updateLimit)}</>;
}}
</LogRowContextProvider>
</>
);
}
return this.renderLogRow();
}
}

View File

@@ -1,234 +0,0 @@
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';
interface LogRowContextProps {
row: LogRowModel;
context: LogRowContextRows;
errors?: LogRowContextQueryErrors;
hasMoreContextRows: HasMoreContextRows;
onOutsideClick: () => void;
onLoadMoreContext: () => void;
}
const getLogRowContextStyles = (theme: GrafanaTheme) => {
const gradientTop = selectThemeVariant(
{
light: theme.colors.white,
dark: theme.colors.dark1,
},
theme.type
);
const gradientBottom = selectThemeVariant(
{
light: theme.colors.gray7,
dark: theme.colors.dark2,
},
theme.type
);
const boxShadowColor = selectThemeVariant(
{
light: theme.colors.gray5,
dark: theme.colors.black,
},
theme.type
);
const borderColor = selectThemeVariant(
{
light: theme.colors.gray5,
dark: theme.colors.dark9,
},
theme.type
);
return {
commonStyles: css`
position: absolute;
width: calc(100% + 20px);
left: -10px;
height: 250px;
z-index: 2;
overflow: hidden;
background: ${theme.colors.pageBg};
background: linear-gradient(180deg, ${gradientTop} 0%, ${gradientBottom} 104.25%);
box-shadow: 0px 2px 4px ${boxShadowColor}, 0px 0px 2px ${boxShadowColor};
border: 1px solid ${borderColor};
border-radius: ${theme.border.radius.md};
`,
header: css`
height: 30px;
padding: 0 10px;
display: flex;
align-items: center;
background: ${borderColor};
`,
logs: css`
height: 220px;
padding: 10px;
`,
};
};
interface LogRowContextGroupHeaderProps {
row: LogRowModel;
rows: Array<string | DataQueryError>;
onLoadMoreContext: () => void;
shouldScrollToBottom?: boolean;
canLoadMoreRows?: boolean;
}
interface LogRowContextGroupProps extends LogRowContextGroupHeaderProps {
rows: Array<string | DataQueryError>;
className: string;
error?: string;
}
const LogRowContextGroupHeader: React.FunctionComponent<LogRowContextGroupHeaderProps> = ({
row,
rows,
onLoadMoreContext,
canLoadMoreRows,
}) => {
const theme = useContext(ThemeContext);
const { header } = getLogRowContextStyles(theme);
return (
<div className={header}>
<span
className={css`
opacity: 0.6;
`}
>
Found {rows.length} rows.
</span>
{(rows.length >= 10 || (rows.length > 10 && rows.length % 10 !== 0)) && canLoadMoreRows && (
<span
className={css`
margin-left: 10px;
&:hover {
text-decoration: underline;
cursor: pointer;
}
`}
onClick={() => onLoadMoreContext()}
>
Load 10 more
</span>
)}
</div>
);
};
const LogRowContextGroup: React.FunctionComponent<LogRowContextGroupProps> = ({
row,
rows,
error,
className,
shouldScrollToBottom,
canLoadMoreRows,
onLoadMoreContext,
}) => {
const theme = useContext(ThemeContext);
const { commonStyles, logs } = getLogRowContextStyles(theme);
const [scrollTop, setScrollTop] = useState(0);
const listContainerRef = useRef<HTMLDivElement>();
useLayoutEffect(() => {
if (shouldScrollToBottom && listContainerRef.current) {
setScrollTop(listContainerRef.current.offsetHeight);
}
});
const headerProps = {
row,
rows,
onLoadMoreContext,
canLoadMoreRows,
};
return (
<div className={cx(className, commonStyles)}>
{/* When displaying "after" context */}
{shouldScrollToBottom && !error && <LogRowContextGroupHeader {...headerProps} />}
<div className={logs}>
<CustomScrollbar autoHide scrollTop={scrollTop}>
<div ref={listContainerRef}>
{!error && (
<List
items={rows}
renderItem={item => {
return (
<div
className={css`
padding: 5px 0;
`}
>
{item}
</div>
);
}}
/>
)}
{error && <Alert message={error} />}
</div>
</CustomScrollbar>
</div>
{/* When displaying "before" context */}
{!shouldScrollToBottom && !error && <LogRowContextGroupHeader {...headerProps} />}
</div>
);
};
export const LogRowContext: React.FunctionComponent<LogRowContextProps> = ({
row,
context,
errors,
onOutsideClick,
onLoadMoreContext,
hasMoreContextRows,
}) => {
return (
<ClickOutsideWrapper onClick={onOutsideClick}>
<div>
{context.after && (
<LogRowContextGroup
rows={context.after}
error={errors && errors.after}
row={row}
className={css`
top: -250px;
`}
shouldScrollToBottom
canLoadMoreRows={hasMoreContextRows.after}
onLoadMoreContext={onLoadMoreContext}
/>
)}
{context.before && (
<LogRowContextGroup
onLoadMoreContext={onLoadMoreContext}
canLoadMoreRows={hasMoreContextRows.before}
row={row}
rows={context.before}
error={errors && errors.before}
className={css`
top: 100%;
`}
/>
)}
</div>
</ClickOutsideWrapper>
);
};

View File

@@ -1,74 +0,0 @@
import { DataFrameHelper, FieldType, LogRowModel } from '@grafana/data';
import { getRowContexts } from './LogRowContextProvider';
describe('getRowContexts', () => {
describe('when called with a DataFrame and results are returned', () => {
it('then the result should be in correct format', async () => {
const firstResult = new DataFrameHelper({
refId: 'B',
labels: {},
fields: [
{ name: 'ts', type: FieldType.time, values: [3, 2, 1] },
{ name: 'line', type: FieldType.string, values: ['3', '2', '1'] },
],
});
const secondResult = new DataFrameHelper({
refId: 'B',
labels: {},
fields: [
{ name: 'ts', type: FieldType.time, values: [6, 5, 4] },
{ name: 'line', type: FieldType.string, values: ['6', '5', '4'] },
],
});
const row: LogRowModel = {
entry: '4',
labels: null,
hasAnsi: false,
raw: '4',
logLevel: null,
timeEpochMs: 4,
timeFromNow: '',
timeLocal: '',
timeUtc: '',
timestamp: '4',
};
const getRowContext = jest
.fn()
.mockResolvedValueOnce({ data: [firstResult] })
.mockResolvedValueOnce({ data: [secondResult] });
const result = await getRowContexts(getRowContext, row, 10);
expect(result).toEqual({ data: [[['3', '2', '1']], [['6', '5', '4']]], errors: [null, null] });
});
});
describe('when called with a DataFrame and errors occur', () => {
it('then the result should be in correct format', async () => {
const firstError = new Error('Error 1');
const secondError = new Error('Error 2');
const row: LogRowModel = {
entry: '4',
labels: null,
hasAnsi: false,
raw: '4',
logLevel: null,
timeEpochMs: 4,
timeFromNow: '',
timeLocal: '',
timeUtc: '',
timestamp: '4',
};
const getRowContext = jest
.fn()
.mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(secondError);
const result = await getRowContexts(getRowContext, row, 10);
expect(result).toEqual({ data: [[], []], errors: ['Error 1', 'Error 2'] });
});
});
});

View File

@@ -1,167 +0,0 @@
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';
export interface LogRowContextRows {
before?: string[];
after?: string[];
}
export interface LogRowContextQueryErrors {
before?: string;
after?: string;
}
export interface HasMoreContextRows {
before: boolean;
after: boolean;
}
interface LogRowContextProviderProps {
row: LogRowModel;
getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
children: (props: {
result: LogRowContextRows;
errors: LogRowContextQueryErrors;
hasMoreContextRows: HasMoreContextRows;
updateLimit: () => void;
}) => JSX.Element;
}
export const getRowContexts = async (
getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>,
row: LogRowModel,
limit: number
) => {
const promises = [
getRowContext(row, {
limit,
}),
getRowContext(row, {
limit: limit + 1, // Lets add one more to the limit as we're filtering out one row see comment below
direction: 'FORWARD',
}),
];
const results: Array<DataQueryResponse | DataQueryError> = await Promise.all(promises.map(p => p.catch(e => e)));
return {
data: results.map(result => {
const dataResult: DataQueryResponse = result as DataQueryResponse;
if (!dataResult.data) {
return [];
}
const data: any[] = [];
for (let index = 0; index < dataResult.data.length; index++) {
const dataFrame = toDataFrame(dataResult.data[index]);
const timestampField: Field<string> = dataFrame.fields.filter(field => field.name === 'ts')[0];
for (let fieldIndex = 0; fieldIndex < timestampField.values.length; fieldIndex++) {
const timestamp = timestampField.values.get(fieldIndex);
// We need to filter out the row we're basing our search from because of how start/end params work in Loki API
// see https://github.com/grafana/loki/issues/597#issuecomment-506408980
// the alternative to create our own add 1 nanosecond method to the a timestamp string would be quite complex
if (timestamp === row.timestamp) {
continue;
}
const lineField: Field<string> = dataFrame.fields.filter(field => field.name === 'line')[0];
const line = lineField.values.get(fieldIndex); // assuming that both fields have same length
if (data.length === 0) {
data[0] = [line];
} else {
data[0].push(line);
}
}
}
return data;
}),
errors: results.map(result => {
const errorResult: DataQueryError = result as DataQueryError;
if (!errorResult.message) {
return null;
}
return errorResult.message;
}),
};
};
export const LogRowContextProvider: React.FunctionComponent<LogRowContextProviderProps> = ({
getRowContext,
row,
children,
}) => {
// React Hook that creates a number state value called limit to component state and a setter function called setLimit
// The intial value for limit is 10
// Used for the number of rows to retrieve from backend from a specific point in time
const [limit, setLimit] = useState(10);
// React Hook that creates an object state value called result to component state and a setter function called setResult
// The intial value for result is null
// Used for sorting the response from backend
const [result, setResult] = useState<{
data: string[][];
errors: string[];
}>(null);
// 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}
// Used for indicating in UI if there are more rows to load in a given direction
const [hasMoreContextRows, setHasMoreContextRows] = useState({
before: true,
after: true,
});
// React Hook that resolves two promises every time the limit prop changes
// First promise fetches limit number of rows backwards in time from a specific point in time
// Second promise fetches limit number of rows forwards in time from a specific point in time
const { value } = useAsync(async () => {
return await getRowContexts(getRowContext, row, limit); // Moved it to a separate function for debugging purposes
}, [limit]);
// React Hook that performs a side effect every time the value (from useAsync hook) prop changes
// The side effect changes the result state with the response from the useAsync hook
// The side effect changes the hasMoreContextRows state if there are more context rows before or after the current result
useEffect(() => {
if (value) {
setResult(currentResult => {
let hasMoreLogsBefore = true,
hasMoreLogsAfter = true;
if (currentResult && currentResult.data[0].length === value.data[0].length) {
hasMoreLogsBefore = false;
}
if (currentResult && currentResult.data[1].length === value.data[1].length) {
hasMoreLogsAfter = false;
}
setHasMoreContextRows({
before: hasMoreLogsBefore,
after: hasMoreLogsAfter,
});
return value;
});
}
}, [value]);
return children({
result: {
before: result ? flatten(result.data[0]) : [],
after: result ? flatten(result.data[1]) : [],
},
errors: {
before: result ? result.errors[0] : null,
after: result ? result.errors[1] : null,
},
hasMoreContextRows,
updateLimit: () => setLimit(limit + 10),
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || [];

View File

@@ -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,
};

View File

@@ -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[];

View File

@@ -1,85 +0,0 @@
import React from 'react';
import { GraphSeriesXY } from '@grafana/data';
import difference from 'lodash/difference';
import isEqual from 'lodash/isEqual';
interface GraphSeriesTogglerAPI {
onSeriesToggle: (label: string, event: React.MouseEvent<HTMLElement>) => void;
toggledSeries: GraphSeriesXY[];
}
interface GraphSeriesTogglerProps {
children: (api: GraphSeriesTogglerAPI) => JSX.Element;
series: GraphSeriesXY[];
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
}
interface GraphSeriesTogglerState {
hiddenSeries: string[];
toggledSeries: GraphSeriesXY[];
}
export class GraphSeriesToggler extends React.Component<GraphSeriesTogglerProps, GraphSeriesTogglerState> {
constructor(props: GraphSeriesTogglerProps) {
super(props);
this.onSeriesToggle = this.onSeriesToggle.bind(this);
this.state = {
hiddenSeries: [],
toggledSeries: props.series,
};
}
componentDidUpdate(prevProps: Readonly<GraphSeriesTogglerProps>) {
const { series } = this.props;
if (!isEqual(prevProps.series, series)) {
this.setState({ hiddenSeries: [], toggledSeries: series });
}
}
onSeriesToggle(label: string, event: React.MouseEvent<HTMLElement>) {
const { series, onHiddenSeriesChanged } = this.props;
const { hiddenSeries } = this.state;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
// Toggling series with key makes the series itself to toggle
const newHiddenSeries =
hiddenSeries.indexOf(label) > -1
? hiddenSeries.filter(series => series !== label)
: hiddenSeries.concat([label]);
const toggledSeries = series.map(series => ({
...series,
isVisible: newHiddenSeries.indexOf(series.label) === -1,
}));
this.setState({ hiddenSeries: newHiddenSeries, toggledSeries }, () =>
onHiddenSeriesChanged ? onHiddenSeriesChanged(newHiddenSeries) : undefined
);
return;
}
// Toggling series with out key toggles all the series but the clicked one
const allSeriesLabels = series.map(series => series.label);
const newHiddenSeries =
hiddenSeries.length + 1 === allSeriesLabels.length ? [] : difference(allSeriesLabels, [label]);
const toggledSeries = series.map(series => ({
...series,
isVisible: newHiddenSeries.indexOf(series.label) === -1,
}));
this.setState({ hiddenSeries: newHiddenSeries, toggledSeries }, () =>
onHiddenSeriesChanged ? onHiddenSeriesChanged(newHiddenSeries) : undefined
);
}
render() {
const { children } = this.props;
const { toggledSeries } = this.state;
return children({
onSeriesToggle: this.onSeriesToggle,
toggledSeries,
});
}
}

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

View File

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

View File

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