Logs: Add possibility to download logs in JSON format (#61394)

* add implementation of `logRowsToReadableJson`

* add test for logRowsToReadableJson

* add json, txt download buttons

* changed downloadmenu to `Menu`

* set closed state when menu closes

* removed unused css

* removed unused imports

* remove isOpen state

* remove unused import

* add tests

* remove untouched file
pull/61291/head^2
Sven Grossmann 2 years ago committed by GitHub
parent 0eeeeef08b
commit 82d8b2036f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 186
      public/app/features/explore/LogsMetaRow.test.tsx
  2. 45
      public/app/features/explore/LogsMetaRow.tsx
  3. 2
      public/app/features/logs/components/logParser.ts
  4. 56
      public/app/features/logs/utils.test.ts
  5. 20
      public/app/features/logs/utils.ts

@ -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<typeof LogsMetaRow>;
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(<LogsMetaRow {...props} />);
};
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)));
});
});

@ -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<LogsMetaItem | MetaItemProps> = [...meta];
@ -107,6 +128,12 @@ export const LogsMetaRow = React.memo(
),
});
}
const downloadMenu = (
<Menu>
<Menu.Item label="txt" onClick={() => downloadLogs(DownloadFormat.Text)} />
<Menu.Item label="json" onClick={() => downloadLogs(DownloadFormat.Json)} />
</Menu>
);
return (
<>
{logsMetaItem && (
@ -119,9 +146,11 @@ export const LogsMetaRow = React.memo(
};
})}
/>
<ToolbarButton onClick={downloadLogs} variant="default" icon="download-alt">
Download logs
</ToolbarButton>
<Dropdown overlay={downloadMenu}>
<ToolbarButton isOpen={false} variant="default" icon="download-alt">
Download
</ToolbarButton>
</Dropdown>
</div>
)}
</>

@ -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<LinkModel<Field>>

@ -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' } }]);
});
});

@ -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<Record<string, string>>((acc, field) => {
acc[field.key] = field.value;
return acc;
}, {});
return {
line: log.entry,
timestamp: log.timeEpochNs,
fields: {
...fields,
...log.labels,
},
};
});
}

Loading…
Cancel
Save