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, } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { TooltipDisplayMode } from '@grafana/schema'; import { RadioButtonGroup, LogRows, Button, InlineField, InlineFieldRow, InlineSwitch, withTheme2, Themeable2, } from '@grafana/ui'; import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; import { dedupLogRows, filterLogLevels } from 'app/core/logs_model'; import store from 'app/core/store'; import { ExploreId } from 'app/types/explore'; import { ExploreGraph } from './ExploreGraph'; import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; const SETTINGS_KEYS = { showLabels: 'grafana.explore.logs.showLabels', showTime: 'grafana.explore.logs.showTime', wrapLogMessage: 'grafana.explore.logs.wrapLogMessage', prettifyLogMessage: 'grafana.explore.logs.prettifyLogMessage', logsSortOrder: 'grafana.explore.logs.sortOrder', }; interface Props extends Themeable2 { width: number; 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; 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) => Array>; addResultsToCache: () => void; clearCache: () => void; } interface State { showLabels: boolean; showTime: boolean; wrapLogMessage: boolean; prettifyLogMessage: boolean; dedupStrategy: LogsDedupStrategy; hiddenLogLevels: LogLevel[]; logsSortOrder: LogsSortOrder | null; isFlipping: boolean; showDetectedFields: string[]; forceEscape: boolean; } class UnthemedLogs extends PureComponent { flipOrderTimer?: number; cancelFlippingTimer?: number; topLogsRef = createRef(); 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, }; componentWillUnmount() { if (this.flipOrderTimer) { window.clearTimeout(this.flipOrderTimer); } if (this.cancelFlippingTimer) { window.clearTimeout(this.cancelFlippingTimer); } } 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 }); }; 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, logRows, logsMeta, logsSeries, visibleRange, loading = false, loadingState, onClickFilterLabel, onClickFilterOutLabel, timeZone, scanning, scanRange, showContextToggle, absoluteRange, onChangeTime, getFieldLinks, theme, logsQueries, clearCache, addResultsToCache, exploreId, } = 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 ( <> {logsSeries && logsSeries.length ? ( <>
This datasource does not support full-range histograms. The graph is based on the logs seen in the response.
) : undefined}
({ 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(2, 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%; `, infoText: css` font-size: ${theme.typography.size.sm}; color: ${theme.colors.text.secondary}; `, }; };