mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
|
||||
import * as gaugePanel from 'app/plugins/panel/gauge/module';
|
||||
import * as pieChartPanel from 'app/plugins/panel/piechart/module';
|
||||
import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
|
||||
import * as logsPanel from 'app/plugins/panel/logs/module';
|
||||
|
||||
import * as exampleApp from 'app/plugins/app/example-app/module';
|
||||
|
||||
@@ -70,6 +71,7 @@ const builtInPlugins: any = {
|
||||
'app/plugins/panel/gauge/module': gaugePanel,
|
||||
'app/plugins/panel/piechart/module': pieChartPanel,
|
||||
'app/plugins/panel/bargauge/module': barGaugePanel,
|
||||
'app/plugins/panel/logs/module': logsPanel,
|
||||
|
||||
'app/plugins/app/example-app/module': exampleApp,
|
||||
};
|
||||
|
||||
@@ -1,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[];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
39
public/app/plugins/panel/logs/LogsPanel.tsx
Normal file
39
public/app/plugins/panel/logs/LogsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
46
public/app/plugins/panel/logs/LogsPanelEditor.tsx
Normal file
46
public/app/plugins/panel/logs/LogsPanelEditor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
8
public/app/plugins/panel/logs/img/icn-logs-panel.svg
Normal file
8
public/app/plugins/panel/logs/img/icn-logs-panel.svg
Normal 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 |
6
public/app/plugins/panel/logs/module.tsx
Normal file
6
public/app/plugins/panel/logs/module.tsx
Normal 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);
|
||||
17
public/app/plugins/panel/logs/plugin.json
Normal file
17
public/app/plugins/panel/logs/plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
public/app/plugins/panel/logs/types.ts
Normal file
11
public/app/plugins/panel/logs/types.ts
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user