diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 364c4e1b646..09e46121320 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3168,14 +3168,6 @@ "count": 1 } }, - "public/app/features/logs/utils.ts": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 6 - } - }, "public/app/features/manage-dashboards/DashboardImportPage.tsx": { "no-restricted-syntax": { "count": 2 diff --git a/public/app/features/inspector/utils/download.test.ts b/public/app/features/inspector/utils/download.test.ts index b2e76a29c89..1b1b69fc9eb 100644 --- a/public/app/features/inspector/utils/download.test.ts +++ b/public/app/features/inspector/utils/download.test.ts @@ -1,6 +1,7 @@ import saveAs from 'file-saver'; import { dataFrameFromJSON, DataFrameJSON, dateTimeFormat, FieldType, LogRowModel, LogsMetaKind } from '@grafana/data'; +import { createLogRow } from 'app/features/logs/components/mocks/logRow'; import { downloadAsJson, downloadDataFrameAsCsv, downloadLogsModelAsTxt } from './download'; @@ -33,13 +34,13 @@ describe('inspector download', () => { 'should, when logsModel is %s and title is %s, resolve in %s', async (dataFrame, title, expected) => { downloadDataFrameAsCsv(dataFrame, title); - const call = (saveAs as unknown as jest.Mock).mock.calls[0]; + const call = jest.mocked(saveAs).mock.calls[0]; const blob = call[0]; const filename = call[1]; - const text = await blob.text(); + const text = typeof blob === 'string' ? blob : await blob.text(); // By default the BOM character should not be included - expect(await getBomType(blob)).toBeUndefined(); + expect(blob instanceof Blob ? await getBomType(blob) : undefined).toBeUndefined(); expect(text).toEqual(expected); expect(filename).toEqual(`${title}-data-${dateTimeFormat(1400000000000)}.csv`); } @@ -48,15 +49,18 @@ describe('inspector download', () => { it('should use \t as the delimiter and the file should be utf16le if excelCompatibilityMode is true', async () => { downloadDataFrameAsCsv(dataFrameFromJSON(json), 'test', undefined, undefined, true); - const call = (saveAs as unknown as jest.Mock).mock.calls[0]; + const call = jest.mocked(saveAs).mock.calls[0]; const blob = call[0]; const filename = call[1]; - const text = await blob.text(); + const text = typeof blob === 'string' ? blob : await blob.text(); - expect(await getBomType(blob)).toBe('utf-16le'); - expect(blob.type).toBe('text/csv;charset=utf-16le'); + if (blob instanceof Blob) { + expect(await getBomType(blob)).toBe('utf-16le'); + expect(blob.type).toBe('text/csv;charset=utf-16le'); + } expect(text).toEqual('"time"\t"name"\t"value"\r\n100\tÅäö中文العربية\t1'); expect(filename).toEqual(`test-data-${dateTimeFormat(1400000000000)}.csv`); + expect.assertions(4); }); }); @@ -67,10 +71,10 @@ describe('inspector download', () => { [{ foo: 'bar' }, 'test', '{"foo":"bar"}'], ])('should, when logsModel is %s and title is %s, resolve in %s', async (logsModel, title, expected) => { downloadAsJson(logsModel, title); - const call = (saveAs as unknown as jest.Mock).mock.calls[0]; + const call = jest.mocked(saveAs).mock.calls[0]; const blob = call[0]; const filename = call[1]; - const text = await blob.text(); + const text = typeof blob === 'string' ? blob : await blob.text(); expect(text).toEqual(expected); expect(filename).toEqual(`${title}-${dateTimeFormat(1400000000000)}.json`); @@ -110,10 +114,10 @@ describe('inspector download', () => { ], ])('should, when logsModel is %s and title is %s, resolve in %s', async (logsModel, title, expected) => { downloadLogsModelAsTxt(logsModel, title); - const call = (saveAs as unknown as jest.Mock).mock.calls[0]; + const call = jest.mocked(saveAs).mock.calls[0]; const blob = call[0]; const filename = call[1]; - const text = await blob.text(); + const text = typeof blob === 'string' ? blob : await blob.text(); expect(text).toEqual(expected); expect(filename).toEqual(`${title}-logs-${dateTimeFormat(1400000000000)}.txt`); @@ -121,10 +125,32 @@ describe('inspector download', () => { it('should, when title is empty, resolve in %s', async () => { downloadLogsModelAsTxt({ meta: [], rows: [] }); - const call = (saveAs as unknown as jest.Mock).mock.calls[0]; + const call = jest.mocked(saveAs).mock.calls[0]; const filename = call[1]; expect(filename).toEqual(`Logs-${dateTimeFormat(1400000000000)}.txt`); }); + + it('should, when title is empty, resolve in %s', async () => { + downloadLogsModelAsTxt({ meta: [], rows: [] }); + const call = jest.mocked(saveAs).mock.calls[0]; + const filename = call[1]; + expect(filename).toEqual(`Logs-${dateTimeFormat(1400000000000)}.txt`); + }); + + it('should should download selected fields', async () => { + const logsModel = { + meta: [], + rows: [ + createLogRow({ timeEpochMs: 100, entry: 'testEntry', labels: { label: 'value', otherLabel: 'other value' } }), + ], + }; + downloadLogsModelAsTxt(logsModel, undefined, ['label', 'otherLabel']); + const call = jest.mocked(saveAs).mock.calls[0]; + const blob = call[0]; + const text = typeof blob === 'string' ? blob : await blob.text(); + + expect(text).toContain('value other value'); + }); }); }); diff --git a/public/app/features/inspector/utils/download.ts b/public/app/features/inspector/utils/download.ts index 1332180acf2..3a6c05c0f71 100644 --- a/public/app/features/inspector/utils/download.ts +++ b/public/app/features/inspector/utils/download.ts @@ -21,7 +21,7 @@ import { transformToZipkin } from '../../../plugins/datasource/zipkin/utils/tran * @param {(Pick)} logsModel * @param {string} title */ -export function downloadLogsModelAsTxt(logsModel: Pick, title = '') { +export function downloadLogsModelAsTxt(logsModel: Pick, title = '', fields: string[] = []) { let textToDownload = ''; logsModel.meta?.forEach((metaItem) => { @@ -31,7 +31,8 @@ export function downloadLogsModelAsTxt(logsModel: Pick { - const newRow = row.timeEpochMs + '\t' + dateTime(row.timeEpochMs).toISOString() + '\t' + row.entry + '\n'; + const entry = !fields.length ? row.entry : fields.map((field) => row.labels[field] ?? '').join(' '); + const newRow = row.timeEpochMs + '\t' + dateTime(row.timeEpochMs).toISOString() + '\t' + entry + '\n'; textToDownload = textToDownload + newRow; }); diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index efa4dfcda8a..0539743fb02 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -559,9 +559,9 @@ export const LogListContextProvider = ({ logListState.filterLevels.length === 0 ? logs : logs.filter((log) => logListState.filterLevels.includes(log.logLevel)); - download(format, filteredLogs, logsMeta); + download(format, filteredLogs, logsMeta, displayedFields); }, - [logListState.filterLevels, logs, logsMeta] + [displayedFields, logListState.filterLevels, logs, logsMeta] ); const closeDetails = useCallback(() => { diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx index 29955c8d46d..f5325d21108 100644 --- a/public/app/features/logs/components/panel/LogListControls.test.tsx +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -448,7 +448,7 @@ describe('LogListControls', () => { await userEvent.click(screen.getByLabelText(DOWNLOAD_LOGS_LABEL_COPY)); await userEvent.click(await screen.findByText(label)); expect(downloadLogs).toHaveBeenCalledTimes(1); - expect(downloadLogs).toHaveBeenCalledWith(format, [], undefined); + expect(downloadLogs).toHaveBeenCalledWith(format, [], undefined, []); }); test('Allows to download logs filtered logs', async () => { @@ -465,7 +465,7 @@ describe('LogListControls', () => { ); await userEvent.click(screen.getByLabelText(DOWNLOAD_LOGS_LABEL_COPY)); await userEvent.click(await screen.findByText('txt')); - expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined); + expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined, []); }); test('Controls new lines', async () => { diff --git a/public/app/features/logs/utils.test.ts b/public/app/features/logs/utils.test.ts index f23a8ae9069..2ae39fbeb10 100644 --- a/public/app/features/logs/utils.test.ts +++ b/public/app/features/logs/utils.test.ts @@ -1,3 +1,5 @@ +import saveAs from 'file-saver'; + import { AbsoluteTimeRange, FieldType, @@ -11,6 +13,7 @@ import { } from '@grafana/data'; import { getMockFrames } from 'app/plugins/datasource/loki/mocks/frames'; +import { createLogRow } from './components/mocks/logRow'; import { logSeriesToLogsModel } from './logsModel'; import { calculateLogsLabelStats, @@ -25,8 +28,12 @@ import { mergeLogsVolumeDataFrames, sortLogsResult, checkLogsSampled, + downloadLogs, + DownloadFormat, } from './utils'; +jest.mock('file-saver', () => jest.fn()); + describe('getLoglevel()', () => { it('returns no log level on empty line', () => { expect(getLogLevel('')).toBe(LogLevel.unknown); @@ -588,3 +595,66 @@ describe('findMatchingRow', () => { } }); }); + +describe('downloadLogs', () => { + const logs = [ + createLogRow({ timeEpochMs: 100, entry: 'test entry', labels: { label: 'value', otherLabel: 'other value' } }), + ]; + describe('Text format', () => { + beforeEach(() => { + jest.mocked(saveAs).mockClear(); + }); + + it('Downloads logs in txt format', async () => { + downloadLogs(DownloadFormat.Text, logs); + + const blob = jest.mocked(saveAs).mock.calls[0][0]; + const text = typeof blob === 'string' ? blob : await blob.text(); + + expect(text).toContain('test entry'); + }); + + it('Downloads selected fields in txt format', async () => { + downloadLogs(DownloadFormat.Text, logs, [], ['label', 'otherLabel']); + + const blob = jest.mocked(saveAs).mock.calls[0][0]; + const text = typeof blob === 'string' ? blob : await blob.text(); + + expect(text).toContain('value other value'); + }); + }); + + describe('JSON format', () => { + beforeEach(() => { + jest.mocked(saveAs).mockClear(); + }); + + it('Downloads logs in JSON format', async () => { + downloadLogs(DownloadFormat.Json, logs); + + const blob = jest.mocked(saveAs).mock.calls[0][0]; + const text = typeof blob === 'string' ? blob : await blob.text(); + + expect(JSON.parse(text)[0]).toEqual( + expect.objectContaining({ + line: 'test entry', + fields: { label: 'value', otherLabel: 'other value' }, + }) + ); + }); + + it('Downloads selected fields in JSON format', async () => { + downloadLogs(DownloadFormat.Json, logs, [], ['otherLabel']); + + const blob = jest.mocked(saveAs).mock.calls[0][0]; + const text = typeof blob === 'string' ? blob : await blob.text(); + + expect(JSON.parse(text)[0]).toEqual( + expect.objectContaining({ + line: 'test entry', + fields: { otherLabel: 'other value' }, + }) + ); + }); + }); +}); diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 0d16732ba7d..61f428745a5 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -29,6 +29,7 @@ import { getTimeField, Field, LogsMetaItem, + store, } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getConfig } from 'app/core/config'; @@ -67,6 +68,7 @@ export function getLogLevel(line: string): LogLevel { } export function getLogLevelFromKey(key: string | number): LogLevel { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const level = LogLevel[key.toString().toLowerCase() as keyof typeof LogLevel]; if (level) { return level; @@ -179,7 +181,7 @@ export const checkLogsSampled = (logRow: LogRowModel): string | undefined => { 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[]) { +export function logRowsToReadableJson(logs: LogRowModel[], pickFields: string[] = []) { return logs.map((log) => { const fields = getDataframeFields(log).reduce>((acc, field) => { const key = field.keys[0]; @@ -187,14 +189,20 @@ export function logRowsToReadableJson(logs: LogRowModel[]) { return acc; }, {}); + let logFields = { + ...fields, + ...log.labels, + }; + + if (pickFields.length) { + logFields = Object.fromEntries(Object.entries(logFields).filter(([key]) => pickFields.includes(key))); + } + return { line: log.entry, timestamp: log.timeEpochNs, date: dateTime(log.timeEpochMs).toISOString(), - fields: { - ...fields, - ...log.labels, - }, + fields: logFields, }; }); } @@ -423,15 +431,15 @@ function getDataSourceLabelType(labelType: string, datasourceType: string, plura const POPOVER_STORAGE_KEY = 'logs.popover.disabled'; export function disablePopoverMenu() { - localStorage.setItem(POPOVER_STORAGE_KEY, 'true'); + store.set(POPOVER_STORAGE_KEY, 'true'); } export function enablePopoverMenu() { - localStorage.removeItem(POPOVER_STORAGE_KEY); + store.delete(POPOVER_STORAGE_KEY); } export function isPopoverMenuDisabled() { - return Boolean(localStorage.getItem(POPOVER_STORAGE_KEY)); + return Boolean(store.get(POPOVER_STORAGE_KEY)); } export enum DownloadFormat { @@ -440,13 +448,18 @@ export enum DownloadFormat { CSV = 'csv', } -export const downloadLogs = async (format: DownloadFormat, logRows: LogRowModel[], meta?: LogsMetaItem[]) => { +export const downloadLogs = async ( + format: DownloadFormat, + logRows: LogRowModel[], + meta?: LogsMetaItem[], + fields: string[] = [] +) => { switch (format) { case DownloadFormat.Text: - downloadLogsModelAsTxt({ meta, rows: logRows }); + downloadLogsModelAsTxt({ meta, rows: logRows }, '', fields); break; case DownloadFormat.Json: - const jsonLogs = logRowsToReadableJson(logRows); + const jsonLogs = logRowsToReadableJson(logRows, fields); const blob = new Blob([JSON.stringify(jsonLogs)], { type: 'application/json;charset=utf-8', }); @@ -462,18 +475,29 @@ export const downloadLogs = async (format: DownloadFormat, logRows: LogRowModel[ }); dataFrameMap.forEach(async (dataFrame) => { const transforms: Array = getLogsExtractFields(dataFrame); - transforms.push( - { - id: 'organize', + if (fields.length) { + transforms.push(addISODateTransformation, { + id: 'filterFieldsByName', options: { - excludeByName: { - ['labels']: true, - ['labelTypes']: true, + include: { + names: ['Date', ...fields], }, }, - }, - addISODateTransformation - ); + }); + } else { + transforms.push( + { + id: 'organize', + options: { + excludeByName: { + ['labels']: true, + ['labelTypes']: true, + }, + }, + }, + addISODateTransformation + ); + } const transformedDataFrame = await lastValueFrom(transformDataFrame(transforms, [dataFrame])); downloadDataFrameAsCsv(transformedDataFrame[0], `Logs-${dataFrame.refId}`); });