From 572effce0f546b95e2405bb449b8bfa73514821c Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 28 Nov 2025 15:43:11 +0200 Subject: [PATCH 01/20] ui: delete download report button --- .../bulk-actions/BulkDelete/BulkDelete.tsx | 27 +------ .../BulkDeleteSummaryButton.spec.tsx | 79 ------------------- .../BulkDeleteSummaryButton.tsx | 57 ------------- .../BulkDeleteSummaryButton/index.ts | 3 - 4 files changed, 1 insertion(+), 165 deletions(-) delete mode 100644 redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.spec.tsx delete mode 100644 redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.tsx delete mode 100644 redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/index.ts 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..3ef875321a 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,29 +6,22 @@ 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 }, @@ -43,9 +36,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 ( <> @@ -60,22 +51,6 @@ const BulkDelete = (props: Props) => { > - - {isCompleted && ( - - - Keys deleted - - - )} diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.spec.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.spec.tsx deleted file mode 100644 index 6ec0d7ff55..0000000000 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDeleteSummaryButton/BulkDeleteSummaryButton.spec.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react' -import BulkDeleteSummaryButton from './BulkDeleteSummaryButton' -import { render, screen } from 'uiSrc/utils/test-utils' - -const readBlobContent = async (blob: Blob): Promise => { - 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 From 69d6fd04f74c8abce97dd790595ac31d14aacd15 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 28 Nov 2025 16:02:36 +0200 Subject: [PATCH 02/20] be: remove keys from summary --- .../bulk-actions/bulk-import.service.spec.ts | 5 ++++ .../models/bulk-action-summary.spec.ts | 18 ++++++++++++ .../bulk-actions/models/bulk-action.spec.ts | 29 +++++-------------- ...abstract.bulk-action.simple.runner.spec.ts | 5 +--- .../abstract.bulk-action.simple.runner.ts | 1 - .../BulkDelete/BulkDelete.spec.tsx | 5 +++- 6 files changed, 36 insertions(+), 27 deletions(-) diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts index 0468e09ef5..77dc6641f6 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts @@ -61,6 +61,7 @@ const mockSummary: BulkActionSummary = Object.assign(new BulkActionSummary(), { succeed: 100, failed: 0, errors: [], + keys: [], }); const mockEmptySummary: BulkActionSummary = Object.assign( @@ -70,6 +71,7 @@ const mockEmptySummary: BulkActionSummary = Object.assign( succeed: 0, failed: 0, errors: [], + keys: [], }, ); @@ -78,6 +80,7 @@ const mockSummaryWithErrors = Object.assign(new BulkActionSummary(), { succeed: 99, failed: 1, errors: [], + keys: [], }); const mockImportResult: IBulkActionOverview = { @@ -255,6 +258,7 @@ describe('BulkImportService', () => { processed: 10_000, succeed: 10_000, failed: 0, + keys: [], }), ); expect( @@ -281,6 +285,7 @@ describe('BulkImportService', () => { processed: 0, succeed: 0, failed: 0, + keys: [], }), ); expect( 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..3cf37fbe0f 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 @@ -139,7 +139,6 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 0, failed: 0, errors: [], - keys: [], }); await new Promise((res) => setTimeout(res, 100)); @@ -167,7 +166,6 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 0, failed: 0, errors: [], - keys: [], }); await new Promise((res) => setTimeout(res, 100)); @@ -195,7 +193,6 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 0, failed: 0, errors: [], - keys: [], }); await new Promise((res) => setTimeout(res, 100)); @@ -238,7 +235,6 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 900_000, failed: 100_000, errors: generateMockBulkActionErrors(500, false), - keys: [], }); }); it('should return overview for cluster', async () => { @@ -259,7 +255,6 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 2_700_000, failed: 300_000, errors: generateMockBulkActionErrors(500, false), - keys: [], }); }); }); @@ -292,7 +287,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 90, failed: 10, errors: [], - keys: ['key1', 'key2'], + keys: [], }), }; mockSummary2 = { @@ -301,7 +296,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 180, failed: 20, errors: [], - keys: ['key3'], + keys: [], }), }; mockSummary3 = { @@ -310,7 +305,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 270, failed: 30, errors: [], - keys: ['key4', 'key5', 'key6'], + keys: [], }), }; @@ -331,21 +326,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); }); }); 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..748176ab0d 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 @@ -251,18 +251,16 @@ describe('AbstractBulkActionSimpleRunner', () => { describe('processIterationResults', () => { let addProcessedSpy; - let addKeysSpy; let addSuccessSpy; let addErrorsSpy; 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'); }); - 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,7 +275,6 @@ 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 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..6a5b58ea6f 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,7 +86,6 @@ 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 = []; diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx index 8a90b9005c..c08ffec401 100644 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx @@ -13,7 +13,10 @@ jest.mock('uiSrc/slices/browser/bulkActions', () => ({ loading: false, }), bulkActionsDeleteSummarySelector: jest.fn().mockReturnValue({ - keys: ['key1', 'key2'], + processed: 100, + succeed: 98, + failed: 2, + errors: [], }), })) From 2a1fefc4913ff36032edb09743255c09d6df7940 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Fri, 28 Nov 2025 18:54:33 +0200 Subject: [PATCH 03/20] this works --- .../bulk-actions/bulk-actions.controller.ts | 38 ++++++ .../bulk-actions/bulk-actions.module.ts | 3 +- .../bulk-actions/bulk-actions.service.ts | 30 ++++- .../dto/create-bulk-action.dto.ts | 6 + .../bulk-action-overview.interface.ts | 1 + .../interfaces/bulk-action.interface.ts | 1 + .../bulk-actions/models/bulk-action.ts | 125 +++++++++++++++++- .../abstract.bulk-action.simple.runner.ts | 6 + .../providers/bulk-actions.provider.ts | 1 + .../bulk-actions-config/BulkActionsConfig.tsx | 31 ++++- .../ui/src/slices/browser/bulkActions.ts | 8 ++ .../ui/src/slices/interfaces/bulkActions.ts | 1 + 12 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts 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..5378042d9e --- /dev/null +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts @@ -0,0 +1,38 @@ +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.ts b/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts index 5e1c5ef212..f154d23544 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,6 @@ import { Socket } from 'socket.io'; -import { Injectable } from '@nestjs/common'; +import { Response } from 'express'; +import { BadRequestException, Injectable } 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 +45,31 @@ 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.isReportEnabled()) { + throw new BadRequestException( + 'Report generation was not enabled for this bulk action', + ); + } + + // Set headers for file download + res.setHeader('Content-Type', 'text/plain'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="bulk-delete-report.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..48c40c31d5 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,5 @@ export interface IBulkActionOverview { filter: IBulkActionFilterOverview; // Note: This can be null, according to the API response progress: IBulkActionProgressOverview; summary: IBulkActionSummaryOverview; + downloadUrl?: 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..99d6d2ff0e 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: string, success: boolean, error?: string): void; } 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..90fe565de5 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,9 +109,106 @@ export class BulkAction implements IBulkAction { this.logger.error('Error on BulkAction Runner', e); this.error = e; this.setStatus(BulkActionStatus.Failed); + } finally { + this.finalizeReport(); + } + } + + /** + * Wait for streaming response to be attached if report generation is enabled + */ + private async waitForStreamIfNeeded(): Promise { + if (!this.generateReport) { + return; + } + + if (this.streamingResponse) { + return; + } + + return new Promise((resolve) => { + this.streamReadyResolver = resolve; + }); + } + + /** + * Check if report generation is enabled + */ + isReportEnabled(): boolean { + return this.generateReport; + } + + /** + * Set the streaming response for report generation + * This is called when the download endpoint is hit + */ + setStreamingResponse(res: Response): void { + this.streamingResponse = res; + + // Write report header + this.writeReportHeader(); + + // Resolve the promise if bulk action is waiting + if (this.streamReadyResolver) { + this.streamReadyResolver(); + this.streamReadyResolver = null; } } + /** + * Write report header + */ + 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); + } + + /** + * Write a key result to the report stream + */ + writeToReport(keyName: string, success: boolean, error?: string): void { + if (!this.streamingResponse) return; + + const line = success + ? `${keyName} - OK\n` + : `${keyName} - Error: ${error || 'Unknown error'}\n`; + + this.streamingResponse.write(line); + } + + /** + * Finalize the report with summary and close the stream + */ + 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(); + } + /** * Get overview for BulkAction with progress details and summary */ @@ -141,7 +250,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 +260,20 @@ export class BulkAction implements IBulkAction { progress, summary, }; + + if (this.generateReport) { + overview.downloadUrl = this.getDownloadUrl(); + } + + return overview; + } + + /** + * Get download URL for the report + * Route: /databases/:dbInstance/bulk-actions/:id/report/download + */ + private getDownloadUrl(): string { + return `databases/${this.databaseId}/bulk-actions/${this.id}/report/download`; } getId() { 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 6a5b58ea6f..0a527bc315 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 @@ -90,10 +90,16 @@ export abstract class AbstractBulkActionSimpleRunner extends AbstractBulkActionR const errors = []; res.forEach(([err], i) => { + const keyName = keys[i].toString(); + if (err) { errors.push({ key: keys[i], error: err.message }); + // Write error to report stream + this.bulkAction.writeToReport(keyName, false, err.message); } else { this.summary.addSuccess(1); + // Write success to report stream + this.bulkAction.writeToReport(keyName, true); } }); 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..42a57aa52c 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Socket } from 'socket.io-client' @@ -28,11 +28,13 @@ import { import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { appCsrfSelector } from 'uiSrc/slices/app/csrf' import { useIoConnection } from 'uiSrc/services/hooks/useIoConnection' +import { IBulkActionOverview } from 'uiSrc/slices/interfaces' +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) @@ -45,6 +47,17 @@ const BulkActionsConfig = () => { const dispatch = useDispatch() + const triggerDownload = useCallback((downloadUrl: string) => { + const link = document.createElement('a') + // Build full URL using API base URL + link.href = `${getBaseUrl()}${downloadUrl}` + link.download = 'bulk-delete-report.txt' + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + }, []) + useEffect(() => { if (!isDeleteTriggered || !instanceId || socketRef.current?.connected) { return @@ -101,8 +114,20 @@ const BulkActionsConfig = () => { type: filter, match: search || '*', }, + generateReport, + }, + (response: IBulkActionOverview | { status: string; error: unknown }) => { + onBulkDeleting(response) + + // Trigger download if report generation is enabled + if ( + generateReport && + 'downloadUrl' in response && + response.downloadUrl + ) { + triggerDownload(response.downloadUrl) + } }, - onBulkDeleting, ) } 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 From 77e9b5b346382bd89d6bb9d2df3328972f67ac0f Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 10:26:09 +0200 Subject: [PATCH 04/20] unit tests --- .../bulk-actions/bulk-actions.service.spec.ts | 57 +++++ .../bulk-actions/bulk-actions.service.ts | 10 +- .../bulk-actions/models/bulk-action.spec.ts | 219 ++++++++++++++++++ ...abstract.bulk-action.simple.runner.spec.ts | 27 +++ 4 files changed, 312 insertions(+), 1 deletion(-) 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..24ac616676 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,61 @@ 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); + + await service.streamReport('bulk-action-id', mockResponse); + + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'text/plain', + ); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="bulk-delete-report.txt"', + ); + 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 f154d23544..8041a66304 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts @@ -1,6 +1,10 @@ import { Socket } from 'socket.io'; import { Response } from 'express'; -import { BadRequestException, Injectable } from '@nestjs/common'; +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'; @@ -54,6 +58,10 @@ export class BulkActionsService { 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', 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 3cf37fbe0f..75141b0cfc 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 @@ -475,4 +475,223 @@ 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', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + bulkActionWithReport.setStreamingResponse(mockResponse); + + expect(bulkActionWithReport['streamingResponse']).toBe(mockResponse); + }); + + it('should write header when response is set', () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + bulkActionWithReport.setStreamingResponse(mockResponse); + + 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:'); + }); + }); + + describe('writeToReport', () => { + it('should not write when streaming response is not set', () => { + bulkAction.writeToReport('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('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('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('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); + }); + }); + }); }); 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 748176ab0d..d97adac6f6 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 @@ -253,11 +253,13 @@ describe('AbstractBulkActionSimpleRunner', () => { let addProcessedSpy; let addSuccessSpy; let addErrorsSpy; + let writeToReportSpy; beforeEach(() => { addProcessedSpy = jest.spyOn(deleteRunner['summary'], 'addProcessed'); addSuccessSpy = jest.spyOn(deleteRunner['summary'], 'addSuccess'); addErrorsSpy = jest.spyOn(deleteRunner['summary'], 'addErrors'); + writeToReportSpy = jest.spyOn(bulkAction, 'writeToReport'); }); it('should correctly process results and update summary counters', () => { @@ -285,5 +287,30 @@ describe('AbstractBulkActionSimpleRunner', () => { }, ]); }); + + 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, 'key1', true); + expect(writeToReportSpy).toHaveBeenNthCalledWith( + 2, + 'key2', + false, + mockRESPError, + ); + expect(writeToReportSpy).toHaveBeenNthCalledWith(3, 'key3', true); + }); }); }); From 4bc0e7a741235039ef3742ea65e6c3825db91eb2 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 10:47:31 +0200 Subject: [PATCH 05/20] ui: add download report toggle --- .../BulkDeleteFooter.spec.tsx | 22 +++++++++++ .../BulkDeleteFooter/BulkDeleteFooter.tsx | 38 +++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) 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..5331645d19 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' @@ -29,7 +30,8 @@ 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 +43,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 +95,37 @@ const BulkDeleteFooter = (props: Props) => { } return ( - + {status && } - + + + + ) => + dispatch(setBulkDeleteGenerateReport(e.target.checked)) + } + label="Download report" + data-testid="download-report-checkbox" + /> + + + + + + {!loading && ( Date: Mon, 1 Dec 2025 10:53:11 +0200 Subject: [PATCH 06/20] ui: stop showing errors --- .../BulkDelete/BulkDeleteFooter/BulkDeleteFooter.tsx | 3 --- 1 file changed, 3 deletions(-) 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 5331645d19..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 @@ -27,7 +27,6 @@ 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, RiTooltip } from 'uiSrc/components' @@ -96,8 +95,6 @@ const BulkDeleteFooter = (props: Props) => { return ( - {status && } - Date: Mon, 1 Dec 2025 10:53:23 +0200 Subject: [PATCH 07/20] be: stop reporting error messages, just count --- ...abstract.bulk-action.simple.runner.spec.ts | 42 ++----------------- .../abstract.bulk-action.simple.runner.ts | 6 +-- 2 files changed, 4 insertions(+), 44 deletions(-) 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 d97adac6f6..9dda6325a1 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, - }, - ]); }); }); @@ -252,13 +221,13 @@ describe('AbstractBulkActionSimpleRunner', () => { describe('processIterationResults', () => { let addProcessedSpy; let addSuccessSpy; - let addErrorsSpy; + let addFailedSpy; let writeToReportSpy; beforeEach(() => { addProcessedSpy = jest.spyOn(deleteRunner['summary'], 'addProcessed'); addSuccessSpy = jest.spyOn(deleteRunner['summary'], 'addSuccess'); - addErrorsSpy = jest.spyOn(deleteRunner['summary'], 'addErrors'); + addFailedSpy = jest.spyOn(deleteRunner['summary'], 'addFailed'); writeToReportSpy = jest.spyOn(bulkAction, 'writeToReport'); }); @@ -280,12 +249,7 @@ describe('AbstractBulkActionSimpleRunner', () => { 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', () => { 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 0a527bc315..86f6447f3e 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 @@ -87,13 +87,11 @@ export abstract class AbstractBulkActionSimpleRunner extends AbstractBulkActionR ) { this.summary.addProcessed(res.length); - const errors = []; - res.forEach(([err], i) => { const keyName = keys[i].toString(); if (err) { - errors.push({ key: keys[i], error: err.message }); + this.summary.addFailed(1); // Write error to report stream this.bulkAction.writeToReport(keyName, false, err.message); } else { @@ -102,7 +100,5 @@ export abstract class AbstractBulkActionSimpleRunner extends AbstractBulkActionR this.bulkAction.writeToReport(keyName, true); } }); - - this.summary.addErrors(errors); } } From e5bb9d6c38476fca20ba540ec6f12d60fee691b6 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 10:57:06 +0200 Subject: [PATCH 08/20] use timestamp in the report name --- .../src/modules/bulk-actions/bulk-actions.service.spec.ts | 6 ++++-- .../api/src/modules/bulk-actions/bulk-actions.service.ts | 3 ++- .../components/bulk-actions-config/BulkActionsConfig.tsx | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) 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 24ac616676..a7cf2b3223 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 @@ -147,8 +147,10 @@ describe('BulkActionsService', () => { it('should set headers and attach stream to bulk action', async () => { bulkActionProvider.get = jest.fn().mockReturnValue(mockBulkActionWithReport); + const mockTimestamp = '1733056800000'; // 2024-12-01T10:00:00.000Z + const expectedFilename = 'bulk-delete-report-2024-12-01T10-00-00-000Z.txt'; - await service.streamReport('bulk-action-id', mockResponse); + await service.streamReport(mockTimestamp, mockResponse); expect(mockResponse.setHeader).toHaveBeenCalledWith( 'Content-Type', @@ -156,7 +158,7 @@ describe('BulkActionsService', () => { ); expect(mockResponse.setHeader).toHaveBeenCalledWith( 'Content-Disposition', - 'attachment; filename="bulk-delete-report.txt"', + `attachment; filename="${expectedFilename}"`, ); expect(mockResponse.setHeader).toHaveBeenCalledWith( 'Transfer-Encoding', 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 8041a66304..9581cca9ee 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.service.ts @@ -69,10 +69,11 @@ export class BulkActionsService { } // 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.txt"', + `attachment; filename="bulk-delete-report-${timestamp}.txt"`, ); res.setHeader('Transfer-Encoding', 'chunked'); diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx index 42a57aa52c..cb0e9ef49f 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -51,7 +51,6 @@ const BulkActionsConfig = () => { const link = document.createElement('a') // Build full URL using API base URL link.href = `${getBaseUrl()}${downloadUrl}` - link.download = 'bulk-delete-report.txt' link.style.display = 'none' document.body.appendChild(link) link.click() From 54a67deffece5fdf7f32f569f1849128a77b2bfd Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 11:21:44 +0200 Subject: [PATCH 09/20] fe: fix tests --- .../BulkDelete/BulkDelete.spec.tsx | 94 ------------------- 1 file changed, 94 deletions(-) delete mode 100644 redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx diff --git a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx b/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx deleted file mode 100644 index c08ffec401..0000000000 --- a/redisinsight/ui/src/pages/browser/components/bulk-actions/BulkDelete/BulkDelete.spec.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react' -import { render, screen } from 'uiSrc/utils/test-utils' -import * as keysSelectors from 'uiSrc/slices/browser/keys' -import BulkDelete, { Props } from './BulkDelete' - -jest.mock('uiSrc/slices/browser/bulkActions', () => ({ - ...jest.requireActual('uiSrc/slices/browser/bulkActions'), - bulkActionsDeleteOverviewSelector: jest.fn().mockReturnValue({ - status: 'completed', - filter: {}, - }), - bulkActionsDeleteSelector: jest.fn().mockReturnValue({ - loading: false, - }), - bulkActionsDeleteSummarySelector: jest.fn().mockReturnValue({ - processed: 100, - succeed: 98, - failed: 2, - errors: [], - }), -})) - -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') - }) -}) From 4431ce276fd56bb3f4cc2f8454fe4c7ca37be06c Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 12:25:31 +0200 Subject: [PATCH 10/20] lint issues --- .../bulk-actions/bulk-actions.controller.ts | 1 - .../bulk-actions/bulk-actions.service.spec.ts | 21 +++++++++++------ .../bulk-actions/models/bulk-action.ts | 23 ------------------- .../abstract.bulk-action.simple.runner.ts | 2 -- 4 files changed, 14 insertions(+), 33 deletions(-) diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts b/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts index 5378042d9e..6affa59904 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-actions.controller.ts @@ -35,4 +35,3 @@ export class BulkActionsController { await this.service.streamReport(id, res); } } - 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 a7cf2b3223..4af0ac2ef5 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 @@ -138,17 +138,24 @@ describe('BulkActionsService', () => { it('should throw BadRequestException when report not enabled', async () => { mockBulkActionWithReport.isReportEnabled.mockReturnValue(false); - bulkActionProvider.get = jest.fn().mockReturnValue(mockBulkActionWithReport); + 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'); + ).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); + bulkActionProvider.get = jest + .fn() + .mockReturnValue(mockBulkActionWithReport); const mockTimestamp = '1733056800000'; // 2024-12-01T10:00:00.000Z - const expectedFilename = 'bulk-delete-report-2024-12-01T10-00-00-000Z.txt'; + const expectedFilename = + 'bulk-delete-report-2024-12-01T10-00-00-000Z.txt'; await service.streamReport(mockTimestamp, mockResponse); @@ -164,9 +171,9 @@ describe('BulkActionsService', () => { 'Transfer-Encoding', 'chunked', ); - expect(mockBulkActionWithReport.setStreamingResponse).toHaveBeenCalledWith( - mockResponse, - ); + expect( + mockBulkActionWithReport.setStreamingResponse, + ).toHaveBeenCalledWith(mockResponse); }); }); }); 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 90fe565de5..9b3d25905e 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts @@ -114,9 +114,6 @@ export class BulkAction implements IBulkAction { } } - /** - * Wait for streaming response to be attached if report generation is enabled - */ private async waitForStreamIfNeeded(): Promise { if (!this.generateReport) { return; @@ -131,9 +128,6 @@ export class BulkAction implements IBulkAction { }); } - /** - * Check if report generation is enabled - */ isReportEnabled(): boolean { return this.generateReport; } @@ -145,7 +139,6 @@ export class BulkAction implements IBulkAction { setStreamingResponse(res: Response): void { this.streamingResponse = res; - // Write report header this.writeReportHeader(); // Resolve the promise if bulk action is waiting @@ -155,9 +148,6 @@ export class BulkAction implements IBulkAction { } } - /** - * Write report header - */ private writeReportHeader(): void { if (!this.streamingResponse) return; @@ -172,9 +162,6 @@ export class BulkAction implements IBulkAction { this.streamingResponse.write(header); } - /** - * Write a key result to the report stream - */ writeToReport(keyName: string, success: boolean, error?: string): void { if (!this.streamingResponse) return; @@ -185,9 +172,6 @@ export class BulkAction implements IBulkAction { this.streamingResponse.write(line); } - /** - * Finalize the report with summary and close the stream - */ private finalizeReport(): void { if (!this.streamingResponse) return; @@ -209,9 +193,6 @@ export class BulkAction implements IBulkAction { this.streamingResponse.end(); } - /** - * Get overview for BulkAction with progress details and summary - */ getOverview(): IBulkActionOverview { const progress = this.runners .map((runner) => runner.getProgress().getOverview()) @@ -268,10 +249,6 @@ export class BulkAction implements IBulkAction { return overview; } - /** - * Get download URL for the report - * Route: /databases/:dbInstance/bulk-actions/:id/report/download - */ private getDownloadUrl(): string { return `databases/${this.databaseId}/bulk-actions/${this.id}/report/download`; } 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 86f6447f3e..79a9d1fe21 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 @@ -92,11 +92,9 @@ export abstract class AbstractBulkActionSimpleRunner extends AbstractBulkActionR if (err) { this.summary.addFailed(1); - // Write error to report stream this.bulkAction.writeToReport(keyName, false, err.message); } else { this.summary.addSuccess(1); - // Write success to report stream this.bulkAction.writeToReport(keyName, true); } }); From a66892f285d0db6f89a04a1226f420318438ddca Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 12:29:13 +0200 Subject: [PATCH 11/20] fix test --- .../api/src/modules/bulk-actions/bulk-actions.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4af0ac2ef5..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 @@ -153,7 +153,7 @@ describe('BulkActionsService', () => { bulkActionProvider.get = jest .fn() .mockReturnValue(mockBulkActionWithReport); - const mockTimestamp = '1733056800000'; // 2024-12-01T10:00:00.000Z + const mockTimestamp = '1733047200000'; // 2024-12-01T10:00:00.000Z const expectedFilename = 'bulk-delete-report-2024-12-01T10-00-00-000Z.txt'; From 6231d39ac20bc8c6679a7850302f998c1dac1651 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 12:59:51 +0200 Subject: [PATCH 12/20] cleanup --- .../api/src/modules/bulk-actions/models/bulk-action.ts | 5 ----- 1 file changed, 5 deletions(-) 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 9b3d25905e..41bf622f0f 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts @@ -132,16 +132,11 @@ export class BulkAction implements IBulkAction { return this.generateReport; } - /** - * Set the streaming response for report generation - * This is called when the download endpoint is hit - */ setStreamingResponse(res: Response): void { this.streamingResponse = res; this.writeReportHeader(); - // Resolve the promise if bulk action is waiting if (this.streamReadyResolver) { this.streamReadyResolver(); this.streamReadyResolver = null; From b9cc9c4b6301b9a0be71213feca8643780c8cbcd Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 13:38:50 +0200 Subject: [PATCH 13/20] fix tests --- .../api/src/modules/bulk-actions/models/bulk-action.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 75141b0cfc..46ec61b5f2 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 @@ -139,6 +139,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 0, failed: 0, errors: [], + keys: [], }); await new Promise((res) => setTimeout(res, 100)); @@ -166,6 +167,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 0, failed: 0, errors: [], + keys: [], }); await new Promise((res) => setTimeout(res, 100)); @@ -193,6 +195,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 0, failed: 0, errors: [], + keys: [], }); await new Promise((res) => setTimeout(res, 100)); @@ -235,6 +238,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 900_000, failed: 100_000, errors: generateMockBulkActionErrors(500, false), + keys: [], }); }); it('should return overview for cluster', async () => { @@ -255,6 +259,7 @@ describe('AbstractBulkActionSimpleRunner', () => { succeed: 2_700_000, failed: 300_000, errors: generateMockBulkActionErrors(500, false), + keys: [], }); }); }); From 5c566c02a81caf3d26cf99c37b24cb462745cf8d Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 13:44:54 +0200 Subject: [PATCH 14/20] cleanup: leftovers --- .../api/src/modules/bulk-actions/bulk-import.service.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts index 77dc6641f6..0468e09ef5 100644 --- a/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/bulk-import.service.spec.ts @@ -61,7 +61,6 @@ const mockSummary: BulkActionSummary = Object.assign(new BulkActionSummary(), { succeed: 100, failed: 0, errors: [], - keys: [], }); const mockEmptySummary: BulkActionSummary = Object.assign( @@ -71,7 +70,6 @@ const mockEmptySummary: BulkActionSummary = Object.assign( succeed: 0, failed: 0, errors: [], - keys: [], }, ); @@ -80,7 +78,6 @@ const mockSummaryWithErrors = Object.assign(new BulkActionSummary(), { succeed: 99, failed: 1, errors: [], - keys: [], }); const mockImportResult: IBulkActionOverview = { @@ -258,7 +255,6 @@ describe('BulkImportService', () => { processed: 10_000, succeed: 10_000, failed: 0, - keys: [], }), ); expect( @@ -285,7 +281,6 @@ describe('BulkImportService', () => { processed: 0, succeed: 0, failed: 0, - keys: [], }), ); expect( From fa2ef44cf75324313638b6203ccc6f157df396f3 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Mon, 1 Dec 2025 14:20:44 +0200 Subject: [PATCH 15/20] handle missing HTTP request to start actual delete process --- .../bulk-action-overview.interface.ts | 1 + .../bulk-actions/models/bulk-action.spec.ts | 70 +++++++++++++++++++ .../bulk-actions/models/bulk-action.ts | 23 +++++- .../BulkActionsInfo/BulkActionsInfo.tsx | 3 + .../BulkActionsStatusDisplay.tsx | 12 ++++ .../bulk-actions/BulkDelete/BulkDelete.tsx | 2 + 6 files changed, 108 insertions(+), 3 deletions(-) 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 48c40c31d5..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 @@ -16,4 +16,5 @@ export interface IBulkActionOverview { progress: IBulkActionProgressOverview; summary: IBulkActionSummaryOverview; downloadUrl?: string; + error?: string; } 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 46ec61b5f2..15348c16d5 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 @@ -448,6 +448,7 @@ describe('AbstractBulkActionSimpleRunner', () => { { ...mockBulkActionOverviewMatcher, status: 'failed', + error: 'some error', }, new Error('some error'), ); @@ -697,6 +698,75 @@ describe('AbstractBulkActionSimpleRunner', () => { 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 resolve immediately when streamingResponse is already set', async () => { + const bulkActionWithReport = new BulkAction( + mockCreateBulkActionDto.id, + mockCreateBulkActionDto.databaseId, + mockCreateBulkActionDto.type, + mockBulkActionFilter, + mockSocket, + mockBulkActionsAnalytics() as any, + true, + ); + + bulkActionWithReport.setStreamingResponse(mockResponse); + + await bulkActionWithReport['waitForStreamIfNeeded'](); + + expect(bulkActionWithReport['streamingResponse']).toBe(mockResponse); + }); + + 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(); + }); }); }); }); 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 41bf622f0f..98952db21e 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts @@ -114,6 +114,8 @@ export class BulkAction implements IBulkAction { } } + private static readonly STREAM_TIMEOUT_MS = 5_000; + private async waitForStreamIfNeeded(): Promise { if (!this.generateReport) { return; @@ -123,8 +125,16 @@ export class BulkAction implements IBulkAction { return; } - return new Promise((resolve) => { - this.streamReadyResolver = resolve; + 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(); + }; }); } @@ -241,6 +251,10 @@ export class BulkAction implements IBulkAction { overview.downloadUrl = this.getDownloadUrl(); } + if (this.error) { + overview.error = this.error.message; + } + return overview; } @@ -273,7 +287,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/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 ( { status, filter: { match, type: filterType }, progress, + error, } = useSelector(bulkActionsDeleteOverviewSelector) ?? { filter: {} } const [showPlaceholder, setShowPlaceholder] = useState( @@ -48,6 +49,7 @@ const BulkDelete = (props: Props) => { filter={isUndefined(filterType) ? filter : filterType} status={status} progress={progress} + error={error} > From 56a3d5f08b4f69542aa8ff89ac11f7058618f3b4 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 2 Dec 2025 10:25:38 +0200 Subject: [PATCH 16/20] use buffers --- .../bulk-actions/interfaces/bulk-action.interface.ts | 2 +- .../modules/bulk-actions/models/bulk-action.spec.ts | 12 ++++++++---- .../src/modules/bulk-actions/models/bulk-action.ts | 7 ++++--- .../abstract.bulk-action.simple.runner.spec.ts | 6 +++--- .../simple/abstract.bulk-action.simple.runner.ts | 2 +- 5 files changed, 17 insertions(+), 12 deletions(-) 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 99d6d2ff0e..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,5 +7,5 @@ export interface IBulkAction { getFilter(): BulkActionFilter; changeState(): void; getSocket(): Socket; - writeToReport(keyName: string, success: boolean, error?: string): void; + writeToReport(keyName: Buffer, success: boolean, error?: string): void; } 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 15348c16d5..d87cdf1cf8 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 @@ -550,7 +550,7 @@ describe('AbstractBulkActionSimpleRunner', () => { describe('writeToReport', () => { it('should not write when streaming response is not set', () => { - bulkAction.writeToReport('testKey', true); + bulkAction.writeToReport(Buffer.from('testKey'), true); // No error thrown, just no-op }); @@ -566,7 +566,7 @@ describe('AbstractBulkActionSimpleRunner', () => { ); bulkActionWithReport['streamingResponse'] = mockResponse; - bulkActionWithReport.writeToReport('testKey', true); + bulkActionWithReport.writeToReport(Buffer.from('testKey'), true); expect(mockResponse.write).toHaveBeenCalledWith('testKey - OK\n'); }); @@ -583,7 +583,11 @@ describe('AbstractBulkActionSimpleRunner', () => { ); bulkActionWithReport['streamingResponse'] = mockResponse; - bulkActionWithReport.writeToReport('testKey', false, 'NOPERM'); + bulkActionWithReport.writeToReport( + Buffer.from('testKey'), + false, + 'NOPERM', + ); expect(mockResponse.write).toHaveBeenCalledWith( 'testKey - Error: NOPERM\n', @@ -602,7 +606,7 @@ describe('AbstractBulkActionSimpleRunner', () => { ); bulkActionWithReport['streamingResponse'] = mockResponse; - bulkActionWithReport.writeToReport('testKey', false); + bulkActionWithReport.writeToReport(Buffer.from('testKey'), false); expect(mockResponse.write).toHaveBeenCalledWith( 'testKey - Error: Unknown error\n', 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 98952db21e..61dba7adcf 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts @@ -167,12 +167,13 @@ export class BulkAction implements IBulkAction { this.streamingResponse.write(header); } - writeToReport(keyName: string, success: boolean, error?: string): void { + writeToReport(keyName: Buffer, success: boolean, error?: string): void { if (!this.streamingResponse) return; + const keyNameStr = keyName.toString(); const line = success - ? `${keyName} - OK\n` - : `${keyName} - Error: ${error || 'Unknown error'}\n`; + ? `${keyNameStr} - OK\n` + : `${keyNameStr} - Error: ${error || 'Unknown error'}\n`; this.streamingResponse.write(line); } 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 9dda6325a1..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 @@ -267,14 +267,14 @@ describe('AbstractBulkActionSimpleRunner', () => { deleteRunner.processIterationResults(keys, results); expect(writeToReportSpy).toHaveBeenCalledTimes(3); - expect(writeToReportSpy).toHaveBeenNthCalledWith(1, 'key1', true); + expect(writeToReportSpy).toHaveBeenNthCalledWith(1, keys[0], true); expect(writeToReportSpy).toHaveBeenNthCalledWith( 2, - 'key2', + keys[1], false, mockRESPError, ); - expect(writeToReportSpy).toHaveBeenNthCalledWith(3, 'key3', true); + 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 79a9d1fe21..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 @@ -88,7 +88,7 @@ export abstract class AbstractBulkActionSimpleRunner extends AbstractBulkActionR this.summary.addProcessed(res.length); res.forEach(([err], i) => { - const keyName = keys[i].toString(); + const keyName = keys[i]; if (err) { this.summary.addFailed(1); From 6ad83e09092689e1954b4e45131a9f01d188b4fd Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 2 Dec 2025 10:31:01 +0200 Subject: [PATCH 17/20] extract downloadFile util --- .../bulk-actions-config/BulkActionsConfig.tsx | 15 +++------------ redisinsight/ui/src/utils/dom/downloadFile.ts | 13 +++++++++++++ redisinsight/ui/src/utils/dom/index.ts | 1 + 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx index cb0e9ef49f..6318f74101 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useEffect, useRef } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Socket } from 'socket.io-client' @@ -14,6 +14,7 @@ import { setLoading, } from 'uiSrc/slices/browser/bulkActions' import { getSocketApiUrl, Nullable } from 'uiSrc/utils' +import { triggerDownloadFromUrl } from 'uiSrc/utils/dom' import { sessionStorageService } from 'uiSrc/services' import { keysSelector } from 'uiSrc/slices/browser/keys' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' @@ -47,16 +48,6 @@ const BulkActionsConfig = () => { const dispatch = useDispatch() - const triggerDownload = useCallback((downloadUrl: string) => { - const link = document.createElement('a') - // Build full URL using API base URL - link.href = `${getBaseUrl()}${downloadUrl}` - link.style.display = 'none' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - }, []) - useEffect(() => { if (!isDeleteTriggered || !instanceId || socketRef.current?.connected) { return @@ -124,7 +115,7 @@ const BulkActionsConfig = () => { 'downloadUrl' in response && response.downloadUrl ) { - triggerDownload(response.downloadUrl) + triggerDownloadFromUrl(`${getBaseUrl()}${response.downloadUrl}`) } }, ) diff --git a/redisinsight/ui/src/utils/dom/downloadFile.ts b/redisinsight/ui/src/utils/dom/downloadFile.ts index cef13f3f6d..dae6228b8c 100644 --- a/redisinsight/ui/src/utils/dom/downloadFile.ts +++ b/redisinsight/ui/src/utils/dom/downloadFile.ts @@ -13,3 +13,16 @@ export const downloadFile = ( saveAs(file, fileName) } + +/** + * 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) +} diff --git a/redisinsight/ui/src/utils/dom/index.ts b/redisinsight/ui/src/utils/dom/index.ts index f8375ffa10..9aaf270add 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 './downloadFile' export { removePagePlaceholder } from './pagePlaceholder' From b68adec7650f5aa3f1638464c3040431c154edb1 Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 2 Dec 2025 10:35:14 +0200 Subject: [PATCH 18/20] address PR comment --- .../bulk-actions-config/BulkActionsConfig.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx index 6318f74101..7a0e86d3ab 100644 --- a/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx +++ b/redisinsight/ui/src/components/bulk-actions-config/BulkActionsConfig.tsx @@ -13,8 +13,7 @@ import { setDeleteOverviewStatus, setLoading, } from 'uiSrc/slices/browser/bulkActions' -import { getSocketApiUrl, Nullable } from 'uiSrc/utils' -import { triggerDownloadFromUrl } from 'uiSrc/utils/dom' +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' @@ -29,7 +28,6 @@ import { import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { appCsrfSelector } from 'uiSrc/slices/app/csrf' import { useIoConnection } from 'uiSrc/services/hooks/useIoConnection' -import { IBulkActionOverview } from 'uiSrc/slices/interfaces' import { getBaseUrl } from 'uiSrc/services/apiService' const BulkActionsConfig = () => { @@ -106,18 +104,7 @@ const BulkActionsConfig = () => { }, generateReport, }, - (response: IBulkActionOverview | { status: string; error: unknown }) => { - onBulkDeleting(response) - - // Trigger download if report generation is enabled - if ( - generateReport && - 'downloadUrl' in response && - response.downloadUrl - ) { - triggerDownloadFromUrl(`${getBaseUrl()}${response.downloadUrl}`) - } - }, + onBulkDeleting, ) } @@ -152,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)) From c1c74d764fc6f6c08380ea555db84437fdc8010d Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 2 Dec 2025 11:34:13 +0200 Subject: [PATCH 19/20] move download util to a separate file --- redisinsight/ui/src/utils/dom/downloadFile.ts | 13 ------------- redisinsight/ui/src/utils/dom/index.ts | 2 +- .../ui/src/utils/dom/triggerDownloadFromUrl.ts | 12 ++++++++++++ 3 files changed, 13 insertions(+), 14 deletions(-) create mode 100644 redisinsight/ui/src/utils/dom/triggerDownloadFromUrl.ts diff --git a/redisinsight/ui/src/utils/dom/downloadFile.ts b/redisinsight/ui/src/utils/dom/downloadFile.ts index dae6228b8c..cef13f3f6d 100644 --- a/redisinsight/ui/src/utils/dom/downloadFile.ts +++ b/redisinsight/ui/src/utils/dom/downloadFile.ts @@ -13,16 +13,3 @@ export const downloadFile = ( saveAs(file, fileName) } - -/** - * 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) -} diff --git a/redisinsight/ui/src/utils/dom/index.ts b/redisinsight/ui/src/utils/dom/index.ts index 9aaf270add..dbaa37d833 100644 --- a/redisinsight/ui/src/utils/dom/index.ts +++ b/redisinsight/ui/src/utils/dom/index.ts @@ -3,7 +3,7 @@ import setTitle from './setPageTitle' export * from './scrollIntoView' export * from './handlePlatforms' export * from './handleBrowsers' -export * from './downloadFile' +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) +} From 9ce17acf10cd4ed4399186499c1aa825cdec52cb Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Tue, 2 Dec 2025 12:34:50 +0200 Subject: [PATCH 20/20] handle late or failed stream --- .../bulk-actions/models/bulk-action.spec.ts | 77 ++++++++++++++++--- .../bulk-actions/models/bulk-action.ts | 13 +++- 2 files changed, 76 insertions(+), 14 deletions(-) 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 d87cdf1cf8..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 @@ -512,7 +512,7 @@ describe('AbstractBulkActionSimpleRunner', () => { }); describe('setStreamingResponse', () => { - it('should set streaming response and resolve promise', () => { + it('should set streaming response and resolve promise when waiting', async () => { const bulkActionWithReport = new BulkAction( mockCreateBulkActionDto.id, mockCreateBulkActionDto.databaseId, @@ -523,12 +523,18 @@ describe('AbstractBulkActionSimpleRunner', () => { 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', () => { + it('should write header when response is set', async () => { const bulkActionWithReport = new BulkAction( mockCreateBulkActionDto.id, mockCreateBulkActionDto.databaseId, @@ -539,13 +545,41 @@ describe('AbstractBulkActionSimpleRunner', () => { 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', () => { @@ -727,7 +761,9 @@ describe('AbstractBulkActionSimpleRunner', () => { jest.useRealTimers(); }); - it('should resolve immediately when streamingResponse is already set', async () => { + it('should clear timeout when stream is set before timeout', async () => { + jest.useFakeTimers(); + const bulkActionWithReport = new BulkAction( mockCreateBulkActionDto.id, mockCreateBulkActionDto.databaseId, @@ -738,14 +774,21 @@ describe('AbstractBulkActionSimpleRunner', () => { true, ); + const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); + + jest.advanceTimersByTime(100); bulkActionWithReport.setStreamingResponse(mockResponse); - await bulkActionWithReport['waitForStreamIfNeeded'](); + await waitPromise; + + jest.advanceTimersByTime(BulkAction['STREAM_TIMEOUT_MS']); expect(bulkActionWithReport['streamingResponse']).toBe(mockResponse); + + jest.useRealTimers(); }); - it('should clear timeout when stream is set before timeout', async () => { + it('should immediately end response when stream arrives after timeout', async () => { jest.useFakeTimers(); const bulkActionWithReport = new BulkAction( @@ -760,14 +803,28 @@ describe('AbstractBulkActionSimpleRunner', () => { const waitPromise = bulkActionWithReport['waitForStreamIfNeeded'](); - jest.advanceTimersByTime(100); - bulkActionWithReport.setStreamingResponse(mockResponse); + // Let the timeout expire + jest.advanceTimersByTime(BulkAction['STREAM_TIMEOUT_MS'] + 100); - await waitPromise; + await expect(waitPromise).rejects.toThrow( + 'Unable to start report download. Please try again.', + ); - jest.advanceTimersByTime(BulkAction['STREAM_TIMEOUT_MS']); + // Now the late stream arrives + const lateResponse = { + write: jest.fn(), + end: jest.fn(), + }; + bulkActionWithReport.setStreamingResponse(lateResponse as any); - expect(bulkActionWithReport['streamingResponse']).toBe(mockResponse); + // 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 61dba7adcf..4a1a1a9219 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.ts @@ -143,14 +143,19 @@ export class BulkAction implements IBulkAction { } 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(); - if (this.streamReadyResolver) { - this.streamReadyResolver(); - this.streamReadyResolver = null; - } + this.streamReadyResolver(); + this.streamReadyResolver = null; } private writeReportHeader(): void {