diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx index 9e506d6b61f7..83755343cdbb 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx @@ -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`); + }); }); diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 5bf8cf7a0341..55f5bdacc028 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -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(); + 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 = 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( downloadLogs(DownloadFormat.Text)} /> downloadLogs(DownloadFormat.Json)} /> + downloadLogs(DownloadFormat.CSV)} /> ); return ( diff --git a/public/app/features/explore/Logs/LogsTable.tsx b/public/app/features/explore/Logs/LogsTable.tsx index 23c78fed2bf7..8f50a8d8ba8d 100644 --- a/public/app/features/explore/Logs/LogsTable.tsx +++ b/public/app/features/explore/Logs/LogsTable.tsx @@ -105,7 +105,7 @@ export function LogsTable(props: Props) { } // create extract JSON transformation for every field that is `json.RawMessage` - const transformations: Array = extractFields(dataFrame); + const transformations: Array = 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 =