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);