diff --git a/frontend/src/api/v1/download/downloadExportData.ts b/frontend/src/api/v1/download/downloadExportData.ts new file mode 100644 index 000000000000..30bc7b25dd78 --- /dev/null +++ b/frontend/src/api/v1/download/downloadExportData.ts @@ -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 => { + 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(`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); + } +}; + +export default downloadExportData; diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss new file mode 100644 index 000000000000..e9f2d92df378 --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.styles.scss @@ -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); + } + } + } +} diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx new file mode 100644 index 000000000000..eaf7456039c2 --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.test.tsx @@ -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( + , + ); +}; + +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; + 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( + , + ); + + 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(); + }); +}); diff --git a/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx new file mode 100644 index 000000000000..655da183cd4d --- /dev/null +++ b/frontend/src/components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu.tsx @@ -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(DownloadFormats.CSV); + const [rowLimit, setRowLimit] = useState(DownloadRowCounts.TEN_K); + const [columnsScope, setColumnsScope] = useState( + DownloadColumnsScopes.ALL, + ); + const [isDownloading, setIsDownloading] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const handleExportRawData = useCallback(async (): Promise => { + 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( + () => ( +
+
+ FORMAT + setExportFormat(e.target.value)} + > + csv + jsonl + +
+ +
+ +
+ Number of Rows + setRowLimit(e.target.value)} + > + 10k + 30k + 50k + +
+ +
+ +
+ Columns + setColumnsScope(e.target.value)} + > + All + Selected + +
+ + +
+ ), + [exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData], + ); + + return ( + + +
- +
+ +
- 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 newShowFrequencyChart = !showFrequencyChart; @@ -654,11 +620,12 @@ function LogsExplorerViewsContainer({ handleToggleFrequencyChart={handleToggleFrequencyChart} orderBy={orderBy} setOrderBy={setOrderBy} - flattenLogData={flattenLogData} isFetching={isFetching} isLoading={isLoading} isError={isError} isSuccess={isSuccess} + minTime={minTime} + maxTime={maxTime} /> )} diff --git a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx index 166313359026..be14befffd71 100644 --- a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx +++ b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx @@ -185,13 +185,16 @@ describe('LogsExplorerViews -', () => { lodsQueryServerRequest(); 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 - expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument(); + expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument(); + expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument(); // 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(); // Test that the menu items are present @@ -200,7 +203,8 @@ describe('LogsExplorerViews -', () => { expect(menuItems.length).toBe(expectedMenuItemsCount); // Test that the component renders without crashing - expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument(); + expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument(); + expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument(); }); it('check isLoading state', async () => { diff --git a/frontend/src/types/api/exportRawData/getExportRawData.ts b/frontend/src/types/api/exportRawData/getExportRawData.ts new file mode 100644 index 000000000000..0548c6dcf55e --- /dev/null +++ b/frontend/src/types/api/exportRawData/getExportRawData.ts @@ -0,0 +1,10 @@ +export interface ExportRawDataProps { + source: string; + format: string; + start: number; + end: number; + columns: string[]; + filter: string; + orderBy: string; + limit: number; +}