Logs panel: Respect selected fields for downloading logs (#111753)
* txt: download selected fields * csv: download selected fields * json: download selected fields * Address lint issues * Update tests * Update tests * Update suppressions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { transformToZipkin } from '../../../plugins/datasource/zipkin/utils/tran
|
||||
* @param {(Pick<LogsModel, 'meta' | 'rows'>)} logsModel
|
||||
* @param {string} title
|
||||
*/
|
||||
export function downloadLogsModelAsTxt(logsModel: Pick<LogsModel, 'meta' | 'rows'>, title = '') {
|
||||
export function downloadLogsModelAsTxt(logsModel: Pick<LogsModel, 'meta' | 'rows'>, title = '', fields: string[] = []) {
|
||||
let textToDownload = '';
|
||||
|
||||
logsModel.meta?.forEach((metaItem) => {
|
||||
@@ -31,7 +31,8 @@ export function downloadLogsModelAsTxt(logsModel: Pick<LogsModel, 'meta' | 'rows
|
||||
textToDownload = textToDownload + '\n\n';
|
||||
|
||||
logsModel.rows.forEach((row) => {
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, string>>((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<DataTransformerConfig | CustomTransformOperator> = 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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user