Logs Panel: Add CSV to download options (#82480)

* add CSV download to logs panel
This commit is contained in:
Galen Kistler 2024-02-15 09:42:58 -06:00 committed by GitHub
parent 0f47a6fa10
commit d071f4170d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 153 additions and 7 deletions

View File

@ -3,10 +3,12 @@ import userEvent from '@testing-library/user-event';
import saveAs from 'file-saver';
import React, { ComponentProps } from 'react';
import { FieldType, LogLevel, LogsDedupStrategy, toDataFrame } from '@grafana/data';
import { FieldType, LogLevel, LogsDedupStrategy, standardTransformersRegistry, toDataFrame } from '@grafana/data';
import { organizeFieldsTransformer } from '@grafana/data/src/transformations/transformers/organize';
import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage';
import { logRowsToReadableJson } from '../../logs/utils';
import { extractFieldsTransformer } from '../../transformers/extractFields/extractFields';
import { LogsMetaRow } from './LogsMetaRow';
@ -200,4 +202,112 @@ describe('LogsMetaRow', () => {
const text = await blob.text();
expect(text).toBe(JSON.stringify(logRowsToReadableJson(rows)));
});
it('renders a button to download CSV', async () => {
const transformers = [extractFieldsTransformer, organizeFieldsTransformer];
standardTransformersRegistry.setInit(() => {
return transformers.map((t) => {
return {
id: t.id,
aliasIds: t.aliasIds,
name: t.name,
transformation: t,
description: t.description,
editor: () => null,
};
});
});
const rows = [
{
rowIndex: 1,
entryFieldIndex: 0,
dataFrame: toDataFrame({
name: 'logs',
refId: 'A',
fields: [
{
name: 'time',
type: FieldType.time,
values: ['1970-01-01T00:00:00Z'],
},
{
name: 'message',
type: FieldType.string,
values: ['INFO 1'],
labels: {
foo: 'bar',
},
},
],
}),
entry: 'test entry',
hasAnsi: false,
hasUnescapedContent: false,
labels: {
foo: 'bar',
},
logLevel: LogLevel.info,
raw: '',
timeEpochMs: 10,
timeEpochNs: '123456789',
timeFromNow: '',
timeLocal: '',
timeUtc: '',
uid: '2',
},
{
rowIndex: 2,
entryFieldIndex: 1,
dataFrame: toDataFrame({
name: 'logs',
refId: 'B',
fields: [
{
name: 'time',
type: FieldType.time,
values: ['1970-01-02T00:00:00Z'],
},
{
name: 'message',
type: FieldType.string,
values: ['INFO 1'],
labels: {
foo: 'bar',
},
},
],
}),
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: 'csv',
})
);
expect(saveAs).toBeCalled();
const blob = (saveAs as unknown as jest.Mock).mock.lastCall[0];
expect(blob.type).toBe('text/csv;charset=utf-8');
const text = await blob.text();
expect(text).toBe(`"time","message bar"\r\n1970-01-02T00:00:00Z,INFO 1`);
});
});

View File

@ -1,17 +1,31 @@
import { css } from '@emotion/css';
import saveAs from 'file-saver';
import React from 'react';
import { lastValueFrom } from 'rxjs';
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, dateTimeFormat } from '@grafana/data';
import {
LogsDedupStrategy,
LogsMetaItem,
LogsMetaKind,
LogRowModel,
CoreApp,
dateTimeFormat,
transformDataFrame,
DataTransformerConfig,
CustomTransformOperator,
} from '@grafana/data';
import { DataFrame } from '@grafana/data/';
import { reportInteraction } from '@grafana/runtime';
import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
import { downloadLogsModelAsTxt } from '../../inspector/utils/download';
import { downloadDataFrameAsCsv, 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';
import { getLogsExtractFields } from './LogsTable';
const getStyles = () => ({
metaContainer: css`
flex: 1;
@ -35,6 +49,7 @@ export type Props = {
enum DownloadFormat {
Text = 'text',
Json = 'json',
CSV = 'csv',
}
export const LogsMetaRow = React.memo(
@ -51,7 +66,7 @@ export const LogsMetaRow = React.memo(
}: Props) => {
const style = useStyles2(getStyles);
const downloadLogs = (format: DownloadFormat) => {
const downloadLogs = async (format: DownloadFormat) => {
reportInteraction('grafana_logs_download_logs_clicked', {
app: CoreApp.Explore,
format,
@ -67,10 +82,30 @@ export const LogsMetaRow = React.memo(
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;
case DownloadFormat.CSV:
const dataFrameMap = new Map<string, DataFrame>();
logRows.forEach((row) => {
if (row.dataFrame?.refId && !dataFrameMap.has(row.dataFrame?.refId)) {
dataFrameMap.set(row.dataFrame?.refId, row.dataFrame);
}
});
dataFrameMap.forEach(async (dataFrame) => {
const transforms: Array<DataTransformerConfig | CustomTransformOperator> = getLogsExtractFields(dataFrame);
transforms.push({
id: 'organize',
options: {
excludeByName: {
['labels']: true,
['labelTypes']: true,
},
},
});
const transformedDataFrame = await lastValueFrom(transformDataFrame(transforms, [dataFrame]));
downloadDataFrameAsCsv(transformedDataFrame[0], `Explore-logs-${dataFrame.refId}`);
});
}
};
@ -131,6 +166,7 @@ export const LogsMetaRow = React.memo(
<Menu>
<Menu.Item label="txt" onClick={() => downloadLogs(DownloadFormat.Text)} />
<Menu.Item label="json" onClick={() => downloadLogs(DownloadFormat.Json)} />
<Menu.Item label="csv" onClick={() => downloadLogs(DownloadFormat.CSV)} />
</Menu>
);
return (

View File

@ -105,7 +105,7 @@ export function LogsTable(props: Props) {
}
// create extract JSON transformation for every field that is `json.RawMessage`
const transformations: Array<DataTransformerConfig | CustomTransformOperator> = extractFields(dataFrame);
const transformations: Array<DataTransformerConfig | CustomTransformOperator> = getLogsExtractFields(dataFrame);
let labelFilters = buildLabelFilters(columnsWithMeta);
@ -197,7 +197,7 @@ const isFieldFilterable = (field: Field, bodyName: string, timeName: string) =>
// TODO: explore if `logsFrame.ts` can help us with getting the right fields
// TODO Why is typeInfo not defined on the Field interface?
function extractFields(dataFrame: DataFrame) {
export function getLogsExtractFields(dataFrame: DataFrame) {
return dataFrame.fields
.filter((field: Field & { typeInfo?: { frame: string } }) => {
const isFieldLokiLabels =