mirror of https://github.com/grafana/grafana
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 againpull/18752/head
parent
98a512a3c7
commit
e5e7bd3153
@ -0,0 +1,84 @@ |
||||
export interface TextMatch { |
||||
text: string; |
||||
start: number; |
||||
length: number; |
||||
end: number; |
||||
} |
||||
|
||||
/** |
||||
* Adapt findMatchesInText for react-highlight-words findChunks handler. |
||||
* See https://github.com/bvaughn/react-highlight-words#props
|
||||
*/ |
||||
export function findHighlightChunksInText({ |
||||
searchWords, |
||||
textToHighlight, |
||||
}: { |
||||
searchWords: string[]; |
||||
textToHighlight: string; |
||||
}) { |
||||
return searchWords.reduce((acc: any, term: string) => [...acc, ...findMatchesInText(textToHighlight, term)], []); |
||||
} |
||||
|
||||
const cleanNeedle = (needle: string): string => { |
||||
return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); |
||||
}; |
||||
|
||||
/** |
||||
* Returns a list of substring regexp matches. |
||||
*/ |
||||
export function findMatchesInText(haystack: string, needle: string): TextMatch[] { |
||||
// Empty search can send re.exec() into infinite loop, exit early
|
||||
if (!haystack || !needle) { |
||||
return []; |
||||
} |
||||
const matches: TextMatch[] = []; |
||||
const { cleaned, flags } = parseFlags(cleanNeedle(needle)); |
||||
let regexp: RegExp; |
||||
try { |
||||
regexp = new RegExp(`(?:${cleaned})`, flags); |
||||
} catch (error) { |
||||
return matches; |
||||
} |
||||
haystack.replace(regexp, (substring, ...rest) => { |
||||
if (substring) { |
||||
const offset = rest[rest.length - 2]; |
||||
matches.push({ |
||||
text: substring, |
||||
start: offset, |
||||
length: substring.length, |
||||
end: offset + substring.length, |
||||
}); |
||||
} |
||||
return ''; |
||||
}); |
||||
return matches; |
||||
} |
||||
|
||||
const CLEAR_FLAG = '-'; |
||||
const FLAGS_REGEXP = /\(\?([ims-]+)\)/g; |
||||
|
||||
/** |
||||
* Converts any mode modifers in the text to the Javascript equivalent flag |
||||
*/ |
||||
export function parseFlags(text: string): { cleaned: string; flags: string } { |
||||
const flags: Set<string> = new Set(['g']); |
||||
|
||||
const cleaned = text.replace(FLAGS_REGEXP, (str, group) => { |
||||
const clearAll = group.startsWith(CLEAR_FLAG); |
||||
|
||||
for (let i = 0; i < group.length; ++i) { |
||||
const flag = group.charAt(i); |
||||
if (clearAll || group.charAt(i - 1) === CLEAR_FLAG) { |
||||
flags.delete(flag); |
||||
} else if (flag !== CLEAR_FLAG) { |
||||
flags.add(flag); |
||||
} |
||||
} |
||||
return ''; // Remove flag from text
|
||||
}); |
||||
|
||||
return { |
||||
cleaned: cleaned, |
||||
flags: Array.from(flags).join(''), |
||||
}; |
||||
} |
@ -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,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'; |
@ -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'; |
@ -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'; |
@ -0,0 +1,43 @@ |
||||
import React, { FunctionComponent, useContext } from 'react'; |
||||
import { css, cx } from 'emotion'; |
||||
import { Labels, LogRowModel } from '@grafana/data'; |
||||
|
||||
import { LogLabel } from './LogLabel'; |
||||
import { GrafanaTheme } from '../../types/theme'; |
||||
import { ThemeContext } from '../../themes/ThemeContext'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({ |
||||
logsLabels: css` |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
`,
|
||||
}); |
||||
|
||||
interface Props { |
||||
labels: Labels; |
||||
getRows: () => LogRowModel[]; |
||||
plain?: boolean; |
||||
onClickLabel?: (label: string, value: string) => void; |
||||
} |
||||
|
||||
export const LogLabels: FunctionComponent<Props> = ({ getRows, labels, onClickLabel, plain }) => { |
||||
const theme = useContext(ThemeContext); |
||||
const styles = getStyles(theme); |
||||
|
||||
return ( |
||||
<span className={cx([styles.logsLabels])}> |
||||
{Object.keys(labels).map(key => ( |
||||
<LogLabel |
||||
key={key} |
||||
getRows={getRows} |
||||
label={key} |
||||
value={labels[key]} |
||||
plain={plain} |
||||
onClickLabel={onClickLabel} |
||||
/> |
||||
))} |
||||
</span> |
||||
); |
||||
}; |
||||
|
||||
LogLabels.displayName = 'LogLabels'; |
@ -1,5 +1,5 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import ansicolor from 'vendor/ansicolor/ansicolor'; |
||||
import ansicolor from '../../utils/ansicolor'; |
||||
|
||||
interface Style { |
||||
[key: string]: string; |
@ -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'; |
@ -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; |
||||
`,
|
||||
}; |
||||
}; |
@ -1,75 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
import { LogLabelStats } from './LogLabelStats'; |
||||
import { LogRowModel, LogLabelStatsModel } from '@grafana/data'; |
||||
import { calculateLogsLabelStats } from 'app/core/logs_model'; |
||||
|
||||
interface Props { |
||||
getRows?: () => LogRowModel[]; |
||||
label: string; |
||||
plain?: boolean; |
||||
value: string; |
||||
onClickLabel?: (label: string, value: string) => void; |
||||
} |
||||
|
||||
interface State { |
||||
showStats: boolean; |
||||
stats: LogLabelStatsModel[]; |
||||
} |
||||
|
||||
export class LogLabel extends PureComponent<Props, State> { |
||||
state: State = { |
||||
stats: null, |
||||
showStats: false, |
||||
}; |
||||
|
||||
onClickClose = () => { |
||||
this.setState({ showStats: false }); |
||||
}; |
||||
|
||||
onClickLabel = () => { |
||||
const { onClickLabel, label, value } = this.props; |
||||
if (onClickLabel) { |
||||
onClickLabel(label, value); |
||||
} |
||||
}; |
||||
|
||||
onClickStats = () => { |
||||
this.setState(state => { |
||||
if (state.showStats) { |
||||
return { showStats: false, stats: null }; |
||||
} |
||||
const allRows = this.props.getRows(); |
||||
const stats = calculateLogsLabelStats(allRows, this.props.label); |
||||
return { showStats: true, stats }; |
||||
}); |
||||
}; |
||||
|
||||
render() { |
||||
const { getRows, label, plain, value } = this.props; |
||||
const { showStats, stats } = this.state; |
||||
const tooltip = `${label}: ${value}`; |
||||
return ( |
||||
<span className="logs-label"> |
||||
<span className="logs-label__value" title={tooltip}> |
||||
{value} |
||||
</span> |
||||
{!plain && ( |
||||
<span title="Filter for label" onClick={this.onClickLabel} className="logs-label__icon fa fa-search-plus" /> |
||||
)} |
||||
{!plain && getRows && <span onClick={this.onClickStats} className="logs-label__icon fa fa-signal" />} |
||||
{showStats && ( |
||||
<span className="logs-label__stats"> |
||||
<LogLabelStats |
||||
stats={stats} |
||||
rowCount={getRows().length} |
||||
label={label} |
||||
value={value} |
||||
onClickClose={this.onClickClose} |
||||
/> |
||||
</span> |
||||
)} |
||||
</span> |
||||
); |
||||
} |
||||
} |
@ -1,76 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import classnames from 'classnames'; |
||||
import { LogLabelStatsModel } from '@grafana/data'; |
||||
|
||||
function LogLabelStatsRow(logLabelStatsModel: LogLabelStatsModel) { |
||||
const { active, count, proportion, value } = logLabelStatsModel; |
||||
const percent = `${Math.round(proportion * 100)}%`; |
||||
const barStyle = { width: percent }; |
||||
const className = classnames('logs-stats-row', { 'logs-stats-row--active': active }); |
||||
|
||||
return ( |
||||
<div className={className}> |
||||
<div className="logs-stats-row__label"> |
||||
<div className="logs-stats-row__value" title={value}> |
||||
{value} |
||||
</div> |
||||
<div className="logs-stats-row__count">{count}</div> |
||||
<div className="logs-stats-row__percent">{percent}</div> |
||||
</div> |
||||
<div className="logs-stats-row__bar"> |
||||
<div className="logs-stats-row__innerbar" style={barStyle} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const STATS_ROW_LIMIT = 5; |
||||
|
||||
interface Props { |
||||
stats: LogLabelStatsModel[]; |
||||
label: string; |
||||
value: string; |
||||
rowCount: number; |
||||
onClickClose: () => void; |
||||
} |
||||
|
||||
export class LogLabelStats extends PureComponent<Props> { |
||||
render() { |
||||
const { label, rowCount, stats, value, onClickClose } = this.props; |
||||
const topRows = stats.slice(0, STATS_ROW_LIMIT); |
||||
let activeRow = topRows.find(row => row.value === value); |
||||
let otherRows = stats.slice(STATS_ROW_LIMIT); |
||||
const insertActiveRow = !activeRow; |
||||
|
||||
// Remove active row from other to show extra
|
||||
if (insertActiveRow) { |
||||
activeRow = otherRows.find(row => row.value === value); |
||||
otherRows = otherRows.filter(row => row.value !== value); |
||||
} |
||||
|
||||
const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); |
||||
const topCount = topRows.reduce((sum, row) => sum + row.count, 0); |
||||
const total = topCount + otherCount; |
||||
const otherProportion = otherCount / total; |
||||
|
||||
return ( |
||||
<div className="logs-stats"> |
||||
<div className="logs-stats__header"> |
||||
<span className="logs-stats__title"> |
||||
{label}: {total} of {rowCount} rows have that label |
||||
</span> |
||||
<span className="logs-stats__close fa fa-remove" onClick={onClickClose} /> |
||||
</div> |
||||
<div className="logs-stats__body"> |
||||
{topRows.map(stat => ( |
||||
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} /> |
||||
))} |
||||
{insertActiveRow && activeRow && <LogLabelStatsRow key={activeRow.value} {...activeRow} active />} |
||||
{otherCount > 0 && ( |
||||
<LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} /> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -1,31 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
import { LogLabel } from './LogLabel'; |
||||
import { Labels, LogRowModel } from '@grafana/data'; |
||||
|
||||
interface Props { |
||||
getRows?: () => LogRowModel[]; |
||||
labels: Labels; |
||||
plain?: boolean; |
||||
onClickLabel?: (label: string, value: string) => void; |
||||
} |
||||
|
||||
export class LogLabels extends PureComponent<Props> { |
||||
render() { |
||||
const { getRows, labels, onClickLabel, plain } = this.props; |
||||
return ( |
||||
<span className="logs-labels"> |
||||
{Object.keys(labels).map(key => ( |
||||
<LogLabel |
||||
key={key} |
||||
getRows={getRows} |
||||
label={key} |
||||
value={labels[key]} |
||||
plain={plain} |
||||
onClickLabel={onClickLabel} |
||||
/> |
||||
))} |
||||
</span> |
||||
); |
||||
} |
||||
} |
@ -1,43 +0,0 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
interface Props { |
||||
isOpen: boolean; |
||||
label: string; |
||||
loading?: boolean; |
||||
collapsible?: boolean; |
||||
onToggle?: (isOpen: boolean) => void; |
||||
} |
||||
|
||||
export default class Panel extends PureComponent<Props> { |
||||
onClickToggle = () => { |
||||
const { onToggle, isOpen } = this.props; |
||||
if (onToggle) { |
||||
onToggle(!isOpen); |
||||
} |
||||
}; |
||||
|
||||
render() { |
||||
const { isOpen, loading, collapsible } = this.props; |
||||
const panelClass = collapsible |
||||
? 'explore-panel explore-panel--collapsible panel-container' |
||||
: 'explore-panel panel-container'; |
||||
const iconClass = isOpen ? 'fa fa-caret-up' : 'fa fa-caret-down'; |
||||
const loaderClass = loading ? 'explore-panel__loader explore-panel__loader--active' : 'explore-panel__loader'; |
||||
return ( |
||||
<div className={panelClass}> |
||||
<div className="explore-panel__header" onClick={this.onClickToggle}> |
||||
<div className="explore-panel__header-buttons"> |
||||
<span className={iconClass} /> |
||||
</div> |
||||
<div className="explore-panel__header-label">{this.props.label}</div> |
||||
</div> |
||||
{isOpen && ( |
||||
<div className="explore-panel__body"> |
||||
<div className={loaderClass} /> |
||||
{this.props.children} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,39 @@ |
||||
import React from 'react'; |
||||
import { PanelProps, LogRows, CustomScrollbar } from '@grafana/ui'; |
||||
import { Options } from './types'; |
||||
import { LogsDedupStrategy } from '@grafana/data'; |
||||
import { dataFrameToLogsModel } from 'app/core/logs_model'; |
||||
import { sortLogsResult } from 'app/core/utils/explore'; |
||||
|
||||
interface LogsPanelProps extends PanelProps<Options> {} |
||||
|
||||
export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({ |
||||
data, |
||||
timeZone, |
||||
options: { showTime, sortOrder }, |
||||
width, |
||||
}) => { |
||||
if (!data) { |
||||
return ( |
||||
<div className="panel-empty"> |
||||
<p>No data found in response</p> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const newResults = data ? dataFrameToLogsModel(data.series, data.request.intervalMs) : null; |
||||
const sortedNewResults = sortLogsResult(newResults, sortOrder); |
||||
|
||||
return ( |
||||
<CustomScrollbar autoHide> |
||||
<LogRows |
||||
data={sortedNewResults} |
||||
dedupStrategy={LogsDedupStrategy.none} |
||||
highlighterExpressions={[]} |
||||
showTime={showTime} |
||||
showLabels={false} |
||||
timeZone={timeZone} |
||||
/> |
||||
</CustomScrollbar> |
||||
); |
||||
}; |
@ -0,0 +1,46 @@ |
||||
// Libraries
|
||||
import React, { PureComponent } from 'react'; |
||||
import { PanelEditorProps, Switch, PanelOptionsGrid, PanelOptionsGroup, FormLabel, Select } from '@grafana/ui'; |
||||
|
||||
// Types
|
||||
import { Options } from './types'; |
||||
import { SortOrder } from 'app/core/utils/explore'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
|
||||
const sortOrderOptions = [ |
||||
{ value: SortOrder.Descending, label: 'Descending' }, |
||||
{ value: SortOrder.Ascending, label: 'Ascending' }, |
||||
]; |
||||
|
||||
export class LogsPanelEditor extends PureComponent<PanelEditorProps<Options>> { |
||||
onToggleTime = () => { |
||||
const { options, onOptionsChange } = this.props; |
||||
const { showTime } = options; |
||||
|
||||
onOptionsChange({ ...options, showTime: !showTime }); |
||||
}; |
||||
|
||||
onShowValuesChange = (item: SelectableValue<SortOrder>) => { |
||||
const { options, onOptionsChange } = this.props; |
||||
onOptionsChange({ ...options, sortOrder: item.value }); |
||||
}; |
||||
|
||||
render() { |
||||
const { showTime, sortOrder } = this.props.options; |
||||
const value = sortOrderOptions.filter(option => option.value === sortOrder)[0]; |
||||
|
||||
return ( |
||||
<> |
||||
<PanelOptionsGrid> |
||||
<PanelOptionsGroup title="Columns"> |
||||
<Switch label="Time" labelClass="width-10" checked={showTime} onChange={this.onToggleTime} /> |
||||
<div className="gf-form"> |
||||
<FormLabel>Order</FormLabel> |
||||
<Select options={sortOrderOptions} value={value} onChange={this.onShowValuesChange} /> |
||||
</div> |
||||
</PanelOptionsGroup> |
||||
</PanelOptionsGrid> |
||||
</> |
||||
); |
||||
} |
||||
} |
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,6 @@ |
||||
import { PanelPlugin } from '@grafana/ui'; |
||||
import { Options, defaults } from './types'; |
||||
import { LogsPanel } from './LogsPanel'; |
||||
import { LogsPanelEditor } from './LogsPanelEditor'; |
||||
|
||||
export const plugin = new PanelPlugin<Options>(LogsPanel).setDefaults(defaults).setEditor(LogsPanelEditor); |
@ -0,0 +1,17 @@ |
||||
{ |
||||
"type": "panel", |
||||
"name": "Logs", |
||||
"id": "logs", |
||||
"state": "alpha", |
||||
|
||||
"info": { |
||||
"author": { |
||||
"name": "Grafana Project", |
||||
"url": "https://grafana.com" |
||||
}, |
||||
"logos": { |
||||
"small": "img/icn-logs-panel.svg", |
||||
"large": "img/icn-logs-panel.svg" |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,11 @@ |
||||
import { SortOrder } from 'app/core/utils/explore'; |
||||
|
||||
export interface Options { |
||||
showTime: boolean; |
||||
sortOrder: SortOrder; |
||||
} |
||||
|
||||
export const defaults: Options = { |
||||
showTime: true, |
||||
sortOrder: SortOrder.Descending, |
||||
}; |
Loading…
Reference in new issue