import { css, cx } from '@emotion/css'; import { capitalize, groupBy } from 'lodash'; import { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import * as React from 'react'; import { usePrevious, useUnmount } from 'react-use'; import { SplitOpen, LogRowModel, LogsMetaItem, DataFrame, AbsoluteTimeRange, GrafanaTheme2, LoadingState, TimeZone, RawTimeRange, DataQueryResponse, LogRowContextOptions, LinkModel, EventBus, ExplorePanelsState, Field, TimeRange, LogsDedupStrategy, LogsSortOrder, LogLevel, DataTopic, CoreApp, LogsDedupDescription, rangeUtil, ExploreLogsPanelState, DataHoverClearEvent, DataHoverEvent, serializeStateToUrlParam, urlUtil, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { Button, InlineField, InlineFieldRow, InlineSwitch, PanelChrome, PopoverContent, RadioButtonGroup, SeriesVisibilityChangeMode, Themeable2, withTheme2, } from '@grafana/ui'; import { mapMouseEventToMode } from '@grafana/ui/internal'; import { Trans, t } from 'app/core/internationalization'; import store from 'app/core/store'; import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks'; import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll'; import { LogRows } from 'app/features/logs/components/LogRows'; import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal'; import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList'; import { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel'; import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; import { LokiQueryDirection } from 'app/plugins/datasource/loki/dataquery.gen'; import { isLokiQuery } from 'app/plugins/datasource/loki/queryUtils'; import { getState } from 'app/store/store'; import { ExploreItemState, useDispatch } from 'app/types'; import { contentOutlineTrackLevelFilter, contentOutlineTrackPinAdded, contentOutlineTrackPinClicked, contentOutlineTrackPinLimitReached, contentOutlineTrackPinRemoved, contentOutlineTrackUnpinClicked, } from '../ContentOutline/ContentOutlineAnalyticEvents'; import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; import { getUrlStateFromPaneState } from '../hooks/useStateSync'; import { changePanelState } from '../state/explorePane'; import { changeQueries, runQueries } from '../state/query'; import { LogsFeedback } from './LogsFeedback'; import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } 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, cacheFilters?: boolean ) => 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; onClickFilterString?: (value: string, refId?: string) => void; onClickFilterOutString?: (value: string, refId?: string) => void; loadMoreLogs?(range: AbsoluteTimeRange): void; onPinLineCallback?: () => void; } export type LogsVisualisationType = 'table' | 'logs'; // we need to define the order of these explicitly const DEDUP_OPTIONS = [ LogsDedupStrategy.none, LogsDedupStrategy.exact, LogsDedupStrategy.numbers, LogsDedupStrategy.signature, ]; const getDefaultVisualisationType = (): LogsVisualisationType => { const visualisationType = store.get(visualisationTypeKey); if (visualisationType === 'table') { return 'table'; } if (visualisationType === 'logs') { return 'logs'; } if (config.featureToggles.logsExploreTableDefaultVisualization) { return 'table'; } return 'logs'; }; const PINNED_LOGS_LIMIT = 10; const PINNED_LOGS_TITLE = 'Pinned log'; const PINNED_LOGS_MESSAGE = 'Pin to content outline'; const PINNED_LOGS_PANELID = 'Logs'; const UnthemedLogs: React.FunctionComponent = (props: Props) => { 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, loadMoreLogs, panelState, eventBus, onPinLineCallback, scrollElement, } = props; const [showLabels, setShowLabels] = useState(store.getBool(SETTINGS_KEYS.showLabels, false)); const [showTime, setShowTime] = useState(store.getBool(SETTINGS_KEYS.showTime, true)); const [wrapLogMessage, setWrapLogMessage] = useState(store.getBool(SETTINGS_KEYS.wrapLogMessage, true)); const [prettifyLogMessage, setPrettifyLogMessage] = useState( store.getBool(SETTINGS_KEYS.prettifyLogMessage, false) ); const [dedupStrategy, setDedupStrategy] = useState(LogsDedupStrategy.none); const [hiddenLogLevels, setHiddenLogLevels] = useState([]); const [logsSortOrder, setLogsSortOrder] = useState( store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending ); const [isFlipping, setIsFlipping] = useState(false); const [displayedFields, setDisplayedFields] = useState([]); const [forceEscape, setForceEscape] = useState(false); const [contextOpen, setContextOpen] = useState(false); const [contextRow, setContextRow] = useState(undefined); const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState(PINNED_LOGS_MESSAGE); const [visualisationType, setVisualisationType] = useState( panelState?.logs?.visualisationType ?? getDefaultVisualisationType() ); const logsContainerRef = useRef(null); const dispatch = useDispatch(); const previousLoading = usePrevious(loading); const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false }); const { register, unregister, outlineItems, updateItem, unregisterAllChildren } = useContentOutlineContext() ?? {}; const flipOrderTimer = useRef(undefined); const cancelFlippingTimer = useRef(undefined); const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {}); const topLogsRef = useRef(null); const logLevelsRef = useRef(null); const tableHeight = getLogsTableHeight(); const styles = getStyles(theme, wrapLogMessage, tableHeight); const hasData = logRows && logRows.length > 0; const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; // Get pinned log lines const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); const pinnedLogs = useMemo( () => logsParent?.children ?.filter((outlines) => outlines.title === PINNED_LOGS_TITLE) .map((pinnedLogs) => pinnedLogs.id), [logsParent?.children] ); const getPinnedLogsCount = useCallback(() => { const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); return logsParent?.children?.filter((child) => child.title === PINNED_LOGS_TITLE).length ?? 0; }, [outlineItems]); const registerLogLevelsWithContentOutline = useCallback(() => { const levelsArr = Object.keys(LogLevelColor); const logVolumeDataFrames = new Set(logsVolumeData?.data); // TODO remove this once filtering multiple log volumes is supported const logVolData = logsVolumeData?.data.filter( (frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations ); const grouped = groupBy(logVolData, 'meta.custom.datasourceName'); const numberOfLogVolumes = Object.keys(grouped).length; // clean up all current log levels if (unregisterAllChildren) { unregisterAllChildren((items) => { const logsParent = items?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); return logsParent?.id; }, 'filter'); } // check if we have dataFrames that return the same level const logLevelsArray: LogLevel[] = []; logVolumeDataFrames.forEach((dataFrame) => { const { level } = getLogLevelInfo(dataFrame, logsVolumeData?.data ?? []); logLevelsArray.push(getLogLevelFromKey(level)); }); const sortedLLArray = logLevelsArray.sort((a: string, b: string) => levelsArr.indexOf(a) > levelsArr.indexOf(b) ? 1 : -1 ); const logLevels = new Set(sortedLLArray); logLevelsRef.current = Array.from(logLevels); if (logLevels.size > 1 && logsVolumeEnabled && numberOfLogVolumes === 1) { logLevels.forEach((level) => { const allLevelsSelected = hiddenLogLevels.length === 0; const currentLevelSelected = !hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level); if (register) { register({ title: level, icon: 'gf-logs', panelId: PINNED_LOGS_PANELID, level: 'child', type: 'filter', highlight: currentLevelSelected && !allLevelsSelected, onClick: (e: React.MouseEvent) => { toggleLegendRef.current?.(level, mapMouseEventToMode(e)); contentOutlineTrackLevelFilter(level); }, ref: null, color: LogLevelColor[level], }); } }); } }, [logsVolumeData?.data, unregisterAllChildren, logsVolumeEnabled, hiddenLogLevels, register, toggleLegendRef]); useEffect(() => { if (getPinnedLogsCount() === PINNED_LOGS_LIMIT) { setPinLineButtonTooltipTitle( ❗️ Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another. ); } else { setPinLineButtonTooltipTitle(PINNED_LOGS_MESSAGE); } }, [outlineItems, getPinnedLogsCount]); useEffect(() => { if (loading && !previousLoading && panelState?.logs?.id) { // loading stopped, so we need to remove any permalinked log lines delete panelState.logs.id; dispatch( changePanelState(exploreId, 'logs', { ...panelState, }) ); } }, [dispatch, exploreId, loading, panelState, previousLoading]); useEffect(() => { const visualisationType = panelState?.logs?.visualisationType ?? getDefaultVisualisationType(); setVisualisationType(visualisationType); store.set(visualisationTypeKey, visualisationType); }, [panelState?.logs?.visualisationType]); useEffect(() => { let displayedFields: string[] = []; if (Array.isArray(panelState?.logs?.displayedFields)) { displayedFields = panelState?.logs?.displayedFields; } else if (panelState?.logs?.displayedFields && typeof panelState?.logs?.displayedFields === 'object') { displayedFields = Object.values(panelState?.logs?.displayedFields); } setDisplayedFields(displayedFields); }, [panelState?.logs?.displayedFields]); useEffect(() => { registerLogLevelsWithContentOutline(); }, [logsVolumeData?.data, hiddenLogLevels, registerLogLevelsWithContentOutline]); useUnmount(() => { if (flipOrderTimer) { window.clearTimeout(flipOrderTimer.current); } if (cancelFlippingTimer) { window.clearTimeout(cancelFlippingTimer.current); } }); useUnmount(() => { // If we're unmounting logs (e.g. switching to another datasource), we need to remove the logs specific panel state, otherwise it will persist in the explore url if ( panelState?.logs?.columns || panelState?.logs?.refId || panelState?.logs?.labelFieldName || panelState?.logs?.displayedFields ) { dispatch( changePanelState(exploreId, 'logs', { ...panelState?.logs, columns: undefined, visualisationType: visualisationType, labelFieldName: undefined, refId: undefined, displayedFields: undefined, }) ); } }); const updatePanelState = useCallback( (logsPanelState: Partial) => { const state: ExploreItemState | undefined = getState().explore.panes[exploreId]; if (state?.panelsState) { dispatch( changePanelState(exploreId, 'logs', { ...state.panelsState.logs, columns: logsPanelState.columns ?? panelState?.logs?.columns, visualisationType: logsPanelState.visualisationType ?? visualisationType, labelFieldName: logsPanelState.labelFieldName, refId: logsPanelState.refId ?? panelState?.logs?.refId, displayedFields: logsPanelState.displayedFields ?? panelState?.logs?.displayedFields, }) ); } }, [ dispatch, exploreId, panelState?.logs?.columns, panelState?.logs?.displayedFields, panelState?.logs?.refId, visualisationType, ] ); // actions const onLogRowHover = useCallback( (row?: LogRowModel) => { if (!row) { props.eventBus.publish(new DataHoverClearEvent()); } else { props.eventBus.publish( new DataHoverEvent({ point: { time: row.timeEpochMs, }, }) ); } }, [props.eventBus] ); const scrollIntoView = useCallback( (element: HTMLElement) => { if (config.featureToggles.logsInfiniteScrolling) { if (logsContainerRef.current) { topLogsRef.current?.scrollIntoView(); logsContainerRef.current.scroll({ behavior: 'smooth', top: logsContainerRef.current.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, }); } return; } if (scrollElement) { scrollElement.scroll({ behavior: 'smooth', top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, }); } }, [scrollElement] ); const sortOrderChanged = useCallback( (newSortOrder: LogsSortOrder) => { if (!logsQueries) { return; } let hasLokiQueries = false; const newQueries = logsQueries.map((query) => { if (query.datasource?.type !== 'loki' || !isLokiQuery(query)) { return query; } if (query.direction === LokiQueryDirection.Scan) { // Don't override Scan. When the direction is Scan it means that the user specifically assigned this direction to the query. return query; } hasLokiQueries = true; const newDirection = newSortOrder === LogsSortOrder.Ascending ? LokiQueryDirection.Forward : LokiQueryDirection.Backward; if (newDirection !== query.direction) { query.direction = newDirection; } return query; }); if (hasLokiQueries) { dispatch(changeQueries({ exploreId, queries: newQueries })); dispatch(runQueries({ exploreId })); } }, [dispatch, exploreId, logsQueries] ); const onChangeLogsSortOrder = useCallback( (newSortOrder: LogsSortOrder) => { setIsFlipping(true); // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs flipOrderTimer.current = window.setTimeout(() => { store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); sortOrderChanged(newSortOrder); setLogsSortOrder(newSortOrder); }, 0); cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000); }, [sortOrderChanged] ); const onEscapeNewlines = useCallback(() => { setForceEscape(!forceEscape); }, [forceEscape]); const onChangeVisualisation = useCallback( (visualisation: LogsVisualisationType) => { setVisualisationType(visualisation); const payload = { ...panelState?.logs, visualisationType: visualisation, }; updatePanelState(payload); reportInteraction('grafana_explore_logs_visualisation_changed', { newVisualizationType: visualisation, datasourceType: props.datasourceType ?? 'unknown', defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs', }); }, [panelState?.logs, props.datasourceType, updatePanelState] ); const onChangeDedup = useCallback( (dedupStrategy: LogsDedupStrategy) => { reportInteraction('grafana_explore_logs_deduplication_clicked', { deduplicationType: dedupStrategy, datasourceType: props.datasourceType, }); setDedupStrategy(dedupStrategy); }, [props.datasourceType] ); const onChangeLabels = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const showLabels = target.checked; setShowLabels(showLabels); store.set(SETTINGS_KEYS.showLabels, showLabels); } }, []); const onChangeShowTime = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const showTime = target.checked; setShowTime(showTime); store.set(SETTINGS_KEYS.showTime, showTime); } }, []); const onChangeWrapLogMessage = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const wrapLogMessage = target.checked; setWrapLogMessage(wrapLogMessage); store.set(SETTINGS_KEYS.wrapLogMessage, wrapLogMessage); } }, []); const onChangePrettifyLogMessage = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const prettifyLogMessage = target.checked; setPrettifyLogMessage(prettifyLogMessage); store.set(SETTINGS_KEYS.prettifyLogMessage, prettifyLogMessage); } }, []); const onToggleLogLevel = useCallback((hiddenRawLevels: string[]) => { const hiddenLogLevels = hiddenRawLevels.map(getLogLevelFromKey); setHiddenLogLevels(hiddenLogLevels); }, []); const onToggleLogsVolumeCollapse = useCallback( (collapsed: boolean) => { props.onSetLogsVolumeEnabled(!collapsed); reportInteraction('grafana_explore_logs_histogram_toggle_clicked', { datasourceType: props.datasourceType, type: !collapsed ? 'open' : 'close', }); }, [props] ); const onClickScan = useCallback( (event: React.SyntheticEvent) => { event.preventDefault(); if (props.onStartScanning) { props.onStartScanning(); reportInteraction('grafana_explore_logs_scanning_button_clicked', { type: 'start', datasourceType: props.datasourceType, }); } }, [props] ); const onClickStopScan = useCallback( (event: React.SyntheticEvent) => { event.preventDefault(); if (props.onStopScanning) { props.onStopScanning(); } }, [props] ); const showField = useCallback( (key: string) => { const index = displayedFields.indexOf(key); if (index === -1) { const updatedDisplayedFields = displayedFields.concat(key); setDisplayedFields(updatedDisplayedFields); updatePanelState({ ...panelState?.logs, displayedFields: updatedDisplayedFields, }); } }, [displayedFields, panelState?.logs, updatePanelState] ); const hideField = useCallback( (key: string) => { const index = displayedFields.indexOf(key); if (index > -1) { const updatedDisplayedFields = displayedFields.filter((k) => key !== k); setDisplayedFields(updatedDisplayedFields); updatePanelState({ ...panelState?.logs, displayedFields: updatedDisplayedFields, }); } }, [displayedFields, panelState?.logs, updatePanelState] ); const clearDetectedFields = useCallback(() => { updatePanelState({ ...panelState?.logs, displayedFields: [], }); setDisplayedFields([]); }, [panelState?.logs, updatePanelState]); const onCloseCallbackRef = useRef<() => void>(() => {}); let onCloseContext = useCallback(() => { setContextOpen(false); setContextRow(undefined); reportInteraction('grafana_explore_logs_log_context_closed', { datasourceType: contextRow?.datasourceType, logRowUid: contextRow?.uid, }); onCloseCallbackRef?.current(); }, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]); const onOpenContext = useCallback((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 setContextOpen(true); setContextRow(row); reportInteraction('grafana_explore_logs_log_context_opened', { datasourceType: row.datasourceType, logRowUid: row.uid, }); onCloseCallbackRef.current = onClose; }, []); const onPermalinkClick = useCallback( 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[exploreId]!); urlState.panelsState = { ...panelState, logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields }, }; urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange); // 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, }); }, [absoluteRange, displayedFields, exploreId, logRows, panelState, visualisationType] ); const scrollToTopLogs = useCallback(() => { if (config.featureToggles.logsInfiniteScrolling) { if (logsContainerRef.current) { logsContainerRef.current.scroll({ behavior: 'auto', top: 0, }); } } topLogsRef.current?.scrollIntoView(); }, []); const onPinToContentOutlineClick = useCallback( (row: LogRowModel, allowUnPin = true) => { if (getPinnedLogsCount() === PINNED_LOGS_LIMIT && !allowUnPin) { contentOutlineTrackPinLimitReached(); return; } // find the Logs parent item const logsParent = outlineItems?.find((item) => item.panelId === PINNED_LOGS_PANELID && item.level === 'root'); //update the parent's expanded state if (logsParent && updateItem) { updateItem(logsParent.id, { expanded: true }); } const alreadyPinned = pinnedLogs?.find((pin) => pin === row.rowId); if (alreadyPinned && row.rowId && allowUnPin) { unregister?.(row.rowId); contentOutlineTrackPinRemoved(); } else if (getPinnedLogsCount() !== PINNED_LOGS_LIMIT && !alreadyPinned) { register?.({ id: row.rowId, icon: 'gf-logs', title: PINNED_LOGS_TITLE, panelId: PINNED_LOGS_PANELID, level: 'child', ref: null, color: LogLevelColor[row.logLevel], childOnTop: true, onClick: () => { onOpenContext(row, () => {}); contentOutlineTrackPinClicked(); }, onRemove: (id: string) => { unregister?.(id); contentOutlineTrackUnpinClicked(); }, }); contentOutlineTrackPinAdded(); } onPinLineCallback?.(); }, [getPinnedLogsCount, onOpenContext, onPinLineCallback, outlineItems, pinnedLogs, register, unregister, updateItem] ); const hasUnescapedContent = useMemo(() => checkUnescapedContent(logRows), [logRows]); const filteredLogs = useMemo(() => filterRows(logRows, hiddenLogLevels), [hiddenLogLevels, logRows]); const { dedupedRows, dedupCount } = useMemo( () => dedupRows(filteredLogs, dedupStrategy), [dedupStrategy, filteredLogs] ); const navigationRange = useMemo(() => createNavigationRange(logRows), [logRows]); const infiniteScrollAvailable = useMemo( () => !logsQueries?.some((query) => 'direction' in query && query.direction === LokiQueryDirection.Scan), [logsQueries] ); const onLogOptionsChange = useCallback( (option: keyof LogListControlOptions, value: string | string[] | boolean) => { if (option === 'sortOrder' && (value === LogsSortOrder.Ascending || value === LogsSortOrder.Descending)) { sortOrderChanged(value); } else if (option === 'filterLevels' && Array.isArray(value)) { if (value.length === 0) { setHiddenLogLevels([]); return; } const allLevels = logLevelsRef.current ?? Object.keys(LogLevelColor).map(getLogLevelFromKey); if (hiddenLogLevels.length === 0) { toggleLegendRef.current?.(value[0], SeriesVisibilityChangeMode.ToggleSelection); setHiddenLogLevels(allLevels.filter((level) => level !== value[0])); return; } const appendsLevel = value.find((level) => hiddenLogLevels.includes(getLogLevelFromKey(level))); const removesLevel = allLevels.find((level) => !value.includes(level) && !hiddenLogLevels.includes(level)); if (appendsLevel) { toggleLegendRef.current?.(appendsLevel, SeriesVisibilityChangeMode.AppendToSelection); setHiddenLogLevels(hiddenLogLevels.filter((hiddenLevel) => hiddenLevel === appendsLevel)); return; } else if (removesLevel) { toggleLegendRef.current?.(removesLevel, SeriesVisibilityChangeMode.AppendToSelection); setHiddenLogLevels([...hiddenLogLevels, removesLevel]); } } }, [hiddenLogLevels, sortOrderChanged] ); const filterLevels: LogLevel[] | undefined = useMemo( () => !logLevelsRef.current ? undefined : logLevelsRef.current.filter((level) => hiddenLogLevels.length > 0 && !hiddenLogLevels.includes(level)), [hiddenLogLevels] ); return ( <> {getRowContext && contextRow && ( getRowContext(row, contextRow, options)} getRowContextQuery={getRowContextQuery} getLogRowContextUi={getLogRowContextUi} logsSortOrder={logsSortOrder} timeZone={timeZone} /> )} {logsVolumeEnabled && ( onToggleLogsVolumeCollapse(true)} /> )} ) ) : null, ]} title={'Logs'} actions={ <> {config.featureToggles.logsExploreTableVisualisation && (
)} } loadingState={loading ? LoadingState.Loading : LoadingState.Done} >
{visualisationType !== 'table' && !config.featureToggles.newLogsPanel && (
({ label: capitalize(dedupType), value: dedupType, description: LogsDedupDescription[dedupType], }))} value={dedupStrategy} onChange={onChangeDedup} className={styles.radioButtons} />
)}
{visualisationType === 'table' && hasData && (
{/* Width should be full width minus logs navigation and padding */}
)} {visualisationType === 'logs' && hasData && !config.featureToggles.newLogsPanel && ( <>
)} {visualisationType === 'logs' && hasData && config.featureToggles.newLogsPanel && (
{logsContainerRef.current && ( )}
)} {!loading && !hasData && !scanning && (
No logs found.
)} {scanning && (
{scanText}
)}
); }; export const Logs = withTheme2(UnthemedLogs); const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: number) => { return { noDataWrapper: css({ display: 'flex', justifyContent: 'center', width: '100%', paddingBottom: theme.spacing(2), }), noData: css({ display: 'inline-block', }), scanButton: css({ marginLeft: theme.spacing(1), }), logOptions: css({ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', flexWrap: 'wrap', backgroundColor: theme.colors.background.primary, padding: `${theme.spacing(1)} ${theme.spacing(2)}`, borderRadius: 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': { marginRight: '0', }, }), horizontalInlineSwitch: css({ padding: `0 ${theme.spacing(1)} 0 0`, }), radioButtons: css({ margin: '0', }), logsSection: css({ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', position: 'relative', }), logsTable: css({ maxHeight: `${tableHeight}px`, }), scrollableLogRows: css({ overflowY: 'scroll', width: '100%', maxHeight: '75vh', }), logRows: css({ overflowX: `${wrapLogMessage ? 'unset' : 'scroll'}`, overflowY: 'visible', width: '100%', }), logRowsWrapper: css({ width: '100%', }), visualisationType: css({ display: 'flex', flex: '1', justifyContent: 'space-between', }), visualisationTypeRadio: css({ margin: `0 0 0 ${theme.spacing(1)}`, }), stickyNavigation: css({ overflow: 'visible', ...(config.featureToggles.logsInfiniteScrolling && { marginBottom: '0px' }), }), }; }; const checkUnescapedContent = (logRows: LogRowModel[]) => { return logRows.some((r) => r.hasUnescapedContent); }; const dedupRows = (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 }; }; const filterRows = (logRows: LogRowModel[], hiddenLogLevels: string[]) => { return filterLogLevels(logRows, new Set(hiddenLogLevels)); }; const createNavigationRange = (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 }; };