mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Logs Panel: Add CSV to download options (#82480)
* add CSV download to logs panel
This commit is contained in:
parent
0f47a6fa10
commit
d071f4170d
@ -3,10 +3,12 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import saveAs from 'file-saver';
|
import saveAs from 'file-saver';
|
||||||
import React, { ComponentProps } from 'react';
|
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 { MAX_CHARACTERS } from '../../logs/components/LogRowMessage';
|
||||||
import { logRowsToReadableJson } from '../../logs/utils';
|
import { logRowsToReadableJson } from '../../logs/utils';
|
||||||
|
import { extractFieldsTransformer } from '../../transformers/extractFields/extractFields';
|
||||||
|
|
||||||
import { LogsMetaRow } from './LogsMetaRow';
|
import { LogsMetaRow } from './LogsMetaRow';
|
||||||
|
|
||||||
@ -200,4 +202,112 @@ describe('LogsMetaRow', () => {
|
|||||||
const text = await blob.text();
|
const text = await blob.text();
|
||||||
expect(text).toBe(JSON.stringify(logRowsToReadableJson(rows)));
|
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`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,17 +1,31 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import saveAs from 'file-saver';
|
import saveAs from 'file-saver';
|
||||||
import React from 'react';
|
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 { reportInteraction } from '@grafana/runtime';
|
||||||
import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
|
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 { LogLabels } from '../../logs/components/LogLabels';
|
||||||
import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage';
|
import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage';
|
||||||
import { logRowsToReadableJson } from '../../logs/utils';
|
import { logRowsToReadableJson } from '../../logs/utils';
|
||||||
import { MetaInfoText, MetaItemProps } from '../MetaInfoText';
|
import { MetaInfoText, MetaItemProps } from '../MetaInfoText';
|
||||||
|
|
||||||
|
import { getLogsExtractFields } from './LogsTable';
|
||||||
|
|
||||||
const getStyles = () => ({
|
const getStyles = () => ({
|
||||||
metaContainer: css`
|
metaContainer: css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -35,6 +49,7 @@ export type Props = {
|
|||||||
enum DownloadFormat {
|
enum DownloadFormat {
|
||||||
Text = 'text',
|
Text = 'text',
|
||||||
Json = 'json',
|
Json = 'json',
|
||||||
|
CSV = 'csv',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LogsMetaRow = React.memo(
|
export const LogsMetaRow = React.memo(
|
||||||
@ -51,7 +66,7 @@ export const LogsMetaRow = React.memo(
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const style = useStyles2(getStyles);
|
const style = useStyles2(getStyles);
|
||||||
|
|
||||||
const downloadLogs = (format: DownloadFormat) => {
|
const downloadLogs = async (format: DownloadFormat) => {
|
||||||
reportInteraction('grafana_logs_download_logs_clicked', {
|
reportInteraction('grafana_logs_download_logs_clicked', {
|
||||||
app: CoreApp.Explore,
|
app: CoreApp.Explore,
|
||||||
format,
|
format,
|
||||||
@ -67,10 +82,30 @@ export const LogsMetaRow = React.memo(
|
|||||||
const blob = new Blob([JSON.stringify(jsonLogs)], {
|
const blob = new Blob([JSON.stringify(jsonLogs)], {
|
||||||
type: 'application/json;charset=utf-8',
|
type: 'application/json;charset=utf-8',
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileName = `Explore-logs-${dateTimeFormat(new Date())}.json`;
|
const fileName = `Explore-logs-${dateTimeFormat(new Date())}.json`;
|
||||||
saveAs(blob, fileName);
|
saveAs(blob, fileName);
|
||||||
break;
|
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>
|
||||||
<Menu.Item label="txt" onClick={() => downloadLogs(DownloadFormat.Text)} />
|
<Menu.Item label="txt" onClick={() => downloadLogs(DownloadFormat.Text)} />
|
||||||
<Menu.Item label="json" onClick={() => downloadLogs(DownloadFormat.Json)} />
|
<Menu.Item label="json" onClick={() => downloadLogs(DownloadFormat.Json)} />
|
||||||
|
<Menu.Item label="csv" onClick={() => downloadLogs(DownloadFormat.CSV)} />
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
@ -105,7 +105,7 @@ export function LogsTable(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// create extract JSON transformation for every field that is `json.RawMessage`
|
// 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);
|
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: explore if `logsFrame.ts` can help us with getting the right fields
|
||||||
// TODO Why is typeInfo not defined on the Field interface?
|
// TODO Why is typeInfo not defined on the Field interface?
|
||||||
function extractFields(dataFrame: DataFrame) {
|
export function getLogsExtractFields(dataFrame: DataFrame) {
|
||||||
return dataFrame.fields
|
return dataFrame.fields
|
||||||
.filter((field: Field & { typeInfo?: { frame: string } }) => {
|
.filter((field: Field & { typeInfo?: { frame: string } }) => {
|
||||||
const isFieldLokiLabels =
|
const isFieldLokiLabels =
|
||||||
|
Loading…
Reference in New Issue
Block a user