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

* WIP: intial commit

* Switch: Adds tooltip

* Refactor: Adds props to LogsPanelEditor

* Refactor: Moves LogRowContextProvider to grafana/ui

* Refactor: Moves LogRowContext and Alert to grafana/ui

* Refactor: Moves LogLabelStats to grafana/ui

* Refactor: Moves LogLabels and LogLabel to grafana/ui

* Refactor: Moves LogMessageAnsi and ansicolor to grafana/ui

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

* Refactor: Moves findHighlightChunksInText to grafana/data

* Refactor: Moves LogRow to grafana/ui

* Refactor: Moving ExploreGraphPanel to grafana/ui

* Refactor: Copies Logs to grafana/ui

* Refactor: Moves ToggleButtonGroup to grafana/ui

* Refactor: Adds Logs to LogsPanel

* Refactor: Moves styles to emotion

* Feature: Adds LogsRows

* Refactor: Introduces render limit

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Refactor: Adds sorting to LogsPanelEditor

* Tests: Adds tests for sorting

* Refactor: Changes according to PR comments

* Refactor: Changes according to PR comments

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

* Fix: Shows the Show context label again
This commit is contained in:
Hugo Häggmark 2019-08-26 08:11:07 +02:00 committed by GitHub
parent 98a512a3c7
commit e5e7bd3153
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1765 additions and 1293 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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