diff --git a/.betterer.results b/.betterer.results index 07a6575cde8..d881ab0ce7f 100644 --- a/.betterer.results +++ b/.betterer.results @@ -59,7 +59,7 @@ exports[`no enzyme tests`] = { "public/app/features/dimensions/editors/ThresholdsEditor/ThresholdsEditor.test.tsx:145048794": [ [0, 17, 13, "RegExp match", "2409514259"] ], - "public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:4057721851": [ + "public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:3543762762": [ [1, 19, 13, "RegExp match", "2409514259"] ] }` @@ -4673,6 +4673,19 @@ exports[`better eslint`] = { "public/app/features/live/pages/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/features/logs/components/LogRowContextProvider.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + ], + "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/manage-dashboards/DashboardImportPage.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -5895,6 +5908,9 @@ exports[`better eslint`] = { "public/app/plugins/datasource/cloud-monitoring/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/plugins/datasource/cloudwatch/__mocks__/LogsQueryRunner.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/plugins/datasource/cloudwatch/__mocks__/monarch/Monaco.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/package.json b/package.json index 669280c46d4..ca649b4375c 100644 --- a/package.json +++ b/package.json @@ -298,6 +298,7 @@ "angular-bindonce": "0.3.1", "angular-route": "1.8.3", "angular-sanitize": "1.8.3", + "ansicolor": "1.1.100", "app": "link:./public/app", "baron": "3.0.3", "brace": "0.11.1", diff --git a/public/app/features/explore/LiveLogs.tsx b/public/app/features/explore/LiveLogs.tsx index b73d5e1296d..d5cfb36ef03 100644 --- a/public/app/features/explore/LiveLogs.tsx +++ b/public/app/features/explore/LiveLogs.tsx @@ -3,7 +3,10 @@ import React, { PureComponent } from 'react'; import tinycolor from 'tinycolor2'; import { LogRowModel, TimeZone, dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; -import { LogMessageAnsi, getLogRowStyles, Icon, Button, Themeable2, withTheme2 } from '@grafana/ui'; +import { Icon, Button, Themeable2, withTheme2 } from '@grafana/ui'; + +import { LogMessageAnsi } from '../logs/components/LogMessageAnsi'; +import { getLogRowStyles } from '../logs/components/getLogRowStyles'; import { ElapsedTime } from './ElapsedTime'; diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index af348a1fd15..55f6c387cb5 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -26,7 +26,6 @@ import { import { reportInteraction } from '@grafana/runtime'; import { RadioButtonGroup, - LogRows, Button, InlineField, InlineFieldRow, @@ -34,11 +33,13 @@ import { withTheme2, Themeable2, } from '@grafana/ui'; -import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; import { dedupLogRows, filterLogLevels } from 'app/core/logsModel'; import store from 'app/core/store'; import { ExploreId } from 'app/types/explore'; +import { RowContextOptions } from '../logs/components/LogRowContextProvider'; +import { LogRows } from '../logs/components/LogRows'; + import { LogsMetaRow } from './LogsMetaRow'; import LogsNavigation from './LogsNavigation'; import { LogsVolumePanel } from './LogsVolumePanel'; diff --git a/public/app/features/explore/LogsMetaRow.tsx b/public/app/features/explore/LogsMetaRow.tsx index f65d1d9d2f6..7ea421edcc8 100644 --- a/public/app/features/explore/LogsMetaRow.tsx +++ b/public/app/features/explore/LogsMetaRow.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel } from '@grafana/data'; -import { Button, Tooltip, LogLabels } from '@grafana/ui'; -import { MAX_CHARACTERS } from '@grafana/ui/src/components/Logs/LogRowMessage'; +import { Button, Tooltip } from '@grafana/ui'; + +import { LogLabels } from '../logs/components/LogLabels'; +import { MAX_CHARACTERS } from '../logs/components/LogRowMessage'; import { MetaInfoText, MetaItemProps } from './MetaInfoText'; diff --git a/public/app/features/logs/components/LogDetails.test.tsx b/public/app/features/logs/components/LogDetails.test.tsx new file mode 100644 index 00000000000..1effa11aec3 --- /dev/null +++ b/public/app/features/logs/components/LogDetails.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, within } from '@testing-library/react'; +import React from 'react'; + +import { Field, LogLevel, LogRowModel, MutableDataFrame, createTheme } from '@grafana/data'; + +import { LogDetails, Props } from './LogDetails'; +import { createLogRow } from './__mocks__/logRow'; + +const setup = (propOverrides?: Partial, rowOverrides?: Partial) => { + const props: Props = { + showDuplicates: false, + wrapLogMessage: false, + row: createLogRow({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides }), + getRows: () => [], + onClickFilterLabel: () => {}, + onClickFilterOutLabel: () => {}, + theme: createTheme(), + ...(propOverrides || {}), + }; + + render( + + + + +
+ ); +}; + +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); + }); + it('should render labels', () => { + setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }); + expect(screen.getByRole('cell', { name: 'key1' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'label1' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'key2' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'label2' })).toBeInTheDocument(); + }); + }); + describe('when log row has error', () => { + it('should not render log level border', () => { + // Is this a good test case for RTL?? + setup({ hasError: true }, undefined); + 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); + }); + 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', () => { + it('should render no details available message', () => { + setup(undefined, { entry: '' }); + expect(screen.getByText('No details available')).toBeInTheDocument(); + }); + it('should not render headings', () => { + setup(undefined, { entry: '' }); + expect(screen.queryAllByLabelText('Log labels')).toHaveLength(0); + expect(screen.queryAllByLabelText('Detected fields')).toHaveLength(0); + }); + }); + + it('should render fields from dataframe with links', () => { + const entry = 'traceId=1234 msg="some message"'; + const dataFrame = new MutableDataFrame({ + fields: [ + { name: 'entry', values: [entry] }, + // As we have traceId in message already this will shadow it. + { + name: 'traceId', + values: ['1234'], + config: { links: [{ title: 'link', url: 'localhost:3210/${__value.text}' }] }, + }, + { name: 'userId', values: ['5678'] }, + ], + }); + setup( + { + getFieldLinks: (field: Field, rowIndex: number) => { + if (field.config && field.config.links) { + return field.config.links.map((link) => { + return { + href: link.url.replace('${__value.text}', field.values.get(rowIndex)), + title: link.title, + target: '_blank', + origin: field, + }; + }); + } + return []; + }, + }, + { entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 } + ); + expect(screen.getAllByRole('table')).toHaveLength(2); + const rowDetailsTable = screen.getAllByRole('table')[1]; + const rowDetailRows = within(rowDetailsTable).getAllByRole('row'); + expect(rowDetailRows).toHaveLength(4); // 3 LogDetailsRow + 1 header + const traceIdRow = within(rowDetailsTable).getByRole('cell', { name: 'traceId' }).closest('tr'); + expect(traceIdRow).toBeInTheDocument(); + const link = within(traceIdRow!).getByRole('link', { name: 'link' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'localhost:3210/1234'); + }); +}); diff --git a/public/app/features/logs/components/LogDetails.tsx b/public/app/features/logs/components/LogDetails.tsx new file mode 100644 index 00000000000..3cb8801667c --- /dev/null +++ b/public/app/features/logs/components/LogDetails.tsx @@ -0,0 +1,176 @@ +import { css, cx } from '@emotion/css'; +import memoizeOne from 'memoize-one'; +import React, { PureComponent } from 'react'; + +import { + calculateFieldStats, + calculateLogsLabelStats, + calculateStats, + Field, + getParser, + LinkModel, + LogRowModel, + GrafanaTheme2, +} from '@grafana/data'; +import { withTheme2, Themeable2, Icon, Tooltip } from '@grafana/ui'; + +import { LogDetailsRow } from './LogDetailsRow'; +import { getLogRowStyles } from './getLogRowStyles'; +import { getAllFields } from './logParser'; + +//Components + +export interface Props extends Themeable2 { + row: LogRowModel; + showDuplicates: boolean; + getRows: () => LogRowModel[]; + wrapLogMessage: boolean; + className?: string; + hasError?: boolean; + + onClickFilterLabel?: (key: string, value: string) => void; + onClickFilterOutLabel?: (key: string, value: string) => void; + getFieldLinks?: (field: Field, rowIndex: number) => Array>; + showDetectedFields?: string[]; + onClickShowDetectedField?: (key: string) => void; + onClickHideDetectedField?: (key: string) => void; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + logsRowLevelDetails: css` + label: logs-row__level_details; + &::after { + top: -3px; + } + `, + logDetails: css` + label: logDetailsDefaultCursor; + cursor: default; + + &:hover { + background-color: ${theme.colors.background.primary}; + } + `, + }; +}; + +class UnThemedLogDetails extends PureComponent { + getParser = memoizeOne(getParser); + + getStatsForDetectedField = (key: string) => { + const matcher = this.getParser(this.props.row.entry)!.buildMatcher(key); + return calculateFieldStats(this.props.getRows(), matcher); + }; + + render() { + const { + row, + theme, + hasError, + onClickFilterOutLabel, + onClickFilterLabel, + getRows, + showDuplicates, + className, + onClickShowDetectedField, + onClickHideDetectedField, + showDetectedFields, + getFieldLinks, + wrapLogMessage, + } = this.props; + const style = getLogRowStyles(theme, row.logLevel); + 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; + // If logs with error, we are not showing the level color + const levelClassName = cx(!hasError && [style.logsRowLevel, styles.logsRowLevelDetails]); + + return ( + + {showDuplicates && } + + +
+ + + {labelsAvailable && ( + + + + )} + {Object.keys(labels) + .sort() + .map((key) => { + const value = labels[key]; + return ( + calculateLogsLabelStats(getRows(), key)} + onClickFilterOutLabel={onClickFilterOutLabel} + onClickFilterLabel={onClickFilterLabel} + /> + ); + })} + + {detectedFieldsAvailable && ( + + + + )} + {fields.sort().map((field) => { + const { key, value, links, fieldIndex } = field; + return ( + + fieldIndex === undefined + ? this.getStatsForDetectedField(key) + : calculateStats(row.dataFrame.fields[fieldIndex].values.toArray()) + } + showDetectedFields={showDetectedFields} + wrapLogMessage={wrapLogMessage} + /> + ); + })} + {!detectedFieldsAvailable && !labelsAvailable && ( + + + + )} + +
+ Log labels +
+ Detected fields + + + +
+ No details available +
+
+ + + ); + } +} + +export const LogDetails = withTheme2(UnThemedLogDetails); +LogDetails.displayName = 'LogDetails'; diff --git a/public/app/features/logs/components/LogDetailsRow.test.tsx b/public/app/features/logs/components/LogDetailsRow.test.tsx new file mode 100644 index 00000000000..bf5583b6511 --- /dev/null +++ b/public/app/features/logs/components/LogDetailsRow.test.tsx @@ -0,0 +1,121 @@ +import { screen, render, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ComponentProps } from 'react'; + +import { LogDetailsRow } from './LogDetailsRow'; + +type Props = ComponentProps; + +const setup = (propOverrides?: Partial) => { + const props: Props = { + parsedValue: '', + parsedKey: '', + isLabel: true, + wrapLogMessage: false, + getStats: () => null, + onClickFilterLabel: () => {}, + onClickFilterOutLabel: () => {}, + onClickShowDetectedField: () => {}, + onClickHideDetectedField: () => {}, + showDetectedFields: [], + }; + + Object.assign(props, propOverrides); + + return render( + + + + +
+ ); +}; + +describe('LogDetailsRow', () => { + it('should render parsed key', () => { + setup({ parsedKey: 'test key' }); + expect(screen.getByText('test key')).toBeInTheDocument(); + }); + it('should render parsed value', () => { + setup({ parsedValue: 'test value' }); + expect(screen.getByText('test value')).toBeInTheDocument(); + }); + + it('should render metrics button', () => { + setup(); + expect(screen.getAllByTitle('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); + }); + 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(); + }); + }); + + 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(); + }); + }); + + it('should render stats when stats icon is clicked', () => { + setup({ + parsedKey: 'key', + parsedValue: 'value', + getStats: () => { + return [ + { + count: 1, + proportion: 1 / 2, + value: 'value', + }, + { + count: 1, + proportion: 1 / 2, + value: 'another value', + }, + ]; + }, + }); + + expect(screen.queryByTestId('logLabelStats')).not.toBeInTheDocument(); + const adHocStatsButton = screen.getByTitle('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 new file mode 100644 index 00000000000..9bd82f5d680 --- /dev/null +++ b/public/app/features/logs/components/LogDetailsRow.tsx @@ -0,0 +1,219 @@ +import { css, cx } from '@emotion/css'; +import React, { PureComponent } from 'react'; + +import { Field, LinkModel, LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data'; +import { withTheme2, Themeable2, ClipboardButton, DataLinkButton, IconButton } from '@grafana/ui'; + +import { LogLabelStats } from './LogLabelStats'; +import { getLogRowStyles } from './getLogRowStyles'; + +//Components + +export interface Props extends Themeable2 { + parsedValue: string; + parsedKey: string; + wrapLogMessage?: boolean; + isLabel?: boolean; + onClickFilterLabel?: (key: string, value: string) => void; + onClickFilterOutLabel?: (key: string, value: string) => void; + links?: Array>; + getStats: () => LogLabelStatsModel[] | null; + showDetectedFields?: string[]; + onClickShowDetectedField?: (key: string) => void; + onClickHideDetectedField?: (key: string) => void; +} + +interface State { + showFieldsStats: boolean; + fieldCount: number; + fieldStats: LogLabelStatsModel[] | null; + mouseOver: boolean; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + noHoverBackground: css` + label: noHoverBackground; + :hover { + background-color: transparent; + } + `, + hoverCursor: css` + label: hoverCursor; + cursor: pointer; + `, + wordBreakAll: css` + label: wordBreakAll; + word-break: break-all; + `, + 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: 20px; + width: 26px; + height: 26px; + `, + wrapLine: css` + label: wrapLine; + white-space: pre-wrap; + `, + }; +}; +class UnThemedLogDetailsRow extends PureComponent { + state: State = { + showFieldsStats: false, + fieldCount: 0, + fieldStats: null, + mouseOver: false, + }; + + showField = () => { + const { onClickShowDetectedField, parsedKey } = this.props; + if (onClickShowDetectedField) { + onClickShowDetectedField(parsedKey); + } + }; + + hideField = () => { + const { onClickHideDetectedField, parsedKey } = this.props; + if (onClickHideDetectedField) { + onClickHideDetectedField(parsedKey); + } + }; + + filterLabel = () => { + const { onClickFilterLabel, parsedKey, parsedValue } = this.props; + if (onClickFilterLabel) { + onClickFilterLabel(parsedKey, parsedValue); + } + }; + + filterOutLabel = () => { + const { onClickFilterOutLabel, parsedKey, parsedValue } = this.props; + if (onClickFilterOutLabel) { + onClickFilterOutLabel(parsedKey, parsedValue); + } + }; + + showStats = () => { + const { showFieldsStats } = this.state; + if (!showFieldsStats) { + const fieldStats = this.props.getStats(); + const fieldCount = fieldStats ? fieldStats.reduce((sum, stat) => sum + stat.count, 0) : 0; + this.setState({ fieldStats, fieldCount }); + } + this.toggleFieldsStats(); + }; + + toggleFieldsStats() { + this.setState((state) => { + return { + showFieldsStats: !state.showFieldsStats, + }; + }); + } + + hoverValueCopy() { + const mouseOver = !this.state.mouseOver; + this.setState({ mouseOver }); + } + + render() { + const { + theme, + parsedKey, + parsedValue, + isLabel, + links, + showDetectedFields, + wrapLogMessage, + onClickShowDetectedField, + onClickHideDetectedField, + onClickFilterLabel, + onClickFilterOutLabel, + } = this.props; + const { showFieldsStats, fieldStats, fieldCount, mouseOver } = this.state; + const styles = getStyles(theme); + const style = getLogRowStyles(theme); + + const hasDetectedFieldsFunctionality = onClickShowDetectedField && onClickHideDetectedField; + const hasFilteringFunctionality = onClickFilterLabel && onClickFilterOutLabel; + + const toggleFieldButton = + !isLabel && showDetectedFields && showDetectedFields.includes(parsedKey) ? ( + + ) : ( + + ); + + return ( + + {/* Action buttons - show stats/filter results */} + + + + + {hasFilteringFunctionality && isLabel && ( + <> + + + + + + + + )} + + {hasDetectedFieldsFunctionality && !isLabel && ( + + {toggleFieldButton} + + )} + + {/* Key - value columns */} + {parsedKey} + + {parsedValue} + {mouseOver && ( + parsedValue} + title="Copy value to clipboard" + fill="text" + variant="secondary" + icon="copy" + size="sm" + className={styles.hoverValueCopy} + /> + )} + {links?.map((link) => ( + +   + + + ))} + {showFieldsStats && ( + + )} + + + ); + } +} + +export const LogDetailsRow = withTheme2(UnThemedLogDetailsRow); +LogDetailsRow.displayName = 'LogDetailsRow'; diff --git a/public/app/features/logs/components/LogLabelStats.tsx b/public/app/features/logs/components/LogLabelStats.tsx new file mode 100644 index 00000000000..65c71415833 --- /dev/null +++ b/public/app/features/logs/components/LogLabelStats.tsx @@ -0,0 +1,97 @@ +import { css } from '@emotion/css'; +import React, { PureComponent } from 'react'; + +import { LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data'; +import { stylesFactory, withTheme2, Themeable2 } from '@grafana/ui'; + +//Components +import { LogLabelStatsRow } from './LogLabelStatsRow'; + +const STATS_ROW_LIMIT = 5; + +const getStyles = stylesFactory((theme: GrafanaTheme2) => { + return { + logsStats: css` + label: logs-stats; + background: inherit; + color: ${theme.colors.text.primary}; + word-break: break-all; + width: fit-content; + max-width: 100%; + `, + logsStatsHeader: css` + label: logs-stats__header; + border-bottom: 1px solid ${theme.colors.border.medium}; + display: flex; + `, + logsStatsTitle: css` + label: logs-stats__title; + font-weight: ${theme.typography.fontWeightMedium}; + padding-right: ${theme.spacing(2)}; + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; + flex-grow: 1; + `, + logsStatsClose: css` + label: logs-stats__close; + cursor: pointer; + `, + logsStatsBody: css` + label: logs-stats__body; + padding: 5px 0; + `, + }; +}); + +interface Props extends Themeable2 { + stats: LogLabelStatsModel[]; + label: string; + value: string; + rowCount: number; + isLabel?: boolean; +} + +class UnThemedLogLabelStats extends PureComponent { + render() { + const { label, rowCount, stats, value, theme, isLabel } = this.props; + const style = getStyles(theme); + const topRows = stats.slice(0, STATS_ROW_LIMIT); + let activeRow = topRows.find((row) => row.value === value); + let otherRows = stats.slice(STATS_ROW_LIMIT); + const insertActiveRow = !activeRow; + + // Remove active row from other to show extra + if (insertActiveRow) { + activeRow = otherRows.find((row) => row.value === value); + otherRows = otherRows.filter((row) => row.value !== 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; + + return ( +
+
+
+ {label}: {total} of {rowCount} rows have that {isLabel ? 'label' : 'field'} +
+
+
+ {topRows.map((stat) => ( + + ))} + {insertActiveRow && activeRow && } + {otherCount > 0 && ( + + )} +
+
+ ); + } +} + +export const LogLabelStats = withTheme2(UnThemedLogLabelStats); +LogLabelStats.displayName = 'LogLabelStats'; diff --git a/public/app/features/logs/components/LogLabelStatsRow.tsx b/public/app/features/logs/components/LogLabelStatsRow.tsx new file mode 100644 index 00000000000..2681a4bb187 --- /dev/null +++ b/public/app/features/logs/components/LogLabelStatsRow.tsx @@ -0,0 +1,82 @@ +import { css, cx } from '@emotion/css'; +import React, { FunctionComponent } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +const getStyles = (theme: GrafanaTheme2) => ({ + logsStatsRow: css` + label: logs-stats-row; + margin: ${parseInt(theme.spacing(2), 10) / 1.75}px 0; + `, + logsStatsRowActive: css` + label: logs-stats-row--active; + color: ${theme.colors.primary.text}; + position: relative; + `, + logsStatsRowLabel: css` + label: logs-stats-row__label; + display: flex; + margin-bottom: 1px; + `, + logsStatsRowValue: css` + label: logs-stats-row__value; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + `, + logsStatsRowCount: css` + label: logs-stats-row__count; + text-align: right; + margin-left: 0.5em; + `, + logsStatsRowPercent: css` + label: logs-stats-row__percent; + text-align: right; + margin-left: 0.5em; + width: 3em; + `, + logsStatsRowBar: css` + label: logs-stats-row__bar; + height: 4px; + overflow: hidden; + background: ${theme.colors.text.disabled}; + `, + logsStatsRowInnerBar: css` + label: logs-stats-row__innerbar; + height: 4px; + overflow: hidden; + background: ${theme.colors.primary.main}; + `, +}); + +export interface Props { + active?: boolean; + count: number; + proportion: number; + value?: string; +} + +export const LogLabelStatsRow: FunctionComponent = ({ active, count, proportion, value }) => { + const style = useStyles2(getStyles); + const percent = `${Math.round(proportion * 100)}%`; + const barStyle = { width: percent }; + const className = active ? cx([style.logsStatsRow, style.logsStatsRowActive]) : cx([style.logsStatsRow]); + + return ( +
+
+
+ {value} +
+
{count}
+
{percent}
+
+
+
+
+
+ ); +}; + +LogLabelStatsRow.displayName = 'LogLabelStatsRow'; diff --git a/public/app/features/logs/components/LogLabels.test.tsx b/public/app/features/logs/components/LogLabels.test.tsx new file mode 100644 index 00000000000..e76459f0b65 --- /dev/null +++ b/public/app/features/logs/components/LogLabels.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { LogLabels } from './LogLabels'; + +describe('', () => { + it('renders notice when no labels are found', () => { + render(); + expect(screen.queryByText('(no unique labels)')).toBeInTheDocument(); + }); + it('renders labels', () => { + render(); + expect(screen.queryByText('bar')).toBeInTheDocument(); + expect(screen.queryByText('42')).toBeInTheDocument(); + }); + it('excludes labels with certain names or labels starting with underscore', () => { + render(); + expect(screen.queryByText('bar')).toBeInTheDocument(); + expect(screen.queryByText('42')).not.toBeInTheDocument(); + expect(screen.queryByText('13')).not.toBeInTheDocument(); + }); + it('excludes labels with empty string values', () => { + render(); + expect(screen.queryByText('bar')).toBeInTheDocument(); + expect(screen.queryByText('baz')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx new file mode 100644 index 00000000000..b499b19f02d --- /dev/null +++ b/public/app/features/logs/components/LogLabels.tsx @@ -0,0 +1,72 @@ +import { css, cx } from '@emotion/css'; +import React, { FunctionComponent } from 'react'; + +import { GrafanaTheme2, Labels } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +// Levels are already encoded in color, filename is a Loki-ism +const HIDDEN_LABELS = ['level', 'lvl', 'filename']; + +interface Props { + labels: Labels; +} + +export const LogLabels: FunctionComponent = ({ labels }) => { + const styles = useStyles2(getStyles); + const displayLabels = Object.keys(labels).filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label)); + + if (displayLabels.length === 0) { + return ( + + (no unique labels) + + ); + } + + return ( + + {displayLabels.sort().map((label) => { + const value = labels[label]; + if (!value) { + return; + } + const tooltip = `${label}: ${value}`; + return ( + + + {value} + + + ); + })} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + logsLabels: css` + display: flex; + flex-wrap: wrap; + font-size: ${theme.typography.size.xs}; + `, + logsLabel: css` + label: logs-label; + display: flex; + padding: 0 2px; + background-color: ${theme.colors.background.secondary}; + border-radius: ${theme.shape.borderRadius(1)}; + margin: 1px 4px 0 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + `, + logsLabelValue: css` + label: logs-label__value; + display: inline-block; + max-width: 20em; + text-overflow: ellipsis; + overflow: hidden; + `, + }; +}; diff --git a/public/app/features/logs/components/LogMessageAnsi.test.tsx b/public/app/features/logs/components/LogMessageAnsi.test.tsx new file mode 100644 index 00000000000..d6fa4e18b77 --- /dev/null +++ b/public/app/features/logs/components/LogMessageAnsi.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, within } from '@testing-library/react'; +import React from 'react'; + +import { createTheme } from '@grafana/data'; + +import { UnThemedLogMessageAnsi as LogMessageAnsi } from './LogMessageAnsi'; + +describe('', () => { + it('renders string without ANSI codes', () => { + render(); + + expect(screen.queryByTestId('ansiLogLine')).not.toBeInTheDocument(); + expect(screen.queryByText('Lorem ipsum')).toBeInTheDocument(); + }); + it('renders string with ANSI codes', () => { + const value = 'Lorem \u001B[31mipsum\u001B[0m et dolor'; + render(); + + expect(screen.queryByTestId('ansiLogLine')).toBeInTheDocument(); + expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(1); + expect(screen.getAllByTestId('ansiLogLine').at(0)).toHaveAttribute('style', expect.stringMatching('color')); + + const { getByText } = within(screen.getAllByTestId('ansiLogLine').at(0)!); + expect(getByText('ipsum')).toBeInTheDocument(); + }); + it('renders string with ANSI codes with correctly converted css classnames', () => { + const value = 'Lorem \u001B[1;32mIpsum'; + render(); + + expect(screen.queryByTestId('ansiLogLine')).toBeInTheDocument(); + expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(1); + + expect(screen.getAllByTestId('ansiLogLine').at(0)).toHaveAttribute('style', expect.stringMatching('font-weight')); + }); + it('renders string with ANSI dim code with appropriate themed color', () => { + const value = 'Lorem \u001B[1;2mIpsum'; + const theme = createTheme(); + render(); + + expect(screen.queryByTestId('ansiLogLine')).toBeInTheDocument(); + expect(screen.getAllByTestId('ansiLogLine')).toHaveLength(1); + + expect(screen.getAllByTestId('ansiLogLine').at(0)).toHaveStyle({ color: theme.colors.text.secondary }); + }); +}); diff --git a/public/app/features/logs/components/LogMessageAnsi.tsx b/public/app/features/logs/components/LogMessageAnsi.tsx new file mode 100644 index 00000000000..12ac6faa0c6 --- /dev/null +++ b/public/app/features/logs/components/LogMessageAnsi.tsx @@ -0,0 +1,103 @@ +import ansicolor from 'ansicolor'; +import React, { PureComponent } from 'react'; +import Highlighter from 'react-highlight-words'; + +import { findHighlightChunksInText, GrafanaTheme2 } from '@grafana/data'; +import { withTheme2, Themeable2 } from '@grafana/ui'; + +interface Style { + [key: string]: string; +} + +interface ParsedChunk { + style: Style; + text: string; +} + +function convertCSSToStyle(theme: GrafanaTheme2, css: string): Style { + return css.split(/;\s*/).reduce