mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-18 16:07:10 +00:00
Added Download Button to leverage exportRawData API (#9050)
This pull request introduces a new, customizable logs export feature in the Logs Explorer view, replacing the previous hardcoded download functionality. Users can now select export format, row limit, and which columns to include via a dedicated options menu. The implementation includes a new API integration for downloading export data, UI components for export options, and associated styling.
This commit is contained in:
parent
cc77b829af
commit
e8035b7dd2
64
frontend/src/api/v1/download/downloadExportData.ts
Normal file
64
frontend/src/api/v1/download/downloadExportData.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import axios from 'api';
|
||||||
|
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { ErrorV2Resp } from 'types/api';
|
||||||
|
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
|
||||||
|
|
||||||
|
export const downloadExportData = async (
|
||||||
|
props: ExportRawDataProps,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
queryParams.append('start', String(props.start));
|
||||||
|
queryParams.append('end', String(props.end));
|
||||||
|
queryParams.append('filter', props.filter);
|
||||||
|
props.columns.forEach((col) => {
|
||||||
|
queryParams.append('columns', col);
|
||||||
|
});
|
||||||
|
queryParams.append('order_by', props.orderBy);
|
||||||
|
queryParams.append('limit', String(props.limit));
|
||||||
|
queryParams.append('format', props.format);
|
||||||
|
|
||||||
|
const response = await axios.get<Blob>(`export_raw_data?${queryParams}`, {
|
||||||
|
responseType: 'blob', // Important: tell axios to handle response as blob
|
||||||
|
decompress: true, // Enable automatic decompression
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/octet-stream', // Tell server we expect binary data
|
||||||
|
},
|
||||||
|
timeout: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only proceed if the response status is 200
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to download data: server returned status ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Create blob URL from response data
|
||||||
|
const blob = new Blob([response.data], { type: 'application/octet-stream' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create and configure download link
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
|
||||||
|
// Get filename from Content-Disposition header or generate timestamped default
|
||||||
|
const filename =
|
||||||
|
response.headers['content-disposition']
|
||||||
|
?.split('filename=')[1]
|
||||||
|
?.replace(/["']/g, '') || `exported_data.${props.format || 'txt'}`;
|
||||||
|
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default downloadExportData;
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
.logs-download-popover {
|
||||||
|
.ant-popover-inner {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: linear-gradient(
|
||||||
|
139deg,
|
||||||
|
var(--bg-ink-400) 0%,
|
||||||
|
var(--bg-ink-500) 98.68%
|
||||||
|
);
|
||||||
|
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
padding: 0 8px 12px 8px;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-options-container {
|
||||||
|
width: 240px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
color: var(--bg-slate-50);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: 0.88px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-format,
|
||||||
|
.row-limit,
|
||||||
|
.columns-scope {
|
||||||
|
padding: 12px 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
:global(.ant-radio-wrapper) {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-line {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.logs-download-popover {
|
||||||
|
.ant-popover-inner {
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
background: linear-gradient(
|
||||||
|
139deg,
|
||||||
|
var(--bg-vanilla-100) 0%,
|
||||||
|
var(--bg-vanilla-300) 98.68%
|
||||||
|
);
|
||||||
|
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.export-options-container {
|
||||||
|
.title {
|
||||||
|
color: var(--bg-ink-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ant-radio-wrapper) {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-line {
|
||||||
|
background: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,341 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import { ENVIRONMENT } from 'constants/env';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { rest } from 'msw';
|
||||||
|
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||||
|
|
||||||
|
import { DownloadFormats, DownloadRowCounts } from './constants';
|
||||||
|
import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu';
|
||||||
|
|
||||||
|
// Mock antd message
|
||||||
|
jest.mock('antd', () => {
|
||||||
|
const actual = jest.requireActual('antd');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
message: {
|
||||||
|
success: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const TEST_IDS = {
|
||||||
|
DOWNLOAD_BUTTON: 'periscope-btn-download-options',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface TestProps {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
filter: string;
|
||||||
|
columns: TelemetryFieldKey[];
|
||||||
|
orderBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTestProps = (): TestProps => ({
|
||||||
|
startTime: 1631234567890,
|
||||||
|
endTime: 1631234567999,
|
||||||
|
filter: 'status = 200',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'http.status',
|
||||||
|
fieldContext: 'attribute',
|
||||||
|
fieldDataType: 'int64',
|
||||||
|
} as TelemetryFieldKey,
|
||||||
|
],
|
||||||
|
orderBy: 'timestamp:desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const testRenderContent = (props: TestProps): void => {
|
||||||
|
render(
|
||||||
|
<LogsDownloadOptionsMenu
|
||||||
|
startTime={props.startTime}
|
||||||
|
endTime={props.endTime}
|
||||||
|
filter={props.filter}
|
||||||
|
columns={props.columns}
|
||||||
|
orderBy={props.orderBy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testSuccessResponse = (res: any, ctx: any): any =>
|
||||||
|
res(
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.set('Content-Type', 'application/octet-stream'),
|
||||||
|
ctx.set('Content-Disposition', 'attachment; filename="export.csv"'),
|
||||||
|
ctx.body('id,value\n1,2\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('LogsDownloadOptionsMenu', () => {
|
||||||
|
const BASE_URL = ENVIRONMENT.baseURL;
|
||||||
|
const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`;
|
||||||
|
let requestSpy: jest.Mock<any, any>;
|
||||||
|
const setupDefaultServer = (): void => {
|
||||||
|
server.use(
|
||||||
|
rest.get(EXPORT_URL, (req, res, ctx) => {
|
||||||
|
const params = req.url.searchParams;
|
||||||
|
const payload = {
|
||||||
|
start: Number(params.get('start')),
|
||||||
|
end: Number(params.get('end')),
|
||||||
|
filter: params.get('filter'),
|
||||||
|
columns: params.getAll('columns'),
|
||||||
|
order_by: params.get('order_by'),
|
||||||
|
limit: Number(params.get('limit')),
|
||||||
|
format: params.get('format'),
|
||||||
|
};
|
||||||
|
requestSpy(payload);
|
||||||
|
return testSuccessResponse(res, ctx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL used by download logic
|
||||||
|
const originalCreateObjectURL = URL.createObjectURL;
|
||||||
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requestSpy = jest.fn();
|
||||||
|
setupDefaultServer();
|
||||||
|
(message.success as jest.Mock).mockReset();
|
||||||
|
(message.error as jest.Mock).mockReset();
|
||||||
|
// jsdom doesn't implement it by default
|
||||||
|
((URL as unknown) as {
|
||||||
|
createObjectURL: (b: Blob) => string;
|
||||||
|
}).createObjectURL = jest.fn(() => 'blob:mock');
|
||||||
|
((URL as unknown) as {
|
||||||
|
revokeObjectURL: (u: string) => void;
|
||||||
|
}).revokeObjectURL = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server.listen();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
// restore
|
||||||
|
URL.createObjectURL = originalCreateObjectURL;
|
||||||
|
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders download button', () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
|
||||||
|
const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON);
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('periscope-btn', 'ghost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows popover with export options when download button is clicked', () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
render(
|
||||||
|
<LogsDownloadOptionsMenu
|
||||||
|
startTime={props.startTime}
|
||||||
|
endTime={props.endTime}
|
||||||
|
filter={props.filter}
|
||||||
|
columns={props.columns}
|
||||||
|
orderBy={props.orderBy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('FORMAT')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Columns')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows changing export format', () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
|
||||||
|
const csvRadio = screen.getByRole('radio', { name: 'csv' });
|
||||||
|
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
|
||||||
|
|
||||||
|
expect(csvRadio).toBeChecked();
|
||||||
|
fireEvent.click(jsonlRadio);
|
||||||
|
expect(jsonlRadio).toBeChecked();
|
||||||
|
expect(csvRadio).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows changing row limit', () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
|
||||||
|
const tenKRadio = screen.getByRole('radio', { name: '10k' });
|
||||||
|
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
|
||||||
|
|
||||||
|
expect(tenKRadio).toBeChecked();
|
||||||
|
fireEvent.click(fiftyKRadio);
|
||||||
|
expect(fiftyKRadio).toBeChecked();
|
||||||
|
expect(tenKRadio).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows changing columns scope', () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
|
||||||
|
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
|
||||||
|
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
|
||||||
|
|
||||||
|
expect(allColumnsRadio).toBeChecked();
|
||||||
|
fireEvent.click(selectedColumnsRadio);
|
||||||
|
expect(selectedColumnsRadio).toBeChecked();
|
||||||
|
expect(allColumnsRadio).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
|
||||||
|
fireEvent.click(screen.getByText('Export'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
start: props.startTime,
|
||||||
|
end: props.endTime,
|
||||||
|
columns: ['attribute.http.status:int64'],
|
||||||
|
filter: props.filter,
|
||||||
|
order_by: props.orderBy,
|
||||||
|
format: DownloadFormats.CSV,
|
||||||
|
limit: DownloadRowCounts.TEN_K,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls downloadExportData with correct parameters when export button is clicked', async () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
|
||||||
|
fireEvent.click(screen.getByText('Export'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requestSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
start: props.startTime,
|
||||||
|
end: props.endTime,
|
||||||
|
columns: [],
|
||||||
|
filter: props.filter,
|
||||||
|
order_by: props.orderBy,
|
||||||
|
format: DownloadFormats.CSV,
|
||||||
|
limit: DownloadRowCounts.TEN_K,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles successful export with success message', async () => {
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
fireEvent.click(screen.getByText('Export'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(message.success).toHaveBeenCalledWith(
|
||||||
|
'Export completed successfully',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles export failure with error message', async () => {
|
||||||
|
// Override handler to return 500 for this test
|
||||||
|
server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500))));
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
fireEvent.click(screen.getByText('Export'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(message.error).toHaveBeenCalledWith(
|
||||||
|
'Failed to export logs. Please try again.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles UI state correctly during export process', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)),
|
||||||
|
);
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
|
||||||
|
// Open popover
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Start export
|
||||||
|
fireEvent.click(screen.getByText('Export'));
|
||||||
|
|
||||||
|
// Check button is disabled during export
|
||||||
|
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled();
|
||||||
|
|
||||||
|
// Check popover is closed immediately after export starts
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Wait for export to complete and verify button is enabled again
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses filename from Content-Disposition and triggers download click', async () => {
|
||||||
|
server.use(
|
||||||
|
rest.get(EXPORT_URL, (_req, res, ctx) =>
|
||||||
|
res(
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.set('Content-Type', 'application/octet-stream'),
|
||||||
|
ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'),
|
||||||
|
ctx.body('row\n'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalCreateElement = document.createElement.bind(document);
|
||||||
|
const anchorEl = originalCreateElement('a') as HTMLAnchorElement;
|
||||||
|
const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute');
|
||||||
|
const clickSpy = jest.spyOn(anchorEl, 'click');
|
||||||
|
const removeSpy = jest.spyOn(anchorEl, 'remove');
|
||||||
|
const createElSpy = jest
|
||||||
|
.spyOn(document, 'createElement')
|
||||||
|
.mockImplementation((tagName: any): any =>
|
||||||
|
tagName === 'a' ? anchorEl : originalCreateElement(tagName),
|
||||||
|
);
|
||||||
|
const appendSpy = jest.spyOn(document.body, 'appendChild');
|
||||||
|
|
||||||
|
const props = createTestProps();
|
||||||
|
testRenderContent(props);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
|
||||||
|
fireEvent.click(screen.getByText('Export'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(appendSpy).toHaveBeenCalledWith(anchorEl);
|
||||||
|
expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl');
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
expect(removeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(anchorEl.getAttribute('download')).toBe('report.jsonl');
|
||||||
|
|
||||||
|
createElSpy.mockRestore();
|
||||||
|
appendSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
import './LogsDownloadOptionsMenu.styles.scss';
|
||||||
|
|
||||||
|
import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd';
|
||||||
|
import { downloadExportData } from 'api/v1/download/downloadExportData';
|
||||||
|
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DownloadColumnsScopes,
|
||||||
|
DownloadFormats,
|
||||||
|
DownloadRowCounts,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string {
|
||||||
|
const prefix = key.fieldContext ? `${key.fieldContext}.` : '';
|
||||||
|
const suffix = key.fieldDataType ? `:${key.fieldDataType}` : '';
|
||||||
|
return `${prefix}${key.name}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogsDownloadOptionsMenuProps {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
filter: string;
|
||||||
|
columns: TelemetryFieldKey[];
|
||||||
|
orderBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsDownloadOptionsMenu({
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
filter,
|
||||||
|
columns,
|
||||||
|
orderBy,
|
||||||
|
}: LogsDownloadOptionsMenuProps): JSX.Element {
|
||||||
|
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
|
||||||
|
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
|
||||||
|
const [columnsScope, setColumnsScope] = useState<string>(
|
||||||
|
DownloadColumnsScopes.ALL,
|
||||||
|
);
|
||||||
|
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleExportRawData = useCallback(async (): Promise<void> => {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
try {
|
||||||
|
setIsDownloading(true);
|
||||||
|
const downloadOptions = {
|
||||||
|
source: 'logs',
|
||||||
|
start: startTime,
|
||||||
|
end: endTime,
|
||||||
|
columns:
|
||||||
|
columnsScope === DownloadColumnsScopes.SELECTED
|
||||||
|
? columns.map((col) => convertTelemetryFieldKeyToText(col))
|
||||||
|
: [],
|
||||||
|
filter,
|
||||||
|
orderBy,
|
||||||
|
format: exportFormat,
|
||||||
|
limit: rowLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
await downloadExportData(downloadOptions);
|
||||||
|
message.success('Export completed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting logs:', error);
|
||||||
|
message.error('Failed to export logs. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
columnsScope,
|
||||||
|
columns,
|
||||||
|
filter,
|
||||||
|
orderBy,
|
||||||
|
exportFormat,
|
||||||
|
rowLimit,
|
||||||
|
setIsDownloading,
|
||||||
|
setIsPopoverOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const popoverContent = useMemo(
|
||||||
|
() => (
|
||||||
|
<div
|
||||||
|
className="export-options-container"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Export options"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div className="export-format">
|
||||||
|
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||||
|
<Radio.Group
|
||||||
|
value={exportFormat}
|
||||||
|
onChange={(e): void => setExportFormat(e.target.value)}
|
||||||
|
>
|
||||||
|
<Radio value={DownloadFormats.CSV}>csv</Radio>
|
||||||
|
<Radio value={DownloadFormats.JSONL}>jsonl</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="horizontal-line" />
|
||||||
|
|
||||||
|
<div className="row-limit">
|
||||||
|
<Typography.Text className="title">Number of Rows</Typography.Text>
|
||||||
|
<Radio.Group
|
||||||
|
value={rowLimit}
|
||||||
|
onChange={(e): void => setRowLimit(e.target.value)}
|
||||||
|
>
|
||||||
|
<Radio value={DownloadRowCounts.TEN_K}>10k</Radio>
|
||||||
|
<Radio value={DownloadRowCounts.THIRTY_K}>30k</Radio>
|
||||||
|
<Radio value={DownloadRowCounts.FIFTY_K}>50k</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="horizontal-line" />
|
||||||
|
|
||||||
|
<div className="columns-scope">
|
||||||
|
<Typography.Text className="title">Columns</Typography.Text>
|
||||||
|
<Radio.Group
|
||||||
|
value={columnsScope}
|
||||||
|
onChange={(e): void => setColumnsScope(e.target.value)}
|
||||||
|
>
|
||||||
|
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
|
||||||
|
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Download size={16} />}
|
||||||
|
onClick={handleExportRawData}
|
||||||
|
className="export-button"
|
||||||
|
disabled={isDownloading}
|
||||||
|
loading={isDownloading}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={popoverContent}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
arrow={false}
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
rootClassName="logs-download-popover"
|
||||||
|
>
|
||||||
|
<Tooltip title="Download" placement="top">
|
||||||
|
<Button
|
||||||
|
className="periscope-btn ghost"
|
||||||
|
icon={
|
||||||
|
isDownloading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<DownloadIcon size={15} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="periscope-btn-download-options"
|
||||||
|
disabled={isDownloading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/LogsDownloadOptionsMenu/constants.ts
Normal file
15
frontend/src/components/LogsDownloadOptionsMenu/constants.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export const DownloadFormats = {
|
||||||
|
CSV: 'csv',
|
||||||
|
JSONL: 'jsonl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DownloadColumnsScopes = {
|
||||||
|
ALL: 'all',
|
||||||
|
SELECTED: 'selected',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DownloadRowCounts = {
|
||||||
|
TEN_K: 10_000,
|
||||||
|
THIRTY_K: 30_000,
|
||||||
|
FIFTY_K: 50_000,
|
||||||
|
};
|
||||||
@ -460,7 +460,7 @@ export default function LogsFormatOptionsMenu({
|
|||||||
<Button
|
<Button
|
||||||
className="periscope-btn ghost"
|
className="periscope-btn ghost"
|
||||||
icon={<Sliders size={14} />}
|
icon={<Sliders size={14} />}
|
||||||
data-testid="periscope-btn"
|
data-testid="periscope-btn-format-options"
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Switch, Typography } from 'antd';
|
import { Switch, Typography } from 'antd';
|
||||||
import { WsDataEvent } from 'api/common/getQueryStats';
|
import { WsDataEvent } from 'api/common/getQueryStats';
|
||||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
|
import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu';
|
||||||
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
|
||||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import Download from 'container/DownloadV2/DownloadV2';
|
|
||||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||||
import { ArrowUp10, Minus } from 'lucide-react';
|
import { ArrowUp10, Minus } from 'lucide-react';
|
||||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||||
@ -20,11 +20,12 @@ function LogsActionsContainer({
|
|||||||
handleToggleFrequencyChart,
|
handleToggleFrequencyChart,
|
||||||
orderBy,
|
orderBy,
|
||||||
setOrderBy,
|
setOrderBy,
|
||||||
flattenLogData,
|
|
||||||
isFetching,
|
isFetching,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
|
minTime,
|
||||||
|
maxTime,
|
||||||
}: {
|
}: {
|
||||||
listQuery: any;
|
listQuery: any;
|
||||||
selectedPanelType: PANEL_TYPES;
|
selectedPanelType: PANEL_TYPES;
|
||||||
@ -32,12 +33,13 @@ function LogsActionsContainer({
|
|||||||
handleToggleFrequencyChart: () => void;
|
handleToggleFrequencyChart: () => void;
|
||||||
orderBy: string;
|
orderBy: string;
|
||||||
setOrderBy: (value: string) => void;
|
setOrderBy: (value: string) => void;
|
||||||
flattenLogData: any;
|
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isSuccess: boolean;
|
isSuccess: boolean;
|
||||||
queryStats: WsDataEvent | undefined;
|
queryStats: WsDataEvent | undefined;
|
||||||
|
minTime: number;
|
||||||
|
maxTime: number;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const { options, config } = useOptionsMenu({
|
const { options, config } = useOptionsMenu({
|
||||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||||
@ -97,11 +99,15 @@ function LogsActionsContainer({
|
|||||||
dataSource={DataSource.LOGS}
|
dataSource={DataSource.LOGS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Download
|
<div className="download-options-container">
|
||||||
data={flattenLogData}
|
<LogsDownloadOptionsMenu
|
||||||
isLoading={isFetching}
|
startTime={minTime}
|
||||||
fileName="log_data"
|
endTime={maxTime}
|
||||||
/>
|
filter={listQuery?.filter?.expression || ''}
|
||||||
|
columns={config.addColumn?.value || []}
|
||||||
|
orderBy={orderBy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="format-options-container">
|
<div className="format-options-container">
|
||||||
<LogsFormatOptionsMenu
|
<LogsFormatOptionsMenu
|
||||||
items={formatItems}
|
items={formatItems}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import setToLocalstorage from 'api/browser/localstorage/set';
|
|||||||
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
|
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
@ -25,7 +24,6 @@ import LogsExplorerChart from 'container/LogsExplorerChart';
|
|||||||
import LogsExplorerList from 'container/LogsExplorerList';
|
import LogsExplorerList from 'container/LogsExplorerList';
|
||||||
import LogsExplorerTable from 'container/LogsExplorerTable';
|
import LogsExplorerTable from 'container/LogsExplorerTable';
|
||||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||||
@ -33,19 +31,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
|||||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
|
||||||
import { getPaginationQueryDataV2 } from 'lib/newQueryBuilder/getPaginationQueryData';
|
import { getPaginationQueryDataV2 } from 'lib/newQueryBuilder/getPaginationQueryData';
|
||||||
import {
|
import { cloneDeep, defaultTo, isEmpty, isUndefined, set } from 'lodash-es';
|
||||||
cloneDeep,
|
|
||||||
defaultTo,
|
|
||||||
isEmpty,
|
|
||||||
isUndefined,
|
|
||||||
omit,
|
|
||||||
set,
|
|
||||||
} from 'lodash-es';
|
|
||||||
import LiveLogs from 'pages/LiveLogs';
|
import LiveLogs from 'pages/LiveLogs';
|
||||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||||
import { useTimezone } from 'providers/Timezone';
|
|
||||||
import {
|
import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
memo,
|
memo,
|
||||||
@ -607,29 +596,6 @@ function LogsExplorerViewsContainer({
|
|||||||
setIsLoadingQueries,
|
setIsLoadingQueries,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { timezone } = useTimezone();
|
|
||||||
|
|
||||||
const flattenLogData = useMemo(
|
|
||||||
() =>
|
|
||||||
logs.map((log) => {
|
|
||||||
const timestamp =
|
|
||||||
typeof log.timestamp === 'string'
|
|
||||||
? dayjs(log.timestamp)
|
|
||||||
.tz(timezone.value)
|
|
||||||
.format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
|
||||||
: dayjs(log.timestamp / 1e6)
|
|
||||||
.tz(timezone.value)
|
|
||||||
.format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
|
|
||||||
|
|
||||||
return FlatLogData({
|
|
||||||
timestamp,
|
|
||||||
body: log.body,
|
|
||||||
...omit(log, 'timestamp', 'body'),
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
[logs, timezone.value],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggleFrequencyChart = useCallback(() => {
|
const handleToggleFrequencyChart = useCallback(() => {
|
||||||
const newShowFrequencyChart = !showFrequencyChart;
|
const newShowFrequencyChart = !showFrequencyChart;
|
||||||
|
|
||||||
@ -654,11 +620,12 @@ function LogsExplorerViewsContainer({
|
|||||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||||
orderBy={orderBy}
|
orderBy={orderBy}
|
||||||
setOrderBy={setOrderBy}
|
setOrderBy={setOrderBy}
|
||||||
flattenLogData={flattenLogData}
|
|
||||||
isFetching={isFetching}
|
isFetching={isFetching}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
isSuccess={isSuccess}
|
isSuccess={isSuccess}
|
||||||
|
minTime={minTime}
|
||||||
|
maxTime={maxTime}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -185,13 +185,16 @@ describe('LogsExplorerViews -', () => {
|
|||||||
lodsQueryServerRequest();
|
lodsQueryServerRequest();
|
||||||
const { queryByTestId } = renderer();
|
const { queryByTestId } = renderer();
|
||||||
|
|
||||||
const periscopeButtonTestId = 'periscope-btn';
|
const periscopeDownloadButtonTestId = 'periscope-btn-download-options';
|
||||||
|
const periscopeFormatButtonTestId = 'periscope-btn-format-options';
|
||||||
|
|
||||||
// Test that the periscope button is present
|
// Test that the periscope button is present
|
||||||
expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument();
|
expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument();
|
||||||
|
expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument();
|
||||||
|
|
||||||
// Test that the menu opens when clicked
|
// Test that the menu opens when clicked
|
||||||
fireEvent.click(queryByTestId(periscopeButtonTestId) as HTMLElement);
|
fireEvent.click(queryByTestId(periscopeDownloadButtonTestId) as HTMLElement);
|
||||||
|
fireEvent.click(queryByTestId(periscopeFormatButtonTestId) as HTMLElement);
|
||||||
expect(document.querySelector('.menu-container')).toBeInTheDocument();
|
expect(document.querySelector('.menu-container')).toBeInTheDocument();
|
||||||
|
|
||||||
// Test that the menu items are present
|
// Test that the menu items are present
|
||||||
@ -200,7 +203,8 @@ describe('LogsExplorerViews -', () => {
|
|||||||
expect(menuItems.length).toBe(expectedMenuItemsCount);
|
expect(menuItems.length).toBe(expectedMenuItemsCount);
|
||||||
|
|
||||||
// Test that the component renders without crashing
|
// Test that the component renders without crashing
|
||||||
expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument();
|
expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument();
|
||||||
|
expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('check isLoading state', async () => {
|
it('check isLoading state', async () => {
|
||||||
|
|||||||
10
frontend/src/types/api/exportRawData/getExportRawData.ts
Normal file
10
frontend/src/types/api/exportRawData/getExportRawData.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export interface ExportRawDataProps {
|
||||||
|
source: string;
|
||||||
|
format: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
columns: string[];
|
||||||
|
filter: string;
|
||||||
|
orderBy: string;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user