Skip to content

Commit 4fd62f0

Browse files
authored
Merge pull request #2460 from RedisInsight/fe/bugfix/RI-4840_Profiler_logs_download_failure
#RI-4840 - [Regression] Profiler logs download failure
2 parents 2b0c162 + 82d3937 commit 4fd62f0

File tree

8 files changed

+190
-8
lines changed

8 files changed

+190
-8
lines changed

redisinsight/api/src/modules/profiler/profiler.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class ProfilerController {
2424

2525
res.setHeader('Content-Type', 'application/octet-stream');
2626
res.setHeader('Content-Disposition', `attachment;filename="${filename}.txt"`);
27+
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
2728

2829
stream
2930
.on('error', () => res.status(404).send())

redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.spec.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
11
import { cloneDeep } from 'lodash'
22
import React from 'react'
3-
import { resetProfiler, stopMonitor } from 'uiSrc/slices/cli/monitor'
4-
import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils'
3+
import { monitorSelector, resetProfiler, stopMonitor } from 'uiSrc/slices/cli/monitor'
4+
import { act, cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils'
5+
import { sendCliCommand } from 'uiSrc/slices/cli/cli-output'
56
import MonitorLog from './MonitorLog'
67

78
let store: typeof mockedStore
9+
let URLMock: jest.SpyInstance<object>
10+
const mockURLrevokeObjectURL = 123123
11+
12+
jest.mock('file-saver', () => ({
13+
...jest.requireActual('file-saver'),
14+
saveAs: jest.fn(),
15+
}))
16+
17+
jest.mock('uiSrc/slices/cli/monitor', () => ({
18+
...jest.requireActual('uiSrc/slices/cli/monitor'),
19+
monitorSelector: jest.fn().mockReturnValue({
20+
isSaveToFile: false,
21+
logFileId: 'logFileId',
22+
timestamp: {
23+
start: 1,
24+
paused: 2,
25+
unPaused: 3,
26+
duration: 123,
27+
}
28+
}),
29+
}))
830

931
beforeEach(() => {
1032
cleanup()
@@ -13,6 +35,10 @@ beforeEach(() => {
1335
})
1436

1537
describe('MonitorLog', () => {
38+
beforeAll(() => {
39+
URLMock = jest.spyOn(URL, 'revokeObjectURL').mockImplementation(() => mockURLrevokeObjectURL)
40+
})
41+
1642
it('should render', () => {
1743
expect(render(<MonitorLog />)).toBeTruthy()
1844
})
@@ -24,4 +50,28 @@ describe('MonitorLog', () => {
2450
const expectedActions = [stopMonitor(), resetProfiler()]
2551
expect(store.getActions()).toEqual(expectedActions)
2652
})
53+
54+
it('should call fetchMonitorLog after click on Download', async () => {
55+
const monitorSelectorMock = jest.fn().mockReturnValue({
56+
isSaveToFile: true,
57+
logFileId: 'logFileId',
58+
timestamp: {
59+
start: 1,
60+
paused: 2,
61+
unPaused: 3,
62+
duration: 123,
63+
}
64+
});
65+
66+
(monitorSelector as jest.Mock).mockImplementation(monitorSelectorMock)
67+
68+
render(<MonitorLog />)
69+
70+
await act(() => {
71+
fireEvent.click(screen.getByTestId('download-log-btn'))
72+
})
73+
74+
const expectedActions = [sendCliCommand()]
75+
expect(store.getActions().slice(0, expectedActions.length)).toEqual(expectedActions)
76+
})
2777
})

redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { format, formatDuration, intervalToDuration } from 'date-fns'
33
import React from 'react'
44
import { useDispatch, useSelector } from 'react-redux'
55
import AutoSizer from 'react-virtualized-auto-sizer'
6-
import { ApiEndpoints } from 'uiSrc/constants'
6+
77
import { monitorSelector, resetProfiler, stopMonitor } from 'uiSrc/slices/cli/monitor'
8-
import { cutDurationText, getBaseApiUrl } from 'uiSrc/utils'
8+
import { cutDurationText } from 'uiSrc/utils'
9+
import { downloadFile } from 'uiSrc/utils/dom/downloadFile'
10+
import { fetchMonitorLog } from 'uiSrc/slices/cli/cli-output'
911

1012
import styles from './styles.module.scss'
1113

@@ -26,8 +28,6 @@ const MonitorLog = () => {
2628
})
2729
)
2830
)
29-
const baseApiUrl = getBaseApiUrl()
30-
const linkToDownload = `${baseApiUrl}/api/${ApiEndpoints.PROFILER_LOGS}/${logFileId}`
3131

3232
const downloadBtnProps: any = {
3333
target: DOWNLOAD_IFRAME_NAME
@@ -44,6 +44,12 @@ const MonitorLog = () => {
4444
return 18
4545
}
4646

47+
const handleDownloadLog = async (e: React.MouseEvent<HTMLAnchorElement>) => {
48+
e.preventDefault()
49+
50+
dispatch(fetchMonitorLog(logFileId || '', downloadFile))
51+
}
52+
4753
return (
4854
<div className={styles.monitorLogWrapper}>
4955
<iframe title="downloadIframeTarget" name={DOWNLOAD_IFRAME_NAME} style={{ display: 'none' }} />
@@ -76,10 +82,10 @@ const MonitorLog = () => {
7682
<EuiButton
7783
size="s"
7884
color="secondary"
79-
href={linkToDownload}
8085
iconType="download"
8186
className={styles.btn}
8287
data-testid="download-log-btn"
88+
onClick={handleDownloadLog}
8389
{...downloadBtnProps}
8490
>
8591
{width > SMALL_SCREEN_RESOLUTION && ' Download '}

redisinsight/ui/src/setup-tests.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import 'whatwg-fetch'
33

44
import { mswServer } from 'uiSrc/mocks/server'
55

6+
export const URL = 'URL'
7+
window.URL.revokeObjectURL = () => {}
8+
window.URL.createObjectURL = () => URL
9+
610
beforeAll(() => {
711
mswServer.listen()
812
})

redisinsight/ui/src/slices/cli/cli-output.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createSlice } from '@reduxjs/toolkit'
22

3+
import { AxiosError, AxiosResponseHeaders } from 'axios'
34
import { CliOutputFormatterType, cliTexts, ConnectionSuccessOutputText, SelectCommand } from 'uiSrc/constants/cliOutput'
45
import { apiService, localStorageService } from 'uiSrc/services'
56
import { ApiEndpoints, BrowserStorageItem, CommandMonitor, } from 'uiSrc/constants'
@@ -16,6 +17,7 @@ import { SendClusterCommandDto, SendClusterCommandResponse, SendCommandResponse,
1617

1718
import { AppDispatch, RootState } from '../store'
1819
import { CommandExecutionStatus, StateCliOutput } from '../interfaces/cli'
20+
import { addErrorNotification } from '../app/notifications'
1921

2022
export const initialState: StateCliOutput = {
2123
data: [],
@@ -278,3 +280,29 @@ function handleRecreateClient(dispatch: AppDispatch, stateInit: () => RootState,
278280
))
279281
}
280282
}
283+
284+
// Asynchronous thunk action
285+
export function fetchMonitorLog(
286+
logFileId: string = '',
287+
onSuccessAction?: (data: string, headers: AxiosResponseHeaders) => void,
288+
) {
289+
return async (dispatch: AppDispatch) => {
290+
dispatch(sendCliCommand())
291+
292+
try {
293+
const { data, status, headers } = await apiService.get<string>(
294+
`${ApiEndpoints.PROFILER_LOGS}/${logFileId}`
295+
)
296+
297+
if (isStatusSuccessful(status)) {
298+
dispatch(sendCliCommandSuccess())
299+
onSuccessAction?.(data, headers)
300+
}
301+
} catch (err) {
302+
const error = err as AxiosError
303+
const errorMessage = getApiErrorMessage(error)
304+
dispatch(addErrorNotification(error))
305+
dispatch(sendCliCommandFailure(errorMessage))
306+
}
307+
}
308+
}

redisinsight/ui/src/slices/tests/cli/cli-output.spec.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { cloneDeep, first } from 'lodash'
22

3+
import { AxiosError } from 'axios'
34
import { AppDispatch, RootState } from 'uiSrc/slices/store'
45
import { cleanup, clearStoreActions, initialStateDefault, mockedStore, mockStore, } from 'uiSrc/utils/test-utils'
56
import { ClusterNodeRole, CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli'
67
import { apiService } from 'uiSrc/services'
78
import { cliTexts } from 'uiSrc/constants/cliOutput'
89
import { cliParseTextResponseWithOffset, cliParseTextResponseWithRedirect } from 'uiSrc/utils/cliHelper'
910
import ApiErrors from 'uiSrc/constants/apiErrors'
10-
import { processCliClient, updateCliClientAction } from 'uiSrc/slices/cli/cli-settings'
11+
import { processCliClient } from 'uiSrc/slices/cli/cli-settings'
12+
import { addErrorNotification } from 'uiSrc/slices/app/notifications'
1113
import { SendClusterCommandDto, SendClusterCommandResponse } from 'apiSrc/modules/cli/dto/cli.dto'
1214
import reducer, {
1315
concatToOutput,
16+
fetchMonitorLog,
1417
initialState,
1518
outputSelector,
1619
processUnsupportedCommand,
@@ -488,4 +491,54 @@ describe('cliOutput slice', () => {
488491
})
489492
})
490493
})
494+
495+
describe('fetchMonitorLog', () => {
496+
it('call both sendCliCommand and sendCliCommandSuccess when fetch is successed', async () => {
497+
// Arrange
498+
const fileIdMock = 'fileId'
499+
const onSuccessActionMock = jest.fn()
500+
const data = 'test'
501+
const responsePayload = { data, status: 200 }
502+
503+
apiService.get = jest.fn().mockResolvedValue(responsePayload)
504+
505+
// Act
506+
await store.dispatch<any>(fetchMonitorLog(fileIdMock, onSuccessActionMock))
507+
508+
// Assert
509+
const expectedActions = [
510+
sendCliCommand(),
511+
sendCliCommandSuccess(),
512+
]
513+
expect(store.getActions()).toEqual(expectedActions)
514+
expect(onSuccessActionMock).toBeCalled()
515+
})
516+
517+
it('call both sendCliCommand and sendCliCommandFailure when fetch is fail', async () => {
518+
// Arrange
519+
const fileIdMock = 'fileId'
520+
const onSuccessActionMock = jest.fn()
521+
const errorMessage = 'Could not connect to aoeu:123, please check the connection details.'
522+
const responsePayload = {
523+
response: {
524+
status: 500,
525+
data: { message: errorMessage },
526+
},
527+
}
528+
529+
apiService.get = jest.fn().mockRejectedValueOnce(responsePayload)
530+
531+
// Act
532+
await store.dispatch<any>(fetchMonitorLog(fileIdMock, onSuccessActionMock))
533+
534+
// Assert
535+
const expectedActions = [
536+
sendCliCommand(),
537+
addErrorNotification(responsePayload as AxiosError),
538+
sendCliCommandFailure(responsePayload.response.data.message),
539+
]
540+
expect(store.getActions()).toEqual(expectedActions)
541+
expect(onSuccessActionMock).not.toBeCalled()
542+
})
543+
})
491544
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { saveAs } from 'file-saver'
2+
import { AxiosResponseHeaders } from 'axios'
3+
4+
export const DEFAULT_FILE_NAME = 'RedisInsight'
5+
6+
export const downloadFile = (data: string = '', headers: AxiosResponseHeaders) => {
7+
const contentDisposition = headers?.['content-disposition'] || ''
8+
const file = new Blob([data], { type: 'text/plain;charset=utf-8' })
9+
const fileName = contentDisposition.split('"')?.[1] || DEFAULT_FILE_NAME
10+
11+
saveAs(file, fileName)
12+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { saveAs } from 'file-saver'
2+
import { DEFAULT_FILE_NAME, downloadFile } from 'uiSrc/utils/dom/downloadFile'
3+
4+
jest.mock('file-saver', () => ({
5+
...jest.requireActual('file-saver'),
6+
saveAs: jest.fn(),
7+
}))
8+
9+
const getDownloadFileTests: any[] = [
10+
['5123123123', { 'content-disposition': '123"123"123' }, '123'],
11+
['test\ntest123', { 'content-disposition': '123"filename.txt"123' }, 'filename.txt'],
12+
['5123 uoeu aoue ao123123', { 'content-disposition': '123"1uaoeutaoeu"123' }, '1uaoeutaoeu'],
13+
[null, { 'content-disposition': '123"123"123' }, '123'],
14+
['5123 3', {}, DEFAULT_FILE_NAME],
15+
]
16+
17+
describe('downloadFile', () => {
18+
it.each(getDownloadFileTests)('saveAs should be called with: %s (data), %s (headers), ',
19+
(data: string, headers, fileName: string) => {
20+
const saveAsMock = jest.fn();
21+
(saveAs as jest.Mock).mockImplementation(() => saveAsMock)
22+
23+
downloadFile(data, headers)
24+
expect(saveAs).toBeCalledWith(
25+
new Blob([data], { type: 'text/plain;charset=utf-8' }), fileName,
26+
)
27+
})
28+
})

0 commit comments

Comments
 (0)