mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dashboard: Adds Logs Panel (alpha) as visualization option for Dashboards (#18641)
* WIP: intial commit * Switch: Adds tooltip * Refactor: Adds props to LogsPanelEditor * Refactor: Moves LogRowContextProvider to grafana/ui * Refactor: Moves LogRowContext and Alert to grafana/ui * Refactor: Moves LogLabelStats to grafana/ui * Refactor: Moves LogLabels and LogLabel to grafana/ui * Refactor: Moves LogMessageAnsi and ansicolor to grafana/ui * Refactor: Moves calculateFieldStats, LogsParsers and getParser to grafana/data * Refactor: Moves findHighlightChunksInText to grafana/data * Refactor: Moves LogRow to grafana/ui * Refactor: Moving ExploreGraphPanel to grafana/ui * Refactor: Copies Logs to grafana/ui * Refactor: Moves ToggleButtonGroup to grafana/ui * Refactor: Adds Logs to LogsPanel * Refactor: Moves styles to emotion * Feature: Adds LogsRows * Refactor: Introduces render limit * Styles: Moves styles to emotion * Styles: Moves styles to emotion * Styles: Moves styles to emotion * Styles: Moves styles to emotion * Refactor: Adds sorting to LogsPanelEditor * Tests: Adds tests for sorting * Refactor: Changes according to PR comments * Refactor: Changes according to PR comments * Refactor: Moves Logs and ExploreGraphPanel out of grafana/ui * Fix: Shows the Show context label again
This commit is contained in:
32
packages/grafana-ui/src/components/Alert/Alert.tsx
Normal file
32
packages/grafana-ui/src/components/Alert/Alert.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
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';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
75
packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx
Normal file
75
packages/grafana-ui/src/components/Logs/LogMessageAnsi.tsx
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
387
packages/grafana-ui/src/components/Logs/LogRow.tsx
Normal file
387
packages/grafana-ui/src/components/Logs/LogRow.tsx
Normal 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';
|
||||
232
packages/grafana-ui/src/components/Logs/LogRowContext.tsx
Normal file
232
packages/grafana-ui/src/components/Logs/LogRowContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
484
packages/grafana-ui/src/utils/ansicolor.ts
Normal file
484
packages/grafana-ui/src/utils/ansicolor.ts
Normal 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);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user