From b7df121294e10dd6a4ca84f98470acbf0f4048a7 Mon Sep 17 00:00:00 2001 From: Kristina Date: Thu, 20 Jun 2024 08:04:44 -0500 Subject: [PATCH] Explore: Change Logs.tsx to a functional component (#87808) * First pass * WIP * why is my cpu angy * Avoid triggering onHiddenSeriesChanged too often onHiddenSeriesChanged should be called only when dataWithConfig changes, not when the callback changes itself. This is actually causing an infinite loop because onHiddenSeriesChanged may not be memoized in the parent and passed as a new callback function on each render. * fix tests * Remove store mock and clear store between tests instead * Fix filtering * First pass adding callbacks and dependencies * Add useRef hook for toggleLegend and topLogsRef * Remove unwanted reactivity register(...) changes outlineItems. A function that calls register(...) behaves like a setter for the state - it cannot react to the state itself. * Separate unmount clean-ups * Ensure unmount is not reactive to props * Make memoized functions stats * Wrap functions passed down to components with useCallback * Fix reporting interaction when context is closed * Adjust several variables to use refs * Post-merge fixes * Ensure scrollIntoView is called with a ref to the container * Update docs * Ensure scrollIntoView is updated only if the scroll element changes * Fix the default pinned log tooltip * Small clean up * Remove console.log() --------- Co-authored-by: Piotr Jamroz Co-authored-by: harisrozajac --- .../ContentOutline/ContentOutlineContext.tsx | 30 +- .../features/explore/Graph/ExploreGraph.tsx | 10 +- .../app/features/explore/Logs/Logs.test.tsx | 291 ++-- public/app/features/explore/Logs/Logs.tsx | 1490 ++++++++--------- public/app/features/explore/state/main.ts | 2 +- 5 files changed, 902 insertions(+), 921 deletions(-) diff --git a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx index 44b2a989a60..3e91de93e83 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx @@ -16,7 +16,10 @@ export interface ContentOutlineContextProps { outlineItems: ContentOutlineItemContextProps[]; register: RegisterFunction; unregister: (id: string) => void; - unregisterAllChildren: (parentId: string, childType: ITEM_TYPES) => void; + unregisterAllChildren: ( + parentIdGetter: (items: ContentOutlineItemContextProps[]) => string | undefined, + childType: ITEM_TYPES + ) => void; updateOutlineItems: (newItems: ContentOutlineItemContextProps[]) => void; updateItem: (id: string, properties: Partial>) => void; } @@ -193,16 +196,23 @@ export function ContentOutlineContextProvider({ children, refreshDependencies }: ); }, []); - const unregisterAllChildren = useCallback((parentId: string, childType: ITEM_TYPES) => { - setOutlineItems((prevItems) => - prevItems.map((item) => { - if (item.id === parentId) { - item.children = item.children?.filter((child) => child.type !== childType); + const unregisterAllChildren = useCallback( + (parentIdGetter: (items: ContentOutlineItemContextProps[]) => string | undefined, childType: ITEM_TYPES) => { + setOutlineItems((prevItems) => { + const parentId = parentIdGetter(prevItems); + if (!parentId) { + return prevItems; } - return item; - }) - ); - }, []); + return prevItems.map((item) => { + if (item.id === parentId) { + item.children = item.children?.filter((child) => child.type !== childType); + } + return item; + }); + }); + }, + [] + ); useEffect(() => { setOutlineItems((prevItems) => { diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index 86eb5475146..cb09a5481fb 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -1,5 +1,5 @@ import { identity } from 'lodash'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { usePrevious } from 'react-use'; import { @@ -154,8 +154,10 @@ export function ExploreGraph({ const structureRev = useStructureRev(dataWithConfig); + const onHiddenSeriesChangedRef = useRef(onHiddenSeriesChanged); + useEffect(() => { - if (onHiddenSeriesChanged) { + if (onHiddenSeriesChangedRef.current) { const hiddenFrames: string[] = []; dataWithConfig.forEach((frame) => { const allFieldsHidden = frame.fields.map((field) => field.config?.custom?.hideFrom?.viz).every(identity); @@ -163,9 +165,9 @@ export function ExploreGraph({ hiddenFrames.push(getFrameDisplayName(frame)); } }); - onHiddenSeriesChanged(hiddenFrames); + onHiddenSeriesChangedRef.current(hiddenFrames); } - }, [dataWithConfig, onHiddenSeriesChanged]); + }, [dataWithConfig]); const panelContext: PanelContext = { eventsScope: 'explore', diff --git a/public/app/features/explore/Logs/Logs.test.tsx b/public/app/features/explore/Logs/Logs.test.tsx index a8f70e47270..e5f46da4923 100644 --- a/public/app/features/explore/Logs/Logs.test.tsx +++ b/public/app/features/explore/Logs/Logs.test.tsx @@ -1,11 +1,11 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; +import { Provider } from 'react-redux'; import { DataFrame, EventBusSrv, - ExploreLogsPanelState, ExplorePanelsState, LoadingState, LogLevel, @@ -13,25 +13,20 @@ import { standardTransformersRegistry, toUtc, createDataFrame, + ExploreLogsPanelState, } from '@grafana/data'; import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize'; import { config } from '@grafana/runtime'; -import store from 'app/core/store'; import { extractFieldsTransformer } from 'app/features/transformers/extractFields/extractFields'; +import { configureStore } from 'app/store/configureStore'; + +import { initialExploreState } from '../state/main'; +import { makeExplorePaneState } from '../state/utils'; import { Logs } from './Logs'; import { visualisationTypeKey } from './utils/logs'; import { getMockElasticFrame, getMockLokiFrame } from './utils/testMocks.test'; -jest.mock('app/core/store', () => { - return { - getBool: jest.fn(), - getObject: jest.fn((_a, b) => b), - get: jest.fn(), - set: jest.fn(), - }; -}); - const reportInteraction = jest.fn(); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -45,26 +40,11 @@ jest.mock('app/core/utils/shortLinks', () => ({ createAndCopyShortLink: (url: string) => createAndCopyShortLink(url), })); -jest.mock('app/store/store', () => ({ - getState: jest.fn().mockReturnValue({ - explore: { - panes: { - left: { - datasource: 'id', - queries: [{ refId: 'A', expr: '', queryType: 'range', datasource: { type: 'loki', uid: 'id' } }], - range: { raw: { from: 'now-1h', to: 'now' } }, - }, - }, - }, - }), - dispatch: jest.fn(), -})); - -const changePanelState = jest.fn(); +const fakeChangePanelState = jest.fn().mockReturnValue({ type: 'fakeAction' }); jest.mock('../state/explorePane', () => ({ ...jest.requireActual('../state/explorePane'), changePanelState: (exploreId: string, panel: 'logs', panelState: {} | ExploreLogsPanelState) => { - return changePanelState(exploreId, panel, panelState); + return fakeChangePanelState(exploreId, panel, panelState); }, })); @@ -72,6 +52,7 @@ describe('Logs', () => { let originalHref = window.location.href; beforeEach(() => { + localStorage.clear(); jest.clearAllMocks(); }); @@ -120,6 +101,7 @@ describe('Logs', () => { ]; const testDataFrame = dataFrame ?? getMockLokiFrame(); + return ( { /> ); }; + const setup = (partialProps?: Partial>, dataFrame?: DataFrame, logs?: LogRowModel[]) => { - return render(getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)); + const fakeStore = configureStore({ + explore: { + ...initialExploreState, + panes: { + left: makeExplorePaneState(), + }, + }, + }); + + const { rerender } = render( + + {getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)} + + ); + return { rerender, store: fakeStore }; }; describe('scrolling behavior', () => { @@ -216,40 +213,47 @@ describe('Logs', () => { it('should render a load more button', () => { const scanningStarted = jest.fn(); + const store = configureStore({ + explore: { + ...initialExploreState, + }, + }); render( - undefined} - logsVolumeEnabled={true} - onSetLogsVolumeEnabled={() => null} - onClickFilterLabel={() => null} - onClickFilterOutLabel={() => null} - logsVolumeData={undefined} - loadLogsVolumeData={() => undefined} - logRows={[]} - onStartScanning={scanningStarted} - timeZone={'utc'} - width={50} - loading={false} - loadingState={LoadingState.Done} - absoluteRange={{ - from: toUtc('2019-01-01 10:00:00').valueOf(), - to: toUtc('2019-01-01 16:00:00').valueOf(), - }} - range={{ - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { from: 'now-1h', to: 'now' }, - }} - addResultsToCache={() => {}} - onChangeTime={() => {}} - clearCache={() => {}} - getFieldLinks={() => { - return []; - }} - eventBus={new EventBusSrv()} - isFilterLabelActive={jest.fn()} - /> + + undefined} + logsVolumeEnabled={true} + onSetLogsVolumeEnabled={() => null} + onClickFilterLabel={() => null} + onClickFilterOutLabel={() => null} + logsVolumeData={undefined} + loadLogsVolumeData={() => undefined} + logRows={[]} + onStartScanning={scanningStarted} + timeZone={'utc'} + width={50} + loading={false} + loadingState={LoadingState.Done} + absoluteRange={{ + from: toUtc('2019-01-01 10:00:00').valueOf(), + to: toUtc('2019-01-01 16:00:00').valueOf(), + }} + range={{ + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { from: 'now-1h', to: 'now' }, + }} + addResultsToCache={() => {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + eventBus={new EventBusSrv()} + isFilterLabelActive={jest.fn()} + /> + ); const button = screen.getByRole('button', { name: /scan for older logs/i, @@ -259,40 +263,47 @@ describe('Logs', () => { }); it('should render a stop scanning button', () => { + const store = configureStore({ + explore: { + ...initialExploreState, + }, + }); render( - undefined} - logsVolumeEnabled={true} - onSetLogsVolumeEnabled={() => null} - onClickFilterLabel={() => null} - onClickFilterOutLabel={() => null} - logsVolumeData={undefined} - loadLogsVolumeData={() => undefined} - logRows={[]} - scanning={true} - timeZone={'utc'} - width={50} - loading={false} - loadingState={LoadingState.Done} - absoluteRange={{ - from: toUtc('2019-01-01 10:00:00').valueOf(), - to: toUtc('2019-01-01 16:00:00').valueOf(), - }} - range={{ - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { from: 'now-1h', to: 'now' }, - }} - addResultsToCache={() => {}} - onChangeTime={() => {}} - clearCache={() => {}} - getFieldLinks={() => { - return []; - }} - eventBus={new EventBusSrv()} - isFilterLabelActive={jest.fn()} - /> + + undefined} + logsVolumeEnabled={true} + onSetLogsVolumeEnabled={() => null} + onClickFilterLabel={() => null} + onClickFilterOutLabel={() => null} + logsVolumeData={undefined} + loadLogsVolumeData={() => undefined} + logRows={[]} + scanning={true} + timeZone={'utc'} + width={50} + loading={false} + loadingState={LoadingState.Done} + absoluteRange={{ + from: toUtc('2019-01-01 10:00:00').valueOf(), + to: toUtc('2019-01-01 16:00:00').valueOf(), + }} + range={{ + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { from: 'now-1h', to: 'now' }, + }} + addResultsToCache={() => {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + eventBus={new EventBusSrv()} + isFilterLabelActive={jest.fn()} + /> + ); expect( @@ -304,42 +315,48 @@ describe('Logs', () => { it('should render a stop scanning button', () => { const scanningStopped = jest.fn(); - + const store = configureStore({ + explore: { + ...initialExploreState, + }, + }); render( - undefined} - logsVolumeEnabled={true} - onSetLogsVolumeEnabled={() => null} - onClickFilterLabel={() => null} - onClickFilterOutLabel={() => null} - logsVolumeData={undefined} - loadLogsVolumeData={() => undefined} - logRows={[]} - scanning={true} - onStopScanning={scanningStopped} - timeZone={'utc'} - width={50} - loading={false} - loadingState={LoadingState.Done} - absoluteRange={{ - from: toUtc('2019-01-01 10:00:00').valueOf(), - to: toUtc('2019-01-01 16:00:00').valueOf(), - }} - range={{ - from: toUtc('2019-01-01 10:00:00'), - to: toUtc('2019-01-01 16:00:00'), - raw: { from: 'now-1h', to: 'now' }, - }} - addResultsToCache={() => {}} - onChangeTime={() => {}} - clearCache={() => {}} - getFieldLinks={() => { - return []; - }} - eventBus={new EventBusSrv()} - isFilterLabelActive={jest.fn()} - /> + + undefined} + logsVolumeEnabled={true} + onSetLogsVolumeEnabled={() => null} + onClickFilterLabel={() => null} + onClickFilterOutLabel={() => null} + logsVolumeData={undefined} + loadLogsVolumeData={() => undefined} + logRows={[]} + scanning={true} + onStopScanning={scanningStopped} + timeZone={'utc'} + width={50} + loading={false} + loadingState={LoadingState.Done} + absoluteRange={{ + from: toUtc('2019-01-01 10:00:00').valueOf(), + to: toUtc('2019-01-01 16:00:00').valueOf(), + }} + range={{ + from: toUtc('2019-01-01 10:00:00'), + to: toUtc('2019-01-01 16:00:00'), + raw: { from: 'now-1h', to: 'now' }, + }} + addResultsToCache={() => {}} + onChangeTime={() => {}} + clearCache={() => {}} + getFieldLinks={() => { + return []; + }} + eventBus={new EventBusSrv()} + isFilterLabelActive={jest.fn()} + /> + ); const button = screen.getByRole('button', { @@ -363,12 +380,12 @@ describe('Logs', () => { describe('for permalinking', () => { it('should dispatch a `changePanelState` event without the id', () => { const panelState = { logs: { id: '1' } }; - const { rerender } = setup({ loading: false, panelState }); + const { rerender, store } = setup({ loading: false, panelState }); - rerender(getComponent({ loading: true, exploreId: 'right', panelState })); - rerender(getComponent({ loading: false, exploreId: 'right', panelState })); + rerender({getComponent({ loading: true, exploreId: 'right', panelState })}); + rerender({getComponent({ loading: false, exploreId: 'right', panelState })}); - expect(changePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} }); + expect(fakeChangePanelState).toHaveBeenCalledWith('right', 'logs', { logs: {} }); }); it('should scroll the scrollElement into view if rows contain id', () => { @@ -491,23 +508,17 @@ describe('Logs', () => { }); it('should use default state from localstorage - table', async () => { - const oldGet = store.get; - store.get = jest.fn().mockReturnValue('table'); localStorage.setItem(visualisationTypeKey, 'table'); setup({}); const table = await screen.findByTestId('logRowsTable'); expect(table).toBeInTheDocument(); - store.get = oldGet; }); it('should use default state from localstorage - logs', async () => { - const oldGet = store.get; - store.get = jest.fn().mockReturnValue('logs'); localStorage.setItem(visualisationTypeKey, 'logs'); setup({}); const table = await screen.findByTestId('logRows'); expect(table).toBeInTheDocument(); - store.get = oldGet; }); it('should change visualisation to table on toggle (elastic)', async () => { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index a68e8e79e35..fb671e75b54 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -1,38 +1,41 @@ import { css, cx } from '@emotion/css'; import { capitalize, groupBy } from 'lodash'; import memoizeOne from 'memoize-one'; -import React, { createRef, PureComponent } from 'react'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import { usePrevious, useUnmount } from 'react-use'; import { - AbsoluteTimeRange, - CoreApp, + SplitOpen, + LogRowModel, + LogsMetaItem, DataFrame, - DataHoverClearEvent, - DataHoverEvent, + DataQuery, + AbsoluteTimeRange, + GrafanaTheme2, + LoadingState, + TimeZone, + RawTimeRange, DataQueryResponse, + LogRowContextOptions, + LinkModel, EventBus, - ExploreLogsPanelState, ExplorePanelsState, Field, - GrafanaTheme2, - LinkModel, - LoadingState, - LogLevel, - LogRowContextOptions, - LogRowModel, - LogsDedupDescription, - LogsDedupStrategy, - LogsMetaItem, - LogsSortOrder, - rangeUtil, - RawTimeRange, - serializeStateToUrlParam, - SplitOpen, TimeRange, + LogsDedupStrategy, + LogsSortOrder, + LogLevel, + DataTopic, + CoreApp, + LogsDedupDescription, + rangeUtil, + ExploreLogsPanelState, + DataHoverClearEvent, + DataHoverEvent, + serializeStateToUrlParam, urlUtil, } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { DataQuery, DataTopic, TimeZone } from '@grafana/schema'; import { Button, InlineField, @@ -50,21 +53,21 @@ import { Trans } from 'app/core/internationalization'; import store from 'app/core/store'; import { createAndCopyShortLink } 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 { LogLevelColor, dedupLogRows, filterLogLevels } from 'app/features/logs/logsModel'; import { getLogLevel, getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils'; -import { dispatch, getState } from 'app/store/store'; +import { getState } from 'app/store/store'; +import { ExploreItemState, useDispatch } from 'app/types'; -import { ExploreItemState } from '../../../types'; -import { LogRows } from '../../logs/components/LogRows'; -import { LogRowContextModal } from '../../logs/components/log-context/LogRowContextModal'; -import { dedupLogRows, filterLogLevels, LogLevelColor } from '../../logs/logsModel'; -import { ContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; +import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext'; import { getUrlStateFromPaneState } from '../hooks/useStateSync'; import { changePanelState } from '../state/explorePane'; import { LogsFeedback } from './LogsFeedback'; import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; -import { getLogsTableHeight, LogsTableWrap } from './LogsTableWrap'; +import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap'; import { LogsVolumePanelList } from './LogsVolumePanelList'; import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs'; @@ -119,25 +122,6 @@ interface Props extends Themeable2 { 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; - pinLineButtonTooltipTitle?: PopoverContent; -} - // we need to define the order of these explicitly const DEDUP_OPTIONS = [ LogsDedupStrategy.none, @@ -162,82 +146,90 @@ const getDefaultVisualisationType = (): LogsVisualisationType => { const PINNED_LOGS_LIMIT = 3; -class UnthemedLogs extends PureComponent { - flipOrderTimer?: number; - cancelFlippingTimer?: number; - topLogsRef = createRef(); - logsVolumeEventBus: EventBus; - static contextType = ContentOutlineContext; - declare context: React.ContextType; - // @ts-ignore - private toggleLegendRef: React.MutableRefObject<(name: string, mode: SeriesVisibilityChangeMode) => void> = - React.createRef(); +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, + } = 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('Pin to content outline'); + const [visualisationType, setVisualisationType] = useState( + panelState?.logs?.visualisationType ?? getDefaultVisualisationType() + ); + const [scrollIntoView, setScrollIntoView] = useState<((element: HTMLElement) => void) | undefined>(undefined); + const logsContainerRef = useRef(undefined); + const dispatch = useDispatch(); + const previousLoading = usePrevious(loading); - 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 ?? getDefaultVisualisationType(), - logsContainer: undefined, - pinLineButtonTooltipTitle: 'Pin to content outline', - }; + 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); - constructor(props: Props) { - super(props); - this.logsVolumeEventBus = props.eventBus.newScopedBus('logsvolume', { onlyLocal: false }); - } + const tableHeight = getLogsTableHeight(); + const styles = getStyles(theme, wrapLogMessage, tableHeight); + const hasData = logRows && logRows.length > 0; + const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; - componentDidMount(): void { - this.registerLogLevelsWithContentOutline(); - } - - componentWillUnmount() { - if (this.flipOrderTimer) { - window.clearTimeout(this.flipOrderTimer); - } - - if (this.cancelFlippingTimer) { - window.clearTimeout(this.cancelFlippingTimer); - } - - // If we're unmounting logs (e.g. switching to another datasource), we need to remove the table specific panel state, otherwise it will persist in the explore url - if ( - this.props?.panelState?.logs?.columns || - this.props?.panelState?.logs?.refId || - this.props?.panelState?.logs?.labelFieldName - ) { - dispatch( - changePanelState(this.props.exploreId, 'logs', { - ...this.props.panelState?.logs, - columns: undefined, - visualisationType: this.state.visualisationType, - labelFieldName: undefined, - refId: undefined, - }) - ); - } - } - - registerLogLevelsWithContentOutline = () => { + const registerLogLevelsWithContentOutline = useCallback(() => { const levelsArr = Object.keys(LogLevelColor); - const logVolumeDataFrames = new Set(this.props.logsVolumeData?.data); + const logVolumeDataFrames = new Set(logsVolumeData?.data); // TODO remove this once filtering multiple log volumes is supported - const numberOfLogVolumes = this.getNumberOfLogVolumes(); + 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 - const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root'); - if (logsParent) { - this.context?.unregisterAllChildren(logsParent.id, 'filter'); + if (unregisterAllChildren) { + unregisterAllChildren((items) => { + const logsParent = items?.find((item) => item.panelId === 'Logs' && item.level === 'root'); + return logsParent?.id; + }, 'filter'); } // check if we have dataFrames that return the same level @@ -255,279 +247,322 @@ class UnthemedLogs extends PureComponent { const logLevels = new Set(sortedLLArray); - if (logLevels.size > 1 && this.props.logsVolumeEnabled && numberOfLogVolumes === 1) { + if (logLevels.size > 1 && logsVolumeEnabled && numberOfLogVolumes === 1) { logLevels.forEach((level) => { - const allLevelsSelected = this.state.hiddenLogLevels.length === 0; - const currentLevelSelected = !this.state.hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level.levelStr); - this.context?.register({ - title: level.levelStr, - icon: 'gf-logs', - panelId: 'Logs', - level: 'child', - type: 'filter', - highlight: currentLevelSelected && !allLevelsSelected, - onClick: (e: React.MouseEvent) => { - this.toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); - }, - ref: null, - color: LogLevelColor[level.logLevel], - }); + const allLevelsSelected = hiddenLogLevels.length === 0; + const currentLevelSelected = !hiddenLogLevels.find((hiddenLevel) => hiddenLevel === level.levelStr); + if (register) { + register({ + title: level.levelStr, + icon: 'gf-logs', + panelId: 'Logs', + level: 'child', + type: 'filter', + highlight: currentLevelSelected && !allLevelsSelected, + onClick: (e: React.MouseEvent) => { + toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); + }, + ref: null, + color: LogLevelColor[level.logLevel], + }); + } }); } - }; + }, [logsVolumeData?.data, unregisterAllChildren, logsVolumeEnabled, hiddenLogLevels, register, toggleLegendRef]); - 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, - labelFieldName: logsPanelState.labelFieldName, - refId: logsPanelState.refId ?? this.props.panelState?.logs?.refId, - }) - ); - } - }; - - getNumberOfLogVolumes() { - const data = this.props.logsVolumeData?.data.filter( - (frame: DataFrame) => frame.meta?.dataTopic !== DataTopic.Annotations - ); - const grouped = groupBy(data, 'meta.custom.datasourceName'); - const numberOfLogVolumes = Object.keys(grouped).length; - return numberOfLogVolumes; - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { - if (this.props.loading && !prevProps.loading && this.props.panelState?.logs?.id) { + useEffect(() => { + if (loading && !previousLoading && panelState?.logs?.id) { // loading stopped, so we need to remove any permalinked log lines - delete this.props.panelState.logs.id; + delete panelState.logs.id; dispatch( - changePanelState(this.props.exploreId, 'logs', { - ...this.props.panelState, + changePanelState(exploreId, 'logs', { + ...panelState, }) ); } - if (this.props.panelState?.logs?.visualisationType !== prevProps.panelState?.logs?.visualisationType) { - const visualisationType = this.props.panelState?.logs?.visualisationType ?? getDefaultVisualisationType(); + }, [dispatch, exploreId, loading, panelState, previousLoading]); - this.setState({ - visualisationType: visualisationType, + useEffect(() => { + const visualisationType = panelState?.logs?.visualisationType ?? getDefaultVisualisationType(); + setVisualisationType(visualisationType); + + store.set(visualisationTypeKey, visualisationType); + }, [panelState?.logs?.visualisationType]); + + 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 table specific panel state, otherwise it will persist in the explore url + if (panelState?.logs?.columns || panelState?.logs?.refId || panelState?.logs?.labelFieldName) { + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState?.logs, + columns: undefined, + visualisationType: visualisationType, + labelFieldName: undefined, + refId: 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, + }) + ); + } + }, + [dispatch, exploreId, panelState?.logs?.columns, 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 onLogsContainerRef = useCallback( + (node: HTMLDivElement) => { + logsContainerRef.current = node; + + // In theory this should be just a function passed down to LogRows but: + // - LogRow.componentDidMount which calls scrollIntoView is called BEFORE the logsContainerRef is set + // - the if check below if (logsContainerRef.current) was falsy and scrolling doesn't happen + // - and LogRow.scrollToLogRow marks the line as scrolled anyway (and won't perform scrolling when the ref is set) + // - see more details in https://github.com/facebook/react/issues/29897 + // We can change it once LogRow is converted into a functional component + setScrollIntoView(() => (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; + } + const scrollElement = props.scrollElement; + + if (scrollElement) { + scrollElement.scroll({ + behavior: 'smooth', + top: scrollElement.scrollTop + element.getBoundingClientRect().top - window.innerHeight / 2, + }); + } }); - store.set(visualisationTypeKey, visualisationType); - } + }, + [props.scrollElement] + ); - if ( - prevProps.logsVolumeData?.data !== this.props.logsVolumeData?.data || - prevState.hiddenLogLevels !== this.state.hiddenLogLevels - ) { - this.registerLogLevelsWithContentOutline(); - } - } - - 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 }); + const onChangeLogsSortOrder = () => { + setIsFlipping(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 }; - }); + flipOrderTimer.current = window.setTimeout(() => { + const newSortOrder = + logsSortOrder === LogsSortOrder.Descending ? LogsSortOrder.Ascending : LogsSortOrder.Descending; + store.set(SETTINGS_KEYS.logsSortOrder, newSortOrder); + setLogsSortOrder(newSortOrder); }, 0); - this.cancelFlippingTimer = window.setTimeout(() => this.setState({ isFlipping: false }), 1000); + cancelFlippingTimer.current = window.setTimeout(() => setIsFlipping(false), 1000); }; - onEscapeNewlines = () => { - this.setState((prevState) => ({ - forceEscape: !prevState.forceEscape, - })); - }; + const onEscapeNewlines = useCallback(() => { + setForceEscape(!forceEscape); + }, [forceEscape]); - onChangeVisualisation = (visualisation: LogsVisualisationType) => { - this.setState(() => ({ - visualisationType: visualisation, - })); - const payload = { - ...this.props.panelState?.logs, - visualisationType: visualisation, - }; - this.updatePanelState(payload); + const onChangeVisualisation = useCallback( + (visualisation: LogsVisualisationType) => { + setVisualisationType(visualisation); + const payload = { + ...panelState?.logs, + visualisationType: visualisation, + }; + updatePanelState(payload); - reportInteraction('grafana_explore_logs_visualisation_changed', { - newVisualizationType: visualisation, - datasourceType: this.props.datasourceType ?? 'unknown', - defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs', - }); - }; + reportInteraction('grafana_explore_logs_visualisation_changed', { + newVisualizationType: visualisation, + datasourceType: props.datasourceType ?? 'unknown', + defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs', + }); + }, + [panelState?.logs, props.datasourceType, updatePanelState] + ); - onChangeDedup = (dedupStrategy: LogsDedupStrategy) => { - reportInteraction('grafana_explore_logs_deduplication_clicked', { - deduplicationType: dedupStrategy, - datasourceType: this.props.datasourceType, - }); - this.setState({ dedupStrategy }); - }; + const onChangeDedup = useCallback( + (dedupStrategy: LogsDedupStrategy) => { + reportInteraction('grafana_explore_logs_deduplication_clicked', { + deduplicationType: dedupStrategy, + datasourceType: props.datasourceType, + }); + setDedupStrategy(dedupStrategy); + }, + [props.datasourceType] + ); - onChangeLabels = (event: React.ChangeEvent) => { + const onChangeLabels = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const showLabels = target.checked; - this.setState({ - showLabels, - }); + + setShowLabels(showLabels); store.set(SETTINGS_KEYS.showLabels, showLabels); } - }; + }, []); - onChangeTime = (event: React.ChangeEvent) => { + const onChangeShowTime = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const showTime = target.checked; - this.setState({ - showTime, - }); + + setShowTime(showTime); store.set(SETTINGS_KEYS.showTime, showTime); } - }; + }, []); - onChangeWrapLogMessage = (event: React.ChangeEvent) => { + const onChangeWrapLogMessage = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const wrapLogMessage = target.checked; - this.setState({ - wrapLogMessage, - }); + + setWrapLogMessage(wrapLogMessage); store.set(SETTINGS_KEYS.wrapLogMessage, wrapLogMessage); } - }; + }, []); - onChangePrettifyLogMessage = (event: React.ChangeEvent) => { + const onChangePrettifyLogMessage = useCallback((event: React.ChangeEvent) => { const { target } = event; if (target) { const prettifyLogMessage = target.checked; - this.setState({ - prettifyLogMessage, - }); + + setPrettifyLogMessage(prettifyLogMessage); store.set(SETTINGS_KEYS.prettifyLogMessage, prettifyLogMessage); } - }; + }, []); - onToggleLogLevel = (hiddenRawLevels: string[]) => { + const onToggleLogLevel = useCallback((hiddenRawLevels: string[]) => { const hiddenLogLevels = hiddenRawLevels.map((level) => getLogLevelFromKey(level)); - this.setState({ hiddenLogLevels }); - }; + setHiddenLogLevels(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, + const onToggleLogsVolumeCollapse = useCallback( + (collapsed: boolean) => { + props.onSetLogsVolumeEnabled(!collapsed); + reportInteraction('grafana_explore_logs_histogram_toggle_clicked', { + datasourceType: props.datasourceType, + type: !collapsed ? 'open' : 'close', }); - } - }; + }, + [props] + ); - onClickStopScan = (event: React.SyntheticEvent) => { - event.preventDefault(); - if (this.props.onStopScanning) { - this.props.onStopScanning(); - } - }; + 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] + ); - showField = (key: string) => { - const index = this.state.displayedFields.indexOf(key); + const onClickStopScan = useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + if (props.onStopScanning) { + props.onStopScanning(); + } + }, + [props] + ); - if (index === -1) { - this.setState((state) => { - return { - displayedFields: state.displayedFields.concat(key), - }; - }); - } - }; + const showField = useCallback( + (key: string) => { + const index = displayedFields.indexOf(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), - }; - }); - } - }; + if (index === -1) { + setDisplayedFields(displayedFields.concat(key)); + } + }, + [displayedFields] + ); - clearDetectedFields = () => { - this.setState((state) => { - return { - displayedFields: [], - }; + const hideField = useCallback( + (key: string) => { + const index = displayedFields.indexOf(key); + if (index > -1) { + setDisplayedFields(displayedFields.filter((k) => key !== k)); + } + }, + [displayedFields] + ); + + const clearDetectedFields = useCallback(() => { + setDisplayedFields([]); + }, []); + + 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]); - onCloseContext = () => { - this.setState({ - contextOpen: false, - contextRow: undefined, - }); - }; - - onOpenContext = (row: LogRowModel, onClose: () => void) => { + const 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, - }); + setContextOpen(true); + setContextRow(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(); - }; + onCloseCallbackRef.current = onClose; }; - getPreviousLog(row: LogRowModel, allLogs: LogRowModel[]): LogRowModel | null { + const getPreviousLog = useCallback((row: LogRowModel, allLogs: LogRowModel[]) => { for (let i = allLogs.indexOf(row) - 1; i >= 0; i--) { if (allLogs[i].timeEpochMs > row.timeEpochMs) { return allLogs[i]; @@ -535,39 +570,42 @@ class UnthemedLogs extends PureComponent { } return null; - } + }, []); - getPermalinkRange(row: LogRowModel) { - const range = { - from: new Date(this.props.absoluteRange.from).toISOString(), - to: new Date(this.props.absoluteRange.to).toISOString(), - }; - if (!config.featureToggles.logsInfiniteScrolling) { - return range; - } - - // With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust - // Look for the previous sibling log, and use its timestamp - const allLogs = this.props.logRows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId); - const prevLog = this.getPreviousLog(row, allLogs); - - if (row.timeEpochMs > this.props.absoluteRange.to && !prevLog) { - // Because there's no sibling and the current `to` is oldest than the log, we have no reference we can use for the interval - // This only happens when you scroll into the future and you want to share the first log of the list - return { - from: new Date(this.props.absoluteRange.from).toISOString(), - // Slide 1ms otherwise it's very likely to be omitted in the results - to: new Date(row.timeEpochMs + 1).toISOString(), + const getPermalinkRange = useCallback( + (row: LogRowModel) => { + const range = { + from: new Date(absoluteRange.from).toISOString(), + to: new Date(absoluteRange.to).toISOString(), }; - } + if (!config.featureToggles.logsInfiniteScrolling) { + return range; + } - return { - from: new Date(this.props.absoluteRange.from).toISOString(), - to: new Date(prevLog ? prevLog.timeEpochMs : this.props.absoluteRange.to).toISOString(), - }; - } + // With infinite scrolling, the time range of the log line can be after the absolute range or beyond the request line limit, so we need to adjust + // Look for the previous sibling log, and use its timestamp + const allLogs = logRows.filter((logRow) => logRow.dataFrame.refId === row.dataFrame.refId); + const prevLog = getPreviousLog(row, allLogs); - onPermalinkClick = async (row: LogRowModel) => { + if (row.timeEpochMs > absoluteRange.to && !prevLog) { + // Because there's no sibling and the current `to` is oldest than the log, we have no reference we can use for the interval + // This only happens when you scroll into the future and you want to share the first log of the list + return { + from: new Date(absoluteRange.from).toISOString(), + // Slide 1ms otherwise it's very likely to be omitted in the results + to: new Date(row.timeEpochMs + 1).toISOString(), + }; + } + + return { + from: new Date(absoluteRange.from).toISOString(), + to: new Date(prevLog ? prevLog.timeEpochMs : absoluteRange.to).toISOString(), + }; + }, + [absoluteRange.from, absoluteRange.to, getPreviousLog, logRows] + ); + + const 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 @@ -577,12 +615,12 @@ class UnthemedLogs extends PureComponent { } // get explore state, add log-row-id and make timerange absolute - const urlState = getUrlStateFromPaneState(getState().explore.panes[this.props.exploreId]!); + const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); urlState.panelsState = { - ...this.props.panelState, - logs: { id: row.uid, visualisationType: this.state.visualisationType ?? getDefaultVisualisationType() }, + ...panelState, + logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType() }, }; - urlState.range = this.getPermalinkRange(row); + urlState.range = getPermalinkRange(row); // append changed urlState to baseUrl const serializedState = serializeStateToUrlParam(urlState); @@ -597,92 +635,40 @@ class UnthemedLogs extends PureComponent { }); }; - scrollIntoView = (element: HTMLElement) => { + const scrollToTopLogs = useCallback(() => { if (config.featureToggles.logsInfiniteScrolling) { - 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.logsInfiniteScrolling) { - if (this.state.logsContainer) { - this.state.logsContainer.scroll({ + if (logsContainerRef.current) { + logsContainerRef.current.scroll({ behavior: 'auto', top: 0, }); } } - this.topLogsRef.current?.scrollIntoView(); - }; + topLogsRef.current?.scrollIntoView(); + }, [logsContainerRef, topLogsRef]); - onPinToContentOutlineClick = (row: LogRowModel) => { - if (this.getPinnedLogsCount() === PINNED_LOGS_LIMIT) { - this.setState({ - pinLineButtonTooltipTitle: ( - - ❗️ - - Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another. - - - ), - }); + const onPinToContentOutlineClick = (row: LogRowModel) => { + if (getPinnedLogsCount() === PINNED_LOGS_LIMIT) { + setPinLineButtonTooltipTitle( + + ❗️ + + Maximum of {{ PINNED_LOGS_LIMIT }} pinned logs reached. Unpin a log to add another. + + + ); return; } // find the Logs parent item - const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root'); + const logsParent = outlineItems?.find((item) => item.panelId === 'Logs' && item.level === 'root'); //update the parent's expanded state - if (logsParent) { - this.context?.updateItem(logsParent.id, { expanded: true }); + if (logsParent && updateItem) { + updateItem(logsParent.id, { expanded: true }); } - this.context?.register({ + register?.({ icon: 'gf-logs', title: 'Pinned log', panelId: 'Logs', @@ -690,362 +676,306 @@ class UnthemedLogs extends PureComponent { ref: null, color: LogLevelColor[row.logLevel], childOnTop: true, - onClick: () => this.onOpenContext(row, () => {}), + onClick: () => onOpenContext(row, () => {}), onRemove: (id: string) => { - this.context?.unregister(id); - if (this.getPinnedLogsCount() < PINNED_LOGS_LIMIT) { - this.setState({ - pinLineButtonTooltipTitle: 'Pin to content outline', - }); + unregister?.(id); + if (getPinnedLogsCount() < PINNED_LOGS_LIMIT) { + setPinLineButtonTooltipTitle('Pin to content outline'); } }, }); - this.props.onPinLineCallback?.(); + props.onPinLineCallback?.(); }; - getPinnedLogsCount = () => { - const logsParent = this.context?.outlineItems.find((item) => item.panelId === 'Logs' && item.level === 'root'); + const getPinnedLogsCount = () => { + const logsParent = outlineItems?.find((item) => item.panelId === 'Logs' && item.level === 'root'); return logsParent?.children?.filter((child) => child.title === 'Pinned log').length ?? 0; }; - 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, - loadMoreLogs, - } = this.props; + const hasUnescapedContent = checkUnescapedContent(logRows); + const filteredLogs = filterRows(logRows, hiddenLogLevels); + const { dedupedRows, dedupCount } = dedupRows(filteredLogs, dedupStrategy); + const navigationRange = createNavigationRange(logRows); - 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} + return ( + <> + {getRowContext && contextRow && ( + getRowContext(row, contextRow, options)} + getRowContextQuery={getRowContextQuery} + getLogRowContextUi={getLogRowContextUi} + logsSortOrder={logsSortOrder} + timeZone={timeZone} + /> + )} + + {logsVolumeEnabled && ( + onToggleLogsVolumeCollapse(true)} /> )} - - {logsVolumeEnabled && ( - this.onToggleLogsVolumeCollapse(true)} - /> - )} - - - - - ) - ) : null, - ]} - title={'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 */} - + + + + ) + ) : null, + ]} + title={'Logs'} + actions={ + <> + {config.featureToggles.logsExploreTableVisualisation && ( +
+
)} - {this.state.visualisationType === 'logs' && hasData && ( -
- - + } + loadingState={loading ? LoadingState.Loading : LoadingState.Done} + > +
+ {visualisationType !== 'table' && ( +
+ + + - + + + + + + + + + + + + ({ + label: capitalize(dedupType), + value: dedupType, + description: LogsDedupDescription[dedupType], + }))} + value={dedupStrategy} + onChange={onChangeDedup} + className={styles.radioButtons} + /> + + + +
+ + +
- )} - {!loading && !hasData && !scanning && ( -
-
- No logs found. - -
+
+ )} +
+ +
+
+ {visualisationType === 'table' && hasData && ( +
+ {/* Width should be full width minus logs navigation and padding */} + +
+ )} + {visualisationType === 'logs' && hasData && ( +
+ + + +
+ )} + {!loading && !hasData && !scanning && ( +
+
+ No logs found. +
- )} - {scanning && ( -
-
- {scanText} - -
+
+ )} + {scanning && ( +
+
+ {scanText} +
- )} - -
- - - ); - } -} +
+ )} + +
+ + + ); +}; export const Logs = withTheme2(UnthemedLogs); @@ -1113,3 +1043,31 @@ const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean, tableHeight: n }), }; }; + +const checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => { + return logRows.some((r) => r.hasUnescapedContent); +}); + +const 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 }; +}); + +const filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => { + return filterLogLevels(logRows, new Set(hiddenLogLevels)); +}); + +const 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 }; +}); diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 2f453f17c25..306fc6ed3f1 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -270,7 +270,7 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): }; } - if (initializeExplore.pending.match(action)) { + if (initializeExplore?.pending.match(action)) { const initialPanes = Object.entries(state.panes); const before = initialPanes.slice(0, action.meta.arg.position); const after = initialPanes.slice(before.length);