Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Logs Panel: Add CSV to download options #82480

Merged
merged 7 commits into from Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
112 changes: 111 additions & 1 deletion public/app/features/explore/Logs/LogsMetaRow.test.tsx
Expand Up @@ -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';

Expand Down Expand Up @@ -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`);
});
});
44 changes: 40 additions & 4 deletions 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;
Expand All @@ -35,6 +49,7 @@ export type Props = {
enum DownloadFormat {
Text = 'text',
Json = 'json',
CSV = 'csv',
}

export const LogsMetaRow = React.memo(
Expand All @@ -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,
Expand All @@ -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}`);
});
}
};

Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions public/app/features/explore/Logs/LogsTable.tsx
Expand Up @@ -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);

Expand Down Expand Up @@ -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 =
Expand Down