import { css } from '@emotion/css'; import { capitalize } from 'lodash'; import memoizeOne from 'memoize-one'; import React, { PureComponent, createRef } from 'react'; import { rangeUtil, RawTimeRange, LogLevel, TimeZone, AbsoluteTimeRange, LogsDedupStrategy, LogRowModel, LogsDedupDescription, LogsMetaItem, LogsSortOrder, LinkModel, Field, DataQuery, DataFrame, GrafanaTheme2, LoadingState, SplitOpen, DataQueryResponse, CoreApp, DataHoverEvent, DataHoverClearEvent, EventBus, } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { RadioButtonGroup, Button, InlineField, InlineFieldRow, InlineSwitch, withTheme2, Themeable2, Collapse, } from '@grafana/ui'; import { dedupLogRows, filterLogLevels } from 'app/core/logsModel'; import store from 'app/core/store'; import { ExploreId } from 'app/types/explore'; import { RowContextOptions } from '../logs/components/LogRowContextProvider'; import { LogRows } from '../logs/components/LogRows'; import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { LogsVolumePanel } from './LogsVolumePanel'; import { SETTINGS_KEYS } from './utils/logs'; interface Props extends Themeable2 { width: number; splitOpen: SplitOpen; logRows: LogRowModel[]; logsMeta?: LogsMetaItem[]; logsSeries?: DataFrame[]; logsQueries?: DataQuery[]; visibleRange?: AbsoluteTimeRange; theme: GrafanaTheme2; loading: boolean; loadingState: LoadingState; absoluteRange: AbsoluteTimeRange; timeZone: TimeZone; scanning?: boolean; scanRange?: RawTimeRange; exploreId: ExploreId; datasourceType?: string; logsVolumeEnabled: boolean; logsVolumeData: DataQueryResponse | undefined; scrollElement?: HTMLDivElement; onSetLogsVolumeEnabled: (enabled: boolean) => void; loadLogsVolumeData: (exploreId: ExploreId) => void; showContextToggle?: (row?: LogRowModel) => boolean; onChangeTime: (range: AbsoluteTimeRange) => void; onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; onStartScanning?: () => void; onStopScanning?: () => void; getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise; getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; addResultsToCache: () => void; clearCache: () => void; eventBus: EventBus; } interface State { showLabels: boolean; showTime: boolean; wrapLogMessage: boolean; prettifyLogMessage: boolean; dedupStrategy: LogsDedupStrategy; hiddenLogLevels: LogLevel[]; logsSortOrder: LogsSortOrder | null; isFlipping: boolean; showDetectedFields: string[]; forceEscape: boolean; } // We need to override css overflow of divs in Collapse element to enable sticky Logs navigation const styleOverridesForStickyNavigation = css` & > div { overflow: visible; & > div { overflow: visible; } } `; class UnthemedLogs extends PureComponent { flipOrderTimer?: number; cancelFlippingTimer?: number; topLogsRef = createRef(); logsVolumeEventBus: EventBus; state: State = { showLabels: store.getBool(SETTINGS_KEYS.showLabels, false), showTime: store.getBool(SETTINGS_KEYS.showTime, true), wrapLogMessage: store.getBool(SETTINGS_KEYS.wrapLogMessage, true), prettifyLogMessage: store.getBool(SETTINGS_KEYS.prettifyLogMessage, false), dedupStrategy: LogsDedupStrategy.none, hiddenLogLevels: [], logsSortOrder: store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending, isFlipping: false, showDetectedFields: [], forceEscape: false, }; constructor(props: Props) { super(props); this.logsVolumeEventBus = props.eventBus.newScopedBus('logsvolume', { onlyLocal: false }); } componentWillUnmount() { if (this.flipOrderTimer) { window.clearTimeout(this.flipOrderTimer); } if (this.cancelFlippingTimer) { window.clearTimeout(this.cancelFlippingTimer); } } onLogRowHover = (row?: LogRowModel) => { if (!row) { this.props.eventBus.publish(new DataHoverClearEvent()); } else { this.props.eventBus.publish( new DataHoverEvent({ point: { time: row.timeEpochMs, }, }) ); } }; onChangeLogsSortOrder = () => { this.setState({ isFlipping: true }); // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs this.flipOrderTimer = window.setTimeout(() => { this.setState((prevState) => { const newSortOrder = prevState.logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); return { logsSortOrder: newSortOrder }; }); }, 0); this.cancelFlippingTimer = window.setTimeout(() => this.setState({ isFlipping: false }), 1000); }; onEscapeNewlines = () => { this.setState((prevState) => ({ forceEscape: !prevState.forceEscape, })); }; onChangeDedup = (dedupStrategy: LogsDedupStrategy) => { reportInteraction('grafana_explore_logs_deduplication_clicked', { deduplicationType: dedupStrategy, datasourceType: this.props.datasourceType, }); this.setState({ dedupStrategy }); }; onChangeLabels = (event: React.ChangeEvent) => { const { target } = event; if (target) { const showLabels = target.checked; this.setState({ showLabels, }); store.set(SETTINGS_KEYS.showLabels, showLabels); } }; onChangeTime = (event: React.ChangeEvent) => { const { target } = event; if (target) { const showTime = target.checked; this.setState({ showTime, }); store.set(SETTINGS_KEYS.showTime, showTime); } }; onChangeWrapLogMessage = (event: React.ChangeEvent) => { const { target } = event; if (target) { const wrapLogMessage = target.checked; this.setState({ wrapLogMessage, }); store.set(SETTINGS_KEYS.wrapLogMessage, wrapLogMessage); } }; onChangePrettifyLogMessage = (event: React.ChangeEvent) => { const { target } = event; if (target) { const prettifyLogMessage = target.checked; this.setState({ prettifyLogMessage, }); store.set(SETTINGS_KEYS.prettifyLogMessage, prettifyLogMessage); } }; onToggleLogLevel = (hiddenRawLevels: string[]) => { const hiddenLogLevels = hiddenRawLevels.map((level) => LogLevel[level as LogLevel]); this.setState({ hiddenLogLevels }); }; onToggleLogsVolumeCollapse = (isOpen: boolean) => { this.props.onSetLogsVolumeEnabled(isOpen); reportInteraction('grafana_explore_logs_histogram_toggle_clicked', { datasourceType: this.props.datasourceType, type: isOpen ? 'open' : 'close', }); }; onClickScan = (event: React.SyntheticEvent) => { event.preventDefault(); if (this.props.onStartScanning) { this.props.onStartScanning(); } }; onClickStopScan = (event: React.SyntheticEvent) => { event.preventDefault(); if (this.props.onStopScanning) { this.props.onStopScanning(); } }; showDetectedField = (key: string) => { const index = this.state.showDetectedFields.indexOf(key); if (index === -1) { this.setState((state) => { return { showDetectedFields: state.showDetectedFields.concat(key), }; }); } }; hideDetectedField = (key: string) => { const index = this.state.showDetectedFields.indexOf(key); if (index > -1) { this.setState((state) => { return { showDetectedFields: state.showDetectedFields.filter((k) => key !== k), }; }); } }; clearDetectedFields = () => { this.setState((state) => { return { showDetectedFields: [], }; }); }; checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => { return !!logRows.some((r) => r.hasUnescapedContent); }); dedupRows = memoizeOne((logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => { const dedupedRows = dedupLogRows(logRows, dedupStrategy); const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0); return { dedupedRows, dedupCount }; }); filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => { return filterLogLevels(logRows, new Set(hiddenLogLevels)); }); createNavigationRange = memoizeOne((logRows: LogRowModel[]): { from: number; to: number } | undefined => { if (!logRows || logRows.length === 0) { return undefined; } const firstTimeStamp = logRows[0].timeEpochMs; const lastTimeStamp = logRows[logRows.length - 1].timeEpochMs; if (lastTimeStamp < firstTimeStamp) { return { from: lastTimeStamp, to: firstTimeStamp }; } return { from: firstTimeStamp, to: lastTimeStamp }; }); scrollToTopLogs = () => this.topLogsRef.current?.scrollIntoView(); render() { const { width, splitOpen, logRows, logsMeta, logsSeries, visibleRange, logsVolumeEnabled, logsVolumeData, loadLogsVolumeData, loading = false, loadingState, onClickFilterLabel, onClickFilterOutLabel, timeZone, scanning, scanRange, showContextToggle, absoluteRange, onChangeTime, getFieldLinks, theme, logsQueries, clearCache, addResultsToCache, exploreId, scrollElement, } = this.props; const { showLabels, showTime, wrapLogMessage, prettifyLogMessage, dedupStrategy, hiddenLogLevels, logsSortOrder, isFlipping, showDetectedFields, forceEscape, } = this.state; const styles = getStyles(theme, wrapLogMessage); const hasData = logRows && logRows.length > 0; const hasUnescapedContent = this.checkUnescapedContent(logRows); const filteredLogs = this.filterRows(logRows, hiddenLogLevels); const { dedupedRows, dedupCount } = this.dedupRows(filteredLogs, dedupStrategy); const navigationRange = this.createNavigationRange(logRows); const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; return ( <> {logsVolumeEnabled && ( loadLogsVolumeData(exploreId)} onHiddenSeriesChanged={this.onToggleLogLevel} eventBus={this.logsVolumeEventBus} /> )}
({ label: capitalize(dedupType), value: dedupType, description: LogsDedupDescription[dedupType], }))} value={dedupStrategy} onChange={this.onChangeDedup} className={styles.radioButtons} />
{!loading && !hasData && !scanning && (
No logs found.
)} {scanning && (
{scanText}
)}
); } } export const Logs = withTheme2(UnthemedLogs); const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean) => { return { noData: css` > * { margin-left: 0.5em; } `, logOptions: css` display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; background-color: ${theme.colors.background.primary}; padding: ${theme.spacing(1, 2)}; border-radius: ${theme.shape.borderRadius()}; margin: ${theme.spacing(0, 0, 1)}; border: 1px solid ${theme.colors.border.medium}; `, headerButton: css` margin: ${theme.spacing(0.5, 0, 0, 1)}; `, horizontalInlineLabel: css` > label { margin-right: 0; } `, horizontalInlineSwitch: css` padding: 0 ${theme.spacing(1)} 0 0; `, radioButtons: css` margin: 0; `, logsSection: css` display: flex; flex-direction: row; justify-content: space-between; `, logRows: css` overflow-x: ${wrapLogMessage ? 'unset' : 'scroll'}; overflow-y: visible; width: 100%; `, }; };