import { css, cx } from '@emotion/css'; import { capitalize } from 'lodash'; import memoizeOne from 'memoize-one'; import React, { createRef, PureComponent } from 'react'; import { AbsoluteTimeRange, CoreApp, DataFrame, DataHoverClearEvent, DataHoverEvent, DataQueryResponse, EventBus, ExploreLogsPanelState, ExplorePanelsState, FeatureState, Field, GrafanaTheme2, LinkModel, LoadingState, LogLevel, LogRowContextOptions, LogRowModel, LogsDedupDescription, LogsDedupStrategy, LogsMetaItem, LogsSortOrder, rangeUtil, RawTimeRange, serializeStateToUrlParam, SplitOpen, TimeRange, TimeZone, urlUtil, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { Button, FeatureBadge, InlineField, InlineFieldRow, InlineSwitch, PanelChrome, RadioButtonGroup, Themeable2, withTheme2, } from '@grafana/ui'; import store from 'app/core/store'; import { createAndCopyShortLink } from 'app/core/utils/shortLinks'; import { dispatch, getState } from 'app/store/store'; import { ExploreItemState } from '../../../types'; import { LogRows } from '../../logs/components/LogRows'; import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal'; import { dedupLogRows, filterLogLevels } from '../../logs/logsModel'; import { getUrlStateFromPaneState } from '../hooks/useStateSync'; import { changePanelState } from '../state/explorePane'; import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { getLogsTableHeight, LogsTableWrap } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; 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: string; datasourceType?: string; logsVolumeEnabled: boolean; logsVolumeData: DataQueryResponse | undefined; onSetLogsVolumeEnabled: (enabled: boolean) => void; loadLogsVolumeData: () => void; showContextToggle?: (row: LogRowModel) => boolean; onChangeTime: (range: AbsoluteTimeRange) => void; onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; onStartScanning?: () => void; onStopScanning?: () => void; getRowContext?: (row: LogRowModel, origRow: LogRowModel, options: LogRowContextOptions) => Promise; getRowContextQuery?: (row: LogRowModel, options?: LogRowContextOptions) => Promise; getLogRowContextUi?: (row: LogRowModel, runContextQuery?: () => void) => React.ReactNode; getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; addResultsToCache: () => void; clearCache: () => void; eventBus: EventBus; panelState?: ExplorePanelsState; scrollElement?: HTMLDivElement; isFilterLabelActive?: (key: string, value: string, refId?: string) => Promise; logsFrames?: DataFrame[]; range: TimeRange; onClickFilterValue?: (value: string, refId?: string) => void; onClickFilterOutValue?: (value: string, refId?: string) => void; } export type LogsVisualisationType = 'table' | 'logs'; interface State { showLabels: boolean; showTime: boolean; wrapLogMessage: boolean; prettifyLogMessage: boolean; dedupStrategy: LogsDedupStrategy; hiddenLogLevels: LogLevel[]; logsSortOrder: LogsSortOrder; isFlipping: boolean; displayedFields: string[]; forceEscape: boolean; contextOpen: boolean; contextRow?: LogRowModel; tableFrame?: DataFrame; visualisationType?: LogsVisualisationType; logsContainer?: HTMLDivElement; } const scrollableLogsContainer = config.featureToggles.exploreScrollableLogsContainer; // we need to define the order of these explicitly const DEDUP_OPTIONS = [ LogsDedupStrategy.none, LogsDedupStrategy.exact, LogsDedupStrategy.numbers, LogsDedupStrategy.signature, ]; 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, displayedFields: [], forceEscape: false, contextOpen: false, contextRow: undefined, tableFrame: undefined, visualisationType: this.props.panelState?.logs?.visualisationType ?? 'logs', logsContainer: undefined, }; 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); } } updatePanelState = (logsPanelState: Partial) => { const state: ExploreItemState | undefined = getState().explore.panes[this.props.exploreId]; if (state?.panelsState) { dispatch( changePanelState(this.props.exploreId, 'logs', { ...state.panelsState.logs, columns: logsPanelState.columns ?? this.props.panelState?.logs?.columns, visualisationType: logsPanelState.visualisationType ?? this.state.visualisationType, refId: logsPanelState.refId ?? this.props.panelState?.logs?.refId, }) ); } }; componentDidUpdate(prevProps: Readonly): void { if (this.props.loading && !prevProps.loading && this.props.panelState?.logs?.id) { // loading stopped, so we need to remove any permalinked log lines delete this.props.panelState.logs.id; dispatch( changePanelState(this.props.exploreId, 'logs', { ...this.props.panelState, }) ); } if (this.props.panelState?.logs?.visualisationType !== prevProps.panelState?.logs?.visualisationType) { this.setState({ visualisationType: this.props.panelState?.logs?.visualisationType ?? 'logs', }); } } onLogRowHover = (row?: LogRowModel) => { if (!row) { this.props.eventBus.publish(new DataHoverClearEvent()); } else { this.props.eventBus.publish( new DataHoverEvent({ point: { time: row.timeEpochMs, }, }) ); } }; onLogsContainerRef = (node: HTMLDivElement) => { this.setState({ logsContainer: node }); }; 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, })); }; onChangeVisualisation = (visualisation: LogsVisualisationType) => { this.setState(() => ({ visualisationType: visualisation, })); const payload = { ...this.props.panelState?.logs, visualisationType: visualisation, }; this.updatePanelState(payload); reportInteraction('grafana_explore_logs_visualisation_changed', { newVisualizationType: visualisation, }); }; 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 = (collapsed: boolean) => { this.props.onSetLogsVolumeEnabled(!collapsed); reportInteraction('grafana_explore_logs_histogram_toggle_clicked', { datasourceType: this.props.datasourceType, type: !collapsed ? 'open' : 'close', }); }; onClickScan = (event: React.SyntheticEvent) => { event.preventDefault(); if (this.props.onStartScanning) { this.props.onStartScanning(); reportInteraction('grafana_explore_logs_scanning_button_clicked', { type: 'start', datasourceType: this.props.datasourceType, }); } }; onClickStopScan = (event: React.SyntheticEvent) => { event.preventDefault(); if (this.props.onStopScanning) { this.props.onStopScanning(); } }; showField = (key: string) => { const index = this.state.displayedFields.indexOf(key); if (index === -1) { this.setState((state) => { return { displayedFields: state.displayedFields.concat(key), }; }); } }; hideField = (key: string) => { const index = this.state.displayedFields.indexOf(key); if (index > -1) { this.setState((state) => { return { displayedFields: state.displayedFields.filter((k) => key !== k), }; }); } }; clearDetectedFields = () => { this.setState((state) => { return { displayedFields: [], }; }); }; onCloseContext = () => { this.setState({ contextOpen: false, contextRow: undefined, }); }; onOpenContext = (row: LogRowModel, onClose: () => void) => { // we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open this.setState({ contextOpen: true, contextRow: row, }); reportInteraction('grafana_explore_logs_log_context_opened', { datasourceType: row.datasourceType, logRowUid: row.uid, }); this.onCloseContext = () => { this.setState({ contextOpen: false, contextRow: undefined, }); reportInteraction('grafana_explore_logs_log_context_closed', { datasourceType: row.datasourceType, logRowUid: row.uid, }); onClose(); }; }; onPermalinkClick = async (row: LogRowModel) => { // this is an extra check, to be sure that we are not // creating permalinks for logs without an id-field. // normally it should never happen, because we do not // display the permalink button in such cases. if (row.rowId === undefined) { return; } // get explore state, add log-row-id and make timerange absolute const urlState = getUrlStateFromPaneState(getState().explore.panes[this.props.exploreId]!); urlState.panelsState = { ...this.props.panelState, logs: { id: row.uid, visualisationType: this.state.visualisationType ?? 'logs' }, }; urlState.range = { from: new Date(this.props.absoluteRange.from).toISOString(), to: new Date(this.props.absoluteRange.to).toISOString(), }; // append changed urlState to baseUrl const serializedState = serializeStateToUrlParam(urlState); const baseUrl = /.*(?=\/explore)/.exec(`${window.location.href}`)![0]; const url = urlUtil.renderUrl(`${baseUrl}/explore`, { left: serializedState }); await createAndCopyShortLink(url); reportInteraction('grafana_explore_logs_permalink_clicked', { datasourceType: row.datasourceType ?? 'unknown', logRowUid: row.uid, logRowLevel: row.logLevel, }); }; scrollIntoView = (element: HTMLElement) => { if (config.featureToggles.exploreScrollableLogsContainer) { if (this.state.logsContainer) { this.topLogsRef.current?.scrollIntoView(); this.state.logsContainer.scroll({ behavior: 'smooth', top: this.state.logsContainer.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, }); } return; } const { scrollElement } = this.props; if (scrollElement) { scrollElement.scroll({ behavior: 'smooth', top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, }); } }; 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 = () => { if (config.featureToggles.exploreScrollableLogsContainer) { if (this.state.logsContainer) { this.state.logsContainer.scroll({ behavior: 'auto', top: 0, }); } } else { this.topLogsRef.current?.scrollIntoView(); } }; render() { const { width, splitOpen, logRows, logsMeta, logsVolumeEnabled, logsVolumeData, loadLogsVolumeData, loading = false, onClickFilterLabel, onClickFilterOutLabel, timeZone, scanning, scanRange, showContextToggle, absoluteRange, onChangeTime, getFieldLinks, theme, logsQueries, clearCache, addResultsToCache, exploreId, getRowContext, getLogRowContextUi, getRowContextQuery, } = this.props; const { showLabels, showTime, wrapLogMessage, prettifyLogMessage, dedupStrategy, hiddenLogLevels, logsSortOrder, isFlipping, displayedFields, forceEscape, contextOpen, contextRow, } = this.state; const tableHeight = getLogsTableHeight(); const styles = getStyles(theme, wrapLogMessage, tableHeight); 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 ( <> {getRowContext && contextRow && ( getRowContext(row, contextRow, options)} getRowContextQuery={getRowContextQuery} getLogRowContextUi={getLogRowContextUi} logsSortOrder={logsSortOrder} timeZone={timeZone} /> )} {logsVolumeEnabled && ( this.onToggleLogsVolumeCollapse(true)} /> )} ) ) : null, ]} title={ config.featureToggles.logsExploreTableVisualisation ? this.state.visualisationType === 'logs' ? 'Logs' : 'Table' : 'Logs' } actions={ <> {config.featureToggles.logsExploreTableVisualisation && (
)} } loadingState={loading ? LoadingState.Loading : LoadingState.Done} >
{this.state.visualisationType !== 'table' && (
({ label: capitalize(dedupType), value: dedupType, description: LogsDedupDescription[dedupType], }))} value={dedupStrategy} onChange={this.onChangeDedup} className={styles.radioButtons} />
)}
{this.state.visualisationType === 'table' && hasData && (
{/* Width should be full width minus logs navigation and padding */}
)} {this.state.visualisationType === 'logs' && hasData && (
)} {!loading && !hasData && !scanning && (
No logs found.
)} {scanning && (
{scanText}
)}
); } } export const Logs = withTheme2(UnthemedLogs); const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: number) => { 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.radius.default}; 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; `, logsTable: css({ maxHeight: `${tableHeight}px`, }), logRows: css` overflow-x: ${scrollableLogsContainer ? 'scroll;' : `${wrapLogMessage ? 'unset' : 'scroll'};`} overflow-y: visible; width: 100%; ${scrollableLogsContainer && 'max-height: calc(100vh - 170px);'} `, visualisationType: css` display: flex; flex: 1; justify-content: space-between; `, visualisationTypeRadio: css` margin: 0 0 0 ${theme.spacing(1)}; `, stickyNavigation: css` ${scrollableLogsContainer && 'margin-bottom: 0px'} overflow: visible; `, }; };