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 React, { PureComponent } from 'react'; |
||||||
import ansicolor from 'vendor/ansicolor/ansicolor'; |
import ansicolor from '../../utils/ansicolor'; |
||||||
|
|
||||||
interface Style { |
interface Style { |
||||||
[key: string]: string; |
[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