From 41014f29edab853409c53f5284b448d403300c8f Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 3 Jul 2025 22:57:14 +0200 Subject: [PATCH] New Log Details: Create initial component for Log Details (#107466) * Log Details: fork and refactor as functional * LogDetailsBody: refactor styles * LogDetails: decouple from old panel * LogDetails: extract and centralize styles * LogDetailsRow: refactor as functional * Fix unused * Fix wrong label * LogDetails: create new component * LogLineDetails: process links and add sample sections * LogLineDetails: create and use LogLineDetailsFields * LogLineDetails: group labels by type * LogLineDetails: render all fields * Removed unused components * Fix imports * LogLineDetails: fix label * LogLineDetailsFields: fix stats * LogLinedetailsFields: add base styles * LogLineDetails: store open state * getLabelTypeFromRow: internationalize and add plural support * LogLineDetails: get plural field types * LogLineDetails: sticky header * LogLineDetails: introduce log details header * LogLineDetails: extract into submodules * LogDetails: add more header options and store collapsed state * LogDetails: add scroll for log line * LogLineDetailsHeader: add log line toggle button * LogLineDetailsFieldS: improve sizes * LogLineDetails: add search handler * LogLineDetailsFields: implement search * LogLineDetailsFields: switch to fixed key width * LogLineDetailsFields: refactor fields display * Link: remove tooltip * Fix translations * Revert "Link: remove tooltip" This reverts commit cd927302a7889b9430008ae3b81ace0aed343f5f. * LogLineDetailsFields: switch to css grid * Remap new translations * LogLineDetails: implement disable actions * LogLineDetailsFields: migrate links to css grid * LogLineDetailsFields: migrate stats to css grid * LogLabelStats: make functional * LogLineDetailsHeader: refactor listener * LogLineDetailsFields: decrease column minwidth * Reuse current panel unit tests * Translations * Test search * Update public/app/features/logs/components/panel/LogLineDetails.test.tsx * LogLineDetailsHeader: fix zIndex * Create LogLineDetailsDisplayedFields * Revert "Create LogLineDetailsDisplayedFields" This reverts commit 57d460d966483c3126738994e2705b6578aac120. * LogList: recover unwrapped horizontal scroll * LogLineDetails: apply styles suggestion * LogLineDetailsComponent: fix group option name * LogLineDetailsHeader: tweak styles * LogLineDetailsComponent: remove margin of last collapsable --- .../features/logs/components/LogDetails.tsx | 15 +- .../logs/components/LogLabelStats.tsx | 94 ++-- .../logs/components/getLogRowStyles.ts | 8 - .../logs/components/panel/LogLine.tsx | 3 - .../components/panel/LogLineDetails.test.tsx | 460 +++++++++++++++ .../logs/components/panel/LogLineDetails.tsx | 77 +-- .../panel/LogLineDetailsComponent.tsx | 182 ++++++ .../components/panel/LogLineDetailsFields.tsx | 522 ++++++++++++++++++ .../components/panel/LogLineDetailsHeader.tsx | 216 ++++++++ .../logs/components/panel/LogList.tsx | 4 +- .../panel/__mocks__/LogListContext.tsx | 6 +- public/app/features/logs/utils.ts | 29 +- public/locales/en-US/grafana.json | 43 +- 13 files changed, 1510 insertions(+), 149 deletions(-) create mode 100644 public/app/features/logs/components/panel/LogLineDetails.test.tsx create mode 100644 public/app/features/logs/components/panel/LogLineDetailsComponent.tsx create mode 100644 public/app/features/logs/components/panel/LogLineDetailsFields.tsx create mode 100644 public/app/features/logs/components/panel/LogLineDetailsHeader.tsx diff --git a/public/app/features/logs/components/LogDetails.tsx b/public/app/features/logs/components/LogDetails.tsx index 69a5552950f..99313cf1ae9 100644 --- a/public/app/features/logs/components/LogDetails.tsx +++ b/public/app/features/logs/components/LogDetails.tsx @@ -43,7 +43,6 @@ export interface Props extends Themeable2 { onPinLine?: (row: LogRowModel) => void; pinLineButtonTooltipTitle?: PopoverContent; - mode?: 'inline' | 'sidebar'; links?: Record; } @@ -51,7 +50,7 @@ interface LinkModelWithIcon extends LinkModel { icon?: IconName; } -const useAttributesExtensionLinks = (row: LogRowModel) => { +export const useAttributesExtensionLinks = (row: LogRowModel) => { // Stable context for useMemo inside usePluginLinks const context: PluginExtensionResourceAttributesContext = useMemo(() => { return { @@ -121,7 +120,6 @@ class UnThemedLogDetails extends PureComponent { onPinLine, styles, pinLineButtonTooltipTitle, - mode = 'inline', links, } = this.props; const levelStyles = getLogLevelStyles(theme, row.logLevel); @@ -152,14 +150,9 @@ class UnThemedLogDetails extends PureComponent { return ( {showDuplicates && } - {mode === 'inline' && ( - - )} + -
+
{displayedFields && displayedFields.length > 0 && ( @@ -168,7 +161,7 @@ class UnThemedLogDetails extends PureComponent { diff --git a/public/app/features/logs/components/LogLabelStats.tsx b/public/app/features/logs/components/LogLabelStats.tsx index b8d9a5d62a1..bee555170f2 100644 --- a/public/app/features/logs/components/LogLabelStats.tsx +++ b/public/app/features/logs/components/LogLabelStats.tsx @@ -1,15 +1,15 @@ import { css } from '@emotion/css'; -import { PureComponent } from 'react'; +import { useMemo } from 'react'; import { LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { stylesFactory, withTheme2, Themeable2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import { LogLabelStatsRow } from './LogLabelStatsRow'; const STATS_ROW_LIMIT = 5; -const getStyles = stylesFactory((theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2) => { return { logsStats: css({ label: 'logs-stats', @@ -42,9 +42,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { padding: '5px 0px', }), }; -}); +}; -interface Props extends Themeable2 { +interface Props { + className?: string; stats: LogLabelStatsModel[]; label: string; value: string; @@ -52,10 +53,9 @@ interface Props extends Themeable2 { isLabel?: boolean; } -class UnThemedLogLabelStats extends PureComponent { - render() { - const { label, rowCount, stats, value, theme, isLabel } = this.props; - const style = getStyles(theme); +export const LogLabelStats = ({ className, label, rowCount, stats, value, isLabel }: Props) => { + const style = useStyles2(getStyles); + const rows = useMemo(() => { const topRows = stats.slice(0, STATS_ROW_LIMIT); let activeRow = topRows.find((row) => row.value === value); let otherRows = stats.slice(STATS_ROW_LIMIT); @@ -66,45 +66,45 @@ class UnThemedLogLabelStats extends PureComponent { activeRow = otherRows.find((row) => row.value === value); otherRows = otherRows.filter((row) => row.value !== value); } + return { topRows, otherRows, insertActiveRow, activeRow }; + }, [stats, value]); - const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); - const topCount = topRows.reduce((sum, row) => sum + row.count, 0); - const total = topCount + otherCount; - const otherProportion = otherCount / total; + const otherCount = useMemo(() => rows.otherRows.reduce((sum, row) => sum + row.count, 0), [rows.otherRows]); + const topCount = useMemo(() => rows.topRows.reduce((sum, row) => sum + row.count, 0), [rows.topRows]); + const total = topCount + otherCount; + const otherProportion = otherCount / total; - return ( -
-
-
- {isLabel - ? t( - 'logs.un-themed-log-label-stats.label-log-stats', - '{{label}}: {{total}} of {{rowCount}} rows have that label', - { - label, - total, - rowCount, - } - ) - : t( - 'logs.un-themed-log-label-stats.field-log-stats', - '{{label}}: {{total}} of {{rowCount}} rows have that field' - )} -
-
-
- {topRows.map((stat) => ( - - ))} - {insertActiveRow && activeRow && } - {otherCount > 0 && ( - - )} + return ( +
+
+
+ {isLabel + ? t( + 'logs.un-themed-log-label-stats.label-log-stats', + '{{label}}: {{total}} of {{rowCount}} rows have that label', + { + label, + total, + rowCount, + } + ) + : t( + 'logs.un-themed-log-label-stats.field-log-stats', + '{{label}}: {{total}} of {{rowCount}} rows have that field' + )}
- ); - } -} - -export const LogLabelStats = withTheme2(UnThemedLogLabelStats); -LogLabelStats.displayName = 'LogLabelStats'; +
+ {rows.topRows.map((stat) => ( + + ))} + {rows.insertActiveRow && rows.activeRow && ( + + )} + {otherCount > 0 && ( + + )} +
+
+ ); +}; diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index 79e88c1a485..1bd1c5c7a7a 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -187,14 +187,6 @@ 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', diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 3e7ac11bc96..ec3062ee3b5 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -528,9 +528,6 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER), whiteSpace: 'pre', paddingBottom: theme.spacing(0.75), - '& .field': { - overflow: 'hidden', - }, }), wrappedLogLine: css({ alignSelf: 'flex-start', diff --git a/public/app/features/logs/components/panel/LogLineDetails.test.tsx b/public/app/features/logs/components/panel/LogLineDetails.test.tsx new file mode 100644 index 00000000000..626863e4aec --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineDetails.test.tsx @@ -0,0 +1,460 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + Field, + LogLevel, + LogRowModel, + FieldType, + createDataFrame, + DataFrameType, + PluginExtensionPoints, + toDataFrame, + LogsSortOrder, + DataFrame, + ScopedVars, +} from '@grafana/data'; +import { setPluginLinksHook } from '@grafana/runtime'; + +import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { createLogLine } from '../mocks/logRow'; + +import { LogLineDetails, Props } from './LogLineDetails'; +import { LogListContext, LogListContextData } from './LogListContext'; +import { defaultValue } from './__mocks__/LogListContext'; + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + usePluginLinks: jest.fn().mockReturnValue({ links: [] }), + }; +}); +jest.mock('./LogListContext'); + +const setup = ( + propOverrides?: Partial, + rowOverrides?: Partial, + contextOverrides?: Partial +) => { + const logs = [createLogLine({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides })]; + + const props: Props = { + containerElement: document.createElement('div'), + logs, + onResize: jest.fn(), + ...(propOverrides || {}), + }; + + const contextData: LogListContextData = { + ...defaultValue, + showDetails: logs, + ...contextOverrides, + }; + + return render( + + + + ); +}; + +describe('LogLineDetails', () => { + describe('when fields are present', () => { + test('should render the fields and the log line', () => { + setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); + expect(screen.getByText('Log line')).toBeInTheDocument(); + expect(screen.getByText('Fields')).toBeInTheDocument(); + }); + test('fields should be visible by default', () => { + setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); + expect(screen.getByText('key1')).toBeInTheDocument(); + expect(screen.getByText('label1')).toBeInTheDocument(); + expect(screen.getByText('key2')).toBeInTheDocument(); + expect(screen.getByText('label2')).toBeInTheDocument(); + }); + test('should show an option to display the log line when displayed fields are used', async () => { + const onClickShowField = jest.fn(); + + setup( + undefined, + { labels: { key1: 'label1' } }, + { displayedFields: ['key1'], onClickShowField, onClickHideField: jest.fn() } + ); + expect(screen.getByText('key1')).toBeInTheDocument(); + expect(screen.getByLabelText('Show log line')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Show log line')); + + expect(onClickShowField).toHaveBeenCalledTimes(1); + }); + test('should show an active option to display the log line when displayed fields are used', async () => { + const onClickHideField = jest.fn(); + + setup( + undefined, + { labels: { key1: 'label1' } }, + { displayedFields: ['key1', LOG_LINE_BODY_FIELD_NAME], onClickHideField, onClickShowField: jest.fn() } + ); + expect(screen.getByText('key1')).toBeInTheDocument(); + expect(screen.getByLabelText('Hide log line')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('Hide log line')); + + expect(onClickHideField).toHaveBeenCalledTimes(1); + }); + test('should not show an option to display the log line when displayed fields are not used', () => { + setup(undefined, { labels: { key1: 'label1' } }, { displayedFields: [] }); + expect(screen.getByText('key1')).toBeInTheDocument(); + expect(screen.queryByLabelText('Show log line')).not.toBeInTheDocument(); + }); + test('should render the filter controls when the callbacks are provided', () => { + setup( + undefined, + { labels: { key1: 'label1' } }, + { + onClickFilterLabel: () => {}, + onClickFilterOutLabel: () => {}, + } + ); + expect(screen.getByLabelText('Filter for value in query A')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter out value in query A')).toBeInTheDocument(); + }); + describe('Toggleable filters', () => { + test('should pass the log row to Explore filter functions', async () => { + const onClickFilterLabelMock = jest.fn(); + const onClickFilterOutLabelMock = jest.fn(); + const isLabelFilterActiveMock = jest.fn().mockResolvedValue(true); + const log = createLogLine({ + logLevel: LogLevel.error, + timeEpochMs: 1546297200000, + labels: { key1: 'label1' }, + }); + + setup( + { + logs: [log], + }, + undefined, + { + onClickFilterLabel: onClickFilterLabelMock, + onClickFilterOutLabel: onClickFilterOutLabelMock, + isLabelFilterActive: isLabelFilterActiveMock, + showDetails: [log], + } + ); + + expect(isLabelFilterActiveMock).toHaveBeenCalledWith('key1', 'label1', log.dataFrame.refId); + + await userEvent.click(screen.getByLabelText('Filter for value in query A')); + expect(onClickFilterLabelMock).toHaveBeenCalledTimes(1); + expect(onClickFilterLabelMock).toHaveBeenCalledWith( + 'key1', + 'label1', + expect.objectContaining({ + fields: [ + expect.objectContaining({ values: [0] }), + expect.objectContaining({ values: ['line1'] }), + expect.objectContaining({ values: [{ app: 'app01' }] }), + ], + length: 1, + }) + ); + + await userEvent.click(screen.getByLabelText('Filter out value in query A')); + expect(onClickFilterOutLabelMock).toHaveBeenCalledTimes(1); + expect(onClickFilterOutLabelMock).toHaveBeenCalledWith( + 'key1', + 'label1', + expect.objectContaining({ + fields: [ + expect.objectContaining({ values: [0] }), + expect.objectContaining({ values: ['line1'] }), + expect.objectContaining({ values: [{ app: 'app01' }] }), + ], + length: 1, + }) + ); + }); + }); + test('should not render filter controls when the callbacks are not provided', () => { + setup( + undefined, + { labels: { key1: 'label1' } }, + { + onClickFilterLabel: undefined, + onClickFilterOutLabel: undefined, + } + ); + expect(screen.queryByLabelText('Filter for value')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Filter out value')).not.toBeInTheDocument(); + }); + }); + describe('when the log has no fields to display', () => { + test('should render no details available message', () => { + setup(undefined, { entry: '' }); + expect(screen.getByText('No fields to display.')).toBeInTheDocument(); + }); + test('should not render headings', () => { + setup(undefined, { entry: '' }); + expect(screen.queryByText('Fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Links')).not.toBeInTheDocument(); + expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument(); + expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument(); + }); + }); + test('should render fields from the dataframe with links', () => { + const entry = 'traceId=1234 msg="some message"'; + const dataFrame = toDataFrame({ + fields: [ + { name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, + { name: 'entry', values: [entry] }, + // As we have traceId in message already this will shadow it. + { + name: 'traceId', + values: ['1234'], + config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] }, + }, + { name: 'userId', values: ['5678'] }, + ], + }); + const log = createLogLine( + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + virtualization: undefined, + wrapLogMessage: true, + getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { + if (field.config && field.config.links) { + return field.config.links.map((link) => { + return { + href: link.url.replace('${__value.text}', field.values[rowIndex]), + title: link.title, + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + } + ); + + setup({ logs: [log] }, undefined, { showDetails: [log] }); + + expect(screen.getByText('Fields')).toBeInTheDocument(); + expect(screen.getByText('Links')).toBeInTheDocument(); + expect(screen.getByText('traceId')).toBeInTheDocument(); + expect(screen.getByText('link title')).toBeInTheDocument(); + expect(screen.getByText('1234')).toBeInTheDocument(); + }); + + test('should show the correct log details fields, links and labels for DataFrameType.LogLines frames', () => { + const entry = 'test'; + const dataFrame = createDataFrame({ + fields: [ + { name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, + { name: 'body', type: FieldType.string, values: [entry] }, + { + name: 'labels', + type: FieldType.other, + values: [ + { + label1: 'value1', + }, + ], + }, + { + name: 'shouldNotShowFieldName', + type: FieldType.string, + values: ['shouldNotShowFieldValue'], + }, + { + name: 'shouldShowLinkName', + type: FieldType.string, + values: ['shouldShowLinkValue'], + config: { links: [{ title: 'link', url: 'localhost:3210/${__value.text}' }] }, + }, + ], + meta: { + type: DataFrameType.LogLines, + }, + }); + + const log = createLogLine( + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0, labels: { label1: 'value1' } }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + virtualization: undefined, + wrapLogMessage: true, + getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => { + if (field.config && field.config.links) { + return field.config.links.map((link) => { + return { + href: link.url.replace('${__value.text}', field.values[rowIndex]), + title: link.title, + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + } + ); + + setup({ logs: [log] }, undefined, { showDetails: [log] }); + + expect(screen.getByText('Log line')).toBeInTheDocument(); + expect(screen.getByText('Fields')).toBeInTheDocument(); + expect(screen.getByText('Links')).toBeInTheDocument(); + + // Don't show additional fields for DataFrameType.LogLines + expect(screen.queryByText('shouldNotShowFieldName')).not.toBeInTheDocument(); + expect(screen.queryByText('shouldNotShowFieldValue')).not.toBeInTheDocument(); + + // Show labels and links + expect(screen.getByText('label1')).toBeInTheDocument(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('shouldShowLinkName')).toBeInTheDocument(); + expect(screen.getByText('shouldShowLinkValue')).toBeInTheDocument(); + }); + + test('should load plugin links for logs view resource attributes extension point', () => { + const usePluginLinksMock = jest.fn().mockReturnValue({ links: [] }); + setPluginLinksHook(usePluginLinksMock); + jest.requireMock('@grafana/runtime').usePluginLinks = usePluginLinksMock; + + const rowOverrides = { + datasourceType: 'loki', + datasourceUid: 'grafanacloud-logs', + labels: { key1: 'label1', key2: 'label2' }, + }; + setup(undefined, rowOverrides); + + expect(usePluginLinksMock).toHaveBeenCalledWith({ + extensionPointId: PluginExtensionPoints.LogsViewResourceAttributes, + limitPerPlugin: 10, + context: { + datasource: { + type: 'loki', + uid: 'grafanacloud-logs', + }, + attributes: { key1: ['label1'], key2: ['label2'] }, + }, + }); + }); + + describe('Label types', () => { + const entry = 'test'; + const labels = { + label1: 'value1', + label2: 'value2', + label3: 'value3', + }; + const dataFrame = createDataFrame({ + fields: [ + { name: 'timestamp', config: {}, type: FieldType.time, values: [1] }, + { name: 'body', type: FieldType.string, values: [entry] }, + { name: 'id', type: FieldType.string, values: ['1'] }, + { + name: 'labels', + type: FieldType.other, + values: [labels], + }, + { + name: 'labelTypes', + type: FieldType.other, + values: [ + { + label1: 'I', + label2: 'S', + label3: 'P', + }, + ], + }, + ], + meta: { + type: DataFrameType.LogLines, + }, + }); + test('should show label types if they are available and supported', () => { + setup(undefined, { + entry, + dataFrame, + entryFieldIndex: 0, + rowIndex: 0, + labels, + datasourceType: 'loki', + rowId: '1', + }); + + // Show labels and links + expect(screen.getByText('label1')).toBeInTheDocument(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('label2')).toBeInTheDocument(); + expect(screen.getByText('value2')).toBeInTheDocument(); + expect(screen.getByText('label3')).toBeInTheDocument(); + expect(screen.getByText('value3')).toBeInTheDocument(); + expect(screen.getByText('Indexed labels')).toBeInTheDocument(); + expect(screen.getByText('Parsed fields')).toBeInTheDocument(); + expect(screen.getByText('Structured metadata')).toBeInTheDocument(); + }); + test('should not show label types if they are unavailable or not supported', () => { + setup( + {}, + { + entry, + dataFrame, + entryFieldIndex: 0, + rowIndex: 0, + labels, + datasourceType: 'other datasource', + rowId: '1', + } + ); + + // Show labels and links + expect(screen.getByText('label1')).toBeInTheDocument(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('label2')).toBeInTheDocument(); + expect(screen.getByText('value2')).toBeInTheDocument(); + expect(screen.getByText('label3')).toBeInTheDocument(); + expect(screen.getByText('value3')).toBeInTheDocument(); + + expect(screen.getByText('Fields')).toBeInTheDocument(); + expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument(); + expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument(); + expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument(); + }); + + test('Should allow to search within fields', async () => { + setup(undefined, { + entry, + dataFrame, + entryFieldIndex: 0, + rowIndex: 0, + labels, + datasourceType: 'loki', + rowId: '1', + }); + + expect(screen.getByText('label1')).toBeInTheDocument(); + expect(screen.getByText('value1')).toBeInTheDocument(); + expect(screen.getByText('label2')).toBeInTheDocument(); + expect(screen.getByText('value2')).toBeInTheDocument(); + expect(screen.getByText('label3')).toBeInTheDocument(); + expect(screen.getByText('value3')).toBeInTheDocument(); + + const input = screen.getByPlaceholderText('Search field names and values'); + + await userEvent.type(input, 'something else'); + + expect(screen.getAllByText('No results to display.')).toHaveLength(3); + }); + }); +}); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 08725535ef2..3f04338bf92 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -3,43 +3,22 @@ import { Resizable } from 're-resizable'; import { useCallback, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { getDragStyles, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; -import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; - -import { LogDetails } from '../LogDetails'; -import { getLogRowStyles } from '../getLogRowStyles'; +import { getDragStyles, useStyles2 } from '@grafana/ui'; +import { LogLineDetailsComponent } from './LogLineDetailsComponent'; import { useLogListContext } from './LogListContext'; import { LogListModel } from './processing'; import { LOG_LIST_MIN_WIDTH } from './virtualization'; -interface Props { +export interface Props { containerElement: HTMLDivElement; - getFieldLinks?: GetFieldLinksFn; + logOptionsStorageKey?: string; 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()); +export const LogLineDetails = ({ containerElement, logOptionsStorageKey, logs, onResize }: Props) => { + const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext(); const styles = useStyles2(getStyles); const dragStyles = useStyles2(getDragStyles); const containerRef = useRef(null); @@ -53,6 +32,10 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH; + if (!showDetails.length) { + return null; + } + return (
- -
Log line
- - - -
+
@@ -104,15 +59,15 @@ const getStyles = (theme: GrafanaTheme2) => ({ container: css({ overflow: 'auto', height: '100%', + boxShadow: theme.shadows.z1, + border: `1px solid ${theme.colors.border.medium}`, + borderRight: 'none', }), scrollContainer: css({ overflow: 'auto', - position: 'relative', height: '100%', }), - closeIcon: css({ - position: 'absolute', - top: theme.spacing(1), - right: theme.spacing(1.5), + componentWrapper: css({ + padding: theme.spacing(0, 1, 1, 1), }), }); diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx new file mode 100644 index 00000000000..7ae570d6500 --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx @@ -0,0 +1,182 @@ +import { css } from '@emotion/css'; +import { camelCase, groupBy } from 'lodash'; +import { startTransition, useCallback, useMemo, useRef, useState } from 'react'; + +import { DataFrameType, GrafanaTheme2, store } from '@grafana/data'; +import { t, Trans } from '@grafana/i18n'; +import { ControlledCollapse, useStyles2 } from '@grafana/ui'; + +import { getLabelTypeFromRow } from '../../utils'; +import { useAttributesExtensionLinks } from '../LogDetails'; +import { createLogLineLinks } from '../logParser'; + +import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields'; +import { LogLineDetailsHeader } from './LogLineDetailsHeader'; +import { LogListModel } from './processing'; + +interface LogLineDetailsComponentProps { + log: LogListModel; + logOptionsStorageKey?: string; + logs: LogListModel[]; +} + +export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => { + const [search, setSearch] = useState(''); + const inputRef = useRef(''); + const styles = useStyles2(getStyles); + const extensionLinks = useAttributesExtensionLinks(log); + const fieldsWithLinks = useMemo(() => { + const fieldsWithLinks = log.fields.filter((f) => f.links?.length); + const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort(); + const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort(); + const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks); + return { + links: displayedFieldsWithLinks, + linksFromVariableMap: fieldsWithLinksFromVariableMap, + }; + }, [log.entryFieldIndex, log.fields]); + const fieldsWithoutLinks = + log.dataFrame.meta?.type === DataFrameType.LogLines + ? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links + [] + : // for other frames, do not show the log message unless there is a link attached + log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort(); + const labelsWithLinks: LabelWithLinks[] = useMemo( + () => + Object.keys(log.labels) + .sort() + .map((label) => ({ + key: label, + value: log.labels[label], + link: extensionLinks?.[label], + })), + [extensionLinks, log.labels] + ); + const groupedLabels = useMemo( + () => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''), + [labelsWithLinks, log] + ); + const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]); + + const logLineOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false) + : false; + const linksOpen = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) : true; + const fieldsOpen = logOptionsStorageKey + ? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true) + : true; + + const handleToggle = useCallback( + (option: string, isOpen: boolean) => { + console.log(option, isOpen); + store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen); + }, + [logOptionsStorageKey] + ); + + const handleSearch = useCallback((newSearch: string) => { + inputRef.current = newSearch; + startTransition(() => { + setSearch(inputRef.current); + }); + }, []); + + const noDetails = + !fieldsWithLinks.links.length && + !fieldsWithLinks.linksFromVariableMap.length && + !labelGroups.length && + !fieldsWithoutLinks.length; + + return ( + <> + +
+ handleToggle('logLineOpen', isOpen)} + > +
{log.raw}
+
+ {fieldsWithLinks.links.length > 0 && ( + handleToggle('linksOpen', isOpen)} + > + + + + )} + {labelGroups.map((group) => + group === '' ? ( + handleToggle('fieldsOpen', isOpen)} + > + + + + ) : ( + handleToggle(groupOptionName(group), isOpen)} + > + + + ) + )} + {!labelGroups.length && fieldsWithoutLinks.length > 0 && ( + handleToggle('fieldsOpen', isOpen)} + > + + + )} + {noDetails && No fields to display.} +
+ + ); +}; + +function groupOptionName(group: string) { + return `${camelCase(group)}Open`; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + collapsable: css({ + '&:last-of-type': { + marginBottom: 0, + }, + }), + componentWrapper: css({ + padding: theme.spacing(0, 1, 1, 1), + }), + logLineWrapper: css({ + maxHeight: '50vh', + overflow: 'auto', + }), +}); diff --git a/public/app/features/logs/components/panel/LogLineDetailsFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx new file mode 100644 index 00000000000..895de79f1cc --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineDetailsFields.tsx @@ -0,0 +1,522 @@ +import { css } from '@emotion/css'; +import { isEqual } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import * as React from 'react'; + +import { CoreApp, Field, fuzzySearch, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { ClipboardButton, DataLinkButton, IconButton, useStyles2 } from '@grafana/ui'; + +import { logRowToSingleRowDataFrame } from '../../logsModel'; +import { calculateLogsLabelStats, calculateStats } from '../../utils'; +import { LogLabelStats } from '../LogLabelStats'; +import { FieldDef } from '../logParser'; + +import { useLogListContext } from './LogListContext'; +import { LogListModel } from './processing'; + +interface LogLineDetailsFieldsProps { + disableActions?: boolean; + fields: FieldDef[]; + log: LogListModel; + logs: LogListModel[]; + search?: string; +} + +export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => { + if (!fields.length) { + return null; + } + const styles = useStyles2(getFieldsStyles); + const getLogs = useCallback(() => logs, [logs]); + const filteredFields = useMemo(() => (search ? filterFields(fields, search) : fields), [fields, search]); + + if (filteredFields.length === 0) { + return t('logs.log-line-details.search.no-results', 'No results to display.'); + } + + return ( +
+ {filteredFields.map((field, i) => ( + + ))} +
+ ); +}; + +interface LinkModelWithIcon extends LinkModel { + icon?: IconName; +} + +export interface LabelWithLinks { + key: string; + value: string; + links?: LinkModelWithIcon[]; +} + +interface LogLineDetailsLabelFieldsProps { + fields: LabelWithLinks[]; + log: LogListModel; + logs: LogListModel[]; + search?: string; +} + +export const LogLineDetailsLabelFields = ({ fields, log, logs, search }: LogLineDetailsLabelFieldsProps) => { + if (!fields.length) { + return null; + } + const styles = useStyles2(getFieldsStyles); + const getLogs = useCallback(() => logs, [logs]); + const filteredFields = useMemo(() => (search ? filterLabels(fields, search) : fields), [fields, search]); + + if (filteredFields.length === 0) { + return t('logs.log-line-details.search.no-results', 'No results to display.'); + } + + return ( +
+ {filteredFields.map((field, i) => ( + + ))} +
+ ); +}; + +const getFieldsStyles = (theme: GrafanaTheme2) => ({ + fieldsTable: css({ + display: 'grid', + gap: theme.spacing(1), + gridTemplateColumns: `${theme.spacing(11.5)} minmax(15%, 30%) 1fr`, + }), + fieldsTableNoActions: css({ + display: 'grid', + gap: theme.spacing(1), + gridTemplateColumns: `minmax(15%, 30%) 1fr`, + }), +}); + +interface LogLineDetailsFieldProps { + keys: string[]; + values: string[]; + disableActions?: boolean; + fieldIndex?: number; + getLogs(): LogListModel[]; + isLabel?: boolean; + links?: LinkModelWithIcon[]; + log: LogListModel; +} + +export const LogLineDetailsField = ({ + disableActions = false, + fieldIndex, + getLogs, + isLabel, + links, + log, + keys, + values, +}: LogLineDetailsFieldProps) => { + const [showFieldsStats, setShowFieldStats] = useState(false); + const [fieldCount, setFieldCount] = useState(0); + const [fieldStats, setFieldStats] = useState(null); + const { + app, + closeDetails, + displayedFields, + isLabelFilterActive, + onClickFilterLabel, + onClickFilterOutLabel, + onClickShowField, + onClickHideField, + onPinLine, + pinLineButtonTooltipTitle, + } = useLogListContext(); + + const styles = useStyles2(getFieldStyles); + + const getStats = useCallback(() => { + if (isLabel) { + return calculateLogsLabelStats(getLogs(), keys[0]); + } + if (fieldIndex !== undefined) { + return calculateStats(log.dataFrame.fields[fieldIndex].values); + } + return []; + }, [fieldIndex, getLogs, isLabel, keys, log.dataFrame.fields]); + + const updateStats = useCallback(() => { + const newStats = getStats(); + const newCount = newStats.reduce((sum, stat) => sum + stat.count, 0); + if (!isEqual(fieldStats, newStats) || fieldCount !== newCount) { + setFieldStats(newStats); + setFieldCount(newCount); + } + }, [fieldCount, fieldStats, getStats]); + + useEffect(() => { + if (showFieldsStats) { + updateStats(); + } + }, [showFieldsStats, updateStats]); + + const showField = useCallback(() => { + if (onClickShowField) { + onClickShowField(keys[0]); + } + + reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { + datasourceType: log.datasourceType, + logRowUid: log.uid, + type: 'enable', + }); + }, [onClickShowField, keys, log.datasourceType, log.uid]); + + const hideField = useCallback(() => { + if (onClickHideField) { + onClickHideField(keys[0]); + } + + reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', { + datasourceType: log.datasourceType, + logRowUid: log.uid, + type: 'disable', + }); + }, [onClickHideField, keys, log.datasourceType, log.uid]); + + const filterLabel = useCallback(() => { + if (onClickFilterLabel) { + onClickFilterLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined); + } + + reportInteraction('grafana_explore_logs_log_details_filter_clicked', { + datasourceType: log.datasourceType, + filterType: 'include', + logRowUid: log.uid, + }); + }, [onClickFilterLabel, keys, values, log]); + + const filterOutLabel = useCallback(() => { + if (onClickFilterOutLabel) { + onClickFilterOutLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined); + } + + reportInteraction('grafana_explore_logs_log_details_filter_clicked', { + datasourceType: log.datasourceType, + filterType: 'exclude', + logRowUid: log.uid, + }); + }, [onClickFilterOutLabel, keys, values, log]); + + const labelFilterActive = useCallback(async () => { + if (isLabelFilterActive) { + return await isLabelFilterActive(keys[0], values[0], log.dataFrame?.refId); + } + return false; + }, [isLabelFilterActive, keys, values, log.dataFrame?.refId]); + + const showStats = useCallback(() => { + setShowFieldStats((showFieldStats: boolean) => !showFieldStats); + + reportInteraction('grafana_explore_logs_log_details_stats_clicked', { + dataSourceType: log.datasourceType, + fieldType: isLabel ? 'label' : 'detectedField', + type: showFieldsStats ? 'close' : 'open', + logRowUid: log.uid, + app, + }); + }, [app, isLabel, log.datasourceType, log.uid, showFieldsStats]); + + const refIdTooltip = useMemo( + () => (app === CoreApp.Explore && log.dataFrame?.refId ? ` in query ${log.dataFrame?.refId}` : ''), + [app, log.dataFrame?.refId] + ); + const singleKey = keys.length === 1; + const singleValue = values.length === 1; + + return ( + <> +
+ {!disableActions && ( +
+ {onClickFilterLabel && ( + + )} + {onClickFilterOutLabel && ( + + )} + {singleKey && displayedFields.includes(keys[0]) && ( + + )} + {singleKey && !displayedFields.includes(keys[0]) && ( + + )} + +
+ )} +
{singleKey ? keys[0] : }
+
+
+ {singleValue ? values[0] : } + {singleValue && } +
+
+
+ {links?.map((link, i) => { + if (link.onClick && onPinLine) { + const originalOnClick = link.onClick; + link.onClick = (e, origin) => { + // Pin the line + onPinLine(log); + + // Execute the link onClick function + originalOnClick(e, origin); + + closeDetails(); + }; + } + return ( +
+
+ +
+
+ ); + })} + {showFieldsStats && fieldStats && ( +
+
+
+ +
+
+ )} + + ); +}; + +const getFieldStyles = (theme: GrafanaTheme2) => ({ + row: css({ + display: 'contents', + }), + actions: css({ + whiteSpace: 'nowrap', + }), + label: css({ + overflowWrap: 'break-word', + wordBreak: 'break-word', + }), + value: css({ + overflowWrap: 'break-word', + wordBreak: 'break-word', + button: { + visibility: 'hidden', + }, + '&:hover': { + button: { + visibility: 'visible', + }, + }, + }), + link: css({ + gridColumn: 'span 3', + }), + linkNoActions: css({ + gridColumn: 'span 2', + }), + stats: css({ + paddingRight: theme.spacing(1), + wordBreak: 'break-all', + width: '100%', + maxWidth: '50vh', + }), + statsColumn: css({ + gridColumn: 'span 2', + }), + valueContainer: css({ + display: 'flex', + alignItems: 'center', + lineHeight: theme.typography.body.lineHeight, + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + }), +}); + +const ClipboardButtonWrapper = ({ value }: { value: string }) => { + const styles = useStyles2(getClipboardButtonStyles); + return ( +
+ value} + title={t('logs.log-line-details.fields.copy-value-to-clipboard', 'Copy value to clipboard')} + fill="text" + variant="secondary" + icon="copy" + size="md" + /> +
+ ); +}; + +const getClipboardButtonStyles = (theme: GrafanaTheme2) => ({ + button: css({ + '& > button': { + color: theme.colors.text.secondary, + padding: 0, + justifyContent: 'center', + borderRadius: theme.shape.radius.circle, + 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, + }, + }, + }, + }), +}); + +const MultipleValue = ({ showCopy, values = [] }: { showCopy?: boolean; values: string[] }) => { + if (values.every((val) => val === '')) { + return null; + } + return ( + + + {values.map((val, i) => { + return ( + + + + + ); + })} + +
{val}{showCopy && val !== '' && }
+ ); +}; + +interface AsyncIconButtonProps extends Pick, 'onClick'> { + name: IconName; + isActive(): Promise; + tooltipSuffix: string; +} + +const AsyncIconButton = ({ isActive, tooltipSuffix, ...rest }: AsyncIconButtonProps) => { + const [active, setActive] = useState(false); + const tooltip = active ? 'Remove filter' : 'Filter for value'; + + useEffect(() => { + isActive().then(setActive); + }, [isActive]); + + return ; +}; + +function filterFields(fields: FieldDef[], search: string) { + const keys = fields.map((field) => field.keys.join(' ')); + const keysIdx = fuzzySearch(keys, search); + const values = fields.map((field) => field.values.join(' ')); + const valuesIdx = fuzzySearch(values, search); + + const results = keysIdx.map((index) => fields[index]); + valuesIdx.forEach((index) => { + if (!results.includes(fields[index])) { + results.push(fields[index]); + } + }); + + return results; +} + +function filterLabels(labels: LabelWithLinks[], search: string) { + const keys = labels.map((field) => field.key); + const keysIdx = fuzzySearch(keys, search); + const values = labels.map((field) => field.value); + const valuesIdx = fuzzySearch(values, search); + + const results = keysIdx.map((index) => labels[index]); + valuesIdx.forEach((index) => { + if (!results.includes(labels[index])) { + results.push(labels[index]); + } + }); + + return results; +} diff --git a/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx new file mode 100644 index 00000000000..2c76d814cb6 --- /dev/null +++ b/public/app/features/logs/components/panel/LogLineDetailsHeader.tsx @@ -0,0 +1,216 @@ +import { css } from '@emotion/css'; +import { useCallback, useMemo, MouseEvent, useRef, ChangeEvent } from 'react'; + +import { colorManipulator, GrafanaTheme2, LogRowModel } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { IconButton, Input, useStyles2 } from '@grafana/ui'; + +import { copyText, handleOpenLogsContextClick } from '../../utils'; +import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; + +import { useLogIsPinned, useLogListContext } from './LogListContext'; +import { LogListModel } from './processing'; + +interface Props { + log: LogListModel; + search: string; + onSearch(newSearch: string): void; +} + +export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => { + const { + closeDetails, + displayedFields, + getRowContextQuery, + logSupportsContext, + onClickHideField, + onClickShowField, + onOpenContext, + onPermalinkClick, + onPinLine, + onUnpinLine, + } = useLogListContext(); + const pinned = useLogIsPinned(log); + const styles = useStyles2(getStyles); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const copyLogLine = useCallback(() => { + copyText(log.entry, containerRef); + }, [log.entry]); + + const copyLinkToLogLine = useCallback(() => { + onPermalinkClick?.(log); + }, [log, onPermalinkClick]); + + const togglePinning = useCallback(() => { + if (pinned) { + onUnpinLine?.(log); + } else { + onPinLine?.(log); + } + }, [log, onPinLine, onUnpinLine, pinned]); + + const shouldlogSupportsContext = useMemo( + () => (logSupportsContext ? logSupportsContext(log) : false), + [log, logSupportsContext] + ); + + const showContext = useCallback( + async (event: MouseEvent) => { + handleOpenLogsContextClick(event, log, getRowContextQuery, (log: LogRowModel) => onOpenContext?.(log, () => {})); + }, + [onOpenContext, getRowContextQuery, log] + ); + + const showLogLineToggle = onClickHideField && onClickShowField && displayedFields.length > 0; + const logLineDisplayed = displayedFields.includes(LOG_LINE_BODY_FIELD_NAME); + + const toggleLogLine = useCallback(() => { + if (logLineDisplayed) { + onClickHideField?.(LOG_LINE_BODY_FIELD_NAME); + } else { + onClickShowField?.(LOG_LINE_BODY_FIELD_NAME); + } + }, [logLineDisplayed, onClickHideField, onClickShowField]); + + const clearSearch = useMemo( + () => ( + { + onSearch(''); + if (inputRef.current) { + inputRef.current.value = ''; + } + }} + tooltip={t('logs.log-line-details.clear-search', 'Clear')} + /> + ), + [onSearch] + ); + + const handleSearch = useCallback( + (e: ChangeEvent) => { + onSearch(e.target.value); + }, + [onSearch] + ); + + return ( +
+ + {showLogLineToggle && ( + + )} + + {onPermalinkClick && log.rowId !== undefined && log.uid && ( + + )} + {pinned && onUnpinLine && ( + + )} + {!pinned && onPinLine && ( + + )} + {shouldlogSupportsContext && ( + + )} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + overflow: 'auto', + height: '100%', + }), + scrollContainer: css({ + overflow: 'auto', + height: '100%', + }), + header: css({ + alignItems: 'center', + background: theme.colors.background.canvas, + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(0.75), + zIndex: theme.zIndex.navbarFixed, + height: theme.spacing(5.5), + marginBottom: theme.spacing(1), + padding: theme.spacing(0.5, 1), + position: 'sticky', + top: 0, + }), + copyLogButton: css({ + padding: 0, + height: theme.spacing(4), + width: theme.spacing(2.5), + overflow: 'hidden', + '&:hover': { + backgroundColor: colorManipulator.alpha(theme.colors.text.primary, 0.12), + }, + }), + componentWrapper: css({ + padding: theme.spacing(0, 1, 1, 1), + }), +}); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index acdbc0dc593..933ca2dde59 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -191,6 +191,7 @@ export const LogList = ({ initialScrollPosition={initialScrollPosition} loading={loading} loadMore={loadMore} + logOptionsStorageKey={logOptionsStorageKey} logs={logs} showControls={showControls} timeRange={timeRange} @@ -209,6 +210,7 @@ const LogListComponent = ({ initialScrollPosition = 'top', loading, loadMore, + logOptionsStorageKey, logs, showControls, timeRange, @@ -459,7 +461,7 @@ const LogListComponent = ({ {showDetails.length > 0 && ( diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index df35eb00704..f0cb9be3ce6 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -73,7 +73,7 @@ export const defaultValue: LogListContextData = { setWrapLogMessage: jest.fn(), closeDetails: jest.fn(), detailsDisplayed: jest.fn(), - detailsWidth: 0, + detailsWidth: 300, downloadLogs: jest.fn(), enableLogDetails: false, filterLevels: [], @@ -130,11 +130,12 @@ export const LogListContextProvider = ({ onUnpinLine = jest.fn(), permalinkedLogId, pinnedLogs = [], + showDetails = [], showTime = true, sortOrder = LogsSortOrder.Descending, syntaxHighlighting = true, wrapLogMessage = true, -}: Partial) => { +}: Partial & { showDetails?: LogListModel[] }) => { const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log)); const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log)); @@ -172,6 +173,7 @@ export const LogListContextProvider = ({ setSortOrder: jest.fn(), setSyntaxHighlighting: jest.fn(), setWrapLogMessage: jest.fn(), + showDetails, showTime, sortOrder, syntaxHighlighting, diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 16460c350ec..8524f6f1956 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -30,6 +30,7 @@ import { Field, LogsMetaItem, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { getConfig } from 'app/core/config'; import { getLogsExtractFields } from '../explore/Logs/LogsTable'; @@ -391,35 +392,33 @@ function getLabelTypeFromFrame(labelKey: string, frame: DataFrame, index: number return typeField[labelKey] ?? null; } -export function getLabelTypeFromRow(label: string, row: LogRowModel) { +export function getLabelTypeFromRow(label: string, row: LogRowModel, plural = false) { if (!row.datasourceType) { return null; } - const idField = row.dataFrame.fields.find((field) => field.name === 'id'); - if (!idField) { - return null; - } - const rowIndex = idField.values.findIndex((id) => id === row.rowId); - if (rowIndex < 0) { - return null; - } - const labelType = getLabelTypeFromFrame(label, row.dataFrame, rowIndex); + const labelType = getLabelTypeFromFrame(label, row.dataFrame, row.rowIndex); if (!labelType) { return null; } - return getDataSourceLabelType(labelType, row.datasourceType); + return getDataSourceLabelType(labelType, row.datasourceType, plural); } -function getDataSourceLabelType(labelType: string, datasourceType: string) { +function getDataSourceLabelType(labelType: string, datasourceType: string, plural: boolean) { switch (datasourceType) { case 'loki': switch (labelType) { case 'I': - return 'Indexed label'; + return plural + ? t('logs.fields.type.loki.indexed-label-plural', 'Indexed labels') + : t('logs.fields.type.loki.indexed-label', 'Indexed label'); case 'S': - return 'Structured metadata'; + return plural + ? t('logs.fields.type.loki.structured-metadata-plural', 'Structured metadata') + : t('logs.fields.type.loki.structured-metadata', 'Structured metadata'); case 'P': - return 'Parsed label'; + return plural + ? t('logs.fields.type.loki.parsed-label-plural', 'Parsed fields') + : t('logs.fields.type.loki.parsedl-label', 'Parsed field'); default: return null; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e1edb952a08..8c65d4ed09f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8356,6 +8356,18 @@ "description-enable-infinite-scrolling": "Experimental. Request more results by scrolling to the bottom of the logs list.", "description-enable-syntax-highlighting": "Use a predefined syntax coloring grammar to highlight relevant parts of the log lines", "description-show-controls": "Display controls to jump to the last or first log line, and filters by log level", + "fields": { + "type": { + "loki": { + "indexed-label": "Indexed label", + "indexed-label-plural": "Indexed labels", + "parsed-label-plural": "Parsed fields", + "parsedl-label": "Parsed field", + "structured-metadata": "Structured metadata", + "structured-metadata-plural": "Structured metadata" + } + } + }, "font-size-options": { "label-default": "Default", "label-small": "Small" @@ -8379,7 +8391,6 @@ "label-wrap-lines": "Wrap lines" }, "log-details": { - "close": "Close log details", "fields": "Fields", "links": "Links", "log-line": "Log line", @@ -8405,6 +8416,35 @@ "show-more": "show more", "tooltip-error": "Error: {{errorMessage}}" }, + "log-line-details": { + "clear-search": "Clear", + "close": "Close log details", + "copy-shortlink": "Copy shortlink", + "copy-to-clipboard": "Copy to clipboard", + "fields": { + "adhoc-statistics": "Ad-hoc statistics", + "copy-value-to-clipboard": "Copy value to clipboard", + "filter-out": "Filter out value", + "filter-out-query": "Filter out value in query {{query}}", + "toggle-field-button": { + "field-instead-message": "Show this field instead of the message", + "hide-this-field": "Hide this field" + } + }, + "fields-section": "Fields", + "hide-log-line": "Hide log line", + "links-section": "Links", + "log-line-section": "Log line", + "no-details": "No fields to display.", + "pin-line": "Pin log", + "search": { + "no-results": "No results to display." + }, + "search-placeholder": "Search field names and values", + "show-context": "Show context", + "show-log-line": "Show log line", + "unpin-line": "Unpin log" + }, "log-line-menu": { "copy-link": "Copy link to log line", "copy-log": "Copy log line", @@ -8531,6 +8571,7 @@ "un-themed-log-details": { "aria-label-data-links": "Data links", "aria-label-fields": "Fields", + "aria-label-line": "Log line", "aria-label-log-level": "Log level", "aria-label-no-details": "No details" },