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

* WIP: intial commit

* Switch: Adds tooltip

* Refactor: Adds props to LogsPanelEditor

* Refactor: Moves LogRowContextProvider to grafana/ui

* Refactor: Moves LogRowContext and Alert to grafana/ui

* Refactor: Moves LogLabelStats to grafana/ui

* Refactor: Moves LogLabels and LogLabel to grafana/ui

* Refactor: Moves LogMessageAnsi and ansicolor to grafana/ui

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

* Refactor: Moves findHighlightChunksInText to grafana/data

* Refactor: Moves LogRow to grafana/ui

* Refactor: Moving ExploreGraphPanel to grafana/ui

* Refactor: Copies Logs to grafana/ui

* Refactor: Moves ToggleButtonGroup to grafana/ui

* Refactor: Adds Logs to LogsPanel

* Refactor: Moves styles to emotion

* Feature: Adds LogsRows

* Refactor: Introduces render limit

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Styles: Moves styles to emotion

* Refactor: Adds sorting to LogsPanelEditor

* Tests: Adds tests for sorting

* Refactor: Changes according to PR comments

* Refactor: Changes according to PR comments

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

* Fix: Shows the Show context label again
This commit is contained in:
Hugo Häggmark
2019-08-26 08:11:07 +02:00
committed by GitHub
parent 98a512a3c7
commit e5e7bd3153
55 changed files with 1765 additions and 1293 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
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', () => {
it('then the result should be in correct format', async () => {
const firstResult = new DataFrameHelper({
refId: 'B',
labels: {},
fields: [
{ name: 'ts', type: FieldType.time, values: [3, 2, 1] },
{ name: 'line', type: FieldType.string, values: ['3', '2', '1'] },
],
});
const secondResult = new DataFrameHelper({
refId: 'B',
labels: {},
fields: [
{ name: 'ts', type: FieldType.time, values: [6, 5, 4] },
{ name: 'line', type: FieldType.string, values: ['6', '5', '4'] },
],
});
const row: LogRowModel = {
entry: '4',
labels: (null as any) as Labels,
hasAnsi: false,
raw: '4',
logLevel: LogLevel.info,
timeEpochMs: 4,
timeFromNow: '',
timeLocal: '',
timeUtc: '',
timestamp: '4',
};
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(getRowContextMock, row, 10);
expect(result).toEqual({ data: [[['3', '2', '1']], [['6', '5', '4']]], errors: ['', ''] });
});
});
describe('when called with a DataFrame and errors occur', () => {
it('then the result should be in correct format', async () => {
const firstError = new Error('Error 1');
const secondError = new Error('Error 2');
const row: LogRowModel = {
entry: '4',
labels: (null as any) as Labels,
hasAnsi: false,
raw: '4',
logLevel: LogLevel.info,
timeEpochMs: 4,
timeFromNow: '',
timeLocal: '',
timeUtc: '',
timestamp: '4',
};
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(getRowContextMock, row, 10);
expect(result).toEqual({ data: [[], []], errors: ['Error 1', 'Error 2'] });
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,484 @@
// Vendored and converted to TS, source: https://github.com/xpl/ansicolor/blob/b82360563ed29de444dc7618b9236191e0a77096/ansicolor.js
// License: Unlicense, author: https://github.com/xpl
const O = Object;
/* See https://misc.flogisoft.com/bash/tip_colors_and_formatting
------------------------------------------------------------------------ */
const colorCodes = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'lightGray', '', 'default'],
colorCodesLight = [
'darkGray',
'lightRed',
'lightGreen',
'lightYellow',
'lightBlue',
'lightMagenta',
'lightCyan',
'white',
'',
],
styleCodes = ['', 'bright', 'dim', 'italic', 'underline', '', '', 'inverse'],
asBright = {
red: 'lightRed',
green: 'lightGreen',
yellow: 'lightYellow',
blue: 'lightBlue',
magenta: 'lightMagenta',
cyan: 'lightCyan',
black: 'darkGray',
lightGray: 'white',
},
types = {
0: 'style',
2: 'unstyle',
3: 'color',
9: 'colorLight',
4: 'bgColor',
10: 'bgColorLight',
},
subtypes = {
color: colorCodes,
colorLight: colorCodesLight,
bgColor: colorCodes,
bgColorLight: colorCodesLight,
style: styleCodes,
unstyle: styleCodes,
};
/* ------------------------------------------------------------------------ */
const clean = (obj: any) => {
for (const k in obj) {
if (!obj[k]) {
delete obj[k];
}
}
return O.keys(obj).length === 0 ? undefined : obj;
};
/* ------------------------------------------------------------------------ */
class Color {
background?: boolean;
name?: string;
brightness?: number;
constructor(background?: boolean, name?: string, brightness?: number) {
this.background = background;
this.name = name;
this.brightness = brightness;
}
get inverse() {
return new Color(!this.background, this.name || (this.background ? 'black' : 'white'), this.brightness);
}
get clean() {
return clean({
name: this.name === 'default' ? '' : this.name,
bright: this.brightness === Code.bright,
dim: this.brightness === Code.dim,
});
}
defaultBrightness(value?: number) {
return new Color(this.background, this.name, this.brightness || value);
}
css(inverted: boolean) {
const color = inverted ? this.inverse : this;
// @ts-ignore
const rgbName = (color.brightness === Code.bright && asBright[color.name]) || color.name;
const prop = color.background ? 'background:' : 'color:';
// @ts-ignore
const rgb = Colors.rgb[rgbName];
const alpha = this.brightness === Code.dim ? 0.5 : 1;
return rgb
? prop + 'rgba(' + [...rgb, alpha].join(',') + ');'
: !color.background && alpha < 1
? 'color:rgba(0,0,0,0.5);'
: ''; // Chrome does not support 'opacity' property...
}
}
/* ------------------------------------------------------------------------ */
class Code {
static reset = 0;
static bright = 1;
static dim = 2;
static inverse = 7;
static noBrightness = 22;
static noItalic = 23;
static noUnderline = 24;
static noInverse = 27;
static noColor = 39;
static noBgColor = 49;
value: number | undefined;
constructor(n?: string | number) {
if (n !== undefined) {
this.value = Number(n);
} else {
this.value = undefined;
}
}
get type() {
// @ts-ignore
return types[Math.floor(this.value / 10)];
}
get subtype() {
// @ts-ignore
return subtypes[this.type][this.value % 10];
}
get str() {
return this.value ? '\u001b[' + this.value + 'm' : '';
}
static str(x: string | number) {
return new Code(x).str;
}
get isBrightness() {
return this.value === Code.noBrightness || this.value === Code.bright || this.value === Code.dim;
}
}
/* ------------------------------------------------------------------------ */
const replaceAll = (str: string, a: string, b: string) => str.split(a).join(b);
/* ANSI brightness codes do not overlap, e.g. "{bright}{dim}foo" will be rendered bright (not dim).
So we fix it by adding brightness canceling before each brightness code, so the former example gets
converted to "{noBrightness}{bright}{noBrightness}{dim}foo" this way it gets rendered as expected.
*/
const denormalizeBrightness = (s: string) => s.replace(/(\u001b\[(1|2)m)/g, '\u001b[22m$1');
const normalizeBrightness = (s: string) => s.replace(/\u001b\[22m(\u001b\[(1|2)m)/g, '$1');
// @ts-ignore
const wrap = (x, openCode, closeCode) => {
const open = Code.str(openCode),
close = Code.str(closeCode);
return String(x)
.split('\n')
.map(line => denormalizeBrightness(open + replaceAll(normalizeBrightness(line), close, open) + close))
.join('\n');
};
/* ------------------------------------------------------------------------ */
const camel = (a: string, b: string) => a + b.charAt(0).toUpperCase() + b.slice(1);
const stringWrappingMethods = (() =>
[
...colorCodes.map((k, i) =>
!k
? []
: [
// color methods
[k, 30 + i, Code.noColor],
[camel('bg', k), 40 + i, Code.noBgColor],
]
),
...colorCodesLight.map((k, i) =>
!k
? []
: [
// light color methods
[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]]
),
...styleCodes.map((k, i) =>
!k
? []
: [
// style methods
[k, i, k === 'bright' || k === 'dim' ? Code.noBrightness : 20 + i],
]
),
].reduce((a, b) => a.concat(b)))();
/* ------------------------------------------------------------------------ */
// @ts-ignore
const assignStringWrappingAPI = (target, wrapBefore = target) =>
stringWrappingMethods.reduce(
(memo, [k, open, close]) =>
O.defineProperty(memo, k, {
// @ts-ignore
get: () => assignStringWrappingAPI(str => wrapBefore(wrap(str, open, close))),
}),
target
);
/* ------------------------------------------------------------------------ */
const TEXT = 0,
BRACKET = 1,
CODE = 2;
function rawParse(s: string) {
let state = TEXT,
buffer = '',
text = '',
code = '',
codes = [];
const spans = [];
for (let i = 0, n = s.length; i < n; i++) {
const c = s[i];
buffer += c;
switch (state) {
case TEXT: {
if (c === '\u001b') {
state = BRACKET;
buffer = c;
} else {
text += c;
}
break;
}
case BRACKET:
if (c === '[') {
state = CODE;
code = '';
codes = [];
} else {
state = TEXT;
text += buffer;
}
break;
case CODE:
if (c >= '0' && c <= '9') {
code += c;
} else if (c === ';') {
codes.push(new Code(code));
code = '';
} else if (c === 'm' && code.length) {
codes.push(new Code(code));
for (const code of codes) {
spans.push({ text, code });
text = '';
}
state = TEXT;
} else {
state = TEXT;
text += buffer;
}
}
}
if (state !== TEXT) {
text += buffer;
}
if (text) {
spans.push({ text, code: new Code() });
}
return spans;
}
/* ------------------------------------------------------------------------ */
/**
* Represents an ANSI-escaped string.
*/
export default class Colors {
spans: any[];
static names = stringWrappingMethods.map(([k]) => k);
static rgb = {
black: [0, 0, 0],
darkGray: [100, 100, 100],
lightGray: [200, 200, 200],
white: [255, 255, 255],
red: [204, 0, 0],
lightRed: [255, 51, 0],
green: [0, 204, 0],
lightGreen: [51, 204, 51],
yellow: [204, 102, 0],
lightYellow: [255, 153, 51],
blue: [0, 0, 255],
lightBlue: [26, 140, 255],
magenta: [204, 0, 204],
lightMagenta: [255, 0, 255],
cyan: [0, 153, 255],
lightCyan: [0, 204, 255],
};
/**
* @param {string} s a string containing ANSI escape codes.
*/
constructor(s?: string) {
this.spans = s ? rawParse(s) : [];
}
get str() {
return this.spans.reduce((str, p) => str + p.text + p.code.str, '');
}
get parsed() {
let styles: Set<string>;
let brightness: number | undefined;
let color: Color;
let bgColor: Color;
function reset() {
(color = new Color()),
(bgColor = new Color(true /* background */)),
(brightness = undefined),
(styles = new Set());
}
reset();
return O.assign(new Colors(), {
spans: this.spans
.map(span => {
const c = span.code;
const inverted = styles.has('inverse'),
underline = styles.has('underline') ? 'text-decoration: underline;' : '',
italic = styles.has('italic') ? 'font-style: italic;' : '',
bold = brightness === Code.bright ? 'font-weight: bold;' : '';
const foreColor = color.defaultBrightness(brightness);
const styledSpan = O.assign(
{ css: bold + italic + underline + foreColor.css(inverted) + bgColor.css(inverted) },
clean({ bold: !!bold, color: foreColor.clean, bgColor: bgColor.clean }),
span
);
for (const k of styles) {
styledSpan[k] = true;
}
if (c.isBrightness) {
brightness = c.value;
} else if (span.code.value !== undefined) {
if (span.code.value === Code.reset) {
reset();
} else {
switch (span.code.type) {
case 'color':
case 'colorLight':
color = new Color(false, c.subtype);
break;
case 'bgColor':
case 'bgColorLight':
bgColor = new Color(true, c.subtype);
break;
case 'style':
styles.add(c.subtype);
break;
case 'unstyle':
styles.delete(c.subtype);
break;
}
}
}
return styledSpan;
})
.filter(s => s.text.length > 0),
});
}
/* Outputs with Chrome DevTools-compatible format */
get asChromeConsoleLogArguments() {
const spans = this.parsed.spans;
return [spans.map(s => '%c' + s.text).join(''), ...spans.map(s => s.css)];
}
get browserConsoleArguments() /* LEGACY, DEPRECATED */ {
return this.asChromeConsoleLogArguments;
}
/**
* @desc installs String prototype extensions
* @example
* require ('ansicolor').nice
* console.log ('foo'.bright.red)
*/
static get nice() {
Colors.names.forEach(k => {
if (!(k in String.prototype)) {
O.defineProperty(String.prototype, k, {
get: function() {
// @ts-ignore
return Colors[k](this);
},
});
}
});
return Colors;
}
/**
* @desc parses a string containing ANSI escape codes
* @return {Colors} parsed representation.
*/
static parse(s: string) {
return new Colors(s).parsed;
}
/**
* @desc strips ANSI codes from a string
* @param {string} s a string containing ANSI escape codes.
* @return {string} clean string.
*/
static strip(s: string) {
return s.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><]/g, ''); // hope V8 caches the regexp
}
/**
* @example
* const spans = [...ansi.parse ('\u001b[7m\u001b[7mfoo\u001b[7mbar\u001b[27m')]
*/
[Symbol.iterator]() {
return this.spans[Symbol.iterator]();
}
}
/* ------------------------------------------------------------------------ */
assignStringWrappingAPI(Colors, (str: string) => str);

View File

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