mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
Dashboard: Adds Logs Panel (alpha) as visualization option for Dashboards (#18641)
* WIP: intial commit * Switch: Adds tooltip * Refactor: Adds props to LogsPanelEditor * Refactor: Moves LogRowContextProvider to grafana/ui * Refactor: Moves LogRowContext and Alert to grafana/ui * Refactor: Moves LogLabelStats to grafana/ui * Refactor: Moves LogLabels and LogLabel to grafana/ui * Refactor: Moves LogMessageAnsi and ansicolor to grafana/ui * Refactor: Moves calculateFieldStats, LogsParsers and getParser to grafana/data * Refactor: Moves findHighlightChunksInText to grafana/data * Refactor: Moves LogRow to grafana/ui * Refactor: Moving ExploreGraphPanel to grafana/ui * Refactor: Copies Logs to grafana/ui * Refactor: Moves ToggleButtonGroup to grafana/ui * Refactor: Adds Logs to LogsPanel * Refactor: Moves styles to emotion * Feature: Adds LogsRows * Refactor: Introduces render limit * Styles: Moves styles to emotion * Styles: Moves styles to emotion * Styles: Moves styles to emotion * Styles: Moves styles to emotion * Refactor: Adds sorting to LogsPanelEditor * Tests: Adds tests for sorting * Refactor: Changes according to PR comments * Refactor: Changes according to PR comments * Refactor: Moves Logs and ExploreGraphPanel out of grafana/ui * Fix: Shows the Show context label again
This commit is contained in:
parent
98a512a3c7
commit
e5e7bd3153
@ -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.',
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
84
packages/grafana-data/src/utils/text.ts
Normal file
84
packages/grafana-data/src/utils/text.ts
Normal 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(''),
|
||||
};
|
||||
}
|
124
packages/grafana-ui/src/components/Collapse/Collapse.tsx
Normal file
124
packages/grafana-ui/src/components/Collapse/Collapse.tsx
Normal 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';
|
@ -3,18 +3,18 @@ import { GraphSeriesXY } from '@grafana/data';
|
||||
import difference from 'lodash/difference';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
interface GraphSeriesTogglerAPI {
|
||||
export interface GraphSeriesTogglerAPI {
|
||||
onSeriesToggle: (label: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||
toggledSeries: GraphSeriesXY[];
|
||||
}
|
||||
|
||||
interface GraphSeriesTogglerProps {
|
||||
export interface GraphSeriesTogglerProps {
|
||||
children: (api: GraphSeriesTogglerAPI) => JSX.Element;
|
||||
series: GraphSeriesXY[];
|
||||
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
|
||||
}
|
||||
|
||||
interface GraphSeriesTogglerState {
|
||||
export interface GraphSeriesTogglerState {
|
||||
hiddenSeries: string[];
|
||||
toggledSeries: GraphSeriesXY[];
|
||||
}
|
126
packages/grafana-ui/src/components/Logs/LogLabel.tsx
Normal file
126
packages/grafana-ui/src/components/Logs/LogLabel.tsx
Normal 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';
|
98
packages/grafana-ui/src/components/Logs/LogLabelStats.tsx
Normal file
98
packages/grafana-ui/src/components/Logs/LogLabelStats.tsx
Normal 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';
|
92
packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx
Normal file
92
packages/grafana-ui/src/components/Logs/LogLabelStatsRow.tsx
Normal 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';
|
43
packages/grafana-ui/src/components/Logs/LogLabels.tsx
Normal file
43
packages/grafana-ui/src/components/Logs/LogLabels.tsx
Normal 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';
|
@ -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;
|
@ -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';
|
@ -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}
|
@ -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'] });
|
||||
});
|
@ -1,9 +1,10 @@
|
||||
import { DataQueryResponse, DataQueryError } from '@grafana/ui';
|
||||
import { LogRowModel, toDataFrame, Field } from '@grafana/data';
|
||||
import { useState, useEffect } from 'react';
|
||||
import flatten from 'lodash/flatten';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { DataQueryResponse, DataQueryError } from '../../types/datasource';
|
||||
|
||||
export interface LogRowContextRows {
|
||||
before?: string[];
|
||||
after?: string[];
|
||||
@ -18,6 +19,11 @@ export interface HasMoreContextRows {
|
||||
after: boolean;
|
||||
}
|
||||
|
||||
interface ResultType {
|
||||
data: string[][];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface LogRowContextProviderProps {
|
||||
row: LogRowModel;
|
||||
getRowContext: (row: LogRowModel, options?: any) => Promise<DataQueryResponse>;
|
||||
@ -84,7 +90,7 @@ export const getRowContexts = async (
|
||||
errors: results.map(result => {
|
||||
const errorResult: DataQueryError = result as DataQueryError;
|
||||
if (!errorResult.message) {
|
||||
return null;
|
||||
return '';
|
||||
}
|
||||
|
||||
return errorResult.message;
|
||||
@ -105,10 +111,7 @@ export const LogRowContextProvider: React.FunctionComponent<LogRowContextProvide
|
||||
// React Hook that creates an object state value called result to component state and a setter function called setResult
|
||||
// The intial value for result is null
|
||||
// Used for sorting the response from backend
|
||||
const [result, setResult] = useState<{
|
||||
data: string[][];
|
||||
errors: string[];
|
||||
}>(null);
|
||||
const [result, setResult] = useState<ResultType>((null as any) as ResultType);
|
||||
|
||||
// React Hook that creates an object state value called hasMoreContextRows to component state and a setter function called setHasMoreContextRows
|
||||
// The intial value for hasMoreContextRows is {before: true, after: true}
|
||||
@ -130,7 +133,7 @@ export const LogRowContextProvider: React.FunctionComponent<LogRowContextProvide
|
||||
// The side effect changes the hasMoreContextRows state if there are more context rows before or after the current result
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setResult(currentResult => {
|
||||
setResult((currentResult: any) => {
|
||||
let hasMoreLogsBefore = true,
|
||||
hasMoreLogsAfter = true;
|
||||
|
||||
@ -158,8 +161,8 @@ export const LogRowContextProvider: React.FunctionComponent<LogRowContextProvide
|
||||
after: result ? flatten(result.data[1]) : [],
|
||||
},
|
||||
errors: {
|
||||
before: result ? result.errors[0] : null,
|
||||
after: result ? result.errors[1] : null,
|
||||
before: result ? result.errors[0] : undefined,
|
||||
after: result ? result.errors[1] : undefined,
|
||||
},
|
||||
hasMoreContextRows,
|
||||
updateLimit: () => setLimit(limit + 10),
|
143
packages/grafana-ui/src/components/Logs/LogRows.tsx
Normal file
143
packages/grafana-ui/src/components/Logs/LogRows.tsx
Normal 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';
|
133
packages/grafana-ui/src/components/Logs/getLogRowStyles.ts
Normal file
133
packages/grafana-ui/src/components/Logs/getLogRowStyles.ts
Normal 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;
|
||||
`,
|
||||
};
|
||||
};
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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)))();
|
||||
|
@ -6,6 +6,7 @@ export * from './fieldDisplay';
|
||||
export * from './validate';
|
||||
export { getFlotPairs } from './flotPairs';
|
||||
export * from './slate';
|
||||
export { default as ansicolor } from './ansicolor';
|
||||
|
||||
// Export with a namespace
|
||||
import * as DOMUtil from './dom'; // includes Element.closest polyfil
|
||||
|
@ -1,7 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import ansicolor from 'vendor/ansicolor/ansicolor';
|
||||
|
||||
import { colors, getFlotPairs } from '@grafana/ui';
|
||||
import { colors, getFlotPairs, ansicolor } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
Labels,
|
||||
@ -16,8 +14,6 @@ import {
|
||||
LogsModel,
|
||||
LogsMetaItem,
|
||||
LogsMetaKind,
|
||||
LogsParser,
|
||||
LogLabelStatsModel,
|
||||
LogsDedupStrategy,
|
||||
DataFrameHelper,
|
||||
GraphSeriesXY,
|
||||
@ -41,89 +37,6 @@ export const LogLevelColor = {
|
||||
[LogLevel.unknown]: getThemeColor('#8e8e8e', '#dde4ed'),
|
||||
};
|
||||
|
||||
export enum LogsDedupDescription {
|
||||
none = 'No de-duplication',
|
||||
exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.',
|
||||
numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.',
|
||||
signature = 'De-duplication of successive lines that have identical punctuation and whitespace.',
|
||||
}
|
||||
const LOGFMT_REGEXP = /(?:^|\s)(\w+)=("[^"]*"|\S+)/;
|
||||
|
||||
export const LogsParsers: { [name: string]: LogsParser } = {
|
||||
JSON: {
|
||||
buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"?([\\d\\.]+|[^"]*)"?`),
|
||||
getFields: line => {
|
||||
const fields: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
_.map(parsed, (value, key) => {
|
||||
const fieldMatcher = new RegExp(`"${key}"\\s*:\\s*"?${_.escapeRegExp(JSON.stringify(value))}"?`);
|
||||
|
||||
const match = line.match(fieldMatcher);
|
||||
if (match) {
|
||||
fields.push(match[0]);
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
return fields;
|
||||
},
|
||||
getLabelFromField: field => (field.match(/^"(\w+)"\s*:/) || [])[1],
|
||||
getValueFromField: field => (field.match(/:\s*(.*)$/) || [])[1],
|
||||
test: line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {}
|
||||
},
|
||||
},
|
||||
|
||||
logfmt: {
|
||||
buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`),
|
||||
getFields: line => {
|
||||
const fields: string[] = [];
|
||||
line.replace(new RegExp(LOGFMT_REGEXP, 'g'), substring => {
|
||||
fields.push(substring.trim());
|
||||
return '';
|
||||
});
|
||||
return fields;
|
||||
},
|
||||
getLabelFromField: field => (field.match(LOGFMT_REGEXP) || [])[1],
|
||||
getValueFromField: field => (field.match(LOGFMT_REGEXP) || [])[2],
|
||||
test: line => LOGFMT_REGEXP.test(line),
|
||||
},
|
||||
};
|
||||
|
||||
export function calculateFieldStats(rows: LogRowModel[], extractor: RegExp): LogLabelStatsModel[] {
|
||||
// Consider only rows that satisfy the matcher
|
||||
const rowsWithField = rows.filter(row => extractor.test(row.entry));
|
||||
const rowCount = rowsWithField.length;
|
||||
|
||||
// Get field value counts for eligible rows
|
||||
const countsByValue = _.countBy(rowsWithField, row => (row as LogRowModel).entry.match(extractor)[1]);
|
||||
const sortedCounts = _.chain(countsByValue)
|
||||
.map((count, value) => ({ count, value, proportion: count / rowCount }))
|
||||
.sortBy('count')
|
||||
.reverse()
|
||||
.value();
|
||||
|
||||
return sortedCounts;
|
||||
}
|
||||
|
||||
export function calculateLogsLabelStats(rows: LogRowModel[], label: string): LogLabelStatsModel[] {
|
||||
// Consider only rows that have the given label
|
||||
const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined);
|
||||
const rowCount = rowsWithLabel.length;
|
||||
|
||||
// Get label value counts for eligible rows
|
||||
const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRowModel).labels[label]);
|
||||
const sortedCounts = _.chain(countsByValue)
|
||||
.map((count, value) => ({ count, value, proportion: count / rowCount }))
|
||||
.sortBy('count')
|
||||
.reverse()
|
||||
.value();
|
||||
|
||||
return sortedCounts;
|
||||
}
|
||||
|
||||
const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g;
|
||||
function isDuplicateRow(row: LogRowModel, other: LogRowModel, strategy: LogsDedupStrategy): boolean {
|
||||
switch (strategy) {
|
||||
@ -165,19 +78,6 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs
|
||||
};
|
||||
}
|
||||
|
||||
export function getParser(line: string): LogsParser {
|
||||
let parser;
|
||||
try {
|
||||
if (LogsParsers.JSON.test(line)) {
|
||||
parser = LogsParsers.JSON;
|
||||
}
|
||||
} catch (error) {}
|
||||
if (!parser && LogsParsers.logfmt.test(line)) {
|
||||
parser = LogsParsers.logfmt;
|
||||
}
|
||||
return parser;
|
||||
}
|
||||
|
||||
export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>): LogsModel {
|
||||
if (hiddenLogLevels.size === 0) {
|
||||
return logs;
|
||||
|
@ -8,14 +8,7 @@ import {
|
||||
DataFrameHelper,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
dedupLogRows,
|
||||
calculateFieldStats,
|
||||
calculateLogsLabelStats,
|
||||
getParser,
|
||||
LogsParsers,
|
||||
dataFrameToLogsModel,
|
||||
} from '../logs_model';
|
||||
import { dedupLogRows, dataFrameToLogsModel } from '../logs_model';
|
||||
|
||||
describe('dedupLogRows()', () => {
|
||||
test('should return rows as is when dedup is set to none', () => {
|
||||
@ -152,193 +145,6 @@ describe('dedupLogRows()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFieldStats()', () => {
|
||||
test('should return no stats for empty rows', () => {
|
||||
expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return no stats if extractor does not match', () => {
|
||||
const rows = [
|
||||
{
|
||||
entry: 'foo=bar',
|
||||
},
|
||||
];
|
||||
|
||||
expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return stats for found field', () => {
|
||||
const rows = [
|
||||
{
|
||||
entry: 'foo="42 + 1"',
|
||||
},
|
||||
{
|
||||
entry: 'foo=503 baz=foo',
|
||||
},
|
||||
{
|
||||
entry: 'foo="42 + 1"',
|
||||
},
|
||||
{
|
||||
entry: 't=2018-12-05T07:44:59+0000 foo=503',
|
||||
},
|
||||
];
|
||||
|
||||
expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([
|
||||
{
|
||||
value: '"42 + 1"',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
value: '503',
|
||||
count: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateLogsLabelStats()', () => {
|
||||
test('should return no stats for empty rows', () => {
|
||||
expect(calculateLogsLabelStats([], '')).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return no stats of label is not found', () => {
|
||||
const rows = [
|
||||
{
|
||||
entry: 'foo 1',
|
||||
labels: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]);
|
||||
});
|
||||
|
||||
test('should return stats for found labels', () => {
|
||||
const rows = [
|
||||
{
|
||||
entry: 'foo 1',
|
||||
labels: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
{
|
||||
entry: 'foo 0',
|
||||
labels: {
|
||||
foo: 'xxx',
|
||||
},
|
||||
},
|
||||
{
|
||||
entry: 'foo 2',
|
||||
labels: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([
|
||||
{
|
||||
value: 'bar',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
value: 'xxx',
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParser()', () => {
|
||||
test('should return no parser on empty line', () => {
|
||||
expect(getParser('')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return no parser on unknown line pattern', () => {
|
||||
expect(getParser('To Be or not to be')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return logfmt parser on key value patterns', () => {
|
||||
expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt);
|
||||
});
|
||||
|
||||
test('should return JSON parser on JSON log lines', () => {
|
||||
// TODO implement other JSON value types than string
|
||||
expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LogsParsers', () => {
|
||||
describe('logfmt', () => {
|
||||
const parser = LogsParsers.logfmt;
|
||||
|
||||
test('should detect format', () => {
|
||||
expect(parser.test('foo')).toBeFalsy();
|
||||
expect(parser.test('foo=bar')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should return parsed fields', () => {
|
||||
expect(parser.getFields('foo=bar baz="42 + 1"')).toEqual(['foo=bar', 'baz="42 + 1"']);
|
||||
});
|
||||
|
||||
test('should return label for field', () => {
|
||||
expect(parser.getLabelFromField('foo=bar')).toBe('foo');
|
||||
});
|
||||
|
||||
test('should return value for field', () => {
|
||||
expect(parser.getValueFromField('foo=bar')).toBe('bar');
|
||||
});
|
||||
|
||||
test('should build a valid value matcher', () => {
|
||||
const matcher = parser.buildMatcher('foo');
|
||||
const match = 'foo=bar'.match(matcher);
|
||||
expect(match).toBeDefined();
|
||||
expect(match[1]).toBe('bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON', () => {
|
||||
const parser = LogsParsers.JSON;
|
||||
|
||||
test('should detect format', () => {
|
||||
expect(parser.test('foo')).toBeFalsy();
|
||||
expect(parser.test('{"foo":"bar"}')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should return parsed fields', () => {
|
||||
expect(parser.getFields('{ "foo" : "bar", "baz" : 42 }')).toEqual(['"foo" : "bar"', '"baz" : 42']);
|
||||
});
|
||||
|
||||
test('should return parsed fields for nested quotes', () => {
|
||||
expect(parser.getFields(`{"foo":"bar: '[value=\\"42\\"]'"}`)).toEqual([`"foo":"bar: '[value=\\"42\\"]'"`]);
|
||||
});
|
||||
|
||||
test('should return label for field', () => {
|
||||
expect(parser.getLabelFromField('"foo" : "bar"')).toBe('foo');
|
||||
});
|
||||
|
||||
test('should return value for field', () => {
|
||||
expect(parser.getValueFromField('"foo" : "bar"')).toBe('"bar"');
|
||||
expect(parser.getValueFromField('"foo" : 42')).toBe('42');
|
||||
expect(parser.getValueFromField('"foo" : 42.1')).toBe('42.1');
|
||||
});
|
||||
|
||||
test('should build a valid value matcher for strings', () => {
|
||||
const matcher = parser.buildMatcher('foo');
|
||||
const match = '{"foo":"bar"}'.match(matcher);
|
||||
expect(match).toBeDefined();
|
||||
expect(match[1]).toBe('bar');
|
||||
});
|
||||
|
||||
test('should build a valid value matcher for integers', () => {
|
||||
const matcher = parser.buildMatcher('foo');
|
||||
const match = '{"foo":42.1}'.match(matcher);
|
||||
expect(match).toBeDefined();
|
||||
expect(match[1]).toBe('42.1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const emptyLogsModel: any = {
|
||||
hasUniqueLabels: false,
|
||||
rows: [],
|
||||
|
@ -9,11 +9,15 @@ import {
|
||||
getValueWithRefId,
|
||||
getFirstQueryErrorWithoutRefId,
|
||||
getRefIds,
|
||||
refreshIntervalToSortOrder,
|
||||
SortOrder,
|
||||
sortLogsResult,
|
||||
} from './explore';
|
||||
import { ExploreUrlState, ExploreMode } from 'app/types/explore';
|
||||
import store from 'app/core/store';
|
||||
import { LogsDedupStrategy } from '@grafana/data';
|
||||
import { LogsDedupStrategy, LogsModel, LogLevel } from '@grafana/data';
|
||||
import { DataQueryError } from '@grafana/ui';
|
||||
import { liveOption, offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
|
||||
|
||||
const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
|
||||
datasource: null,
|
||||
@ -356,3 +360,95 @@ describe('getRefIds', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshIntervalToSortOrder', () => {
|
||||
describe('when called with live option', () => {
|
||||
it('then it should return ascending', () => {
|
||||
const result = refreshIntervalToSortOrder(liveOption.value);
|
||||
|
||||
expect(result).toBe(SortOrder.Ascending);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with off option', () => {
|
||||
it('then it should return descending', () => {
|
||||
const result = refreshIntervalToSortOrder(offOption.value);
|
||||
|
||||
expect(result).toBe(SortOrder.Descending);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with 5s option', () => {
|
||||
it('then it should return descending', () => {
|
||||
const result = refreshIntervalToSortOrder('5s');
|
||||
|
||||
expect(result).toBe(SortOrder.Descending);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with undefined', () => {
|
||||
it('then it should return descending', () => {
|
||||
const result = refreshIntervalToSortOrder(undefined);
|
||||
|
||||
expect(result).toBe(SortOrder.Descending);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortLogsResult', () => {
|
||||
const firstRow = {
|
||||
timestamp: '2019-01-01T21:00:0.0000000Z',
|
||||
entry: '',
|
||||
hasAnsi: false,
|
||||
labels: {},
|
||||
logLevel: LogLevel.info,
|
||||
raw: '',
|
||||
timeEpochMs: 0,
|
||||
timeFromNow: '',
|
||||
timeLocal: '',
|
||||
timeUtc: '',
|
||||
};
|
||||
const sameAsFirstRow = firstRow;
|
||||
const secondRow = {
|
||||
timestamp: '2019-01-01T22:00:0.0000000Z',
|
||||
entry: '',
|
||||
hasAnsi: false,
|
||||
labels: {},
|
||||
logLevel: LogLevel.info,
|
||||
raw: '',
|
||||
timeEpochMs: 0,
|
||||
timeFromNow: '',
|
||||
timeLocal: '',
|
||||
timeUtc: '',
|
||||
};
|
||||
|
||||
describe('when called with SortOrder.Descending', () => {
|
||||
it('then it should sort descending', () => {
|
||||
const logsResult: LogsModel = {
|
||||
rows: [firstRow, sameAsFirstRow, secondRow],
|
||||
hasUniqueLabels: false,
|
||||
};
|
||||
const result = sortLogsResult(logsResult, SortOrder.Descending);
|
||||
|
||||
expect(result).toEqual({
|
||||
rows: [secondRow, firstRow, sameAsFirstRow],
|
||||
hasUniqueLabels: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with SortOrder.Ascending', () => {
|
||||
it('then it should sort ascending', () => {
|
||||
const logsResult: LogsModel = {
|
||||
rows: [secondRow, firstRow, sameAsFirstRow],
|
||||
hasUniqueLabels: false,
|
||||
};
|
||||
const result = sortLogsResult(logsResult, SortOrder.Ascending);
|
||||
|
||||
expect(result).toEqual({
|
||||
rows: [firstRow, sameAsFirstRow, secondRow],
|
||||
hasUniqueLabels: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -510,10 +510,17 @@ const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const sortLogsResult = (logsResult: LogsModel, refreshInterval: string) => {
|
||||
export enum SortOrder {
|
||||
Descending = 'Descending',
|
||||
Ascending = 'Ascending',
|
||||
}
|
||||
|
||||
export const refreshIntervalToSortOrder = (refreshInterval: string) =>
|
||||
isLive(refreshInterval) ? SortOrder.Ascending : SortOrder.Descending;
|
||||
|
||||
export const sortLogsResult = (logsResult: LogsModel, sortOrder: SortOrder) => {
|
||||
const rows = logsResult ? logsResult.rows : [];
|
||||
const live = isLive(refreshInterval);
|
||||
live ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder);
|
||||
sortOrder === SortOrder.Ascending ? rows.sort(sortInAscendingOrder) : rows.sort(sortInDescendingOrder);
|
||||
const result: LogsModel = logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows };
|
||||
|
||||
return result;
|
||||
|
@ -1,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']);
|
||||
|
@ -10,7 +10,7 @@ import { AutoSizer } from 'react-virtualized';
|
||||
import store from 'app/core/store';
|
||||
|
||||
// Components
|
||||
import { Alert } from './Error';
|
||||
import { Alert } from '@grafana/ui';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import LogsContainer from './LogsContainer';
|
||||
import QueryRows from './QueryRows';
|
||||
@ -26,10 +26,11 @@ import {
|
||||
refreshExplore,
|
||||
reconnectDatasource,
|
||||
updateTimeRange,
|
||||
toggleGraph,
|
||||
} from './state/actions';
|
||||
|
||||
// Types
|
||||
import { RawTimeRange, GraphSeriesXY } from '@grafana/data';
|
||||
import { RawTimeRange, GraphSeriesXY, LoadingState, TimeZone, AbsoluteTimeRange } from '@grafana/data';
|
||||
|
||||
import { DataQuery, ExploreStartPageProps, DataSourceApi, DataQueryError } from '@grafana/ui';
|
||||
import {
|
||||
@ -55,7 +56,7 @@ import { FadeIn } from 'app/core/components/Animations/FadeIn';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
import { ErrorContainer } from './ErrorContainer';
|
||||
import { scanStopAction } from './state/actionTypes';
|
||||
import ExploreGraphPanel from './ExploreGraphPanel';
|
||||
import { ExploreGraphPanel } from './ExploreGraphPanel';
|
||||
|
||||
interface ExploreProps {
|
||||
StartPage?: ComponentClass<ExploreStartPageProps>;
|
||||
@ -88,6 +89,13 @@ interface ExploreProps {
|
||||
isLive: boolean;
|
||||
updateTimeRange: typeof updateTimeRange;
|
||||
graphResult?: GraphSeriesXY[];
|
||||
loading?: boolean;
|
||||
absoluteRange: AbsoluteTimeRange;
|
||||
showingGraph?: boolean;
|
||||
showingTable?: boolean;
|
||||
timeZone?: TimeZone;
|
||||
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
|
||||
toggleGraph: typeof toggleGraph;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -190,6 +198,16 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
this.props.scanStopAction({ exploreId: this.props.exploreId });
|
||||
};
|
||||
|
||||
onToggleGraph = (showingGraph: boolean) => {
|
||||
const { toggleGraph, exploreId } = this.props;
|
||||
toggleGraph(exploreId, showingGraph);
|
||||
};
|
||||
|
||||
onUpdateTimeRange = (absoluteRange: AbsoluteTimeRange) => {
|
||||
const { updateTimeRange, exploreId } = this.props;
|
||||
updateTimeRange({ exploreId, absoluteRange });
|
||||
};
|
||||
|
||||
refreshExplore = () => {
|
||||
const { exploreId, update } = this.props;
|
||||
|
||||
@ -227,6 +245,11 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
queryErrors,
|
||||
mode,
|
||||
graphResult,
|
||||
loading,
|
||||
absoluteRange,
|
||||
showingGraph,
|
||||
showingTable,
|
||||
timeZone,
|
||||
} = this.props;
|
||||
const exploreClass = split ? 'explore explore-split' : 'explore';
|
||||
|
||||
@ -262,7 +285,21 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
{!showingStartPage && (
|
||||
<>
|
||||
{mode === ExploreMode.Metrics && (
|
||||
<ExploreGraphPanel exploreId={exploreId} series={graphResult} width={width} />
|
||||
<ExploreGraphPanel
|
||||
series={graphResult}
|
||||
width={width}
|
||||
loading={loading}
|
||||
absoluteRange={absoluteRange}
|
||||
isStacked={false}
|
||||
showPanel={true}
|
||||
showingGraph={showingGraph}
|
||||
showingTable={showingTable}
|
||||
timeZone={timeZone}
|
||||
onToggleGraph={this.onToggleGraph}
|
||||
onUpdateTimeRange={this.onUpdateTimeRange}
|
||||
showBars={false}
|
||||
showLines={true}
|
||||
/>
|
||||
)}
|
||||
{mode === ExploreMode.Metrics && (
|
||||
<TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />
|
||||
@ -311,6 +348,10 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
supportedModes,
|
||||
mode,
|
||||
graphResult,
|
||||
loadingState,
|
||||
showingGraph,
|
||||
showingTable,
|
||||
absoluteRange,
|
||||
} = item;
|
||||
|
||||
const { datasource, queries, range: urlRange, mode: urlMode, ui } = (urlState || {}) as ExploreUrlState;
|
||||
@ -335,6 +376,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
}
|
||||
|
||||
const initialUI = ui || DEFAULT_UI_STATE;
|
||||
const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
|
||||
|
||||
return {
|
||||
StartPage,
|
||||
@ -355,6 +397,10 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
queryErrors,
|
||||
isLive,
|
||||
graphResult,
|
||||
loading,
|
||||
showingGraph,
|
||||
showingTable,
|
||||
absoluteRange,
|
||||
};
|
||||
}
|
||||
|
||||
@ -368,6 +414,7 @@ const mapDispatchToProps = {
|
||||
scanStopAction,
|
||||
setQueries,
|
||||
updateTimeRange,
|
||||
toggleGraph,
|
||||
};
|
||||
|
||||
export default hot(module)(
|
||||
|
@ -1,30 +1,58 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { LegendDisplayMode, GraphWithLegend } from '@grafana/ui';
|
||||
import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone, LoadingState } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone } from '@grafana/data';
|
||||
|
||||
import { GraphSeriesToggler } from 'app/plugins/panel/graph2/GraphSeriesToggler';
|
||||
import Panel from './Panel';
|
||||
import { StoreState, ExploreId, ExploreMode } from 'app/types';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
import { toggleGraph, updateTimeRange } from './state/actions';
|
||||
import {
|
||||
GrafanaTheme,
|
||||
selectThemeVariant,
|
||||
Themeable,
|
||||
GraphWithLegend,
|
||||
LegendDisplayMode,
|
||||
withTheme,
|
||||
Collapse,
|
||||
GraphSeriesToggler,
|
||||
GraphSeriesTogglerAPI,
|
||||
} from '@grafana/ui';
|
||||
|
||||
const MAX_NUMBER_OF_TIME_SERIES = 20;
|
||||
|
||||
interface Props {
|
||||
exploreId: ExploreId;
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
timeSeriesDisclaimer: css`
|
||||
label: time-series-disclaimer;
|
||||
width: 300px;
|
||||
margin: ${theme.spacing.sm} auto;
|
||||
padding: 10px 0;
|
||||
border-radius: ${theme.border.radius.md};
|
||||
text-align: center;
|
||||
background-color: ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.dark4 }, theme.type)};
|
||||
`,
|
||||
disclaimerIcon: css`
|
||||
label: disclaimer-icon;
|
||||
color: ${theme.colors.yellow};
|
||||
margin-right: ${theme.spacing.xs};
|
||||
`,
|
||||
showAllTimeSeries: css`
|
||||
label: show-all-time-series;
|
||||
cursor: pointer;
|
||||
color: ${theme.colors.linkExternal};
|
||||
`,
|
||||
});
|
||||
|
||||
interface Props extends Themeable {
|
||||
series: GraphSeriesXY[];
|
||||
width: number;
|
||||
absoluteRange?: AbsoluteTimeRange;
|
||||
loading?: boolean;
|
||||
mode?: ExploreMode;
|
||||
showingGraph?: boolean;
|
||||
showingTable?: boolean;
|
||||
timeZone?: TimeZone;
|
||||
absoluteRange: AbsoluteTimeRange;
|
||||
loading: boolean;
|
||||
showPanel: boolean;
|
||||
showBars: boolean;
|
||||
showLines: boolean;
|
||||
isStacked: boolean;
|
||||
showingGraph: boolean;
|
||||
showingTable: boolean;
|
||||
timeZone: TimeZone;
|
||||
onUpdateTimeRange: (absoluteRange: AbsoluteTimeRange) => void;
|
||||
onToggleGraph?: (showingGraph: boolean) => void;
|
||||
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
|
||||
toggleGraph: typeof toggleGraph;
|
||||
updateTimeRange: typeof updateTimeRange;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@ -32,7 +60,7 @@ interface State {
|
||||
showAllTimeSeries: boolean;
|
||||
}
|
||||
|
||||
export class ExploreGraphPanel extends PureComponent<Props, State> {
|
||||
class UnThemedExploreGraphPanel extends PureComponent<Props, State> {
|
||||
state: State = {
|
||||
hiddenSeries: [],
|
||||
showAllTimeSeries: false,
|
||||
@ -45,14 +73,15 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
onClickGraphButton = () => {
|
||||
const { toggleGraph, exploreId, showingGraph } = this.props;
|
||||
toggleGraph(exploreId, showingGraph);
|
||||
const { onToggleGraph, showingGraph } = this.props;
|
||||
if (onToggleGraph) {
|
||||
onToggleGraph(showingGraph);
|
||||
}
|
||||
};
|
||||
|
||||
onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
|
||||
const { exploreId, updateTimeRange } = this.props;
|
||||
|
||||
updateTimeRange({ exploreId, absoluteRange });
|
||||
const { onUpdateTimeRange } = this.props;
|
||||
onUpdateTimeRange(absoluteRange);
|
||||
};
|
||||
|
||||
renderGraph = () => {
|
||||
@ -62,9 +91,12 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
|
||||
onHiddenSeriesChanged,
|
||||
timeZone,
|
||||
absoluteRange,
|
||||
mode,
|
||||
showPanel,
|
||||
showingGraph,
|
||||
showingTable,
|
||||
showBars,
|
||||
showLines,
|
||||
isStacked,
|
||||
} = this.props;
|
||||
const { showAllTimeSeries } = this.state;
|
||||
|
||||
@ -80,16 +112,13 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
|
||||
to: dateTimeForTimeZone(timeZone, absoluteRange.to),
|
||||
},
|
||||
};
|
||||
const height = mode === ExploreMode.Logs ? 100 : showingGraph && showingTable ? 200 : 400;
|
||||
const showBars = mode === ExploreMode.Logs ? true : false;
|
||||
const showLines = mode === ExploreMode.Metrics ? true : false;
|
||||
const isStacked = mode === ExploreMode.Logs ? true : false;
|
||||
const lineWidth = mode === ExploreMode.Metrics ? 1 : 5;
|
||||
const height = showPanel === false ? 100 : showingGraph && showingTable ? 200 : 400;
|
||||
const lineWidth = showLines ? 1 : 5;
|
||||
const seriesToShow = showAllTimeSeries ? series : series.slice(0, MAX_NUMBER_OF_TIME_SERIES);
|
||||
|
||||
return (
|
||||
<GraphSeriesToggler series={seriesToShow} onHiddenSeriesChanged={onHiddenSeriesChanged}>
|
||||
{({ onSeriesToggle, toggledSeries }) => {
|
||||
{({ onSeriesToggle, toggledSeries }: GraphSeriesTogglerAPI) => {
|
||||
return (
|
||||
<GraphWithLegend
|
||||
displayMode={LegendDisplayMode.List}
|
||||
@ -116,58 +145,39 @@ export class ExploreGraphPanel extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { series, mode, showingGraph, loading } = this.props;
|
||||
const { series, showPanel, showingGraph, loading, theme } = this.props;
|
||||
const { showAllTimeSeries } = this.state;
|
||||
const style = getStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
{series && series.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
|
||||
<div className="time-series-disclaimer">
|
||||
<i className="fa fa-fw fa-warning disclaimer-icon" />
|
||||
<div className={cx([style.timeSeriesDisclaimer])}>
|
||||
<i className={cx(['fa fa-fw fa-warning', style.disclaimerIcon])} />
|
||||
{`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
|
||||
<span className="show-all-time-series" onClick={this.onShowAllTimeSeries}>{`Show all ${
|
||||
<span className={cx([style.showAllTimeSeries])} onClick={this.onShowAllTimeSeries}>{`Show all ${
|
||||
series.length
|
||||
}`}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === ExploreMode.Metrics && (
|
||||
<Panel label="Graph" collapsible isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
|
||||
{showPanel && (
|
||||
<Collapse
|
||||
label="Graph"
|
||||
collapsible
|
||||
isOpen={showingGraph}
|
||||
loading={loading}
|
||||
onToggle={this.onClickGraphButton}
|
||||
>
|
||||
{this.renderGraph()}
|
||||
</Panel>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
{mode === ExploreMode.Logs && this.renderGraph()}
|
||||
{!showPanel && this.renderGraph()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
|
||||
const explore = state.explore;
|
||||
// @ts-ignore
|
||||
const item: ExploreItemState = explore[exploreId];
|
||||
const { loadingState, showingGraph, showingTable, absoluteRange, mode } = item;
|
||||
const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
|
||||
|
||||
return {
|
||||
loading,
|
||||
showingGraph,
|
||||
showingTable,
|
||||
timeZone: getTimeZone(state.user),
|
||||
absoluteRange,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
toggleGraph,
|
||||
updateTimeRange,
|
||||
};
|
||||
|
||||
export default hot(module)(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(ExploreGraphPanel)
|
||||
);
|
||||
export const ExploreGraphPanel = withTheme(UnThemedExploreGraphPanel);
|
||||
ExploreGraphPanel.displayName = 'ExploreGraphPanel';
|
||||
|
@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
|
||||
import { ExploreId, ExploreMode } from 'app/types/explore';
|
||||
import { DataSourceSelectItem } from '@grafana/ui';
|
||||
import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton } from '@grafana/ui';
|
||||
import { RawTimeRange, TimeZone, TimeRange, LoadingState, SelectableValue } from '@grafana/data';
|
||||
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
|
||||
import { StoreState } from 'app/types/store';
|
||||
@ -17,7 +17,6 @@ import {
|
||||
changeMode,
|
||||
} from './state/actions';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
|
||||
import { ExploreTimeControls } from './ExploreTimeControls';
|
||||
|
||||
enum IconSide {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton } from '@grafana/ui';
|
||||
import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton, getLogRowStyles } from '@grafana/ui';
|
||||
|
||||
import { LogsModel, LogRowModel, TimeZone } from '@grafana/data';
|
||||
|
||||
@ -73,6 +73,7 @@ class LiveLogs extends PureComponent<Props, State> {
|
||||
const styles = getStyles(theme);
|
||||
const rowsToRender: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : [];
|
||||
const showUtc = timeZone === 'utc';
|
||||
const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -80,20 +81,20 @@ class LiveLogs extends PureComponent<Props, State> {
|
||||
{rowsToRender.map((row: any, index) => {
|
||||
return (
|
||||
<div
|
||||
className={row.fresh ? cx(['logs-row', styles.logsRowFresh]) : cx(['logs-row', styles.logsRowOld])}
|
||||
className={row.fresh ? cx([logsRow, styles.logsRowFresh]) : cx([logsRow, styles.logsRowOld])}
|
||||
key={`${row.timeEpochMs}-${index}`}
|
||||
>
|
||||
{showUtc && (
|
||||
<div className="logs-row__localtime" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
|
||||
<div className={cx([logsRowLocalTime])} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
|
||||
{row.timeUtc}
|
||||
</div>
|
||||
)}
|
||||
{!showUtc && (
|
||||
<div className="logs-row__localtime" title={`${row.timeUtc} (${row.timeFromNow})`}>
|
||||
<div className={cx([logsRowLocalTime])} title={`${row.timeUtc} (${row.timeFromNow})`}>
|
||||
{row.timeLocal}
|
||||
</div>
|
||||
)}
|
||||
<div className="logs-row__message">{row.entry}</div>
|
||||
<div className={cx([logsRowMessage])}>{row.entry}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -1,75 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { LogLabelStats } from './LogLabelStats';
|
||||
import { LogRowModel, LogLabelStatsModel } from '@grafana/data';
|
||||
import { calculateLogsLabelStats } from 'app/core/logs_model';
|
||||
|
||||
interface Props {
|
||||
getRows?: () => LogRowModel[];
|
||||
label: string;
|
||||
plain?: boolean;
|
||||
value: string;
|
||||
onClickLabel?: (label: string, value: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
showStats: boolean;
|
||||
stats: LogLabelStatsModel[];
|
||||
}
|
||||
|
||||
export class LogLabel extends PureComponent<Props, State> {
|
||||
state: State = {
|
||||
stats: null,
|
||||
showStats: false,
|
||||
};
|
||||
|
||||
onClickClose = () => {
|
||||
this.setState({ showStats: false });
|
||||
};
|
||||
|
||||
onClickLabel = () => {
|
||||
const { onClickLabel, label, value } = this.props;
|
||||
if (onClickLabel) {
|
||||
onClickLabel(label, value);
|
||||
}
|
||||
};
|
||||
|
||||
onClickStats = () => {
|
||||
this.setState(state => {
|
||||
if (state.showStats) {
|
||||
return { showStats: false, stats: null };
|
||||
}
|
||||
const allRows = this.props.getRows();
|
||||
const stats = calculateLogsLabelStats(allRows, this.props.label);
|
||||
return { showStats: true, stats };
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { getRows, label, plain, value } = this.props;
|
||||
const { showStats, stats } = this.state;
|
||||
const tooltip = `${label}: ${value}`;
|
||||
return (
|
||||
<span className="logs-label">
|
||||
<span className="logs-label__value" title={tooltip}>
|
||||
{value}
|
||||
</span>
|
||||
{!plain && (
|
||||
<span title="Filter for label" onClick={this.onClickLabel} className="logs-label__icon fa fa-search-plus" />
|
||||
)}
|
||||
{!plain && getRows && <span onClick={this.onClickStats} className="logs-label__icon fa fa-signal" />}
|
||||
{showStats && (
|
||||
<span className="logs-label__stats">
|
||||
<LogLabelStats
|
||||
stats={stats}
|
||||
rowCount={getRows().length}
|
||||
label={label}
|
||||
value={value}
|
||||
onClickClose={this.onClickClose}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { LogLabelStatsModel } from '@grafana/data';
|
||||
|
||||
function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) {
|
||||
const { active, count, proportion, value } = logLabelStatsModel;
|
||||
const percent = `${Math.round(proportion * 100)}%`;
|
||||
const barStyle = { width: percent };
|
||||
const className = classnames('logs-stats-row', { 'logs-stats-row--active': active });
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="logs-stats-row__label">
|
||||
<div className="logs-stats-row__value" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="logs-stats-row__count">{count}</div>
|
||||
<div className="logs-stats-row__percent">{percent}</div>
|
||||
</div>
|
||||
<div className="logs-stats-row__bar">
|
||||
<div className="logs-stats-row__innerbar" style={barStyle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATS_ROW_LIMIT = 5;
|
||||
|
||||
interface Props {
|
||||
stats: LogLabelStatsModel[];
|
||||
label: string;
|
||||
value: string;
|
||||
rowCount: number;
|
||||
onClickClose: () => void;
|
||||
}
|
||||
|
||||
export class LogLabelStats extends PureComponent<Props> {
|
||||
render() {
|
||||
const { label, rowCount, stats, value, onClickClose } = this.props;
|
||||
const topRows = stats.slice(0, STATS_ROW_LIMIT);
|
||||
let activeRow = topRows.find(row => row.value === value);
|
||||
let otherRows = stats.slice(STATS_ROW_LIMIT);
|
||||
const insertActiveRow = !activeRow;
|
||||
|
||||
// Remove active row from other to show extra
|
||||
if (insertActiveRow) {
|
||||
activeRow = otherRows.find(row => row.value === value);
|
||||
otherRows = otherRows.filter(row => row.value !== value);
|
||||
}
|
||||
|
||||
const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
|
||||
const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
|
||||
const total = topCount + otherCount;
|
||||
const otherProportion = otherCount / total;
|
||||
|
||||
return (
|
||||
<div className="logs-stats">
|
||||
<div className="logs-stats__header">
|
||||
<span className="logs-stats__title">
|
||||
{label}: {total} of {rowCount} rows have that label
|
||||
</span>
|
||||
<span className="logs-stats__close fa fa-remove" onClick={onClickClose} />
|
||||
</div>
|
||||
<div className="logs-stats__body">
|
||||
{topRows.map(stat => (
|
||||
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
|
||||
))}
|
||||
{insertActiveRow && activeRow && <LogLabelStatsRow key={activeRow.value} {...activeRow} active />}
|
||||
{otherCount > 0 && (
|
||||
<LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { LogLabel } from './LogLabel';
|
||||
import { Labels, LogRowModel } from '@grafana/data';
|
||||
|
||||
interface Props {
|
||||
getRows?: () => LogRowModel[];
|
||||
labels: Labels;
|
||||
plain?: boolean;
|
||||
onClickLabel?: (label: string, value: string) => void;
|
||||
}
|
||||
|
||||
export class LogLabels extends PureComponent<Props> {
|
||||
render() {
|
||||
const { getRows, labels, onClickLabel, plain } = this.props;
|
||||
return (
|
||||
<span className="logs-labels">
|
||||
{Object.keys(labels).map(key => (
|
||||
<LogLabel
|
||||
key={key}
|
||||
getRows={getRows}
|
||||
label={key}
|
||||
value={labels[key]}
|
||||
plain={plain}
|
||||
onClickLabel={onClickLabel}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,9 +1,8 @@
|
||||
import _ from 'lodash';
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import { Switch } from '@grafana/ui';
|
||||
import {
|
||||
rangeUtil,
|
||||
RawTimeRange,
|
||||
LogLevel,
|
||||
TimeZone,
|
||||
@ -12,23 +11,17 @@ import {
|
||||
LogsModel,
|
||||
LogsDedupStrategy,
|
||||
LogRowModel,
|
||||
LogsDedupDescription,
|
||||
} from '@grafana/data';
|
||||
import { Switch, LogLabels, ToggleButtonGroup, ToggleButton, LogRows } from '@grafana/ui';
|
||||
|
||||
import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
|
||||
|
||||
import { LogLabels } from './LogLabels';
|
||||
import { LogRow } from './LogRow';
|
||||
import { LogsDedupDescription } from 'app/core/logs_model';
|
||||
import ExploreGraphPanel from './ExploreGraphPanel';
|
||||
import { ExploreId } from 'app/types';
|
||||
|
||||
const PREVIEW_LIMIT = 100;
|
||||
import { ExploreGraphPanel } from './ExploreGraphPanel';
|
||||
|
||||
function renderMetaItem(value: any, kind: LogsMetaKind) {
|
||||
if (kind === LogsMetaKind.LabelsMap) {
|
||||
return (
|
||||
<span className="logs-meta-item__labels">
|
||||
<LogLabels labels={value} plain />
|
||||
<LogLabels labels={value} plain getRows={() => []} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -39,7 +32,6 @@ interface Props {
|
||||
data?: LogsModel;
|
||||
dedupedData?: LogsModel;
|
||||
width: number;
|
||||
exploreId: ExploreId;
|
||||
highlighterExpressions: string[];
|
||||
loading: boolean;
|
||||
absoluteRange: AbsoluteTimeRange;
|
||||
@ -48,7 +40,7 @@ interface Props {
|
||||
scanRange?: RawTimeRange;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
hiddenLogLevels: Set<LogLevel>;
|
||||
onChangeTime?: (range: AbsoluteTimeRange) => void;
|
||||
onChangeTime: (range: AbsoluteTimeRange) => void;
|
||||
onClickLabel?: (label: string, value: string) => void;
|
||||
onStartScanning?: () => void;
|
||||
onStopScanning?: () => void;
|
||||
@ -58,46 +50,16 @@ interface Props {
|
||||
}
|
||||
|
||||
interface State {
|
||||
deferLogs: boolean;
|
||||
renderAll: boolean;
|
||||
showLabels: boolean;
|
||||
showTime: boolean;
|
||||
}
|
||||
|
||||
export default class Logs extends PureComponent<Props, State> {
|
||||
deferLogsTimer: NodeJS.Timer;
|
||||
renderAllTimer: NodeJS.Timer;
|
||||
|
||||
export class Logs extends PureComponent<Props, State> {
|
||||
state = {
|
||||
deferLogs: true,
|
||||
renderAll: false,
|
||||
showLabels: false,
|
||||
showTime: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Staged rendering
|
||||
if (this.state.deferLogs) {
|
||||
const { data } = this.props;
|
||||
const rowCount = data && data.rows ? data.rows.length : 0;
|
||||
// Render all right away if not too far over the limit
|
||||
const renderAll = rowCount <= PREVIEW_LIMIT * 2;
|
||||
this.deferLogsTimer = setTimeout(() => this.setState({ deferLogs: false, renderAll }), rowCount);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
// Staged rendering
|
||||
if (prevState.deferLogs && !this.state.deferLogs && !this.state.renderAll) {
|
||||
this.renderAllTimer = setTimeout(() => this.setState({ renderAll: true }), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.deferLogsTimer);
|
||||
clearTimeout(this.renderAllTimer);
|
||||
}
|
||||
|
||||
onChangeDedup = (dedup: LogsDedupStrategy) => {
|
||||
const { onDedupStrategyChange } = this.props;
|
||||
if (this.props.dedupStrategy === dedup) {
|
||||
@ -106,39 +68,46 @@ export default class Logs extends PureComponent<Props, State> {
|
||||
return onDedupStrategyChange(dedup);
|
||||
};
|
||||
|
||||
onChangeLabels = (event: React.SyntheticEvent) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.setState({
|
||||
showLabels: target.checked,
|
||||
});
|
||||
onChangeLabels = (event?: React.SyntheticEvent) => {
|
||||
const target = event && (event.target as HTMLInputElement);
|
||||
if (target) {
|
||||
this.setState({
|
||||
showLabels: target.checked,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onChangeTime = (event: React.SyntheticEvent) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.setState({
|
||||
showTime: target.checked,
|
||||
});
|
||||
onChangeTime = (event?: React.SyntheticEvent) => {
|
||||
const target = event && (event.target as HTMLInputElement);
|
||||
if (target) {
|
||||
this.setState({
|
||||
showTime: target.checked,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onToggleLogLevel = (hiddenRawLevels: string[]) => {
|
||||
const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map((level: LogLevel) => LogLevel[level]);
|
||||
const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map(level => LogLevel[level as LogLevel]);
|
||||
this.props.onToggleLogLevel(hiddenLogLevels);
|
||||
};
|
||||
|
||||
onClickScan = (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
this.props.onStartScanning();
|
||||
if (this.props.onStartScanning) {
|
||||
this.props.onStartScanning();
|
||||
}
|
||||
};
|
||||
|
||||
onClickStopScan = (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
this.props.onStopScanning();
|
||||
if (this.props.onStopScanning) {
|
||||
this.props.onStopScanning();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
data,
|
||||
exploreId,
|
||||
highlighterExpressions,
|
||||
loading = false,
|
||||
onClickLabel,
|
||||
@ -147,19 +116,21 @@ export default class Logs extends PureComponent<Props, State> {
|
||||
scanRange,
|
||||
width,
|
||||
dedupedData,
|
||||
absoluteRange,
|
||||
onChangeTime,
|
||||
} = this.props;
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { deferLogs, renderAll, showLabels, showTime } = this.state;
|
||||
const { showLabels, showTime } = this.state;
|
||||
const { dedupStrategy } = this.props;
|
||||
const hasData = data && data.rows && data.rows.length > 0;
|
||||
const hasLabel = hasData && dedupedData.hasUniqueLabels;
|
||||
const dedupCount = dedupedData.rows.reduce((sum, row) => sum + row.duplicates, 0);
|
||||
const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0;
|
||||
const meta = data.meta ? [...data.meta] : [];
|
||||
const dedupCount = dedupedData
|
||||
? dedupedData.rows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0)
|
||||
: 0;
|
||||
const meta = data && data.meta ? [...data.meta] : [];
|
||||
|
||||
if (dedupStrategy !== LogsDedupStrategy.none) {
|
||||
meta.push({
|
||||
@ -169,23 +140,26 @@ export default class Logs extends PureComponent<Props, State> {
|
||||
});
|
||||
}
|
||||
|
||||
// Staged rendering
|
||||
const processedRows = dedupedData.rows;
|
||||
const firstRows = processedRows.slice(0, PREVIEW_LIMIT);
|
||||
const lastRows = processedRows.slice(PREVIEW_LIMIT);
|
||||
const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...';
|
||||
|
||||
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
|
||||
const getRows = () => processedRows;
|
||||
const series = data && data.series ? data.series : [];
|
||||
|
||||
return (
|
||||
<div className="logs-panel">
|
||||
<div className="logs-panel-graph">
|
||||
<ExploreGraphPanel
|
||||
exploreId={exploreId}
|
||||
series={data.series}
|
||||
series={series}
|
||||
width={width}
|
||||
onHiddenSeriesChanged={this.onToggleLogLevel}
|
||||
loading={loading}
|
||||
absoluteRange={absoluteRange}
|
||||
isStacked={true}
|
||||
showPanel={false}
|
||||
showingGraph={true}
|
||||
showingTable={true}
|
||||
timeZone={timeZone}
|
||||
showBars={true}
|
||||
showLines={false}
|
||||
onUpdateTimeRange={onChangeTime}
|
||||
/>
|
||||
</div>
|
||||
<div className="logs-panel-options">
|
||||
@ -220,41 +194,19 @@ export default class Logs extends PureComponent<Props, State> {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="logs-rows">
|
||||
{hasData &&
|
||||
!deferLogs && // Only inject highlighterExpression in the first set for performance reasons
|
||||
firstRows.map((row, index) => (
|
||||
<LogRow
|
||||
key={index}
|
||||
getRows={getRows}
|
||||
getRowContext={this.props.getRowContext}
|
||||
highlighterExpressions={highlighterExpressions}
|
||||
row={row}
|
||||
showDuplicates={showDuplicates}
|
||||
showLabels={showLabels && hasLabel}
|
||||
showTime={showTime}
|
||||
timeZone={timeZone}
|
||||
onClickLabel={onClickLabel}
|
||||
/>
|
||||
))}
|
||||
{hasData &&
|
||||
!deferLogs &&
|
||||
renderAll &&
|
||||
lastRows.map((row, index) => (
|
||||
<LogRow
|
||||
key={PREVIEW_LIMIT + index}
|
||||
getRows={getRows}
|
||||
getRowContext={this.props.getRowContext}
|
||||
row={row}
|
||||
showDuplicates={showDuplicates}
|
||||
showLabels={showLabels && hasLabel}
|
||||
showTime={showTime}
|
||||
timeZone={timeZone}
|
||||
onClickLabel={onClickLabel}
|
||||
/>
|
||||
))}
|
||||
{hasData && deferLogs && <span>Rendering {dedupedData.rows.length} rows...</span>}
|
||||
</div>
|
||||
<LogRows
|
||||
data={data}
|
||||
deduplicatedData={dedupedData}
|
||||
dedupStrategy={dedupStrategy}
|
||||
getRowContext={this.props.getRowContext}
|
||||
highlighterExpressions={highlighterExpressions}
|
||||
onClickLabel={onClickLabel}
|
||||
rowLimit={data ? data.rows.length : undefined}
|
||||
showLabels={showLabels}
|
||||
showTime={showTime}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
|
||||
{!loading && !hasData && !scanning && (
|
||||
<div className="logs-panel-nodata">
|
||||
No logs found.
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { DataSourceApi } from '@grafana/ui';
|
||||
import { DataSourceApi, Collapse } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
RawTimeRange,
|
||||
@ -19,13 +19,12 @@ import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
import { changeDedupStrategy, updateTimeRange } from './state/actions';
|
||||
import Logs from './Logs';
|
||||
import Panel from './Panel';
|
||||
import { toggleLogLevelAction, changeRefreshIntervalAction } from 'app/features/explore/state/actionTypes';
|
||||
import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
import { LiveLogsWithTheme } from './LiveLogs';
|
||||
import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
|
||||
import { Logs } from './Logs';
|
||||
|
||||
interface LogsContainerProps {
|
||||
datasourceInstance: DataSourceApi | null;
|
||||
@ -89,7 +88,6 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
|
||||
render() {
|
||||
const {
|
||||
exploreId,
|
||||
loading,
|
||||
logsHighlighterExpressions,
|
||||
logsResult,
|
||||
@ -108,19 +106,18 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
|
||||
if (isLive) {
|
||||
return (
|
||||
<Panel label="Logs" loading={false} isOpen>
|
||||
<Collapse label="Logs" loading={false} isOpen>
|
||||
<LiveLogsWithTheme logsResult={logsResult} timeZone={timeZone} stopLive={this.onStopLive} />
|
||||
</Panel>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel label="Logs" loading={loading} isOpen>
|
||||
<Collapse label="Logs" loading={loading} isOpen>
|
||||
<Logs
|
||||
dedupStrategy={this.props.dedupStrategy || LogsDedupStrategy.none}
|
||||
data={logsResult}
|
||||
dedupedData={dedupedResult}
|
||||
exploreId={exploreId}
|
||||
highlighterExpressions={logsHighlighterExpressions}
|
||||
loading={loading}
|
||||
onChangeTime={this.onChangeTime}
|
||||
@ -137,7 +134,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
hiddenLogLevels={hiddenLogLevels}
|
||||
getRowContext={this.getLogRowContext}
|
||||
/>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,43 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
collapsible?: boolean;
|
||||
onToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default class Panel extends PureComponent<Props> {
|
||||
onClickToggle = () => {
|
||||
const { onToggle, isOpen } = this.props;
|
||||
if (onToggle) {
|
||||
onToggle(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isOpen, loading, collapsible } = this.props;
|
||||
const panelClass = collapsible
|
||||
? 'explore-panel explore-panel--collapsible panel-container'
|
||||
: 'explore-panel panel-container';
|
||||
const iconClass = isOpen ? 'fa fa-caret-up' : 'fa fa-caret-down';
|
||||
const loaderClass = loading ? 'explore-panel__loader explore-panel__loader--active' : 'explore-panel__loader';
|
||||
return (
|
||||
<div className={panelClass}>
|
||||
<div className="explore-panel__header" onClick={this.onClickToggle}>
|
||||
<div className="explore-panel__header-buttons">
|
||||
<span className={iconClass} />
|
||||
</div>
|
||||
<div className="explore-panel__header-label">{this.props.label}</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="explore-panel__body">
|
||||
<div className={loaderClass} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,15 +1,15 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { LoadingState } from '@grafana/data';
|
||||
import { Collapse } from '@grafana/ui';
|
||||
|
||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||
import { StoreState } from 'app/types';
|
||||
|
||||
import { toggleTable } from './state/actions';
|
||||
import Table from './Table';
|
||||
import Panel from './Panel';
|
||||
import TableModel from 'app/core/table_model';
|
||||
import { LoadingState } from '@grafana/data';
|
||||
|
||||
interface TableContainerProps {
|
||||
exploreId: ExploreId;
|
||||
@ -29,9 +29,9 @@ export class TableContainer extends PureComponent<TableContainerProps> {
|
||||
const { loading, onClickCell, showingTable, tableResult } = this.props;
|
||||
|
||||
return (
|
||||
<Panel label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
|
||||
<Collapse label="Table" loading={loading} collapsible isOpen={showingTable} onToggle={this.onClickTableButton}>
|
||||
{tableResult && <Table data={tableResult} loading={loading} onClickCell={onClickCell} />}
|
||||
</Panel>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
DEFAULT_UI_STATE,
|
||||
generateNewKeyAndAddRefIdIfMissing,
|
||||
sortLogsResult,
|
||||
refreshIntervalToSortOrder,
|
||||
} from 'app/core/utils/explore';
|
||||
import { ExploreItemState, ExploreState, ExploreId, ExploreUpdateState, ExploreMode } from 'app/types/explore';
|
||||
import { LoadingState } from '@grafana/data';
|
||||
@ -183,7 +184,8 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
const { refreshInterval } = action.payload;
|
||||
const live = isLive(refreshInterval);
|
||||
const logsResult = sortLogsResult(state.logsResult, refreshInterval);
|
||||
const sortOrder = refreshIntervalToSortOrder(refreshInterval);
|
||||
const logsResult = sortLogsResult(state.logsResult, sortOrder);
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
@ -14,7 +14,7 @@ import {
|
||||
import { ExploreItemState, ExploreMode } from 'app/types/explore';
|
||||
import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState';
|
||||
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
|
||||
import { sortLogsResult } from 'app/core/utils/explore';
|
||||
import { sortLogsResult, refreshIntervalToSortOrder } from 'app/core/utils/explore';
|
||||
import { dataFrameToLogsModel } from 'app/core/logs_model';
|
||||
import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel';
|
||||
|
||||
@ -80,14 +80,19 @@ export class ResultProcessor {
|
||||
const graphInterval = this.state.queryIntervals.intervalMs;
|
||||
const dataFrame = this.rawData.map(result => guessFieldTypes(toDataFrame(result)));
|
||||
const newResults = this.rawData ? dataFrameToLogsModel(dataFrame, graphInterval) : null;
|
||||
const sortedNewResults = sortLogsResult(newResults, this.state.refreshInterval);
|
||||
const sortOrder = refreshIntervalToSortOrder(this.state.refreshInterval);
|
||||
const sortedNewResults = sortLogsResult(newResults, sortOrder);
|
||||
|
||||
if (this.replacePreviousResults) {
|
||||
return sortedNewResults;
|
||||
const slice = 1000;
|
||||
const rows = sortedNewResults.rows.slice(0, slice);
|
||||
const series = sortedNewResults.series;
|
||||
|
||||
return { ...sortedNewResults, rows, series };
|
||||
}
|
||||
|
||||
const prevLogsResult: LogsModel = this.state.logsResult || { hasUniqueLabels: false, rows: [] };
|
||||
const sortedLogResult = sortLogsResult(prevLogsResult, this.state.refreshInterval);
|
||||
const sortedLogResult = sortLogsResult(prevLogsResult, sortOrder);
|
||||
const rowsInState = sortedLogResult.rows;
|
||||
const seriesInState = sortedLogResult.series || [];
|
||||
|
||||
|
@ -32,6 +32,7 @@ import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
|
||||
import * as gaugePanel from 'app/plugins/panel/gauge/module';
|
||||
import * as pieChartPanel from 'app/plugins/panel/piechart/module';
|
||||
import * as barGaugePanel from 'app/plugins/panel/bargauge/module';
|
||||
import * as logsPanel from 'app/plugins/panel/logs/module';
|
||||
|
||||
import * as exampleApp from 'app/plugins/app/example-app/module';
|
||||
|
||||
@ -70,6 +71,7 @@ const builtInPlugins: any = {
|
||||
'app/plugins/panel/gauge/module': gaugePanel,
|
||||
'app/plugins/panel/piechart/module': pieChartPanel,
|
||||
'app/plugins/panel/bargauge/module': barGaugePanel,
|
||||
'app/plugins/panel/logs/module': logsPanel,
|
||||
|
||||
'app/plugins/app/example-app/module': exampleApp,
|
||||
};
|
||||
|
@ -1,11 +1,10 @@
|
||||
import React from 'react';
|
||||
import { PanelData } from '@grafana/ui';
|
||||
import { PanelData, GraphSeriesToggler } from '@grafana/ui';
|
||||
import { GraphSeriesXY } from '@grafana/data';
|
||||
|
||||
import { getGraphSeriesModel } from './getGraphSeriesModel';
|
||||
import { Options, SeriesOptions } from './types';
|
||||
import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from '@grafana/ui/src/components/Graph/GraphWithLegend';
|
||||
import { GraphSeriesToggler } from './GraphSeriesToggler';
|
||||
|
||||
interface GraphPanelControllerAPI {
|
||||
series: GraphSeriesXY[];
|
||||
|
39
public/app/plugins/panel/logs/LogsPanel.tsx
Normal file
39
public/app/plugins/panel/logs/LogsPanel.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { PanelProps, LogRows, CustomScrollbar } from '@grafana/ui';
|
||||
import { Options } from './types';
|
||||
import { LogsDedupStrategy } from '@grafana/data';
|
||||
import { dataFrameToLogsModel } from 'app/core/logs_model';
|
||||
import { sortLogsResult } from 'app/core/utils/explore';
|
||||
|
||||
interface LogsPanelProps extends PanelProps<Options> {}
|
||||
|
||||
export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
|
||||
data,
|
||||
timeZone,
|
||||
options: { showTime, sortOrder },
|
||||
width,
|
||||
}) => {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const newResults = data ? dataFrameToLogsModel(data.series, data.request.intervalMs) : null;
|
||||
const sortedNewResults = sortLogsResult(newResults, sortOrder);
|
||||
|
||||
return (
|
||||
<CustomScrollbar autoHide>
|
||||
<LogRows
|
||||
data={sortedNewResults}
|
||||
dedupStrategy={LogsDedupStrategy.none}
|
||||
highlighterExpressions={[]}
|
||||
showTime={showTime}
|
||||
showLabels={false}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
);
|
||||
};
|
46
public/app/plugins/panel/logs/LogsPanelEditor.tsx
Normal file
46
public/app/plugins/panel/logs/LogsPanelEditor.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import { PanelEditorProps, Switch, PanelOptionsGrid, PanelOptionsGroup, FormLabel, Select } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { Options } from './types';
|
||||
import { SortOrder } from 'app/core/utils/explore';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
const sortOrderOptions = [
|
||||
{ value: SortOrder.Descending, label: 'Descending' },
|
||||
{ value: SortOrder.Ascending, label: 'Ascending' },
|
||||
];
|
||||
|
||||
export class LogsPanelEditor extends PureComponent<PanelEditorProps<Options>> {
|
||||
onToggleTime = () => {
|
||||
const { options, onOptionsChange } = this.props;
|
||||
const { showTime } = options;
|
||||
|
||||
onOptionsChange({ ...options, showTime: !showTime });
|
||||
};
|
||||
|
||||
onShowValuesChange = (item: SelectableValue<SortOrder>) => {
|
||||
const { options, onOptionsChange } = this.props;
|
||||
onOptionsChange({ ...options, sortOrder: item.value });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showTime, sortOrder } = this.props.options;
|
||||
const value = sortOrderOptions.filter(option => option.value === sortOrder)[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelOptionsGrid>
|
||||
<PanelOptionsGroup title="Columns">
|
||||
<Switch label="Time" labelClass="width-10" checked={showTime} onChange={this.onToggleTime} />
|
||||
<div className="gf-form">
|
||||
<FormLabel>Order</FormLabel>
|
||||
<Select options={sortOrderOptions} value={value} onChange={this.onShowValuesChange} />
|
||||
</div>
|
||||
</PanelOptionsGroup>
|
||||
</PanelOptionsGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
8
public/app/plugins/panel/logs/img/icn-logs-panel.svg
Normal file
8
public/app/plugins/panel/logs/img/icn-logs-panel.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="144px" height="144px" viewBox="0 0 144 144" version="1.1">
|
||||
<g id="surface736507">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(90.196078%,49.411765%,13.333333%);fill-opacity:1;" d="M 93 42 L 132 42 L 132 30 C 132 23.371094 126.628906 18 120 18 L 93 18 Z M 93 42 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(92.54902%,94.117647%,94.509804%);fill-opacity:1;" d="M 120 18 L 42 18 C 35.371094 18 30 23.371094 30 30 L 30 114 C 30 120.628906 35.371094 126 42 126 L 96 126 C 102.628906 126 108 120.628906 108 114 L 108 30 C 108 23.371094 113.371094 18 120 18 Z M 120 18 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(90.196078%,49.411765%,13.333333%);fill-opacity:1;" d="M 96 126 L 24 126 C 17.371094 126 12 120.628906 12 114 L 12 105 L 84 105 L 84 114 C 84 120.628906 89.371094 126 96 126 Z M 48 42 L 90 42 L 90 48 L 48 48 Z M 48 57 L 78 57 L 78 63 L 48 63 Z M 48 72 L 90 72 L 90 78 L 48 78 Z M 48 87 L 78 87 L 78 93 L 48 93 Z M 48 87 "/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
6
public/app/plugins/panel/logs/module.tsx
Normal file
6
public/app/plugins/panel/logs/module.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { PanelPlugin } from '@grafana/ui';
|
||||
import { Options, defaults } from './types';
|
||||
import { LogsPanel } from './LogsPanel';
|
||||
import { LogsPanelEditor } from './LogsPanelEditor';
|
||||
|
||||
export const plugin = new PanelPlugin<Options>(LogsPanel).setDefaults(defaults).setEditor(LogsPanelEditor);
|
17
public/app/plugins/panel/logs/plugin.json
Normal file
17
public/app/plugins/panel/logs/plugin.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "panel",
|
||||
"name": "Logs",
|
||||
"id": "logs",
|
||||
"state": "alpha",
|
||||
|
||||
"info": {
|
||||
"author": {
|
||||
"name": "Grafana Project",
|
||||
"url": "https://grafana.com"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/icn-logs-panel.svg",
|
||||
"large": "img/icn-logs-panel.svg"
|
||||
}
|
||||
}
|
||||
}
|
11
public/app/plugins/panel/logs/types.ts
Normal file
11
public/app/plugins/panel/logs/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { SortOrder } from 'app/core/utils/explore';
|
||||
|
||||
export interface Options {
|
||||
showTime: boolean;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
||||
export const defaults: Options = {
|
||||
showTime: true,
|
||||
sortOrder: SortOrder.Descending,
|
||||
};
|
@ -333,10 +333,3 @@ export interface QueryTransaction {
|
||||
result?: any; // Table model / Timeseries[] / Logs
|
||||
scanning?: boolean;
|
||||
}
|
||||
|
||||
export interface TextMatch {
|
||||
text: string;
|
||||
start: number;
|
||||
length: number;
|
||||
end: number;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user