diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts b/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts new file mode 100644 index 0000000000..6affa59904 --- /dev/null +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts @@ -0,0 +1,37 @@ +import { Response } from 'express'; +import { + Controller, + Get, + Param, + Res, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiParam, ApiTags } from '@nestjs/swagger'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { BulkActionsService } from 'src/modules/bulk-actions/bulk-actions.service'; +import { BulkActionIdDto } from 'src/modules/bulk-actions/dto/bulk-action-id.dto'; + +@UsePipes(new ValidationPipe({ transform: true })) +@ApiTags('Bulk Actions') +@Controller('bulk-actions') +export class BulkActionsController { + constructor(private readonly service: BulkActionsService) {} + + @ApiEndpoint({ + description: 'Stream bulk action report as downloadable file', + statusCode: 200, + }) + @ApiParam({ + name: 'id', + description: 'Bulk action id', + type: String, + }) + @Get(':id/report/download') + async downloadReport( + @Param() { id }: BulkActionIdDto, + @Res() res: Response, + ): Promise { + await this.service.streamReport(id, res); + } +} diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-actions.module.ts b/redisinsight/api/src/modules/bulk-actions/bulk-actions.module.ts index 4e8d4f4727..b82eae46e6 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-actions.module.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.module.ts @@ -3,11 +3,12 @@ import { BulkActionsService } from 'src/modules/bulk-actions/bulk-actions.servic import { BulkActionsProvider } from 'src/modules/bulk-actions/providers/bulk-actions.provider'; import { BulkActionsGateway } from 'src/modules/bulk-actions/bulk-actions.gateway'; import { BulkActionsAnalytics } from 'src/modules/bulk-actions/bulk-actions.analytics'; +import { BulkActionsController } from 'src/modules/bulk-actions/bulk-actions.controller'; import { BulkImportController } from 'src/modules/bulk-actions/bulk-import.controller'; import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service'; @Module({ - controllers: [BulkImportController], + controllers: [BulkActionsController, BulkImportController], providers: [ BulkActionsGateway, BulkActionsService, diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.spec.ts b/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.spec.ts index 5f5c1bb8e8..fa6361d4bb 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.spec.ts @@ -110,4 +110,70 @@ describe('BulkActionsService', () => { expect(bulkActionProvider.abortUsersBulkActions).toHaveBeenCalledTimes(1); }); }); + + describe('streamReport', () => { + let mockResponse: any; + let mockBulkActionWithReport: any; + + beforeEach(() => { + mockResponse = { + setHeader: jest.fn(), + write: jest.fn(), + end: jest.fn(), + }; + + mockBulkActionWithReport = { + setStreamingResponse: jest.fn(), + isReportEnabled: jest.fn().mockReturnValue(true), + }; + }); + + it('should throw NotFoundException when bulk action not found', async () => { + bulkActionProvider.get = jest.fn().mockReturnValue(null); + + await expect( + service.streamReport('non-existent-id', mockResponse), + ).rejects.toThrow('Bulk action not found'); + }); + + it('should throw BadRequestException when report not enabled', async () => { + mockBulkActionWithReport.isReportEnabled.mockReturnValue(false); + bulkActionProvider.get = jest + .fn() + .mockReturnValue(mockBulkActionWithReport); + + await expect( + service.streamReport('bulk-action-id', mockResponse), + ).rejects.toThrow( + 'Report generation was not enabled for this bulk action', + ); + }); + + it('should set headers and attach stream to bulk action', async () => { + bulkActionProvider.get = jest + .fn() + .mockReturnValue(mockBulkActionWithReport); + const mockTimestamp = '1733047200000'; // 2024-12-01T10:00:00.000Z + const expectedFilename = + 'bulk-delete-report-2024-12-01T10-00-00-000Z.txt'; + + await service.streamReport(mockTimestamp, mockResponse); + + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/plain', + ); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + `attachment; filename="${expectedFilename}"`, + ); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Transfer-Encoding', + 'chunked', + ); + expect( + mockBulkActionWithReport.setStreamingResponse, + ).toHaveBeenCalledWith(mockResponse); + }); + }); }); diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts b/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts index 5e1c5ef212..9581cca9ee 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts @@ -1,5 +1,10 @@ import { Socket } from 'socket.io'; -import { Injectable } from '@nestjs/common'; +import { Response } from 'express'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { BulkActionsProvider } from 'src/modules/bulk-actions/providers/bulk-actions.provider'; import { CreateBulkActionDto } from 'src/modules/bulk-actions/dto/create-bulk-action.dto'; import { BulkActionIdDto } from 'src/modules/bulk-actions/dto/bulk-action-id.dto'; @@ -44,4 +49,36 @@ export class BulkActionsService { disconnect(socketId: string) { this.bulkActionsProvider.abortUsersBulkActions(socketId); } + + /** + * Stream bulk action report as downloadable file + * @param id Bulk action id + * @param res Express response object + */ + async streamReport(id: string, res: Response): Promise { + const bulkAction = this.bulkActionsProvider.get(id); + + if (!bulkAction) { + throw new NotFoundException('Bulk action not found'); + } + + if (!bulkAction.isReportEnabled()) { + throw new BadRequestException( + 'Report generation was not enabled for this bulk action', + ); + } + + // Set headers for file download + const timestamp = new Date(Number(id)).toISOString().replace(/[:.]/g, '-'); + res.setHeader('Content-Type', 'text/plain'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="bulk-delete-report-${timestamp}.txt"`, + ); + res.setHeader('Transfer-Encoding', 'chunked'); + + // Attach the response stream to the bulk action + // This will trigger the bulk action to start processing + bulkAction.setStreamingResponse(res); + } } diff --git a/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts b/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts index 480ecae502..dd981ec8d5 100644 --- a/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts +++ b/redisinsight/api/src/modules/bulk-actions/dto/create-bulk-action.dto.ts @@ -1,6 +1,7 @@ import { BulkActionFilter } from 'src/modules/bulk-actions/models/bulk-action-filter'; import { BulkActionType } from 'src/modules/bulk-actions/constants'; import { + IsBoolean, IsEnum, IsNotEmpty, IsNumber, @@ -35,4 +36,9 @@ export class CreateBulkActionDto extends BulkActionIdDto { @Min(0) @Max(2147483647) db?: number; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + generateReport?: boolean; } diff --git a/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts b/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts index 54b11e0955..d3030b3ede 100644 --- a/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts +++ b/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action-overview.interface.ts @@ -15,4 +15,6 @@ export interface IBulkActionOverview { filter: IBulkActionFilterOverview; // Note: This can be null, according to the API response progress: IBulkActionProgressOverview; summary: IBulkActionSummaryOverview; + downloadUrl?: string; + error?: string; } diff --git a/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action.interface.ts b/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action.interface.ts index 8943dcaf02..6c34f13a59 100644 --- a/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action.interface.ts +++ b/redisinsight/api/src/modules/bulk-actions/interfaces/bulk-action.interface.ts @@ -7,4 +7,5 @@ export interface IBulkAction { getFilter(): BulkActionFilter; changeState(): void; getSocket(): Socket; + writeToReport(keyName: Buffer, success: boolean, error?: string): void; } diff --git a/redisinsight/api/src/modules/bulk-actions/models/bulk-action-summary.spec.ts b/redisinsight/api/src/modules/bulk-actions/models/bulk-action-summary.spec.ts index e6b925378c..81b74bfbd3 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action-summary.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action-summary.spec.ts @@ -81,12 +81,30 @@ describe('BulkActionSummary', () => { expect(summary['errors']).toEqual(generateErrors(500)); }); }); + describe('addKeys', () => { + it('should add keys', async () => { + expect(summary['keys']).toEqual([]); + + summary.addKeys([Buffer.from('key1')]); + + expect(summary['keys']).toEqual([Buffer.from('key1')]); + + summary.addKeys([Buffer.from('key2'), Buffer.from('key3')]); + + expect(summary['keys']).toEqual([ + Buffer.from('key1'), + Buffer.from('key2'), + Buffer.from('key3'), + ]); + }); + }); describe('getOverview', () => { it('should get overview and clear errors', async () => { expect(summary['processed']).toEqual(0); expect(summary['succeed']).toEqual(0); expect(summary['failed']).toEqual(0); expect(summary['errors']).toEqual([]); + expect(summary['keys']).toEqual([]); summary.addProcessed(1500); summary.addSuccess(500); diff --git a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts index a232168562..c9b08bdf59 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts @@ -292,7 +292,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 90, failed: 10, errors: [], - keys: ['key1', 'key2'], + keys: [], }), }; mockSummary2 = { @@ -301,7 +301,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 180, failed: 20, errors: [], - keys: ['key3'], + keys: [], }), }; mockSummary3 = { @@ -310,7 +310,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 270, failed: 30, errors: [], - keys: ['key4', 'key5', 'key6'], + keys: [], }), }; @@ -331,21 +331,13 @@ describe('AbstractBulkActionSimpleRunner', () => { bulkAction['status'] = BulkActionStatus.Completed; }); - it('should correctly concatenate keys from all runners', async () => { + it('should correctly aggregate summary data from all runners', async () => { const overview = bulkAction.getOverview(); - // Convert to a Set to make the test order-independent - const expectedKeys = new Set([ - 'key1', - 'key2', - 'key3', - 'key4', - 'key5', - 'key6', - ]); - const actualKey = new Set(overview.summary.keys); - - expect(actualKey).toEqual(expectedKeys); + expect(overview.summary.processed).toBeGreaterThan(0); + expect(overview.summary.succeed).toBeGreaterThan(0); + expect(overview.summary.failed).toBeGreaterThan(0); + expect(Array.isArray(overview.summary.errors)).toBe(true); }); }); @@ -456,6 +448,7 @@ describe('AbstractBulkActionSimpleRunner', () => { { ...mockBulkActionOverviewMatcher, status: 'failed', + error: 'some error', }, new Error('some error'), ); @@ -488,4 +481,353 @@ describe('AbstractBulkActionSimpleRunner', () => { expect(bulkAction.getId()).toEqual(bulkAction['id']); }); }); + + describe('Report streaming', () => { + let mockResponse: any; + + beforeEach(() => { + mockResponse = { + write: jest.fn(), + end: jest.fn(), + }; + }); + + describe('isReportEnabled', () => { + it('should return false when generateReport is not set', () => { + expect(bulkAction.isReportEnabled()).toBe(false); + }); + + it('should return true when generateReport is true', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + expect(bulkActionWithReport.isReportEnabled()).toBe(true); + }); + }); + + describe('setStreamingResponse', () => { + it('should set streaming response and resolve promise when waiting', async () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + // Start waiting for stream + const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); + + // Set streaming response + bulkActionWithReport.setStreamingResponse(mockResponse); + + await waitPromise; + + expect(bulkActionWithReport['streamingResponse']).toBe(mockResponse); + }); + + it('should write header when response is set', async () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + // Start waiting for stream + const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); + + // Set streaming response + bulkActionWithReport.setStreamingResponse(mockResponse); + + await waitPromise; + + expect(mockResponse.write).toHaveBeenCalled(); + const headerCall = mockResponse.write.mock.calls[0][0]; + expect(headerCall).toContain('Bulk Delete Report'); + expect(headerCall).toContain('Command Executed for each key:'); + }); + + it('should immediately end response when called without waiting', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + // Call setStreamingResponse without waitForStreamIfNeeded being called first + bulkActionWithReport.setStreamingResponse(mockResponse); + + // Should immediately end the response + expect(mockResponse.write).toHaveBeenCalledWith( + 'Unable to generate report. Please try again.\n', + ); + expect(mockResponse.end).toHaveBeenCalled(); + expect(bulkActionWithReport['streamingResponse']).toBeNull(); + }); + }); + + describe('writeToReport', () => { + it('should not write when streaming response is not set', () => { + bulkAction.writeToReport(Buffer.from('testKey'), true); + // No error thrown, just no-op + }); + + it('should write success entry to stream', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + bulkActionWithReport['streamingResponse'] = mockResponse; + + bulkActionWithReport.writeToReport(Buffer.from('testKey'), true); + + expect(mockResponse.write).toHaveBeenCalledWith('testKey - OK\n'); + }); + + it('should write error entry to stream', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + bulkActionWithReport['streamingResponse'] = mockResponse; + + bulkActionWithReport.writeToReport( + Buffer.from('testKey'), + false, + 'NOPERM', + ); + + expect(mockResponse.write).toHaveBeenCalledWith( + 'testKey - Error: NOPERM\n', + ); + }); + + it('should write unknown error when error message not provided', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + bulkActionWithReport['streamingResponse'] = mockResponse; + + bulkActionWithReport.writeToReport(Buffer.from('testKey'), false); + + expect(mockResponse.write).toHaveBeenCalledWith( + 'testKey - Error: Unknown error\n', + ); + }); + }); + + describe('finalizeReport', () => { + it('should not finalize when streaming response is not set', () => { + bulkAction['finalizeReport'](); + // No error thrown, just no-op + }); + + it('should write summary and close stream', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + bulkActionWithReport['streamingResponse'] = mockResponse; + bulkActionWithReport['status'] = BulkActionStatus.Completed; + + bulkActionWithReport['finalizeReport'](); + + expect(mockResponse.write).toHaveBeenCalled(); + expect(mockResponse.end).toHaveBeenCalled(); + + const summaryCall = mockResponse.write.mock.calls[0][0]; + expect(summaryCall).toContain('Summary'); + expect(summaryCall).toContain('Processed:'); + expect(summaryCall).toContain('Succeeded:'); + expect(summaryCall).toContain('Failed:'); + expect(summaryCall).toContain('Status:'); + }); + }); + + describe('getOverview with downloadUrl', () => { + it('should not include downloadUrl when generateReport is false', () => { + const overview = bulkAction.getOverview(); + expect(overview.downloadUrl).toBeUndefined(); + }); + + it('should include downloadUrl when generateReport is true', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + const overview = bulkActionWithReport.getOverview(); + + expect(overview.downloadUrl).toBe( + `databases/${mockCreateBulkActionDto.databaseId}/bulk-actions/${mockCreateBulkActionDto.id}/report/download`, + ); + }); + }); + + describe('waitForStreamIfNeeded', () => { + it('should resolve immediately when generateReport is false', async () => { + await bulkAction['waitForStreamIfNeeded'](); + // Should not hang + }); + + it('should wait for stream when generateReport is true', async () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + // Start waiting in background + const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); + + // Set streaming response after a short delay + setTimeout(() => { + bulkActionWithReport.setStreamingResponse(mockResponse); + }, 10); + + // Wait should resolve after setStreamingResponse is called + await waitPromise; + + expect(bulkActionWithReport['streamingResponse']).toBe(mockResponse); + }); + + it('should reject with timeout error when stream is not set in time', async () => { + jest.useFakeTimers(); + + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); + + jest.advanceTimersByTime(BulkAction['STREAM_TIMEOUT_MS'] + 100); + + await expect(waitPromise).rejects.toThrow( + 'Unable to start report download. Please try again.', + ); + + jest.useRealTimers(); + }); + + it('should clear timeout when stream is set before timeout', async () => { + jest.useFakeTimers(); + + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); + + jest.advanceTimersByTime(100); + bulkActionWithReport.setStreamingResponse(mockResponse); + + await waitPromise; + + jest.advanceTimersByTime(BulkAction['STREAM_TIMEOUT_MS']); + + expect(bulkActionWithReport['streamingResponse']).toBe(mockResponse); + + jest.useRealTimers(); + }); + + it('should immediately end response when stream arrives after timeout', async () => { + jest.useFakeTimers(); + + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); + + // Let the timeout expire + jest.advanceTimersByTime(BulkAction['STREAM_TIMEOUT_MS'] + 100); + + await expect(waitPromise).rejects.toThrow( + 'Unable to start report download. Please try again.', + ); + + // Now the late stream arrives + const lateResponse = { + write: jest.fn(), + end: jest.fn(), + }; + bulkActionWithReport.setStreamingResponse(lateResponse as any); + + // Should immediately write error and end response + expect(lateResponse.write).toHaveBeenCalledWith( + 'Unable to generate report. Please try again.\n', + ); + expect(lateResponse.end).toHaveBeenCalled(); + + // Should NOT set the streaming response + expect(bulkActionWithReport['streamingResponse']).toBeNull(); + + jest.useRealTimers(); + }); + }); + }); }); diff --git a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts index c4f11bd510..4a1a1a9219 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts @@ -1,4 +1,5 @@ import { debounce } from 'lodash'; +import { Response } from 'express'; import { BulkActionStatus, BulkActionType, @@ -30,6 +31,12 @@ export class BulkAction implements IBulkAction { private readonly debounce: Function; + private readonly generateReport: boolean; + + private streamingResponse: Response | null = null; + + private streamReadyResolver: (() => void) | null = null; + constructor( private readonly id: string, private readonly databaseId: string, @@ -37,11 +44,13 @@ export class BulkAction implements IBulkAction { private readonly filter: BulkActionFilter, private readonly socket: Socket, private readonly analytics: BulkActionsAnalytics, + generateReport: boolean = false, ) { this.debounce = debounce(this.sendOverview.bind(this), 1000, { maxWait: 1000, }); this.status = BulkActionStatus.Initialized; + this.generateReport = generateReport; } /** @@ -88,6 +97,9 @@ export class BulkAction implements IBulkAction { */ private async run() { try { + // Wait for streaming response to be attached if report generation is enabled + await this.waitForStreamIfNeeded(); + this.setStatus(BulkActionStatus.Running); await Promise.all(this.runners.map((runner) => runner.run())); @@ -97,12 +109,101 @@ export class BulkAction implements IBulkAction { this.logger.error('Error on BulkAction Runner', e); this.error = e; this.setStatus(BulkActionStatus.Failed); + } finally { + this.finalizeReport(); } } - /** - * Get overview for BulkAction with progress details and summary - */ + private static readonly STREAM_TIMEOUT_MS = 5_000; + + private async waitForStreamIfNeeded(): Promise { + if (!this.generateReport) { + return; + } + + if (this.streamingResponse) { + return; + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.streamReadyResolver = null; + reject(new Error('Unable to start report download. Please try again.')); + }, BulkAction.STREAM_TIMEOUT_MS); + + this.streamReadyResolver = () => { + clearTimeout(timeout); + resolve(); + }; + }); + } + + isReportEnabled(): boolean { + return this.generateReport; + } + + setStreamingResponse(res: Response): void { + // If stream arrives too late (after timeout/failure), immediately end it + if (!this.streamReadyResolver) { + res.write('Unable to generate report. Please try again.\n'); + res.end(); + return; + } + + this.streamingResponse = res; + + this.writeReportHeader(); + + this.streamReadyResolver(); + this.streamReadyResolver = null; + } + + private writeReportHeader(): void { + if (!this.streamingResponse) return; + + const header = [ + 'Bulk Delete Report', + `Command Executed for each key: ${this.type.toUpperCase()} key_name`, + 'A summary is provided at the end of this file.', + '==================', + '', + ].join('\n'); + + this.streamingResponse.write(header); + } + + writeToReport(keyName: Buffer, success: boolean, error?: string): void { + if (!this.streamingResponse) return; + + const keyNameStr = keyName.toString(); + const line = success + ? `${keyNameStr} - OK\n` + : `${keyNameStr} - Error: ${error || 'Unknown error'}\n`; + + this.streamingResponse.write(line); + } + + private finalizeReport(): void { + if (!this.streamingResponse) return; + + const overview = this.getOverview(); + + const footer = [ + '', + '=============', + 'Summary:', + '=============', + `Status: ${overview.status}`, + `Processed: ${overview.summary.processed} keys`, + `Succeeded: ${overview.summary.succeed} keys`, + `Failed: ${overview.summary.failed} keys`, + '', + ].join('\n'); + + this.streamingResponse.write(footer); + this.streamingResponse.end(); + } + getOverview(): IBulkActionOverview { const progress = this.runners .map((runner) => runner.getProgress().getOverview()) @@ -141,7 +242,7 @@ export class BulkAction implements IBulkAction { error: error.error.toString(), })); - return { + const overview: IBulkActionOverview = { id: this.id, databaseId: this.databaseId, type: this.type, @@ -151,6 +252,20 @@ export class BulkAction implements IBulkAction { progress, summary, }; + + if (this.generateReport) { + overview.downloadUrl = this.getDownloadUrl(); + } + + if (this.error) { + overview.error = this.error.message; + } + + return overview; + } + + private getDownloadUrl(): string { + return `databases/${this.databaseId}/bulk-actions/${this.id}/report/download`; } getId() { @@ -178,7 +293,10 @@ export class BulkAction implements IBulkAction { if (!this.endTime) { this.endTime = Date.now(); } - // eslint-disable-next-line no-fallthrough + // Queue the state change, then flush immediately for terminal states + this.changeState(); + (this.debounce as ReturnType).flush(); + break; default: this.changeState(); } diff --git a/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.spec.ts b/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.spec.ts index e6fdbb0a48..27879e8ba4 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.spec.ts @@ -145,7 +145,6 @@ describe('AbstractBulkActionSimpleRunner', () => { expect(deleteRunner['summary']['processed']).toEqual(0); expect(deleteRunner['summary']['succeed']).toEqual(0); expect(deleteRunner['summary']['failed']).toEqual(0); - expect(deleteRunner['summary']['errors']).toEqual([]); await deleteRunner.runIteration(); @@ -156,12 +155,6 @@ describe('AbstractBulkActionSimpleRunner', () => { expect(deleteRunner['summary']['processed']).toEqual(2); expect(deleteRunner['summary']['succeed']).toEqual(1); expect(deleteRunner['summary']['failed']).toEqual(1); - expect(deleteRunner['summary']['errors']).toEqual([ - { - key: mockKeyBuffer, - error: mockRESPError, - }, - ]); await deleteRunner.runIteration(); @@ -172,16 +165,6 @@ describe('AbstractBulkActionSimpleRunner', () => { expect(deleteRunner['summary']['processed']).toEqual(4); expect(deleteRunner['summary']['succeed']).toEqual(2); expect(deleteRunner['summary']['failed']).toEqual(2); - expect(deleteRunner['summary']['errors']).toEqual([ - { - key: mockKeyBuffer, - error: mockRESPError, - }, - { - key: mockKeyBuffer, - error: mockRESPError, - }, - ]); await deleteRunner.runIteration(); @@ -192,20 +175,6 @@ describe('AbstractBulkActionSimpleRunner', () => { expect(deleteRunner['summary']['processed']).toEqual(6); expect(deleteRunner['summary']['succeed']).toEqual(3); expect(deleteRunner['summary']['failed']).toEqual(3); - expect(deleteRunner['summary']['errors']).toEqual([ - { - key: mockKeyBuffer, - error: mockRESPError, - }, - { - key: mockKeyBuffer, - error: mockRESPError, - }, - { - key: mockKeyBuffer, - error: mockRESPError, - }, - ]); }); }); @@ -251,18 +220,18 @@ describe('AbstractBulkActionSimpleRunner', () => { describe('processIterationResults', () => { let addProcessedSpy; - let addKeysSpy; let addSuccessSpy; - let addErrorsSpy; + let addFailedSpy; + let writeToReportSpy; beforeEach(() => { addProcessedSpy = jest.spyOn(deleteRunner['summary'], 'addProcessed'); - addKeysSpy = jest.spyOn(deleteRunner['summary'], 'addKeys'); addSuccessSpy = jest.spyOn(deleteRunner['summary'], 'addSuccess'); - addErrorsSpy = jest.spyOn(deleteRunner['summary'], 'addErrors'); + addFailedSpy = jest.spyOn(deleteRunner['summary'], 'addFailed'); + writeToReportSpy = jest.spyOn(bulkAction, 'writeToReport'); }); - it('should add keys to the summary and correctly process results', () => { + it('should correctly process results and update summary counters', () => { const keys = [ Buffer.from('key1'), Buffer.from('key2'), @@ -277,16 +246,35 @@ describe('AbstractBulkActionSimpleRunner', () => { deleteRunner.processIterationResults(keys, results); expect(addProcessedSpy).toHaveBeenCalledWith(3); - expect(addKeysSpy).toHaveBeenCalledWith([keys[0], keys[2]]); expect(addSuccessSpy).toHaveBeenNthCalledWith(1, 1); // first call expect(addSuccessSpy).toHaveBeenNthCalledWith(2, 1); // second call - expect(addErrorsSpy).toHaveBeenCalledWith([ - { - key: Buffer.from('key2'), - error: mockRESPError, - }, - ]); + expect(addFailedSpy).toHaveBeenCalledWith(1); + }); + + it('should call writeToReport for each key result', () => { + const keys = [ + Buffer.from('key1'), + Buffer.from('key2'), + Buffer.from('key3'), + ]; + const results: [Error | null, number | null][] = [ + [null, 1], // Success + [mockReplyError, null], // Error + [null, 1], // Success + ]; + + deleteRunner.processIterationResults(keys, results); + + expect(writeToReportSpy).toHaveBeenCalledTimes(3); + expect(writeToReportSpy).toHaveBeenNthCalledWith(1, keys[0], true); + expect(writeToReportSpy).toHaveBeenNthCalledWith( + 2, + keys[1], + false, + mockRESPError, + ); + expect(writeToReportSpy).toHaveBeenNthCalledWith(3, keys[2], true); }); }); }); diff --git a/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.ts b/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.ts index b95756c8ef..4baaffa296 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/runners/simple/abstract.bulk-action.simple.runner.ts @@ -86,18 +86,17 @@ export abstract class AbstractBulkActionSimpleRunner extends AbstractBulkActionR res: [Error | null, RedisClientCommandReply][], ) { this.summary.addProcessed(res.length); - this.summary.addKeys(keys.filter((_, index) => !res[index][0])); - - const errors = []; res.forEach(([err], i) => { + const keyName = keys[i]; + if (err) { - errors.push({ key: keys[i], error: err.message }); + this.summary.addFailed(1); + this.bulkAction.writeToReport(keyName, false, err.message); } else { this.summary.addSuccess(1); + this.bulkAction.writeToReport(keyName, true); } }); - - this.summary.addErrors(errors); } } diff --git a/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts b/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts index a939bc6531..b97b900dce 100644 --- a/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts +++ b/redisinsight/api/src/modules/bulk-actions/providers/bulk-actions.provider.ts @@ -49,6 +49,7 @@ export class BulkActionsProvider { dto.filter, socket, this.analytics, + dto.generateReport, ); this.bulkActions.set(dto.id, bulkAction); diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx index 285d3d20d0..7a0e86d3ab 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -13,7 +13,7 @@ import { setDeleteOverviewStatus, setLoading, } from 'uiSrc/slices/browser/bulkActions' -import { getSocketApiUrl, Nullable } from 'uiSrc/utils' +import { getSocketApiUrl, Nullable, triggerDownloadFromUrl } from 'uiSrc/utils' import { sessionStorageService } from 'uiSrc/services' import { keysSelector } from 'uiSrc/slices/browser/keys' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' @@ -28,11 +28,12 @@ import { import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { appCsrfSelector } from 'uiSrc/slices/app/csrf' import { useIoConnection } from 'uiSrc/services/hooks/useIoConnection' +import { getBaseUrl } from 'uiSrc/services/apiService' const BulkActionsConfig = () => { const { id: instanceId = '', db } = useSelector(connectedInstanceSelector) const { isConnected } = useSelector(bulkActionsSelector) - const { isActionTriggered: isDeleteTriggered } = useSelector( + const { isActionTriggered: isDeleteTriggered, generateReport } = useSelector( bulkActionsDeleteSelector, ) const { filter, search } = useSelector(keysSelector) @@ -101,6 +102,7 @@ const BulkActionsConfig = () => { type: filter, match: search || '*', }, + generateReport, }, onBulkDeleting, ) @@ -137,6 +139,11 @@ const BulkActionsConfig = () => { dispatch(addErrorNotification({ response: { data: data.error } })) } + // Trigger download if report URL is provided + if ('downloadUrl' in data && data.downloadUrl) { + triggerDownloadFromUrl(`${getBaseUrl()}${data.downloadUrl}`) + } + socketRef.current?.on(BulkActionsServerEvent.Overview, (payload: any) => { dispatch(setBulkDeleteLoading(isProcessingBulkAction(payload.status))) dispatch(setDeleteOverview(payload)) diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx index 5013cf57fe..364d2454ac 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsInfo/BulkActionsInfo.tsx @@ -28,6 +28,7 @@ export interface Props { scanned: Maybe } children?: React.ReactNode + error?: string } const BulkActionsInfo = (props: Props) => { @@ -40,6 +41,7 @@ const BulkActionsInfo = (props: Props) => { progress, title = 'Delete Keys with', subTitle, + error, } = props const { total = 0, scanned = 0 } = progress || {} @@ -49,6 +51,7 @@ const BulkActionsInfo = (props: Props) => { status={status} total={total} scanned={scanned} + error={error} /> diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsStatusDisplay/BulkActionsStatusDisplay.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsStatusDisplay/BulkActionsStatusDisplay.tsx index b07e8f5706..138f9b133c 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsStatusDisplay/BulkActionsStatusDisplay.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkActionsStatusDisplay/BulkActionsStatusDisplay.tsx @@ -13,12 +13,14 @@ export interface BulkActionsStatusDisplayProps { status: Props['status'] total: Maybe scanned: Maybe + error?: string } export const BulkActionsStatusDisplay = ({ status, total, scanned, + error, }: BulkActionsStatusDisplayProps) => { if (!isUndefined(status) && !isProcessedBulkAction(status)) { return ( @@ -55,6 +57,16 @@ export const BulkActionsStatusDisplay = ({ ) } + if (status === BulkActionsStatus.Failed) { + return ( + + ) + } + if (status === BulkActionsStatus.Disconnected) { return ( ({ - ...jest.requireActual('uiSrc/slices/browser/bulkActions'), - bulkActionsDeleteOverviewSelector: jest.fn().mockReturnValue({ - status: 'completed', - filter: {}, - }), - bulkActionsDeleteSelector: jest.fn().mockReturnValue({ - loading: false, - }), - bulkActionsDeleteSummarySelector: jest.fn().mockReturnValue({ - keys: ['key1', 'key2'], - }), -})) - -jest.mock('uiSrc/slices/browser/keys', () => ({ - ...jest.requireActual('uiSrc/slices/browser/keys'), - keysSelector: jest.fn().mockReturnValue({ - filter: '', - search: '', - isSearched: false, - isFiltered: false, - }), -})) - -jest.mock('./BulkDeleteSummaryButton', () => { - // eslint-disable-next-line global-require - const React = require('react') - const MockComponent = ({ keysType }: any) => - React.createElement( - 'div', - { - 'data-testid': 'summary-button', - }, - `Mocked Summary Button with keysType: ${keysType}`, - ) - - return { - __esModule: true, - default: MockComponent, - } -}) - -const mockedProps: Props = { - onCancel: jest.fn(), -} - -const keysSelectorMock = keysSelectors.keysSelector as jest.Mock - -describe('BulkDelete', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should render', () => { - expect(render()).toBeTruthy() - }) - - it('should display the download button when the bulk delete process is completed', () => { - render() - - const downloadButton = screen.queryByTestId('summary-button') - - expect(downloadButton).toBeInTheDocument() - }) - - it('should render keysType as "Any" when filter is empty', () => { - render() - - const summary = screen.getByTestId('summary-button') - expect(summary).toHaveTextContent('keysType: Any') - }) - - it('should render proper keysType when filter is that type', () => { - keysSelectorMock.mockReturnValue({ - filter: 'hash', - search: '', - isSearched: true, - isFiltered: true, - }) - - render() - - const summary = screen.getByTestId('summary-button') - expect(summary).toHaveTextContent('keysType: Hash') - }) -}) diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.tsx index 8a7a5b2204..e5c9cd76c5 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.tsx @@ -6,33 +6,27 @@ import { Text } from 'uiSrc/components/base/text' import { bulkActionsDeleteOverviewSelector, bulkActionsDeleteSelector, - bulkActionsDeleteSummarySelector, } from 'uiSrc/slices/browser/bulkActions' import { keysSelector } from 'uiSrc/slices/browser/keys' -import { getGroupTypeDisplay, NO_TYPE_NAME } from 'uiSrc/utils' -import { Col, Row } from 'uiSrc/components/base/layout/flex' +import { Col } from 'uiSrc/components/base/layout/flex' import BulkDeleteFooter from './BulkDeleteFooter' import BulkDeleteSummary from './BulkDeleteSummary' -import BulkDeleteSummaryButton from './BulkDeleteSummaryButton' import BulkActionsInfo from '../BulkActionsInfo' export interface Props { onCancel: () => void } -const REPORTED_NO_TYPE_NAME = 'Any' - const BulkDelete = (props: Props) => { const { onCancel } = props const { filter, search, isSearched, isFiltered } = useSelector(keysSelector) const { loading } = useSelector(bulkActionsDeleteSelector) - const { keys: deletedKeys } = - useSelector(bulkActionsDeleteSummarySelector) || {} const { status, filter: { match, type: filterType }, progress, + error, } = useSelector(bulkActionsDeleteOverviewSelector) ?? { filter: {} } const [showPlaceholder, setShowPlaceholder] = useState( @@ -43,9 +37,7 @@ const BulkDelete = (props: Props) => { setShowPlaceholder(!status && !isSearched && !isFiltered) }, [status, isSearched, isFiltered]) - const isCompleted = !isUndefined(status) const searchPattern = match || search || '*' - const keysType = getGroupTypeDisplay(filter) return ( <> @@ -57,25 +49,10 @@ const BulkDelete = (props: Props) => { filter={isUndefined(filterType) ? filter : filterType} status={status} progress={progress} + error={error} > - - {isCompleted && ( - - - Keys deleted - - - )} diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.spec.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.spec.tsx index ceeeda415a..5259207153 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.spec.tsx @@ -3,6 +3,14 @@ import { mock } from 'ts-mockito' import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' import BulkDeleteFooter, { Props } from './BulkDeleteFooter' +import { setBulkDeleteGenerateReport } from 'uiSrc/slices/browser/bulkActions' + +jest.mock('uiSrc/slices/browser/bulkActions', () => ({ + ...jest.requireActual('uiSrc/slices/browser/bulkActions'), + setBulkDeleteGenerateReport: jest.fn().mockReturnValue({ + type: 'bulkActions/setBulkDeleteGenerateReport', + }), +})) const mockedProps = { ...mock(), @@ -21,4 +29,18 @@ describe('BulkDeleteFooter', () => { expect(mockOnCancel).toBeCalled() }) + + it('should render download report checkbox', () => { + render() + + expect(screen.getByTestId('download-report-checkbox')).toBeInTheDocument() + }) + + it('should dispatch setBulkDeleteGenerateReport when checkbox is toggled', () => { + render() + + fireEvent.click(screen.getByTestId('download-report-checkbox')) + + expect(setBulkDeleteGenerateReport).toHaveBeenCalledWith(false) + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.tsx index f4d6172040..b7d3e199a1 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteFooter/BulkDeleteFooter.tsx @@ -6,6 +6,7 @@ import { bulkActionsDeleteOverviewSelector, setBulkDeleteStartAgain, toggleBulkDeleteActionTriggered, + setBulkDeleteGenerateReport, bulkActionsDeleteSelector, } from 'uiSrc/slices/browser/bulkActions' import { keysDataSelector, keysSelector } from 'uiSrc/slices/browser/keys' @@ -26,10 +27,10 @@ import { import { RefreshIcon } from 'uiSrc/components/base/icons' import { Text } from 'uiSrc/components/base/text' import { RiIcon } from 'uiSrc/components/base/icons/RiIcon' -import BulkDeleteContent from '../BulkDeleteContent' import { isProcessedBulkAction } from '../../utils' import { Col, Row } from 'uiSrc/components/base/layout/flex' -import { ConfirmationPopover } from 'uiSrc/components' +import { ConfirmationPopover, RiTooltip } from 'uiSrc/components' +import { Checkbox } from 'uiSrc/components/base/forms/checkbox/Checkbox' import { BulkDeleteFooterContainer } from './BulkDeleteFooter.styles' export interface Props { @@ -41,7 +42,7 @@ const BulkDeleteFooter = (props: Props) => { const { instanceId = '' } = useParams<{ instanceId: string }>() const { filter, search } = useSelector(keysSelector) const { scanned, total } = useSelector(keysDataSelector) - const { loading } = useSelector(bulkActionsDeleteSelector) + const { loading, generateReport } = useSelector(bulkActionsDeleteSelector) const { status } = useSelector(bulkActionsDeleteOverviewSelector) ?? {} const [isPopoverOpen, setIsPopoverOpen] = useState(false) @@ -93,9 +94,35 @@ const BulkDeleteFooter = (props: Props) => { } return ( - - {status && } - + + + + ) => + dispatch(setBulkDeleteGenerateReport(e.target.checked)) + } + label="Download report" + data-testid="download-report-checkbox" + /> + + + + + + {!loading && ( => { - return blob.text() -} - -const defaultRenderProps = { - pattern: 'test-pattern', - deletedKeys: ['key1', 'key2'], - children: 'Download report', - keysType: 'Any', -} - -const renderComponent = (props: any = {}) => - render() - -describe('BulkDeleteSummaryButton', () => { - beforeEach(() => { - URL.createObjectURL = jest.fn(() => 'mockFileUrl') - URL.revokeObjectURL = jest.fn() - }) - - it('should render', () => { - expect(renderComponent()).toBeTruthy() - - expect( - screen.getByTestId('download-bulk-delete-report'), - ).toBeInTheDocument() - }) - - it('should generate correct file content', async () => { - renderComponent() - - const blob = (URL.createObjectURL as jest.Mock).mock.calls[0][0] - expect(blob).toBeInstanceOf(Blob) - - const textContent = await readBlobContent(blob) - - // prettier-ignore - const expectedContent = - 'Pattern: test-pattern\n' + - 'Key type: Any\n\n' + - 'Keys:\n\n' + - 'key1\n' + - 'key2' - - expect(textContent).toBe(expectedContent) - }) - - it('should handle empty deletedKeys array correctly', async () => { - renderComponent({ - deletedKeys: [], - }) - - const blob = (URL.createObjectURL as jest.Mock).mock.calls[0][0] - expect(blob).toBeInstanceOf(Blob) - - const textContent = await readBlobContent(blob) - - // prettier-ignore - const expectedContent = - 'Pattern: test-pattern\n' + - 'Key type: Any\n\n' + - 'Keys:\n\n' - - expect(textContent).toBe(expectedContent) - }) - - it('should clean up the file URL on unmount', () => { - const { unmount } = renderComponent({ - deletedKeys: ['key1'], - }) - - unmount() - expect(URL.revokeObjectURL).toHaveBeenCalledWith('mockFileUrl') - }) -}) diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.tsx deleted file mode 100644 index b03ac526c0..0000000000 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useEffect, useMemo } from 'react' -import { Maybe } from 'uiSrc/utils' -import { SecondaryButton } from 'uiSrc/components/base/forms/buttons' -import { DownloadIcon } from 'uiSrc/components/base/icons' -import { Link } from 'uiSrc/components/base/link/Link' -import { RedisString } from 'apiSrc/common/constants' - -export interface BulkDeleteSummaryButtonProps { - pattern: string - deletedKeys: Maybe - keysType: string - children: React.ReactNode -} - -const getFileName = () => `bulk-delete-report-${Date.now()}.txt` - -const BulkDeleteSummaryButton = ({ - pattern, - deletedKeys, - keysType, - children, - ...rest -}: BulkDeleteSummaryButtonProps) => { - const fileUrl = useMemo(() => { - const content = - `Pattern: ${pattern}\n` + - `Key type: ${keysType}\n\n` + - `Keys:\n\n` + - `${deletedKeys?.map((key) => Buffer.from(key).toString()).join('\n')}` - - const blob = new Blob([content], { type: 'text/plain' }) - return URL.createObjectURL(blob) - }, [deletedKeys, pattern, keysType]) - - useEffect( - () => () => { - URL.revokeObjectURL(fileUrl) - }, - [fileUrl], - ) - - return ( - - - {children} - - - ) -} - -export default BulkDeleteSummaryButton diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/index.ts b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/index.ts deleted file mode 100644 index 4399b7a5d9..0000000000 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import BulkDeleteSummaryButton from './BulkDeleteSummaryButton' - -export default BulkDeleteSummaryButton diff --git a/redisinsight/ui/src/slices/browser/bulkActions.ts b/redisinsight/ui/src/slices/browser/bulkActions.ts index 695d981c24..00995585a4 100644 --- a/redisinsight/ui/src/slices/browser/bulkActions.ts +++ b/redisinsight/ui/src/slices/browser/bulkActions.ts @@ -33,6 +33,7 @@ export const initialState: StateBulkActions = { loading: false, error: '', overview: null, + generateReport: true, }, bulkUpload: { loading: false, @@ -78,6 +79,12 @@ const bulkActionsSlice = createSlice({ toggleBulkDeleteActionTriggered: (state) => { state.bulkDelete.isActionTriggered = !state.bulkDelete.isActionTriggered }, + setBulkDeleteGenerateReport: ( + state, + { payload }: PayloadAction, + ) => { + state.bulkDelete.generateReport = payload + }, setDeleteOverview: ( state, { payload }: PayloadAction, @@ -153,6 +160,7 @@ export const { toggleBulkActions, disconnectBulkDeleteAction, toggleBulkDeleteActionTriggered, + setBulkDeleteGenerateReport, setDeleteOverview, setDeleteOverviewStatus, setBulkActionsInitialState, diff --git a/redisinsight/ui/src/slices/interfaces/bulkActions.ts b/redisinsight/ui/src/slices/interfaces/bulkActions.ts index 8b3fbd5570..aed7115ba0 100644 --- a/redisinsight/ui/src/slices/interfaces/bulkActions.ts +++ b/redisinsight/ui/src/slices/interfaces/bulkActions.ts @@ -18,6 +18,7 @@ export interface StateBulkActions { loading: boolean error: string overview: Nullable + generateReport: boolean } bulkUpload: { loading: boolean diff --git a/redisinsight/ui/src/utils/dom/index.ts b/redisinsight/ui/src/utils/dom/index.ts index f8375ffa10..dbaa37d833 100644 --- a/redisinsight/ui/src/utils/dom/index.ts +++ b/redisinsight/ui/src/utils/dom/index.ts @@ -3,6 +3,7 @@ import setTitle from './setPageTitle' export * from './scrollIntoView' export * from './handlePlatforms' export * from './handleBrowsers' +export * from './triggerDownloadFromUrl' export { removePagePlaceholder } from './pagePlaceholder' diff --git a/redisinsight/ui/src/utils/dom/triggerDownloadFromUrl.ts b/redisinsight/ui/src/utils/dom/triggerDownloadFromUrl.ts new file mode 100644 index 0000000000..c41f7daa5f --- /dev/null +++ b/redisinsight/ui/src/utils/dom/triggerDownloadFromUrl.ts @@ -0,0 +1,12 @@ +/** + * Triggers a file download from a URL by creating a temporary link element + * @param url The full URL to download from + */ +export const triggerDownloadFromUrl = (url: string): void => { + const link = document.createElement('a') + link.href = url + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +}