diff --git a/public/app/features/explore/LogsMetaRow.test.tsx b/public/app/features/explore/LogsMetaRow.test.tsx new file mode 100644 index 00000000000..4096e2ab5ed --- /dev/null +++ b/public/app/features/explore/LogsMetaRow.test.tsx @@ -0,0 +1,186 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import saveAs from 'file-saver'; +import React, { ComponentProps } from 'react'; + +import { LogLevel, LogsDedupStrategy, MutableDataFrame } from '@grafana/data'; + +import { MAX_CHARACTERS } from '../logs/components/LogRowMessage'; +import { logRowsToReadableJson } from '../logs/utils'; + +import { LogsMetaRow } from './LogsMetaRow'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: () => null, +})); + +jest.mock('file-saver', () => jest.fn()); + +type LogsMetaRowProps = ComponentProps; +const defaultProps: LogsMetaRowProps = { + meta: [], + dedupStrategy: LogsDedupStrategy.none, + dedupCount: 0, + displayedFields: [], + hasUnescapedContent: false, + forceEscape: false, + logRows: [], + onEscapeNewlines: jest.fn(), + clearDetectedFields: jest.fn(), +}; + +const setup = (propOverrides?: object) => { + const props = { + ...defaultProps, + ...propOverrides, + }; + + return render(); +}; + +describe('LogsMetaRow', () => { + it('renders the dedupe number', async () => { + setup({ dedupStrategy: LogsDedupStrategy.numbers, dedupCount: 1234 }); + expect(await screen.findByText('1234')).toBeInTheDocument(); + }); + + it('renders a highlighting warning', async () => { + setup({ logRows: [{ entry: 'A'.repeat(MAX_CHARACTERS + 1) }] }); + expect( + await screen.findByText('Logs with more than 100,000 characters could not be parsed and highlighted') + ).toBeInTheDocument(); + }); + + it('renders the show original line button', () => { + setup({ displayedFields: ['test'] }); + expect( + screen.getByRole('button', { + name: 'Show original line', + }) + ).toBeInTheDocument(); + }); + + it('renders the displayedfield', async () => { + setup({ displayedFields: ['testField1234'] }); + expect(await screen.findByText('testField1234')).toBeInTheDocument(); + }); + + it('renders a button to clear displayedfields', () => { + const clearSpy = jest.fn(); + setup({ displayedFields: ['testField1234'], clearDetectedFields: clearSpy }); + fireEvent( + screen.getByRole('button', { + name: 'Show original line', + }), + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + expect(clearSpy).toBeCalled(); + }); + + it('renders a button to remove escaping', () => { + setup({ hasUnescapedContent: true, forceEscape: true }); + expect( + screen.getByRole('button', { + name: 'Remove escaping', + }) + ).toBeInTheDocument(); + }); + + it('renders a button to remove escaping', () => { + setup({ hasUnescapedContent: true, forceEscape: false }); + expect( + screen.getByRole('button', { + name: 'Escape newlines', + }) + ).toBeInTheDocument(); + }); + + it('renders a button to remove escaping', () => { + const escapeSpy = jest.fn(); + setup({ hasUnescapedContent: true, forceEscape: false, onEscapeNewlines: escapeSpy }); + fireEvent( + screen.getByRole('button', { + name: 'Escape newlines', + }), + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + expect(escapeSpy).toBeCalled(); + }); + + it('renders a button to show the download menu', () => { + setup(); + expect(screen.getByText('Download').closest('button')).toBeInTheDocument(); + }); + + it('renders a button to show the download menu', async () => { + setup(); + + expect(screen.queryAllByText('txt')).toHaveLength(0); + await userEvent.click(screen.getByText('Download').closest('button')!); + expect( + screen.getByRole('menuitem', { + name: 'txt', + }) + ).toBeInTheDocument(); + }); + + it('renders a button to download txt', async () => { + setup(); + + await userEvent.click(screen.getByText('Download').closest('button')!); + + await userEvent.click( + screen.getByRole('menuitem', { + name: 'txt', + }) + ); + + expect(saveAs).toBeCalled(); + }); + + it('renders a button to download json', async () => { + const rows = [ + { + rowIndex: 1, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: 'test entry', + hasAnsi: false, + hasUnescapedContent: false, + labels: { + foo: 'bar', + }, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '123456789', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }, + ]; + setup({ logRows: rows }); + + await userEvent.click(screen.getByText('Download').closest('button')!); + + await userEvent.click( + screen.getByRole('menuitem', { + name: 'json', + }) + ); + + expect(saveAs).toBeCalled(); + const blob = (saveAs as unknown as jest.Mock).mock.lastCall[0]; + expect(blob.type).toBe('application/json;charset=utf-8'); + const text = await blob.text(); + expect(text).toBe(JSON.stringify(logRowsToReadableJson(rows))); + }); +}); diff --git a/public/app/features/explore/LogsMetaRow.tsx b/public/app/features/explore/LogsMetaRow.tsx index 88642e197a8..402ed08aefb 100644 --- a/public/app/features/explore/LogsMetaRow.tsx +++ b/public/app/features/explore/LogsMetaRow.tsx @@ -1,13 +1,15 @@ import { css } from '@emotion/css'; +import saveAs from 'file-saver'; import React from 'react'; -import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp } from '@grafana/data'; +import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, dateTimeFormat } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { Button, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; +import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; import { downloadLogsModelAsTxt } from '../inspector/utils/download'; import { LogLabels } from '../logs/components/LogLabels'; import { MAX_CHARACTERS } from '../logs/components/LogRowMessage'; +import { logRowsToReadableJson } from '../logs/utils'; import { MetaInfoText, MetaItemProps } from './MetaInfoText'; @@ -31,6 +33,11 @@ export type Props = { clearDetectedFields: () => void; }; +enum DownloadFormat { + Text = 'text', + Json = 'json', +} + export const LogsMetaRow = React.memo( ({ meta, @@ -45,13 +52,27 @@ export const LogsMetaRow = React.memo( }: Props) => { const style = useStyles2(getStyles); - const downloadLogs = () => { + const downloadLogs = (format: DownloadFormat) => { reportInteraction('grafana_logs_download_logs_clicked', { app: CoreApp.Explore, - format: 'logs', + format, area: 'logs-meta-row', }); - downloadLogsModelAsTxt({ meta, rows: logRows }, 'Explore'); + + switch (format) { + case DownloadFormat.Text: + downloadLogsModelAsTxt({ meta, rows: logRows }, 'Explore'); + break; + case DownloadFormat.Json: + const jsonLogs = logRowsToReadableJson(logRows); + const blob = new Blob([JSON.stringify(jsonLogs)], { + type: 'application/json;charset=utf-8', + }); + + const fileName = `Explore-logs-${dateTimeFormat(new Date())}.json`; + saveAs(blob, fileName); + break; + } }; const logsMetaItem: Array = [...meta]; @@ -107,6 +128,12 @@ export const LogsMetaRow = React.memo( ), }); } + const downloadMenu = ( + + downloadLogs(DownloadFormat.Text)} /> + downloadLogs(DownloadFormat.Json)} /> + + ); return ( <> {logsMetaItem && ( @@ -119,9 +146,11 @@ export const LogsMetaRow = React.memo( }; })} /> - - Download logs - + + + Download + + )} diff --git a/public/app/features/logs/components/logParser.ts b/public/app/features/logs/components/logParser.ts index d32db19ff51..22359e26080 100644 --- a/public/app/features/logs/components/logParser.ts +++ b/public/app/features/logs/components/logParser.ts @@ -27,7 +27,7 @@ export const getAllFields = memoizeOne( /** * creates fields from the dataframe-fields, adding data-links, when field.config.links exists */ -const getDataframeFields = memoizeOne( +export const getDataframeFields = memoizeOne( ( row: LogRowModel, getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array> diff --git a/public/app/features/logs/utils.test.ts b/public/app/features/logs/utils.test.ts index b6e3e9f290f..fed857ac22d 100644 --- a/public/app/features/logs/utils.test.ts +++ b/public/app/features/logs/utils.test.ts @@ -7,6 +7,7 @@ import { getLogLevelFromKey, sortLogsResult, checkLogsError, + logRowsToReadableJson, } from './utils'; describe('getLoglevel()', () => { @@ -205,3 +206,58 @@ describe('checkLogsError()', () => { expect(checkLogsError(log)).toStrictEqual({ hasError: true, errorMessage: 'Error Message' }); }); }); + +describe('logRowsToReadableJson', () => { + const testRow: LogRowModel = { + rowIndex: 1, + entryFieldIndex: 0, + dataFrame: new MutableDataFrame(), + entry: 'test entry', + hasAnsi: false, + hasUnescapedContent: false, + labels: { + foo: 'bar', + }, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '123456789', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }; + const testDf = new MutableDataFrame(); + testDf.addField({ name: 'foo2', values: ['bar2'] }); + const testRow2: LogRowModel = { + rowIndex: 0, + entryFieldIndex: -1, + dataFrame: testDf, + entry: 'test entry', + hasAnsi: false, + hasUnescapedContent: false, + labels: { + foo: 'bar', + }, + logLevel: LogLevel.info, + raw: '', + timeEpochMs: 10, + timeEpochNs: '123456789', + timeFromNow: '', + timeLocal: '', + timeUtc: '', + uid: '2', + }; + + it('should format a single row', () => { + const result = logRowsToReadableJson([testRow]); + + expect(result).toEqual([{ line: 'test entry', timestamp: '123456789', fields: { foo: 'bar' } }]); + }); + + it('should format a df field row', () => { + const result = logRowsToReadableJson([testRow2]); + + expect(result).toEqual([{ line: 'test entry', timestamp: '123456789', fields: { foo: 'bar', foo2: 'bar2' } }]); + }); +}); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 298003d419c..66319a9931e 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -2,6 +2,8 @@ import { countBy, chain } from 'lodash'; import { LogLevel, LogRowModel, LogLabelStatsModel, LogsModel, LogsSortOrder } from '@grafana/data'; +import { getDataframeFields } from './components/logParser'; + /** * Returns the log level of a log line. * Parse the line for level words. If no level is found, it returns `LogLevel.unknown`. @@ -129,3 +131,21 @@ export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorM export const escapeUnescapedString = (string: string) => string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); + +export function logRowsToReadableJson(logs: LogRowModel[]) { + return logs.map((log) => { + const fields = getDataframeFields(log).reduce>((acc, field) => { + acc[field.key] = field.value; + return acc; + }, {}); + + return { + line: log.entry, + timestamp: log.timeEpochNs, + fields: { + ...fields, + ...log.labels, + }, + }; + }); +}