diff --git a/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts index 8ac80fa1784..13e0ad8c692 100644 --- a/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logsnew/panelcfg/x/LogsNewPanelCfg_types.gen.ts @@ -13,6 +13,7 @@ import * as common from '@grafana/schema'; export const pluginVersion = "12.1.0-pre"; export interface Options { + controlsStorageKey?: string; dedupStrategy: common.LogsDedupStrategy; enableInfiniteScrolling?: boolean; enableLogDetails: boolean; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 917c145ce88..effc9a75a31 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -466,7 +466,15 @@ export class Explore extends PureComponent { } renderLogsSamplePanel() { - const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props; + const { + logsSample, + timeZone, + setSupplementaryQueryEnabled, + exploreId, + datasourceInstance, + queries, + queryResponse, + } = this.props; return ( { setLogsSampleEnabled={(enabled: boolean) => setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample) } + timeRange={queryResponse.timeRange} /> ); diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index a466cb73c5f..fb61d584ee6 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -781,6 +781,10 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { return; } const allLevels = logLevelsRef.current ?? Object.keys(LogLevelColor).map(getLogLevelFromKey); + if (!allLevels.length) { + // Logs panel is disabled + return; + } if (hiddenLogLevels.length === 0) { toggleLegendRef.current?.(value[0], SeriesVisibilityChangeMode.ToggleSelection); setHiddenLogLevels(allLevels.filter((level) => level !== value[0])); @@ -805,7 +809,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const filterLevels: LogLevel[] | undefined = useMemo( () => - !logLevelsRef.current + !logLevelsRef.current || logLevelsRef.current.length === 0 ? undefined : logLevelsRef.current.filter((level) => hiddenLogLevels.length > 0 && !hiddenLogLevels.includes(level)), [hiddenLogLevels] @@ -1020,57 +1024,59 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { /> )} - {!config.featureToggles.newLogsPanel && config.featureToggles.logsPanelControls && hasData && ( -
- -
- )} + {(!config.featureToggles.newLogsPanel || visualisationType === 'table') && + config.featureToggles.logsPanelControls && + hasData && ( +
+ +
+ )} {!config.featureToggles.logsPanelControls && !config.featureToggles.newLogsPanel && visualisationType === 'logs' && @@ -1148,17 +1154,25 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { - {!config.featureToggles.logsPanelControls && !config.exploreHideLogsDownload && ( - - - Download - - - )} + {!config.featureToggles.logsPanelControls && + !config.featureToggles.newLogsPanel && + !config.exploreHideLogsDownload && ( + + + Download + + + )} )} diff --git a/public/app/features/explore/Logs/LogsSamplePanel.test.tsx b/public/app/features/explore/Logs/LogsSamplePanel.test.tsx index 6ab0e78d187..1ef69a2d4a3 100644 --- a/public/app/features/explore/Logs/LogsSamplePanel.test.tsx +++ b/public/app/features/explore/Logs/LogsSamplePanel.test.tsx @@ -2,7 +2,14 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ComponentProps } from 'react'; -import { FieldType, LoadingState, SupplementaryQueryType, DataSourceApi, createDataFrame } from '@grafana/data'; +import { + FieldType, + LoadingState, + SupplementaryQueryType, + DataSourceApi, + createDataFrame, + getDefaultTimeRange, +} from '@grafana/data'; import { DataQuery } from '@grafana/schema'; import { LogsSamplePanel } from './LogsSamplePanel'; @@ -23,6 +30,7 @@ const createProps = (propOverrides?: Partial void; + timeRange: TimeRange; }; export function LogsSamplePanel(props: Props) { @@ -37,6 +42,8 @@ export function LogsSamplePanel(props: Props) { const styles = useStyles2(getStyles); const { t } = useTranslate(); + const logsContainerRef = useRef(null); + const onToggleLogsSampleCollapse = (isOpen: boolean) => { setLogsSampleEnabled(isOpen); reportInteraction('grafana_explore_logs_sample_toggle_clicked', { @@ -103,24 +110,35 @@ export function LogsSamplePanel(props: Props) { ); } else { const logs = dataFrameToLogsModel(queryResponse.data); - LogsSamplePanelContent = ( - <> - -
- -
- - ); + LogsSamplePanelContent = + config.featureToggles.newLogsPanel && logsContainerRef.current ? ( + + ) : ( + + ); } return queryResponse?.state !== LoadingState.NotStarted ? ( @@ -139,7 +157,10 @@ export function LogsSamplePanel(props: Props) { collapsible={true} onToggle={onToggleLogsSampleCollapse} > - {LogsSamplePanelContent} + +
+ {LogsSamplePanelContent} +
) : null; } @@ -152,7 +173,7 @@ const getStyles = (theme: GrafanaTheme2) => { right: theme.spacing(1), }), logContainer: css({ - overflow: 'scroll', + overflow: config.featureToggles.newLogsPanel ? 'visible' : 'scroll', }), infoTooltip: css({ marginLeft: theme.spacing(1), diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index f81d597f059..f48558c1c86 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -71,6 +71,7 @@ export const ControlledLogRows = forwardRef void; pinLineButtonTooltipTitle?: PopoverContent; + mode?: 'inline' | 'sidebar'; } class UnThemedLogDetails extends PureComponent { @@ -56,6 +57,7 @@ class UnThemedLogDetails extends PureComponent { onPinLine, styles, pinLineButtonTooltipTitle, + mode = 'inline', } = this.props; const levelStyles = getLogLevelStyles(theme, row.logLevel); const labels = row.labels ? row.labels : {}; @@ -85,9 +87,14 @@ class UnThemedLogDetails extends PureComponent { return ( {showDuplicates && } - + {mode === 'inline' && ( + + )} -
+
{displayedFields && displayedFields.length > 0 && ( diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index 57361a70a38..79e88c1a485 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -187,6 +187,14 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { margin: theme.spacing(2.5, 1, 2.5, 2), cursor: 'default', }), + logDetailsSidebarContainer: css({ + label: 'logs-row-details-table', + border: `1px solid ${theme.colors.border.medium}`, + padding: theme.spacing(0, 1, 1), + borderRadius: theme.shape.radius.default, + margin: theme.spacing(0, 1, 0, 1), + cursor: 'default', + }), logDetailsTable: css({ label: 'logs-row-details-table', lineHeight: '18px', @@ -206,7 +214,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { logDetailsLabel: css({ label: 'logs-row-details__label', maxWidth: '30em', - minWidth: '20em', padding: theme.spacing(0, 1), overflowWrap: 'break-word', }), diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index f509845e063..aec46e6ede5 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -27,6 +27,7 @@ interface Props { handleOverflow: (index: number, id: string, height?: number) => void; loadMore?: (range: AbsoluteTimeRange) => void; logs: LogListModel[]; + onClick: (log: LogListModel) => void; scrollElement: HTMLDivElement | null; setInitialScrollPosition: () => void; showTime: boolean; @@ -44,6 +45,7 @@ export const InfiniteScroll = ({ handleOverflow, loadMore, logs, + onClick, scrollElement, setInitialScrollPosition, showTime, @@ -152,6 +154,7 @@ export const InfiniteScroll = ({ displayedFields={displayedFields} index={index} log={logs[index]} + onClick={onClick} showTime={showTime} style={style} styles={styles} @@ -166,6 +169,7 @@ export const InfiniteScroll = ({ handleOverflow, infiniteLoaderState, logs, + onClick, onLoadMore, showTime, sortOrder, diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index a080d3a50df..508af198c18 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -6,8 +6,9 @@ import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { createLogLine } from '../__mocks__/logRow'; -import { getStyles, LogLine } from './LogLine'; +import { getStyles, LogLine, Props } from './LogLine'; import { LogListContextProvider } from './LogListContext'; +import { defaultProps } from './__mocks__/LogListContext'; import { LogListModel } from './processing'; import { getTruncationLength } from './virtualization'; @@ -17,6 +18,7 @@ jest.mock('./virtualization'); const theme = createTheme(); const styles = getStyles(theme); const contextProps = { + ...defaultProps, app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], @@ -28,72 +30,42 @@ const contextProps = { }; describe('LogLine', () => { - let log: LogListModel; + let log: LogListModel, defaultProps: Props; beforeEach(() => { log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); + defaultProps = { + displayedFields: [], + index: 0, + log, + onClick: jest.fn(), + showTime: true, + style: {}, + styles: styles, + wrapLogMessage: true, + }; }); test('Renders a log line', () => { - render( - - ); + render(); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); expect(screen.getByText('log message 1')).toBeInTheDocument(); }); test('Renders a log line with no timestamp', () => { - render( - - ); + render(); expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument(); expect(screen.getByText('log message 1')).toBeInTheDocument(); }); test('Renders a log line with displayed fields', () => { - render( - - ); + render(); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); expect(screen.queryByText(log.body)).not.toBeInTheDocument(); expect(screen.getByText('luna')).toBeInTheDocument(); }); test('Renders a log line with body displayed fields', () => { - render( - - ); + render(); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); expect(screen.getByText('log message 1')).toBeInTheDocument(); expect(screen.getByText('luna')).toBeInTheDocument(); @@ -103,34 +75,73 @@ describe('LogLine', () => { const onLogLineHover = jest.fn(); render( - + ); await userEvent.hover(screen.getByText('log message 1')); expect(onLogLineHover).toHaveBeenCalledTimes(1); }); + test('Listens to on click events', async () => { + const onClick = jest.fn(); + render( + + + + ); + await userEvent.click(screen.getByText('log message 1')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('Shows the deduplication count', async () => { + log.duplicates = 1; + render( + + + + ); + await screen.findByText('log message 1'); + expect(screen.getByText('2x')).toBeInTheDocument(); + }); + + test('Shows log lines with errors', async () => { + log.hasError = true; + jest.spyOn(log, 'errorMessage', 'get').mockReturnValue('error message'); + render( + + + + ); + await screen.findByText('log message 1'); + expect(screen.getByLabelText('Has errors')).toBeInTheDocument(); + }); + + test('Shows sampled log lines', async () => { + log.isSampled = true; + jest.spyOn(log, 'sampledMessage', 'get').mockReturnValue('sampled message'); + render( + + + + ); + await screen.findByText('log message 1'); + expect(screen.getByLabelText('Is sampled')).toBeInTheDocument(); + }); + + test('Does not falsely report sampled or errors in logs', async () => { + render( + + + + ); + await screen.findByText('log message 1'); + expect(screen.queryByLabelText('Has errors')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Is sampled')).not.toBeInTheDocument(); + }); + describe('Log line menu', () => { test('Renders a log line menu', async () => { - render( - - ); + render(); expect(screen.queryByText('Copy log line')).not.toBeInTheDocument(); await userEvent.click(screen.getByLabelText('Log menu')); expect(screen.getByText('Copy log line')).toBeInTheDocument(); @@ -143,17 +154,7 @@ describe('LogLine', () => { }); test('Highlights relevant tokens in the log line', () => { - render( - - ); + render(); expect(screen.getByText('place')).toBeInTheDocument(); expect(screen.getByText('1ms')).toBeInTheDocument(); expect(screen.getByText('3 KB')).toBeInTheDocument(); @@ -163,15 +164,7 @@ describe('LogLine', () => { test('Can be disabled', () => { render( - + ); expect(screen.getByText(`place="luna" 1ms 3 KB`)).toBeInTheDocument(); @@ -186,15 +179,7 @@ describe('LogLine', () => { render( - + ); expect(screen.getByTestId('ansiLogLine')).toBeInTheDocument(); @@ -209,17 +194,7 @@ describe('LogLine', () => { }); test('Logs are not collapsed by default', () => { - render( - - ); + render(); expect(screen.queryByText('show less')).not.toBeInTheDocument(); expect(screen.queryByText('show more')).not.toBeInTheDocument(); }); @@ -228,12 +203,7 @@ describe('LogLine', () => { log.collapsed = true; render( @@ -244,17 +214,7 @@ describe('LogLine', () => { test('Long logs can be collapsed and expanded', async () => { log.collapsed = true; - render( - - ); + render(); expect(screen.getByText('show more')).toBeVisible(); await userEvent.click(screen.getByText('show more')); expect(await screen.findByText('show less')).toBeInTheDocument(); @@ -265,21 +225,33 @@ describe('LogLine', () => { test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => { log.collapsed = true; const onOverflow = jest.fn(); - render( - - ); + render(); await userEvent.click(await screen.findByText('show more')); await userEvent.click(await screen.findByText('show less')); expect(onOverflow).toHaveBeenCalledTimes(2); }); + + test('Syncs the collapsed state with collapsed status changes in the log', async () => { + log.collapsed = true; + const { rerender } = render(); + expect(screen.getByText('show more')).toBeVisible(); + + log.collapsed = undefined; + rerender(); + + expect(screen.queryByText('show more')).not.toBeInTheDocument(); + expect(screen.queryByText('show less')).not.toBeInTheDocument(); + }); + + test('Syncs the collapsed state with wrapping changes', async () => { + log.collapsed = true; + const { rerender } = render(); + expect(screen.getByText('show more')).toBeVisible(); + + rerender(); + + expect(screen.queryByText('show more')).not.toBeInTheDocument(); + expect(screen.queryByText('show less')).not.toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index e03bf75a37f..3e0e441996c 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -2,9 +2,9 @@ import { css } from '@emotion/css'; import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import tinycolor from 'tinycolor2'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, LogsDedupStrategy } from '@grafana/data'; import { useTranslate } from '@grafana/i18n'; -import { Button } from '@grafana/ui'; +import { Button, Icon, Tooltip } from '@grafana/ui'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LogMessageAnsi } from '../LogMessageAnsi'; @@ -20,13 +20,14 @@ import { TRUNCATION_LINE_COUNT, } from './virtualization'; -interface Props { +export interface Props { displayedFields: string[]; index: number; log: LogListModel; showTime: boolean; style: CSSProperties; styles: LogLineStyles; + onClick: (log: LogListModel) => void; onOverflow?: (index: number, id: string, height?: number) => void; variant?: 'infinite-scroll'; wrapLogMessage: boolean; @@ -38,12 +39,13 @@ export const LogLine = ({ log, style, styles, + onClick, onOverflow, showTime, variant, wrapLogMessage, }: Props) => { - const { onLogLineHover } = useLogListContext(); + const { detailsDisplayed, onLogLineHover } = useLogListContext(); const [collapsed, setCollapsed] = useState( wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined ); @@ -59,7 +61,17 @@ export const LogLine = ({ if (actualHeight) { onOverflow(index, log.uid, actualHeight); } - }, [index, log.collapsed, log.uid, onOverflow, style.height]); + }); + + useEffect(() => { + if (!wrapLogMessage) { + setCollapsed(undefined); + } else if (collapsed === undefined && log.collapsed !== undefined) { + setCollapsed(log.collapsed); + } else if (collapsed !== undefined && log.collapsed === undefined) { + setCollapsed(log.collapsed); + } + }, [collapsed, log.collapsed, wrapLogMessage]); const handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]); @@ -71,18 +83,26 @@ export const LogLine = ({ }, [collapsed, index, log, onOverflow]); const { t } = useTranslate(); + const handleClick = useCallback(() => { + onClick(log); + }, [log, onClick]); + + const detailsShown = detailsDisplayed(log); return (
+ {/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
{ + const { dedupStrategy } = useLogListContext(); + const { t } = useTranslate(); return ( <> + {dedupStrategy !== LogsDedupStrategy.none && ( + + {log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null} + + )} + {log.hasError && ( + + + + + + )} + {log.isSampled && ( + + + + + + )} {showTime && {log.timestamp}} { // When logs are unwrapped, we want an empty column space to align with other log lines. @@ -206,7 +261,6 @@ export const getStyles = (theme: GrafanaTheme2) => { fontFamily: theme.typography.fontFamilyMonospace, fontSize: theme.typography.fontSize, wordBreak: 'break-all', - cursor: 'pointer', '&:hover': { background: `hsla(0, 0%, 0%, 0.2)`, }, @@ -256,6 +310,9 @@ export const getStyles = (theme: GrafanaTheme2) => { color: theme.colors.text.primary, }, }), + detailsDisplayed: css({ + background: `hsla(0, 0%, 0%, 0.2)`, + }), pinnedLogLine: css({ backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(), }), @@ -272,6 +329,33 @@ export const getStyles = (theme: GrafanaTheme2) => { color: theme.colors.text.disabled, display: 'inline-block', }), + duplicates: css({ + display: 'inline-block', + textAlign: 'center', + width: theme.spacing(4.5), + }), + hasError: css({ + display: 'inline-block', + width: theme.spacing(2), + '& svg': { + position: 'relative', + top: -1, + }, + }), + isSampled: css({ + display: 'inline-block', + width: theme.spacing(2), + '& svg': { + position: 'relative', + top: -1, + }, + }), + logIconError: css({ + color: theme.colors.warning.main, + }), + logIconInfo: css({ + color: theme.colors.info.main, + }), level: css({ color: theme.colors.text.secondary, fontWeight: theme.typography.fontWeightBold, @@ -302,12 +386,14 @@ export const getStyles = (theme: GrafanaTheme2) => { outline: 'solid 1px red', }), unwrappedLogLine: css({ + cursor: 'pointer', display: 'grid', gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER), whiteSpace: 'pre', paddingBottom: theme.spacing(0.75), }), wrappedLogLine: css({ + cursor: 'pointer', alignSelf: 'flex-start', paddingBottom: theme.spacing(0.75), whiteSpace: 'pre-wrap', diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx new file mode 100644 index 00000000000..d290e43be29 --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -0,0 +1,101 @@ +import { css } from '@emotion/css'; +import { Resizable } from 're-resizable'; +import { useCallback, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useTranslate } from '@grafana/i18n'; +import { IconButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; + +import { LogDetails } from '../LogDetails'; +import { getLogRowStyles } from '../getLogRowStyles'; + +import { useLogListContext } from './LogListContext'; +import { LogListModel } from './processing'; + +interface Props { + containerElement: HTMLDivElement; + getFieldLinks?: GetFieldLinksFn; + logs: LogListModel[]; + onResize(): void; +} + +export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize }: Props) => { + const { + app, + closeDetails, + detailsWidth, + displayedFields, + isLabelFilterActive, + onClickFilterLabel, + onClickFilterOutLabel, + onClickShowField, + onClickHideField, + onPinLine, + pinLineButtonTooltipTitle, + setDetailsWidth, + showDetails, + wrapLogMessage, + } = useLogListContext(); + const getRows = useCallback(() => logs, [logs]); + const logRowsStyles = getLogRowStyles(useTheme2()); + const styles = useStyles2(getStyles); + const containerRef = useRef(null); + const { t } = useTranslate(); + + const handleResize = useCallback(() => { + if (containerRef.current) { + setDetailsWidth(containerRef.current.clientWidth); + } + onResize(); + }, [onResize, setDetailsWidth]); + + return ( + +
+ +
+ + + +
+
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + overflow: 'auto', + position: 'relative', + height: '100%', + }), + closeIcon: css({ + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1.5), + }), +}); diff --git a/public/app/features/logs/components/panel/LogLineMenu.test.tsx b/public/app/features/logs/components/panel/LogLineMenu.test.tsx index 7ea55f2a28e..9b6184cf41b 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.test.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.test.tsx @@ -8,6 +8,7 @@ import { createLogLine } from '../__mocks__/logRow'; import { getStyles } from './LogLine'; import { LogLineMenu } from './LogLineMenu'; import { LogListContextProvider } from './LogListContext'; +import { defaultProps, defaultValue } from './__mocks__/LogListContext'; import { LogListModel } from './processing'; jest.mock('./LogListContext'); @@ -15,6 +16,8 @@ jest.mock('./LogListContext'); const theme = createTheme(); const styles = getStyles(theme); const contextProps = { + ...defaultProps, + ...defaultValue, app: CoreApp.Unknown, dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], @@ -112,5 +115,25 @@ describe('LogLineMenu', () => { await userEvent.click(screen.getByText('Unpin log')); expect(onUnpinLine).toHaveBeenCalledTimes(1); }); + + test('Allows to open log details', async () => { + render( + + + + ); + await userEvent.click(screen.getByLabelText('Log menu')); + await screen.findByText('Show log details'); + }); + + test('Does not show log details option when disabled', async () => { + render( + + + + ); + await userEvent.click(screen.getByLabelText('Log menu')); + expect(screen.queryByText('Show log details')).not.toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/logs/components/panel/LogLineMenu.tsx b/public/app/features/logs/components/panel/LogLineMenu.tsx index 29f4f486fd8..e790ad8a21f 100644 --- a/public/app/features/logs/components/panel/LogLineMenu.tsx +++ b/public/app/features/logs/components/panel/LogLineMenu.tsx @@ -23,8 +23,17 @@ interface Props { } export const LogLineMenu = ({ log, styles }: Props) => { - const { getRowContextQuery, onOpenContext, onPermalinkClick, onPinLine, onUnpinLine, logSupportsContext } = - useLogListContext(); + const { + enableLogDetails, + detailsDisplayed, + getRowContextQuery, + onOpenContext, + onPermalinkClick, + onPinLine, + onUnpinLine, + logSupportsContext, + toggleDetails, + } = useLogListContext(); const pinned = useLogIsPinned(log); const menuRef = useRef(null); @@ -48,6 +57,10 @@ export const LogLineMenu = ({ log, styles }: Props) => { [onOpenContext, getRowContextQuery, log] ); + const toggleLogDetails = useCallback(() => { + toggleDetails(log); + }, [log, toggleDetails]); + const togglePinning = useCallback(() => { if (pinned) { onUnpinLine?.(log); @@ -61,11 +74,16 @@ export const LogLineMenu = ({ log, styles }: Props) => { const menu = useCallback( () => ( - - {onPermalinkClick && log.rowId !== undefined && log.uid && ( - + {enableLogDetails && ( + )} - {(shouldlogSupportsContext || onPinLine || onUnpinLine) && } {shouldlogSupportsContext && ( )} @@ -75,21 +93,28 @@ export const LogLineMenu = ({ log, styles }: Props) => { {pinned && onUnpinLine && ( )} + + + {onPermalinkClick && log.rowId !== undefined && log.uid && ( + + )} ), [ copyLinkToLogLine, copyLogLine, - log.rowId, - log.uid, + detailsDisplayed, + enableLogDetails, + log, onPermalinkClick, onPinLine, onUnpinLine, pinned, shouldlogSupportsContext, showContext, - togglePinning, t, + toggleLogDetails, + togglePinning, ] ); diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index be99c5bb1cd..02607301496 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -5,76 +5,86 @@ import { CoreApp, getDefaultTimeRange, LogRowModel, LogsDedupStrategy, LogsSortO import { createLogRow } from '../__mocks__/logRow'; -import { LogList } from './LogList'; - -const logs: LogRowModel[] = [createLogRow({ uid: '1' }), createLogRow({ uid: '2' })]; +import { LogList, Props } from './LogList'; describe('LogList', () => { + let logs: LogRowModel[], defaultProps: Props; + beforeEach(() => { + logs = [ + createLogRow({ uid: '1', labels: { name_of_the_label: 'value of the label' } }), + createLogRow({ uid: '2' }), + ]; + defaultProps = { + app: CoreApp.Explore, + containerElement: document.createElement('div'), + dedupStrategy: LogsDedupStrategy.none, + displayedFields: [], + enableLogDetails: false, + logs, + showControls: false, + showTime: false, + sortOrder: LogsSortOrder.Descending, + timeRange: getDefaultTimeRange(), + timeZone: 'browser', + wrapLogMessage: false, + }; + }); + test('Renders a list of logs without controls ', async () => { - const containerElement = document.createElement('div'); - render( - - ); + render(); expect(screen.getByText('log message 1')).toBeInTheDocument(); expect(screen.getByText('log message 2')).toBeInTheDocument(); expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument(); }); test('Renders a list of logs with controls', async () => { - const containerElement = document.createElement('div'); - render( - - ); + render(); expect(screen.getByText('log message 1')).toBeInTheDocument(); expect(screen.getByText('log message 2')).toBeInTheDocument(); expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); }); test('Reports mouse over events', async () => { - const containerElement = document.createElement('div'); const onLogRowHover = jest.fn(); - render( - - ); + render(); await userEvent.hover(screen.getByText('log message 1')); expect(onLogRowHover).toHaveBeenCalledTimes(1); expect(onLogRowHover).toHaveBeenCalledWith(expect.objectContaining(logs[0])); }); + + test('Supports showing log details', async () => { + const onClickFilterLabel = jest.fn(); + const onClickFilterOutLabel = jest.fn(); + const onClickShowField = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByText('log message 1')); + await screen.findByText('Fields'); + + expect(screen.getByText('name_of_the_label')).toBeInTheDocument(); + expect(screen.getByText('value of the label')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(onClickFilterLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(onClickFilterOutLabel).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Show this field instead of the message')); + expect(onClickShowField).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByLabelText('Close log details')); + + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Close log details')).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 7efcea74a96..f55a670b526 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -7,6 +7,7 @@ import { VariableSizeList } from 'react-window'; import { AbsoluteTimeRange, CoreApp, + DataFrame, EventBus, EventBusSrv, LogLevel, @@ -22,6 +23,7 @@ import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { InfiniteScroll } from './InfiniteScroll'; import { getGridTemplateColumns } from './LogLine'; +import { LogLineDetails } from './LogLineDetails'; import { GetRowContextQueryFn } from './LogLineMenu'; import { LogListContextProvider, LogListState, useLogListContext } from './LogListContext'; import { LogListControls } from './LogListControls'; @@ -36,23 +38,31 @@ import { storeLogLineSize, } from './virtualization'; -interface Props { +export interface Props { app: CoreApp; containerElement: HTMLDivElement; dedupStrategy: LogsDedupStrategy; displayedFields: string[]; + enableLogDetails: boolean; eventBus?: EventBus; filterLevels?: LogLevel[]; getFieldLinks?: GetFieldLinksFn; getRowContextQuery?: GetRowContextQueryFn; grammar?: Grammar; initialScrollPosition?: 'top' | 'bottom'; + isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise; loading?: boolean; loadMore?: (range: AbsoluteTimeRange) => void; logOptionsStorageKey?: string; logs: LogRowModel[]; logsMeta?: LogsMetaItem[]; logSupportsContext?: (row: LogRowModel) => boolean; + onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; + onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; + onClickFilterString?: (value: string, refId?: string) => void; + onClickFilterOutString?: (value: string, refId?: string) => void; + onClickShowField?: (key: string) => void; + onClickHideField?: (key: string) => void; onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void; onLogLineHover?: (row?: LogRowModel) => void; onPermalinkClick?: (row: LogRowModel) => Promise; @@ -64,7 +74,6 @@ interface Props { showControls: boolean; showTime: boolean; sortOrder: LogsSortOrder; - storageKey?: string; timeRange: TimeRange; timeZone: string; syntaxHighlighting?: boolean; @@ -75,7 +84,14 @@ export type LogListControlOptions = LogListState; type LogListComponentProps = Omit< Props, - 'app' | 'dedupStrategy' | 'displayedFields' | 'showTime' | 'sortOrder' | 'syntaxHighlighting' | 'wrapLogMessage' + | 'app' + | 'dedupStrategy' + | 'displayedFields' + | 'enableLogDetails' + | 'showTime' + | 'sortOrder' + | 'syntaxHighlighting' + | 'wrapLogMessage' >; export const LogList = ({ @@ -83,18 +99,26 @@ export const LogList = ({ displayedFields, containerElement, dedupStrategy, + enableLogDetails, eventBus, filterLevels, getFieldLinks, getRowContextQuery, grammar, initialScrollPosition = 'top', + isLabelFilterActive, loading, loadMore, logOptionsStorageKey, logs, logsMeta, logSupportsContext, + onClickFilterLabel, + onClickFilterOutLabel, + onClickFilterString, + onClickFilterOutString, + onClickShowField, + onClickHideField, onLogOptionsChange, onLogLineHover, onPermalinkClick, @@ -114,14 +138,23 @@ export const LogList = ({ return ( { - const { app, displayedFields, filterLevels, forceEscape, showTime, sortOrder, wrapLogMessage } = useLogListContext(); + const { + app, + displayedFields, + dedupStrategy, + filterLevels, + forceEscape, + showDetails, + showTime, + sortOrder, + toggleDetails, + wrapLogMessage, + } = useLogListContext(); const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState( app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight @@ -174,12 +218,21 @@ const LogListComponent = ({ const theme = useTheme2(); const listRef = useRef(null); const widthRef = useRef(containerElement.clientWidth); + const wrapperRef = useRef(null); const scrollRef = useRef(null); const dimensions = useMemo( () => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)), [displayedFields, processedLogs, wrapLogMessage] ); const styles = getStyles(dimensions, { showTime }); + const widthContainer = wrapperRef.current ?? containerElement; + + const debouncedResetAfterIndex = useMemo(() => { + return debounce((index: number) => { + listRef.current?.resetAfterIndex(index); + overflowIndexRef.current = Infinity; + }, 25); + }, []); useEffect(() => { initVirtualization(theme); @@ -205,7 +258,7 @@ const LogListComponent = ({ useEffect(() => { listRef.current?.resetAfterIndex(0); - }, [wrapLogMessage]); + }, [wrapLogMessage, showDetails, displayedFields]); useEffect(() => { const handleResize = debounce(() => { @@ -219,21 +272,23 @@ const LogListComponent = ({ }, [app, containerElement.clientHeight]); useLayoutEffect(() => { - if (widthRef.current === containerElement.clientWidth) { + if (widthRef.current === widthContainer.clientWidth) { return; } - widthRef.current = containerElement.clientWidth; - listRef.current?.resetAfterIndex(0); + widthRef.current = widthContainer.clientWidth; + debouncedResetAfterIndex(0); }); + const overflowIndexRef = useRef(Infinity); const handleOverflow = useCallback( (index: number, id: string, height?: number) => { - if (containerElement && height !== undefined) { - storeLogLineSize(id, containerElement, height); + if (height !== undefined) { + storeLogLineSize(id, widthContainer, height); } - listRef.current?.resetAfterIndex(index); + overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current; + debouncedResetAfterIndex(overflowIndexRef.current); }, - [containerElement] + [debouncedResetAfterIndex, widthContainer] ); const handleScrollPosition = useCallback(() => { @@ -245,6 +300,17 @@ const LogListComponent = ({ return null; } + const handleLogLineClick = useCallback( + (log: LogListModel) => { + toggleDetails(log); + }, + [toggleDetails] + ); + + const handleLogDetailsResize = useCallback(() => { + debouncedResetAfterIndex(0); + }, [debouncedResetAfterIndex]); + const filteredLogs = useMemo( () => filterLevels.length === 0 ? processedLogs : processedLogs.filter((log) => filterLevels.includes(log.logLevel)), @@ -253,41 +319,52 @@ const LogListComponent = ({ return (
- - {({ getItemKey, itemCount, onItemsRendered, Renderer }) => ( - - {Renderer} - - )} - +
+ + {({ getItemKey, itemCount, onItemsRendered, Renderer }) => ( + + {Renderer} + + )} + +
+ {showDetails.length > 0 && ( + + )} {showControls && }
); @@ -305,6 +382,9 @@ function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: bo logListContainer: css({ display: 'flex', }), + logListWrapper: css({ + width: '100%', + }), }; } diff --git a/public/app/features/logs/components/panel/LogListContext.test.tsx b/public/app/features/logs/components/panel/LogListContext.test.tsx index 1e64ba77ad3..8010efcdf74 100644 --- a/public/app/features/logs/components/panel/LogListContext.test.tsx +++ b/public/app/features/logs/components/panel/LogListContext.test.tsx @@ -4,19 +4,11 @@ import { ReactNode } from 'react'; import { createLogLine } from '../__mocks__/logRow'; import { useLogListContextData, useLogListContext, useLogIsPinned, LogListContext } from './LogListContext'; -import { defaultProps } from './__mocks__/LogListContext'; +import { defaultValue } from './__mocks__/LogListContext'; const log = createLogLine({ rowId: 'yep' }); const value = { - ...defaultProps, - downloadLogs: jest.fn(), - getRowContextQuery: jest.fn(), - logSupportsContext: jest.fn(), - onPermalinkClick: jest.fn(), - onPinLine: jest.fn(), - onOpenContext: jest.fn(), - onUnpinLine: jest.fn(), - pinLineButtonTooltipTitle: 'test', + ...defaultValue, pinnedLogs: ['yep'], }; const wrapper = ({ children }: { children: ReactNode }) => ( diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 7a1269d28be..2e9b35ee165 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -11,6 +11,7 @@ import { import { CoreApp, + DataFrame, LogLevel, LogRowModel, LogsDedupStrategy, @@ -24,13 +25,18 @@ import { PopoverContent } from '@grafana/ui'; import { DownloadFormat, downloadLogs as download } from '../../utils'; import { GetRowContextQueryFn } from './LogLineMenu'; +import { LogListModel } from './processing'; -export interface LogListContextData extends Omit { +export interface LogListContextData extends Omit { + closeDetails: () => void; + detailsDisplayed: (log: LogListModel) => boolean; + detailsWidth: number; downloadLogs: (format: DownloadFormat) => void; + enableLogDetails: boolean; filterLevels: LogLevel[]; hasUnescapedContent?: boolean; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; - setDisplayedFields: (displayedFields: string[]) => void; + setDetailsWidth: (width: number) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; setForceEscape: (forceEscape: boolean) => void; setLogListState: Dispatch>; @@ -41,17 +47,23 @@ export interface LogListContextData extends Omit void; setSortOrder: (sortOrder: LogsSortOrder) => void; setWrapLogMessage: (showTime: boolean) => void; + showDetails: LogListModel[]; + toggleDetails: (log: LogListModel) => void; } export const LogListContext = createContext({ app: CoreApp.Unknown, + closeDetails: () => {}, dedupStrategy: LogsDedupStrategy.none, + detailsDisplayed: () => false, + detailsWidth: 0, displayedFields: [], downloadLogs: () => {}, + enableLogDetails: false, filterLevels: [], hasUnescapedContent: false, setDedupStrategy: () => {}, - setDisplayedFields: () => {}, + setDetailsWidth: () => {}, setFilterLevels: () => {}, setForceEscape: () => {}, setLogListState: () => {}, @@ -62,9 +74,11 @@ export const LogListContext = createContext({ setSortOrder: () => {}, setSyntaxHighlighting: () => {}, setWrapLogMessage: () => {}, + showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, + toggleDetails: () => {}, wrapLogMessage: false, }); @@ -85,7 +99,6 @@ export const useLogIsPinned = (log: LogRowModel) => { export type LogListState = Pick< LogListContextData, | 'dedupStrategy' - | 'displayedFields' | 'forceEscape' | 'filterLevels' | 'hasUnescapedContent' @@ -101,16 +114,25 @@ export type LogListState = Pick< export interface Props { app: CoreApp; children?: ReactNode; + containerElement?: HTMLDivElement; dedupStrategy: LogsDedupStrategy; displayedFields: string[]; + enableLogDetails: boolean; filterLevels?: LogLevel[]; forceEscape?: boolean; hasUnescapedContent?: boolean; getRowContextQuery?: GetRowContextQueryFn; + isLabelFilterActive?: (key: string, value: string, refId?: string) => Promise; logs: LogRowModel[]; logsMeta?: LogsMetaItem[]; logOptionsStorageKey?: string; logSupportsContext?: (row: LogRowModel) => boolean; + onClickFilterLabel?: (key: string, value: string, frame?: DataFrame) => void; + onClickFilterOutLabel?: (key: string, value: string, frame?: DataFrame) => void; + onClickFilterString?: (value: string, refId?: string) => void; + onClickFilterOutString?: (value: string, refId?: string) => void; + onClickShowField?: (key: string) => void; + onClickHideField?: (key: string) => void; onLogOptionsChange?: (option: keyof LogListState, value: string | boolean | string[]) => void; onLogLineHover?: (row?: LogRowModel) => void; onPermalinkClick?: (row: LogRowModel) => Promise; @@ -131,16 +153,25 @@ export interface Props { export const LogListContextProvider = ({ app, children, + containerElement, + enableLogDetails, dedupStrategy, displayedFields, filterLevels, forceEscape = false, hasUnescapedContent, + isLabelFilterActive, getRowContextQuery, logs, logsMeta, logOptionsStorageKey, logSupportsContext, + onClickFilterLabel, + onClickFilterOutLabel, + onClickFilterString, + onClickFilterOutString, + onClickShowField, + onClickHideField, onLogOptionsChange, onLogLineHover, onPermalinkClick, @@ -159,7 +190,6 @@ export const LogListContextProvider = ({ }: Props) => { const [logListState, setLogListState] = useState({ dedupStrategy, - displayedFields, filterLevels: filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), forceEscape, @@ -172,6 +202,7 @@ export const LogListContextProvider = ({ syntaxHighlighting, wrapLogMessage, }); + const [showDetails, setShowDetails] = useState([]); useEffect(() => { // Props are updated in the context only of the panel is being externally controlled. @@ -186,9 +217,6 @@ export const LogListContextProvider = ({ syntaxHighlighting, wrapLogMessage, }; - if (!shallowCompare(logListState.displayedFields, displayedFields)) { - newState.displayedFields = displayedFields; - } if (!shallowCompare(logListState.pinnedLogs ?? [], pinnedLogs ?? [])) { newState.pinnedLogs = pinnedLogs; } @@ -198,7 +226,6 @@ export const LogListContextProvider = ({ }, [ app, dedupStrategy, - displayedFields, logListState, pinnedLogs, showControls, @@ -223,6 +250,11 @@ export const LogListContextProvider = ({ } }, [hasUnescapedContent, logListState]); + const detailsDisplayed = useCallback( + (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), + [showDetails] + ); + const setDedupStrategy = useCallback( (dedupStrategy: LogsDedupStrategy) => { setLogListState({ ...logListState, dedupStrategy }); @@ -231,14 +263,6 @@ export const LogListContextProvider = ({ [logListState, onLogOptionsChange] ); - const setDisplayedFields = useCallback( - (displayedFields: string[]) => { - setLogListState({ ...logListState, displayedFields }); - onLogOptionsChange?.('displayedFields', displayedFields); - }, - [logListState, onLogOptionsChange] - ); - const setForceEscape = useCallback( (forceEscape: boolean) => { setLogListState({ ...logListState, forceEscape }); @@ -339,18 +363,64 @@ export const LogListContextProvider = ({ [logListState.filterLevels, logs, logsMeta] ); + const closeDetails = useCallback(() => { + setShowDetails([]); + }, []); + + const toggleDetails = useCallback( + (log: LogListModel) => { + if (!enableLogDetails) { + return; + } + const found = showDetails.findIndex((stateLog) => stateLog === log || stateLog.uid === log.uid); + if (found >= 0) { + setShowDetails(showDetails.filter((stateLog) => stateLog !== log && stateLog.uid !== log.uid)); + } else { + // Supporting one displayed details for now + setShowDetails([log]); + } + }, + [enableLogDetails, showDetails] + ); + + const setDetailsWidth = useCallback( + (width: number) => { + if (!logOptionsStorageKey) { + return; + } + store.set(`${logOptionsStorageKey}.detailsWidth`, width); + }, + [logOptionsStorageKey] + ); + + const defaultWidth = (containerElement?.clientWidth ?? 0) * 0.4; + const detailsWidth = logOptionsStorageKey + ? parseInt(store.get(`${logOptionsStorageKey}.detailsWidth`), 10) + : defaultWidth; + return ( diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx index b6de426a911..63541e86c3a 100644 --- a/public/app/features/logs/components/panel/LogListControls.test.tsx +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -14,8 +14,10 @@ jest.mock('../../utils'); const contextProps = { app: CoreApp.Unknown, + containerElement: document.createElement('div'), dedupStrategy: LogsDedupStrategy.exact, displayedFields: [], + enableLogDetails: false, logs: [], showControls: true, showTime: false, diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 9ce3ed89700..56f56a8114d 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -395,6 +395,7 @@ const getStyles = (theme: GrafanaTheme2) => { paddingLeft: theme.spacing(1), borderLeft: `solid 1px ${theme.colors.border.medium}`, overflow: 'hidden', + minWidth: theme.spacing(4), }), scrollToTopButton: css({ margin: 0, diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index aef5d840b80..53f002a2a2d 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -6,12 +6,17 @@ import { LogListContextData, Props } from '../LogListContext'; export const LogListContext = createContext({ app: CoreApp.Unknown, + closeDetails: () => {}, dedupStrategy: LogsDedupStrategy.none, + detailsDisplayed: () => false, + detailsWidth: 0, displayedFields: [], downloadLogs: () => {}, + enableLogDetails: false, filterLevels: [], + hasUnescapedContent: false, setDedupStrategy: () => {}, - setDisplayedFields: () => {}, + setDetailsWidth: () => {}, setFilterLevels: () => {}, setForceEscape: () => {}, setLogListState: () => {}, @@ -22,9 +27,11 @@ export const LogListContext = createContext({ setSortOrder: () => {}, setSyntaxHighlighting: () => {}, setWrapLogMessage: () => {}, + showDetails: [], showTime: true, sortOrder: LogsSortOrder.Ascending, syntaxHighlighting: true, + toggleDetails: () => {}, wrapLogMessage: false, }); @@ -42,21 +49,8 @@ export const useLogIsPinned = (log: LogRowModel) => { return pinnedLogs?.some((logId) => logId === log.rowId); }; -export const defaultProps = { - app: CoreApp.Explore, - dedupStrategy: LogsDedupStrategy.none, - displayedFields: [], - filterLevels: [], - getRowContextQuery: jest.fn(), - logSupportsContext: jest.fn(), - logs: [], - onPermalinkClick: jest.fn(), - onPinLine: jest.fn(), - onOpenContext: jest.fn(), - onUnpinLine: jest.fn(), - pinnedLogs: [], +export const defaultValue: LogListContextData = { setDedupStrategy: jest.fn(), - setDisplayedFields: jest.fn(), setFilterLevels: jest.fn(), setForceEscape: jest.fn(), setLogListState: jest.fn(), @@ -67,6 +61,38 @@ export const defaultProps = { setPrettifyJSON: jest.fn(), setSyntaxHighlighting: jest.fn(), setWrapLogMessage: jest.fn(), + closeDetails: jest.fn(), + detailsDisplayed: jest.fn(), + detailsWidth: 0, + downloadLogs: jest.fn(), + enableLogDetails: false, + filterLevels: [], + setDetailsWidth: jest.fn(), + showDetails: [], + toggleDetails: jest.fn(), + app: CoreApp.Explore, + dedupStrategy: LogsDedupStrategy.exact, + displayedFields: [], + showTime: false, + sortOrder: LogsSortOrder.Ascending, + wrapLogMessage: false, +}; + +export const defaultProps: Props = { + app: CoreApp.Explore, + containerElement: document.createElement('div'), + dedupStrategy: LogsDedupStrategy.none, + displayedFields: [], + enableLogDetails: false, + filterLevels: [], + getRowContextQuery: jest.fn(), + logSupportsContext: jest.fn(), + logs: [], + onPermalinkClick: jest.fn(), + onPinLine: jest.fn(), + onOpenContext: jest.fn(), + onUnpinLine: jest.fn(), + pinnedLogs: [], showControls: true, showTime: true, sortOrder: LogsSortOrder.Descending, @@ -79,6 +105,7 @@ export const LogListContextProvider = ({ children, dedupStrategy = LogsDedupStrategy.none, displayedFields = [], + enableLogDetails = false, filterLevels = [], getRowContextQuery = jest.fn(), logSupportsContext = jest.fn(), @@ -96,10 +123,12 @@ export const LogListContextProvider = ({ return ( { container = document.createElement('div'); jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200); entry = new Array(2 * getTruncationLength(null)).fill('e').join(''); - longLog = createLogLine({ entry }); + longLog = createLogLine({ entry, labels: { field: 'value' } }); }); test('Long lines that are not truncated are not modified', () => { @@ -178,6 +178,18 @@ describe('preProcessLogs', () => { expect(entry).toContain(longLog.body); }); + test('Considers the displayed fields to set the collapsed state', () => { + // Make container half of the size + jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(100); + + expect(longLog.collapsed).toBeUndefined(); + + // Log line body is not included in the displayed fields, so it fits in the container + longLog.updateCollapsedState(['field'], container); + + expect(longLog.collapsed).toBeUndefined(); + }); + test('Updates the body based on the collapsed state', () => { expect(longLog.collapsed).toBeUndefined(); expect(longLog.body).toBe(entry); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index a316ba21f14..b666740f1bc 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -3,7 +3,7 @@ import Prism, { Grammar } from 'prismjs'; import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; -import { escapeUnescapedString, sortLogRows } from '../../utils'; +import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows } from '../../utils'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { FieldDef, getAllFields } from '../logParser'; @@ -19,7 +19,9 @@ export class LogListModel implements LogRowModel { entry: string; entryFieldIndex: number; hasAnsi: boolean; + hasError: boolean; hasUnescapedContent: boolean; + isSampled: boolean; labels: Labels; logLevel: LogLevel; raw: string; @@ -49,7 +51,9 @@ export class LogListModel implements LogRowModel { this.entry = log.entry; this.entryFieldIndex = log.entryFieldIndex; this.hasAnsi = log.hasAnsi; + this.hasError = !!checkLogsError(log); this.hasUnescapedContent = log.hasUnescapedContent; + this.isSampled = !!checkLogsSampled(log); this.labels = log.labels; this.logLevel = log.logLevel; this.rowIndex = log.rowIndex; @@ -88,6 +92,10 @@ export class LogListModel implements LogRowModel { return this._body; } + get errorMessage(): string | undefined { + return checkLogsError(this); + } + get fields(): FieldDef[] { if (this._fields === undefined) { this._fields = getAllFields(this, this._getFieldLinks); @@ -103,6 +111,10 @@ export class LogListModel implements LogRowModel { return this._highlightedBody; } + get sampledMessage(): string | undefined { + return checkLogsSampled(this); + } + getDisplayedFieldValue(fieldName: string): string { if (fieldName === LOG_LINE_BODY_FIELD_NAME) { return this.body; @@ -119,7 +131,9 @@ export class LogListModel implements LogRowModel { updateCollapsedState(displayedFields: string[], container: HTMLDivElement | null) { const lineLength = - displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length + this.raw.length; + displayedFields.length > 0 + ? displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length + : this.raw.length; const collapsed = lineLength >= getTruncationLength(container) ? true : undefined; if (this.collapsed === undefined || collapsed === undefined) { this.collapsed = collapsed; diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts index 0fb2e06f09a..d570467b598 100644 --- a/public/app/features/logs/components/panel/virtualization.test.ts +++ b/public/app/features/logs/components/panel/virtualization.test.ts @@ -13,6 +13,7 @@ const TWO_LINES_HEIGHT = 2 * LINE_HEIGHT + PADDING_BOTTOM; const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM; let LETTER_WIDTH: number; let CONTAINER_SIZE = 200; +let TWO_LINES_OF_CHARACTERS: number; describe('Virtualization', () => { let log: LogListModel, container: HTMLDivElement; @@ -22,11 +23,12 @@ describe('Virtualization', () => { jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE); init(createTheme()); LETTER_WIDTH = measureTextWidth('e'); + TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; }); describe('getLogLineSize', () => { test('Returns the a single line if the display mode is unwrapped', () => { - const size = getLogLineSize([log], container, [], { wrap: false, showControls: true, showTime: true }, 0); + const size = getLogLineSize([log], container, [], { wrap: false, showTime: true, showDuplicates: false }, 0); expect(size).toBe(SINGLE_LINE_HEIGHT); }); @@ -36,7 +38,7 @@ describe('Virtualization', () => { logs, container, [], - { wrap: true, showControls: true, showTime: true }, + { wrap: true, showTime: true, showDuplicates: false }, logs.length + 1 ); expect(size).toBe(SINGLE_LINE_HEIGHT); @@ -46,43 +48,35 @@ describe('Virtualization', () => { // Very small container log.collapsed = true; jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); - const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0); + const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0); expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT); }); - test.each([ - [false, false], - [true, false], - [false, true], - [true, true], - ])('Measures a log line with controls %s and displayed time %s', (showControls: boolean, showTime: boolean) => { - const size = getLogLineSize([log], container, [], { wrap: true, showControls, showTime }, 0); + test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => { + const size = getLogLineSize([log], container, [], { wrap: true, showTime, showDuplicates: false }, 0); expect(size).toBe(SINGLE_LINE_HEIGHT); }); - test('Measures a multi-line log line with no controls and no displayed time', () => { - const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; + test('Measures a multi-line log line with no displayed time', () => { log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''), logLevel: undefined, }); - const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0); + const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); expect(size).toBe(TWO_LINES_HEIGHT); }); test('Measures a multi-line log line with level, controls, and displayed time', () => { - const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); - const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0); + const size = getLogLineSize([log], container, [], { wrap: true, showTime: true, showDuplicates: false }, 0); // Two lines for the log and one extra for level and time expect(size).toBe(THREE_LINES_HEIGHT); }); test('Measures a multi-line log line with displayed fields', () => { - const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; log = createLogLine({ labels: { place: 'very very long value for the displayed field that causes a new line' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''), @@ -93,7 +87,7 @@ describe('Virtualization', () => { [log], container, ['place', LOG_LINE_BODY_FIELD_NAME], - { wrap: true, showControls: false, showTime: false }, + { wrap: true, showTime: false, showDuplicates: false }, 0 ); // Two lines for the log and one extra for the displayed fields @@ -101,18 +95,50 @@ describe('Virtualization', () => { }); test('Measures displayed fields in a log line with level, controls, and displayed time', () => { - const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 2; log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); - const size = getLogLineSize([log], container, ['place'], { wrap: true, showControls: true, showTime: true }, 0); + const size = getLogLineSize( + [log], + container, + ['place'], + { wrap: true, showTime: true, showDuplicates: false }, + 0 + ); // Only renders a short displayed field, so a single line expect(size).toBe(SINGLE_LINE_HEIGHT); }); + test('Measures a multi-line log line with duplicates', () => { + log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); + log.duplicates = 1; + + const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: true }, 0); + // Two lines for the log and one extra for duplicates + expect(size).toBe(THREE_LINES_HEIGHT); + }); + + test('Measures a multi-line log line with errors', () => { + log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); + log.hasError = true; + + const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); + // Two lines for the log and one extra for the error icon + expect(size).toBe(THREE_LINES_HEIGHT); + }); + + test('Measures a multi-line sampled log line', () => { + log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') }); + log.isSampled = true; + + const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); + // Two lines for the log and one extra for the sampled icon + expect(size).toBe(THREE_LINES_HEIGHT); + }); + test('Adds an extra line for the expand/collapse controls if present', () => { jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined); log.collapsed = false; - const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0); + const size = getLogLineSize([log], container, [], { wrap: true, showTime: false, showDuplicates: false }, 0); expect(size).toBe(TWO_LINES_HEIGHT); }); }); diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 18ffaeb358a..26c7f8699d9 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -13,7 +13,6 @@ const iconWidth = 24; // Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body export const FIELD_GAP_MULTIPLIER = 1.5; -const LOG_LIST_NAVIGATION_WIDTH = 28; export const getLineHeight = () => lineHeight; @@ -148,16 +147,16 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = } interface DisplayOptions { - wrap: boolean; - showControls: boolean; + showDuplicates: boolean; showTime: boolean; + wrap: boolean; } export function getLogLineSize( logs: LogListModel[], container: HTMLDivElement | null, displayedFields: string[], - { wrap, showControls, showTime }: DisplayOptions, + { showDuplicates, showTime, wrap }: DisplayOptions, index: number ) { if (!container) { @@ -167,7 +166,6 @@ export function getLogLineSize( if (!wrap || !logs[index]) { return lineHeight + paddingBottom; } - // If a long line is collapsed, we show the line count + an extra line for the expand/collapse control logs[index].updateCollapsedState(displayedFields, container); if (logs[index].collapsed) { @@ -182,8 +180,14 @@ export function getLogLineSize( let textToMeasure = ''; const gap = gridSize * FIELD_GAP_MULTIPLIER; let optionsWidth = 0; - if (showControls) { - optionsWidth += LOG_LIST_NAVIGATION_WIDTH; + if (showDuplicates) { + optionsWidth += gridSize * 4.5 + gap; + } + if (logs[index].hasError) { + optionsWidth += gridSize * 2 + gap; + } + if (logs[index].isSampled) { + optionsWidth += gridSize * 2 + gap; } if (showTime) { optionsWidth += gap; diff --git a/public/app/plugins/panel/logs-new/LogsPanel.test.tsx b/public/app/plugins/panel/logs-new/LogsPanel.test.tsx index d9d5d35587e..77570bf19ab 100644 --- a/public/app/plugins/panel/logs-new/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs-new/LogsPanel.test.tsx @@ -115,7 +115,7 @@ beforeAll(() => { describe('LogsPanel', () => { test('Renders a list of logs without controls ', async () => { setup(); - expect(await screen.findByText('logline text')).toBeInTheDocument(); + await screen.findByText('logline text'); expect(screen.queryByLabelText('Scroll to bottom')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Display levels')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Scroll to top')).not.toBeInTheDocument(); @@ -123,7 +123,7 @@ describe('LogsPanel', () => { test('Renders a list of logs with controls', async () => { setup({ options: { ...defaultProps.options, showControls: true } }); - expect(await screen.findByText('logline text')).toBeInTheDocument(); + await screen.findByText('logline text'); expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); expect(screen.getByLabelText('Display levels')).toBeInTheDocument(); expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); diff --git a/public/app/plugins/panel/logs-new/LogsPanel.tsx b/public/app/plugins/panel/logs-new/LogsPanel.tsx index 135c72e28c0..e62051a34c1 100644 --- a/public/app/plugins/panel/logs-new/LogsPanel.tsx +++ b/public/app/plugins/panel/logs-new/LogsPanel.tsx @@ -32,6 +32,7 @@ export const LogsPanel = ({ timeZone, fieldConfig, options: { + controlsStorageKey, dedupStrategy, enableInfiniteScrolling, grammar, @@ -129,6 +130,16 @@ export const LogsPanel = ({ return 'top'; }, [data.request?.app, sortOrder]); + const storageKey = useMemo(() => { + if (controlsStorageKey) { + return controlsStorageKey; + } + if (!data.request) { + return undefined; + } + return `${data.request?.dashboardUID}.${id}`; + }, [controlsStorageKey, data.request, id]); + if (!logs.length) { return ; } @@ -141,6 +152,7 @@ export const LogsPanel = ({ containerElement={logsContainer} dedupStrategy={dedupStrategy} displayedFields={[]} + enableLogDetails grammar={isLogsGrammar(grammar) ? grammar : undefined} initialScrollPosition={initialScrollPosition} logs={logs} @@ -150,6 +162,7 @@ export const LogsPanel = ({ showControls={showControls} showTime={showTime} sortOrder={sortOrder} + logOptionsStorageKey={storageKey} syntaxHighlighting={syntaxHighlighting} timeRange={data.timeRange} timeZone={timeZone} diff --git a/public/app/plugins/panel/logs-new/panelcfg.cue b/public/app/plugins/panel/logs-new/panelcfg.cue index 1b736468f15..419217c31eb 100644 --- a/public/app/plugins/panel/logs-new/panelcfg.cue +++ b/public/app/plugins/panel/logs-new/panelcfg.cue @@ -37,6 +37,7 @@ composableKinds: PanelCfg: { enableInfiniteScrolling?: bool onLogOptionsChange?: _ onNewLogsReceived?: _ + controlsStorageKey?: string } @cuetsy(kind="interface") } }] diff --git a/public/app/plugins/panel/logs-new/panelcfg.gen.ts b/public/app/plugins/panel/logs-new/panelcfg.gen.ts index 017984570be..cce2a64411b 100644 --- a/public/app/plugins/panel/logs-new/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs-new/panelcfg.gen.ts @@ -11,6 +11,7 @@ import * as common from '@grafana/schema'; export interface Options { + controlsStorageKey?: string; dedupStrategy: common.LogsDedupStrategy; enableInfiniteScrolling?: boolean; enableLogDetails: boolean; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5b0f410a274..88dcc32fb55 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6148,6 +6148,7 @@ "label-wrap-lines": "Wrap lines" }, "log-details": { + "close": "Close log details", "fields": "Fields", "links": "Links", "log-line": "Log line", @@ -6167,15 +6168,20 @@ "log-line": "log line" }, "log-line": { + "has-error": "Has errors", + "is-sampled": "Is sampled", "show-less": "show less", - "show-more": "show more" + "show-more": "show more", + "tooltip-error": "Error: {{errorMessage}}" }, "log-line-menu": { "copy-link": "Copy link to log line", "copy-log": "Copy log line", + "hide-details": "Show log details", "icon-label": "Log menu", "pin-to-outline": "Pin log", "show-context": "Show context", + "show-details": "Hide log details", "unpin-from-outline": "Unpin log" }, "log-row-context-modal": {