From 5b2184c4850657158617d7e3c81ea331066b989b Mon Sep 17 00:00:00 2001 From: Sven Grossmann Date: Wed, 11 Jan 2023 19:20:11 +0100 Subject: [PATCH] Logs: Unify detected fields and labels in Log Details (#60448) * removed js-fields * added buttons * rename detectedField to field * removed unused things from `logParser.ts` * improve comment * wip * better way for statistics * better hide-stats button * update tests * updated tests and var names * made props optional again * fix padding * fix unused import * removed test * close elements * renamed `LogRowMessageDetectedFields` to `LogRowMessageDisplayedFields` * add active style to menu button * changed comment in logParser * updated ToolbarButton colors * rename `Data Links` to `Links` * fix stats button being wrongly highlighted --- .betterer.results | 3 - public/app/features/explore/Logs.test.tsx | 2 + public/app/features/explore/Logs.tsx | 32 +- public/app/features/explore/LogsContainer.tsx | 4 +- public/app/features/explore/LogsMetaRow.tsx | 8 +- .../logs/components/LogDetails.test.tsx | 21 +- .../features/logs/components/LogDetails.tsx | 88 ++++-- .../logs/components/LogDetailsRow.test.tsx | 45 +-- .../logs/components/LogDetailsRow.tsx | 295 ++++++++++++------ .../app/features/logs/components/LogRow.tsx | 26 +- ...s.tsx => LogRowMessageDisplayedFields.tsx} | 12 +- .../features/logs/components/LogRows.test.tsx | 30 ++ .../app/features/logs/components/LogRows.tsx | 24 +- .../logs/components/getLogRowStyles.ts | 5 +- .../app/features/logs/components/logParser.ts | 77 +---- 15 files changed, 376 insertions(+), 296 deletions(-) rename public/app/features/logs/components/{LogRowMessageDetectedFields.tsx => LogRowMessageDisplayedFields.tsx} (71%) diff --git a/.betterer.results b/.betterer.results index 37786d5e4ff..c16b06a4d69 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3986,9 +3986,6 @@ exports[`better eslint`] = { "public/app/features/logs/components/LogRows.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/logs/components/logParser.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/logs/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/public/app/features/explore/Logs.test.tsx b/public/app/features/explore/Logs.test.tsx index b53ea559353..59f2120253e 100644 --- a/public/app/features/explore/Logs.test.tsx +++ b/public/app/features/explore/Logs.test.tsx @@ -20,6 +20,8 @@ describe('Logs', () => { splitOpen={() => undefined} logsVolumeEnabled={true} onSetLogsVolumeEnabled={() => null} + onClickFilterLabel={() => null} + onClickFilterOutLabel={() => null} logsVolumeData={undefined} loadLogsVolumeData={() => undefined} logRows={rows} diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index a874f5185f9..0bcdf60f5ca 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -74,8 +74,8 @@ interface Props extends Themeable2 { loadLogsVolumeData: () => void; showContextToggle?: (row?: LogRowModel) => boolean; onChangeTime: (range: AbsoluteTimeRange) => void; - onClickFilterLabel?: (key: string, value: string) => void; - onClickFilterOutLabel?: (key: string, value: string) => void; + onClickFilterLabel: (key: string, value: string) => void; + onClickFilterOutLabel: (key: string, value: string) => void; onStartScanning?: () => void; onStopScanning?: () => void; getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise; @@ -94,7 +94,7 @@ interface State { hiddenLogLevels: LogLevel[]; logsSortOrder: LogsSortOrder | null; isFlipping: boolean; - showDetectedFields: string[]; + displayedFields: string[]; forceEscape: boolean; } @@ -123,7 +123,7 @@ class UnthemedLogs extends PureComponent { hiddenLogLevels: [], logsSortOrder: store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending, isFlipping: false, - showDetectedFields: [], + displayedFields: [], forceEscape: false, }; @@ -255,24 +255,24 @@ class UnthemedLogs extends PureComponent { } }; - showDetectedField = (key: string) => { - const index = this.state.showDetectedFields.indexOf(key); + showField = (key: string) => { + const index = this.state.displayedFields.indexOf(key); if (index === -1) { this.setState((state) => { return { - showDetectedFields: state.showDetectedFields.concat(key), + displayedFields: state.displayedFields.concat(key), }; }); } }; - hideDetectedField = (key: string) => { - const index = this.state.showDetectedFields.indexOf(key); + hideField = (key: string) => { + const index = this.state.displayedFields.indexOf(key); if (index > -1) { this.setState((state) => { return { - showDetectedFields: state.showDetectedFields.filter((k) => key !== k), + displayedFields: state.displayedFields.filter((k) => key !== k), }; }); } @@ -281,7 +281,7 @@ class UnthemedLogs extends PureComponent { clearDetectedFields = () => { this.setState((state) => { return { - showDetectedFields: [], + displayedFields: [], }; }); }; @@ -355,7 +355,7 @@ class UnthemedLogs extends PureComponent { hiddenLogLevels, logsSortOrder, isFlipping, - showDetectedFields, + displayedFields, forceEscape, } = this.state; @@ -477,7 +477,7 @@ class UnthemedLogs extends PureComponent { dedupCount={dedupCount} hasUnescapedContent={hasUnescapedContent} forceEscape={forceEscape} - showDetectedFields={showDetectedFields} + displayedFields={displayedFields} onEscapeNewlines={this.onEscapeNewlines} clearDetectedFields={this.clearDetectedFields} /> @@ -500,9 +500,9 @@ class UnthemedLogs extends PureComponent { timeZone={timeZone} getFieldLinks={getFieldLinks} logsSortOrder={logsSortOrder} - showDetectedFields={showDetectedFields} - onClickShowDetectedField={this.showDetectedField} - onClickHideDetectedField={this.hideDetectedField} + displayedFields={displayedFields} + onClickShowField={this.showField} + onClickHideField={this.hideField} app={CoreApp.Explore} scrollElement={scrollElement} onLogRowHover={this.onLogRowHover} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 85adaf2497d..dbd06c58c15 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -33,8 +33,8 @@ interface LogsContainerProps extends PropsFromRedux { syncedTimes: boolean; loadingState: LoadingState; scrollElement?: HTMLDivElement; - onClickFilterLabel?: (key: string, value: string) => void; - onClickFilterOutLabel?: (key: string, value: string) => void; + onClickFilterLabel: (key: string, value: string) => void; + onClickFilterOutLabel: (key: string, value: string) => void; onStartScanning: () => void; onStopScanning: () => void; eventBus: EventBus; diff --git a/public/app/features/explore/LogsMetaRow.tsx b/public/app/features/explore/LogsMetaRow.tsx index 168e5f205a8..45eeebb5de9 100644 --- a/public/app/features/explore/LogsMetaRow.tsx +++ b/public/app/features/explore/LogsMetaRow.tsx @@ -23,7 +23,7 @@ export type Props = { meta: LogsMetaItem[]; dedupStrategy: LogsDedupStrategy; dedupCount: number; - showDetectedFields: string[]; + displayedFields: string[]; hasUnescapedContent: boolean; forceEscape: boolean; logRows: LogRowModel[]; @@ -36,7 +36,7 @@ export const LogsMetaRow = React.memo( meta, dedupStrategy, dedupCount, - showDetectedFields, + displayedFields, clearDetectedFields, hasUnescapedContent, forceEscape, @@ -74,11 +74,11 @@ export const LogsMetaRow = React.memo( } // Add detected fields info - if (showDetectedFields?.length > 0) { + if (displayedFields?.length > 0) { logsMetaItem.push( { label: 'Showing only detected fields', - value: renderMetaItem(showDetectedFields, LogsMetaKind.LabelsMap), + value: renderMetaItem(displayedFields, LogsMetaKind.LabelsMap), }, { label: '', diff --git a/public/app/features/logs/components/LogDetails.test.tsx b/public/app/features/logs/components/LogDetails.test.tsx index 1effa11aec3..1d7e5806299 100644 --- a/public/app/features/logs/components/LogDetails.test.tsx +++ b/public/app/features/logs/components/LogDetails.test.tsx @@ -8,12 +8,15 @@ import { createLogRow } from './__mocks__/logRow'; const setup = (propOverrides?: Partial, rowOverrides?: Partial) => { const props: Props = { + displayedFields: [], showDuplicates: false, wrapLogMessage: false, row: createLogRow({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides }), getRows: () => [], onClickFilterLabel: () => {}, onClickFilterOutLabel: () => {}, + onClickShowField: () => {}, + onClickHideField: () => {}, theme: createTheme(), ...(propOverrides || {}), }; @@ -31,7 +34,7 @@ describe('LogDetails', () => { describe('when labels are present', () => { it('should render heading', () => { setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); - expect(screen.getAllByLabelText('Log labels')).toHaveLength(1); + expect(screen.getAllByLabelText('Fields')).toHaveLength(1); }); it('should render labels', () => { setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); @@ -48,29 +51,15 @@ describe('LogDetails', () => { expect(screen.getByLabelText('Log level').classList.toString()).not.toContain('logs-row__level'); }); }); - describe('when row entry has parsable fields', () => { - it('should render heading ', () => { - setup(undefined, { entry: 'test=successful' }); - expect(screen.getAllByTitle('Ad-hoc statistics')).toHaveLength(1); - }); - it('should render detected fields', () => { - setup(undefined, { entry: 'test=successful' }); - expect(screen.getByRole('cell', { name: 'test' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: 'successful' })).toBeInTheDocument(); - }); - }); describe('when row entry have parsable fields and labels are present', () => { it('should render all headings', () => { setup(undefined, { entry: 'test=successful', labels: { key: 'label' } }); - expect(screen.getAllByLabelText('Log labels')).toHaveLength(1); - expect(screen.getAllByLabelText('Detected fields')).toHaveLength(1); + expect(screen.getAllByLabelText('Fields')).toHaveLength(1); }); it('should render all labels and detected fields', () => { setup(undefined, { entry: 'test=successful', labels: { key: 'label' } }); expect(screen.getByRole('cell', { name: 'key' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'label' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: 'test' })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: 'successful' })).toBeInTheDocument(); }); }); describe('when row entry and labels are not present', () => { diff --git a/public/app/features/logs/components/LogDetails.tsx b/public/app/features/logs/components/LogDetails.tsx index 6b72afd5a79..8c3fcc5d3aa 100644 --- a/public/app/features/logs/components/LogDetails.tsx +++ b/public/app/features/logs/components/LogDetails.tsx @@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css'; import memoizeOne from 'memoize-one'; import React, { PureComponent } from 'react'; -import { Field, LinkModel, LogRowModel, GrafanaTheme2, CoreApp, DataFrame } from '@grafana/data'; -import { withTheme2, Themeable2, Icon, Tooltip } from '@grafana/ui'; +import { CoreApp, DataFrame, Field, GrafanaTheme2, LinkModel, LogRowModel } from '@grafana/data'; +import { Themeable2, withTheme2 } from '@grafana/ui'; import { calculateFieldStats, calculateLogsLabelStats, calculateStats, getParser } from '../utils'; @@ -25,9 +25,9 @@ export interface Props extends Themeable2 { onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; - showDetectedFields?: string[]; - onClickShowDetectedField?: (key: string) => void; - onClickHideDetectedField?: (key: string) => void; + displayedFields?: string[]; + onClickShowField?: (key: string) => void; + onClickHideField?: (key: string) => void; } const getStyles = (theme: GrafanaTheme2) => { @@ -52,7 +52,7 @@ const getStyles = (theme: GrafanaTheme2) => { class UnThemedLogDetails extends PureComponent { getParser = memoizeOne(getParser); - getStatsForDetectedField = (key: string) => { + getStatsForField = (key: string) => { const matcher = this.getParser(this.props.row.entry)!.buildMatcher(key); return calculateFieldStats(this.props.getRows(), matcher); }; @@ -68,9 +68,9 @@ class UnThemedLogDetails extends PureComponent { getRows, showDuplicates, className, - onClickShowDetectedField, - onClickHideDetectedField, - showDetectedFields, + onClickShowField, + onClickHideField, + displayedFields, getFieldLinks, wrapLogMessage, } = this.props; @@ -78,8 +78,12 @@ class UnThemedLogDetails extends PureComponent { const styles = getStyles(theme); const labels = row.labels ? row.labels : {}; const labelsAvailable = Object.keys(labels).length > 0; - const fields = getAllFields(row, getFieldLinks); - const detectedFieldsAvailable = fields && fields.length > 0; + const fieldsAndLinks = getAllFields(row, getFieldLinks); + const links = fieldsAndLinks.filter((f) => f.links?.length).sort(); + const fields = fieldsAndLinks.filter((f) => f.links?.length === 0).sort(); + const fieldsAvailable = fields && fields.length > 0; + const linksAvailable = links && links.length > 0; + // If logs with error, we are not showing the level color const levelClassName = cx(!hasError && [style.logsRowLevel, styles.logsRowLevelDetails]); @@ -91,10 +95,10 @@ class UnThemedLogDetails extends PureComponent {
- {labelsAvailable && ( + {(labelsAvailable || fieldsAvailable) && ( - )} @@ -111,29 +115,47 @@ class UnThemedLogDetails extends PureComponent { getStats={() => calculateLogsLabelStats(getRows(), key)} onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterLabel={onClickFilterLabel} + onClickShowField={onClickShowField} + onClickHideField={onClickHideField} row={row} app={app} + wrapLogMessage={wrapLogMessage} + displayedFields={displayedFields} /> ); })} + {fields.map((field) => { + const { key, value, fieldIndex } = field; + return ( + + fieldIndex === undefined + ? this.getStatsForField(key) + : calculateStats(row.dataFrame.fields[fieldIndex].values.toArray()) + } + displayedFields={displayedFields} + wrapLogMessage={wrapLogMessage} + row={row} + app={app} + /> + ); + })} - {detectedFieldsAvailable && ( + {linksAvailable && ( - )} - {fields.sort().map((field) => { + {links.map((field) => { const { key, value, links, fieldIndex } = field; return ( { parsedKey={key} parsedValue={value} links={links} - onClickShowDetectedField={onClickShowDetectedField} - onClickHideDetectedField={onClickHideDetectedField} + onClickShowField={onClickShowField} + onClickHideField={onClickHideField} getStats={() => fieldIndex === undefined - ? this.getStatsForDetectedField(key) + ? this.getStatsForField(key) : calculateStats(row.dataFrame.fields[fieldIndex].values.toArray()) } - showDetectedFields={showDetectedFields} + displayedFields={displayedFields} wrapLogMessage={wrapLogMessage} row={row} app={app} /> ); })} - {!detectedFieldsAvailable && !labelsAvailable && ( + {!fieldsAvailable && !labelsAvailable && !linksAvailable && ( - diff --git a/public/app/features/logs/components/LogDetailsRow.test.tsx b/public/app/features/logs/components/LogDetailsRow.test.tsx index 1d13b415ce5..e4bda4cf3e8 100644 --- a/public/app/features/logs/components/LogDetailsRow.test.tsx +++ b/public/app/features/logs/components/LogDetailsRow.test.tsx @@ -1,5 +1,4 @@ import { screen, render, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import React, { ComponentProps } from 'react'; import { LogRowModel } from '@grafana/data'; @@ -17,9 +16,9 @@ const setup = (propOverrides?: Partial) => { getStats: () => null, onClickFilterLabel: () => {}, onClickFilterOutLabel: () => {}, - onClickShowDetectedField: () => {}, - onClickHideDetectedField: () => {}, - showDetectedFields: [], + onClickShowField: () => {}, + onClickHideField: () => {}, + displayedFields: [], row: {} as LogRowModel, }; @@ -51,40 +50,24 @@ describe('LogDetailsRow', () => { it('should render metrics button', () => { setup(); - expect(screen.getAllByTitle('Ad-hoc statistics')).toHaveLength(1); + expect(screen.getAllByRole('button', { name: 'Ad-hoc statistics' })).toHaveLength(1); }); describe('if props is a label', () => { it('should render filter label button', () => { setup(); - expect(screen.getAllByTitle('Filter for value')).toHaveLength(1); + expect(screen.getAllByRole('button', { name: 'Filter for value' })).toHaveLength(1); }); it('should render filter out label button', () => { setup(); - expect(screen.getAllByTitle('Filter out value')).toHaveLength(1); - }); - it('should not render filtering buttons if no filtering functions provided', () => { - setup({ onClickFilterLabel: undefined, onClickFilterOutLabel: undefined }); - expect(screen.queryByTitle('Filter out value')).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Filter out value' })).toHaveLength(1); }); }); describe('if props is not a label', () => { - it('should not render a filter label button', () => { - setup({ isLabel: false }); - expect(screen.queryByTitle('Filter for value')).not.toBeInTheDocument(); - }); it('should render a show toggleFieldButton button', () => { setup({ isLabel: false }); - expect(screen.getAllByTitle('Show this field instead of the message')).toHaveLength(1); - }); - it('should not render a show toggleFieldButton button if no detected fields toggling functions provided', () => { - setup({ - isLabel: false, - onClickShowDetectedField: undefined, - onClickHideDetectedField: undefined, - }); - expect(screen.queryByTitle('Show this field instead of the message')).not.toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Show this field instead of the message' })).toHaveLength(1); }); }); @@ -109,21 +92,9 @@ describe('LogDetailsRow', () => { }); expect(screen.queryByTestId('logLabelStats')).not.toBeInTheDocument(); - const adHocStatsButton = screen.getByTitle('Ad-hoc statistics'); + const adHocStatsButton = screen.getByRole('button', { name: 'Ad-hoc statistics' }); fireEvent.click(adHocStatsButton); expect(screen.getByTestId('logLabelStats')).toBeInTheDocument(); expect(screen.getByTestId('logLabelStats')).toHaveTextContent('another value'); }); - - it('should render clipboard button on hover of log row table value', async () => { - setup({ parsedKey: 'key', parsedValue: 'value' }); - - const valueCell = screen.getByRole('cell', { name: 'value' }); - await userEvent.hover(valueCell); - - expect(screen.getByRole('button', { name: 'Copy value to clipboard' })).toBeInTheDocument(); - await userEvent.unhover(valueCell); - - expect(screen.queryByRole('button', { name: 'Copy value to clipboard' })).not.toBeInTheDocument(); - }); }); diff --git a/public/app/features/logs/components/LogDetailsRow.tsx b/public/app/features/logs/components/LogDetailsRow.tsx index 763fd652fed..c83e467a907 100644 --- a/public/app/features/logs/components/LogDetailsRow.tsx +++ b/public/app/features/logs/components/LogDetailsRow.tsx @@ -1,9 +1,10 @@ import { css, cx } from '@emotion/css'; +import memoizeOne from 'memoize-one'; import React, { PureComponent } from 'react'; -import { Field, LinkModel, LogLabelStatsModel, GrafanaTheme2, LogRowModel, CoreApp } from '@grafana/data'; +import { CoreApp, Field, GrafanaTheme2, LinkModel, LogLabelStatsModel, LogRowModel } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { withTheme2, Themeable2, ClipboardButton, DataLinkButton, IconButton } from '@grafana/ui'; +import { ClipboardButton, DataLinkButton, Themeable2, ToolbarButton, ToolbarButtonRow, withTheme2 } from '@grafana/ui'; import { LogLabelStats } from './LogLabelStats'; import { getLogRowStyles } from './getLogRowStyles'; @@ -19,9 +20,9 @@ export interface Props extends Themeable2 { onClickFilterOutLabel?: (key: string, value: string) => void; links?: Array>; getStats: () => LogLabelStatsModel[] | null; - showDetectedFields?: string[]; - onClickShowDetectedField?: (key: string) => void; - onClickHideDetectedField?: (key: string) => void; + displayedFields?: string[]; + onClickShowField?: (key: string) => void; + onClickHideField?: (key: string) => void; row: LogRowModel; app?: CoreApp; } @@ -30,10 +31,43 @@ interface State { showFieldsStats: boolean; fieldCount: number; fieldStats: LogLabelStatsModel[] | null; - mouseOver: boolean; } -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = memoizeOne((theme: GrafanaTheme2, activeButton: boolean) => { + // those styles come from ToolbarButton. Unfortunately this is needed because we can not control the variant of the menu-button in a ToolbarButtonRow. + const defaultOld = css` + color: ${theme.colors.text.secondary}; + background-color: ${theme.colors.background.primary}; + + &:hover { + color: ${theme.colors.text.primary}; + background: ${theme.colors.background.secondary}; + } + `; + + const defaultTopNav = css` + color: ${theme.colors.text.secondary}; + background-color: transparent; + border-color: transparent; + + &:hover { + color: ${theme.colors.text.primary}; + background: ${theme.colors.background.secondary}; + } + `; + + const active = css` + color: ${theme.v1.palette.orangeDark}; + border-color: ${theme.v1.palette.orangeDark}; + background-color: transparent; + + &:hover { + color: ${theme.colors.text.primary}; + background: ${theme.colors.emphasize(theme.colors.background.canvas, 0.03)}; + } + `; + + const defaultToolbarButtonStyle = theme.flags.topnav ? defaultTopNav : defaultOld; return { noHoverBackground: css` label: noHoverBackground; @@ -52,32 +86,83 @@ const getStyles = (theme: GrafanaTheme2) => { showingField: css` color: ${theme.colors.primary.text}; `, - hoverValueCopy: css` - margin: ${theme.spacing(0, 0, 0, 1.2)}; - position: absolute; - top: 0px; - justify-content: center; - border-radius: ${theme.shape.borderRadius(10)}; - width: ${theme.spacing(3.25)}; - height: ${theme.spacing(3.25)}; + copyButton: css` + & > button { + color: ${theme.colors.text.secondary}; + padding: 0; + justify-content: center; + border-radius: 50%; + height: ${theme.spacing(theme.components.height.sm)}; + width: ${theme.spacing(theme.components.height.sm)}; + svg { + margin: 0; + } + + span > div { + top: -5px; + & button { + color: ${theme.colors.success.main}; + } + } + } `, wrapLine: css` label: wrapLine; white-space: pre-wrap; `, + toolbarButtonRow: css` + label: toolbarButtonRow; + gap: ${theme.spacing(0.5)}; + + max-width: calc(3 * ${theme.spacing(theme.components.height.sm)}); + & > div { + height: ${theme.spacing(theme.components.height.sm)}; + width: ${theme.spacing(theme.components.height.sm)}; + & > button { + border: 0; + background-color: transparent; + height: inherit; + + &:hover { + box-shadow: none; + border-radius: 50%; + } + } + } + & div:last-child > button:not(.stats-button) { + ${activeButton ? active : defaultToolbarButtonStyle}; + } + `, + logDetailsStats: css` + padding: 0 ${theme.spacing(1)}; + `, + logDetailsValue: css` + display: table-cell; + vertical-align: middle; + line-height: 22px; + + .show-on-hover { + display: inline; + visibility: hidden; + } + &:hover { + .show-on-hover { + visibility: visible; + } + } + `, }; -}; +}); class UnThemedLogDetailsRow extends PureComponent { state: State = { showFieldsStats: false, fieldCount: 0, fieldStats: null, - mouseOver: false, }; showField = () => { - const { onClickShowDetectedField, parsedKey, row } = this.props; + const { onClickShowField: onClickShowDetectedField, parsedKey, row } = this.props; if (onClickShowDetectedField) { onClickShowDetectedField(parsedKey); } @@ -90,7 +175,7 @@ class UnThemedLogDetailsRow extends PureComponent { }; hideField = () => { - const { onClickHideDetectedField, parsedKey, row } = this.props; + const { onClickHideField: onClickHideDetectedField, parsedKey, row } = this.props; if (onClickHideDetectedField) { onClickHideDetectedField(parsedKey); } @@ -155,11 +240,6 @@ class UnThemedLogDetailsRow extends PureComponent { }); } - hoverValueCopy() { - const mouseOver = !this.state.mouseOver; - this.setState({ mouseOver }); - } - render() { const { theme, @@ -167,87 +247,120 @@ class UnThemedLogDetailsRow extends PureComponent { parsedValue, isLabel, links, - showDetectedFields, + displayedFields, wrapLogMessage, - onClickShowDetectedField, - onClickHideDetectedField, onClickFilterLabel, onClickFilterOutLabel, } = this.props; - const { showFieldsStats, fieldStats, fieldCount, mouseOver } = this.state; - const styles = getStyles(theme); + const { showFieldsStats, fieldStats, fieldCount } = this.state; + const activeButton = displayedFields?.includes(parsedKey) || showFieldsStats; + const styles = getStyles(theme, activeButton); const style = getLogRowStyles(theme); - - const hasDetectedFieldsFunctionality = onClickShowDetectedField && onClickHideDetectedField; const hasFilteringFunctionality = onClickFilterLabel && onClickFilterOutLabel; const toggleFieldButton = - !isLabel && showDetectedFields && showDetectedFields.includes(parsedKey) ? ( - + displayedFields && displayedFields.includes(parsedKey) ? ( + ) : ( - + ); return ( - - {/* Action buttons - show stats/filter results */} - - - {hasFilteringFunctionality && isLabel && ( - <> - - - - )} - - {hasDetectedFieldsFunctionality && !isLabel && ( - + - )} - {/* Key - value columns */} - - - + {/* Key - value columns */} + + + + {showFieldsStats && ( + + + + + )} + ); } } diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index e9940efd48b..d4b44d4c0f2 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -28,7 +28,7 @@ import { RowContextOptions, } from './LogRowContextProvider'; import { LogRowMessage } from './LogRowMessage'; -import { LogRowMessageDetectedFields } from './LogRowMessageDetectedFields'; +import { LogRowMessageDisplayedFields } from './LogRowMessageDisplayedFields'; import { getLogRowStyles } from './getLogRowStyles'; //Components @@ -44,10 +44,10 @@ interface Props extends Themeable2 { enableLogDetails: boolean; logsSortOrder?: LogsSortOrder | null; forceEscape?: boolean; - showDetectedFields?: string[]; scrollElement?: HTMLDivElement; showRowMenu?: boolean; app?: CoreApp; + displayedFields?: string[]; getRows: () => LogRowModel[]; onClickFilterLabel?: (key: string, value: string) => void; onClickFilterOutLabel?: (key: string, value: string) => void; @@ -55,8 +55,8 @@ interface Props extends Themeable2 { getRowContext: (row: LogRowModel, options?: RowContextOptions) => Promise; getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; showContextToggle?: (row?: LogRowModel) => boolean; - onClickShowDetectedField?: (key: string) => void; - onClickHideDetectedField?: (key: string) => void; + onClickShowField?: (key: string) => void; + onClickHideField?: (key: string) => void; onLogRowHover?: (row?: LogRowModel) => void; toggleContextIsOpen?: () => void; } @@ -149,8 +149,8 @@ class UnThemedLogRow extends PureComponent { getRows, onClickFilterLabel, onClickFilterOutLabel, - onClickShowDetectedField, - onClickHideDetectedField, + onClickShowField, + onClickHideField, enableLogDetails, row, showDuplicates, @@ -158,7 +158,7 @@ class UnThemedLogRow extends PureComponent { showRowMenu, showLabels, showTime, - showDetectedFields, + displayedFields, wrapLogMessage, prettifyLogMessage, theme, @@ -217,10 +217,10 @@ class UnThemedLogRow extends PureComponent { )} - {showDetectedFields && showDetectedFields.length > 0 ? ( - 0 ? ( + @@ -251,13 +251,13 @@ class UnThemedLogRow extends PureComponent { getFieldLinks={getFieldLinks} onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} - onClickShowDetectedField={onClickShowDetectedField} - onClickHideDetectedField={onClickHideDetectedField} + onClickShowField={onClickShowField} + onClickHideField={onClickHideField} getRows={getRows} row={processedRow} wrapLogMessage={wrapLogMessage} hasError={hasError} - showDetectedFields={showDetectedFields} + displayedFields={displayedFields} app={app} /> )} diff --git a/public/app/features/logs/components/LogRowMessageDetectedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx similarity index 71% rename from public/app/features/logs/components/LogRowMessageDetectedFields.tsx rename to public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index 18424e5253a..5394beca55e 100644 --- a/public/app/features/logs/components/LogRowMessageDetectedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -13,7 +13,7 @@ export interface Props extends Themeable2 { getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; } -class UnThemedLogRowMessageDetectedFields extends PureComponent { +class UnThemedLogRowMessageDisplayedFields extends PureComponent { render() { const { row, showDetectedFields, getFieldLinks, wrapLogMessage } = this.props; const fields = getAllFields(row, getFieldLinks); @@ -30,10 +30,14 @@ class UnThemedLogRowMessageDetectedFields extends PureComponent { return key === parsedKey; }); - if (field) { + if (field !== undefined && field !== null) { return `${parsedKey}=${field.value}`; } + if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { + return `${parsedKey}=${row.labels[parsedKey]}`; + } + return null; }) .filter((s) => s !== null) @@ -43,5 +47,5 @@ class UnThemedLogRowMessageDetectedFields extends PureComponent { } } -export const LogRowMessageDetectedFields = withTheme2(UnThemedLogRowMessageDetectedFields); -LogRowMessageDetectedFields.displayName = 'LogRowMessageDetectedFields'; +export const LogRowMessageDisplayedFields = withTheme2(UnThemedLogRowMessageDisplayedFields); +LogRowMessageDisplayedFields.displayName = 'LogRowMessageDisplayedFields'; diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx index 6753d95765a..79a9f854fdc 100644 --- a/public/app/features/logs/components/LogRows.test.tsx +++ b/public/app/features/logs/components/LogRows.test.tsx @@ -20,6 +20,11 @@ describe('LogRows', () => { prettifyLogMessage={true} timeZone={'utc'} enableLogDetails={true} + displayedFields={[]} + onClickFilterLabel={() => {}} + onClickFilterOutLabel={() => {}} + onClickHideField={() => {}} + onClickShowField={() => {}} /> ); @@ -62,6 +67,11 @@ describe('LogRows', () => { timeZone={'utc'} previewLimit={1} enableLogDetails={true} + displayedFields={[]} + onClickFilterLabel={() => {}} + onClickFilterOutLabel={() => {}} + onClickHideField={() => {}} + onClickShowField={() => {}} /> ); @@ -87,6 +97,11 @@ describe('LogRows', () => { prettifyLogMessage={true} timeZone={'utc'} enableLogDetails={true} + displayedFields={[]} + onClickFilterLabel={() => {}} + onClickFilterOutLabel={() => {}} + onClickHideField={() => {}} + onClickShowField={() => {}} /> ); expect(screen.queryAllByRole('row')).toHaveLength(2); @@ -107,6 +122,11 @@ describe('LogRows', () => { prettifyLogMessage={true} timeZone={'utc'} enableLogDetails={true} + displayedFields={[]} + onClickFilterLabel={() => {}} + onClickFilterOutLabel={() => {}} + onClickHideField={() => {}} + onClickShowField={() => {}} /> ); @@ -131,6 +151,11 @@ describe('LogRows', () => { timeZone={'utc'} logsSortOrder={LogsSortOrder.Ascending} enableLogDetails={true} + displayedFields={[]} + onClickFilterLabel={() => {}} + onClickFilterOutLabel={() => {}} + onClickHideField={() => {}} + onClickShowField={() => {}} /> ); @@ -155,6 +180,11 @@ describe('LogRows', () => { timeZone={'utc'} logsSortOrder={LogsSortOrder.Descending} enableLogDetails={true} + displayedFields={[]} + onClickFilterLabel={() => {}} + onClickFilterOutLabel={() => {}} + onClickHideField={() => {}} + onClickShowField={() => {}} /> ); diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 87ec8296f56..462b4e9285e 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -35,7 +35,7 @@ export interface Props extends Themeable2 { logsSortOrder?: LogsSortOrder | null; previewLimit?: number; forceEscape?: boolean; - showDetectedFields?: string[]; + displayedFields?: string[]; app?: CoreApp; scrollElement?: HTMLDivElement; showContextToggle?: (row?: LogRowModel) => boolean; @@ -43,8 +43,8 @@ export interface Props extends Themeable2 { onClickFilterOutLabel?: (key: string, value: string) => void; getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise; getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; - onClickShowDetectedField?: (key: string) => void; - onClickHideDetectedField?: (key: string) => void; + onClickShowField?: (key: string) => void; + onClickHideField?: (key: string) => void; onLogRowHover?: (row?: LogRowModel) => void; } @@ -121,9 +121,9 @@ class UnThemedLogRows extends PureComponent { previewLimit, getFieldLinks, logsSortOrder, - showDetectedFields, - onClickShowDetectedField, - onClickHideDetectedField, + displayedFields, + onClickShowField, + onClickHideField, forceEscape, onLogRowHover, app, @@ -162,15 +162,15 @@ class UnThemedLogRows extends PureComponent { showDuplicates={showDuplicates} showLabels={showLabels} showTime={showTime} - showDetectedFields={showDetectedFields} + displayedFields={displayedFields} wrapLogMessage={wrapLogMessage} prettifyLogMessage={prettifyLogMessage} timeZone={timeZone} enableLogDetails={enableLogDetails} onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} - onClickShowDetectedField={onClickShowDetectedField} - onClickHideDetectedField={onClickHideDetectedField} + onClickShowField={onClickShowField} + onClickHideField={onClickHideField} getFieldLinks={getFieldLinks} logsSortOrder={logsSortOrder} forceEscape={forceEscape} @@ -193,15 +193,15 @@ class UnThemedLogRows extends PureComponent { showDuplicates={showDuplicates} showLabels={showLabels} showTime={showTime} - showDetectedFields={showDetectedFields} + displayedFields={displayedFields} wrapLogMessage={wrapLogMessage} prettifyLogMessage={prettifyLogMessage} timeZone={timeZone} enableLogDetails={enableLogDetails} onClickFilterLabel={onClickFilterLabel} onClickFilterOutLabel={onClickFilterOutLabel} - onClickShowDetectedField={onClickShowDetectedField} - onClickHideDetectedField={onClickHideDetectedField} + onClickShowField={onClickShowField} + onClickHideField={onClickHideField} getFieldLinks={getFieldLinks} logsSortOrder={logsSortOrder} forceEscape={forceEscape} diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index 8b53e186afc..6ce0d408a1b 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -147,8 +147,9 @@ export const getLogRowStyles = (theme: GrafanaTheme2, logLevel?: LogLevel) => { label: logs-row-details__icon; position: relative; color: ${theme.v1.palette.gray3}; - padding-top: ${theme.spacing(0.75)}; - padding-left: ${theme.spacing(0.75)}; + padding-top: 1px; + padding-bottom: 1px; + padding-right: ${theme.spacing(0.75)}; `, logDetailsLabel: css` label: logs-row-details__label; diff --git a/public/app/features/logs/components/logParser.ts b/public/app/features/logs/components/logParser.ts index 031bcc07025..ce97fdbbf40 100644 --- a/public/app/features/logs/components/logParser.ts +++ b/public/app/features/logs/components/logParser.ts @@ -2,12 +2,6 @@ import memoizeOne from 'memoize-one'; import { DataFrame, Field, FieldType, LinkModel, LogRowModel } from '@grafana/data'; -import { getParser } from '../utils'; - -import { MAX_CHARACTERS } from './LogRowMessage'; - -const memoizedGetParser = memoizeOne(getParser); - type FieldDef = { key: string; value: string; @@ -24,56 +18,15 @@ export const getAllFields = memoizeOne( row: LogRowModel, getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array> ) => { - const logMessageFields = parseMessage(row.entry); const dataframeFields = getDataframeFields(row, getFieldLinks); - const fieldsMap = [...dataframeFields, ...logMessageFields].reduce((acc, field) => { - // Strip enclosing quotes for hashing. When values are parsed from log line the quotes are kept, but if same - // value is in the dataFrame it will be without the quotes. We treat them here as the same value. - // We need to handle this scenario: - // - we use derived-fields in Loki - // - we name the derived field the same as the parsed-field-name - // - the same field will appear twice - // - in the fields coming from `logMessageFields` - // - in the fields coming from `dataframeFields` - // - but, in the fields coming from `logMessageFields`, there might be doublequotes around the value - // - we want to "merge" data from both sources, so we remove quotes from the beginning and end - const value = field.value.replace(/(^")|("$)/g, ''); - const fieldHash = `${field.key}=${value}`; - if (acc[fieldHash]) { - acc[fieldHash].links = [...(acc[fieldHash].links || []), ...(field.links || [])]; - } else { - acc[fieldHash] = field; - } - return acc; - }, {} as { [key: string]: FieldDef }); - const allFields = Object.values(fieldsMap); - allFields.sort(sortFieldsLinkFirst); - - return allFields; + return Object.values(dataframeFields); } ); -const parseMessage = memoizeOne((rowEntry): FieldDef[] => { - if (rowEntry.length > MAX_CHARACTERS) { - return []; - } - const parser = memoizedGetParser(rowEntry); - if (!parser) { - return []; - } - // Use parser to highlight detected fields - const detectedFields = parser.getFields(rowEntry); - const fields = detectedFields.map((field) => { - const key = parser.getLabelFromField(field); - const value = parser.getValueFromField(field); - return { key, value }; - }); - - return fields; -}); - -// creates fields from the dataframe-fields, adding data-links, when field.config.links exists +/** + * creates fields from the dataframe-fields, adding data-links, when field.config.links exists + */ const getDataframeFields = memoizeOne( ( row: LogRowModel, @@ -94,30 +47,28 @@ const getDataframeFields = memoizeOne( } ); -function sortFieldsLinkFirst(fieldA: FieldDef, fieldB: FieldDef) { - if (fieldA.links?.length && !fieldB.links?.length) { - return -1; - } - if (!fieldA.links?.length && fieldB.links?.length) { - return 1; - } - return fieldA.key > fieldB.key ? 1 : fieldA.key < fieldB.key ? -1 : 0; -} - function shouldRemoveField(field: Field, index: number, row: LogRowModel) { // Remove field if it is: // "labels" field that is in Loki used to store all labels if (field.name === 'labels' && field.type === FieldType.other) { return true; } - // "id" field which we use for react key - if (field.name === 'id') { + // id and tsNs are arbitrary added fields in the backend and should be hidden in the UI + if (field.name === 'id' || field.name === 'tsNs') { return true; } // entry field which we are showing as the log message if (row.entryFieldIndex === index) { return true; } + const firstTimeField = row.dataFrame.fields.find((f) => f.type === FieldType.time); + if ( + field.name === firstTimeField?.name && + field.type === FieldType.time && + field.values.get(0) === firstTimeField.values.get(0) + ) { + return true; + } // hidden field if (field.config.custom?.hidden) { return true;
- Log labels + + Fields
- Detected fields - - - + + Links
+ No details available
- - - - - - - {toggleFieldButton} + <> +
+ + {hasFilteringFunctionality && ( + + )} + {hasFilteringFunctionality && ( + + )} + {displayedFields && toggleFieldButton} + + {parsedKey} - {parsedValue} - {mouseOver && ( - parsedValue} - title="Copy value to clipboard" - fill="text" - variant="secondary" - icon="copy" - size="sm" - className={styles.hoverValueCopy} - /> - )} - {links?.map((link) => ( - -   - - - ))} - {showFieldsStats && ( - - )} -
{parsedKey} +
+ {parsedValue} + +
+ parsedValue} + title="Copy value to clipboard" + fill="text" + variant="secondary" + icon="copy" + size="md" + /> +
+ + {links?.map((link) => ( + +   + + + ))} +
+
+ + + + +
+ +
+