From 9026e8f04fa051e7cca2c10d0cb77982026abb50 Mon Sep 17 00:00:00 2001 From: Nagy Szabolcs Date: Tue, 19 Dec 2023 22:59:43 +0200 Subject: [PATCH] feat(north-cache): Add actions to file tables --- backend/src/engine/oibus-engine.spec.ts | 18 +++ backend/src/engine/oibus-engine.ts | 12 ++ backend/src/north/north-connector.spec.ts | 21 +++ backend/src/north/north-connector.ts | 22 ++++ .../src/service/cache/archive.service.spec.ts | 24 ++++ backend/src/service/cache/archive.service.ts | 14 ++ .../service/cache/file-cache.service.spec.ts | 42 ++++++ .../src/service/cache/file-cache.service.ts | 28 ++++ .../tests/__mocks__/archive-service.mock.ts | 1 + .../__mocks__/file-cache-service.mock.ts | 2 + .../tests/__mocks__/reload-service.mock.ts | 3 + .../north-connector.controller.spec.ts | 120 ++++++++++++++++++ .../controllers/north-connector.controller.ts | 42 ++++++ backend/src/web-server/routes/index.ts | 7 + .../archive-files.component.html | 8 +- .../archive-files.component.spec.ts | 25 +++- .../archive-files/archive-files.component.ts | 52 +++++++- .../cache-files/cache-files.component.html | 8 +- .../cache-files/cache-files.component.spec.ts | 25 +++- .../cache-files/cache-files.component.ts | 52 +++++++- .../error-files/error-files.component.html | 8 +- .../error-files/error-files.component.spec.ts | 25 +++- .../error-files/error-files.component.ts | 52 +++++++- .../file-content-modal.component.html | 11 ++ .../file-content-modal.component.scss | 0 .../file-content-modal.component.spec.ts | 49 +++++++ .../file-content-modal.component.ts | 52 ++++++++ .../file-table/file-table.component.html | 16 +++ .../file-table/file-table.component.spec.ts | 25 ++++ .../file-table/file-table.component.ts | 25 +++- .../services/north-connector.service.spec.ts | 27 ++++ .../app/services/north-connector.service.ts | 23 +++- frontend/src/i18n/en.json | 8 +- 33 files changed, 816 insertions(+), 31 deletions(-) create mode 100644 frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.html create mode 100644 frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.scss create mode 100644 frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.spec.ts create mode 100644 frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.ts diff --git a/backend/src/engine/oibus-engine.spec.ts b/backend/src/engine/oibus-engine.spec.ts index f1a72c4c3b..0e300e0dcf 100644 --- a/backend/src/engine/oibus-engine.spec.ts +++ b/backend/src/engine/oibus-engine.spec.ts @@ -146,14 +146,17 @@ const createdNorth = { setLogger: jest.fn(), updateScanMode: jest.fn(), getErrorFiles: jest.fn(), + getErrorFileContent: jest.fn(), removeErrorFiles: jest.fn(), retryErrorFiles: jest.fn(), removeAllErrorFiles: jest.fn(), retryAllErrorFiles: jest.fn(), getCacheFiles: jest.fn(), + getCacheFileContent: jest.fn(), removeCacheFiles: jest.fn(), archiveCacheFiles: jest.fn(), getArchiveFiles: jest.fn(), + getArchiveFileContent: jest.fn(), removeArchiveFiles: jest.fn(), retryArchiveFiles: jest.fn(), removeAllArchiveFiles: jest.fn(), @@ -289,6 +292,11 @@ describe('OIBusEngine', () => { await engine.getErrorFiles(northConnectors[1].id, '2020-02-02T02:02:02.222Z', '2022-02-02T02:02:02.222Z', ''); expect(createdNorth.getErrorFiles).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', '2022-02-02T02:02:02.222Z', ''); + await engine.getErrorFileContent('northId', 'file1'); + expect(createdNorth.getErrorFileContent).not.toHaveBeenCalled(); + await engine.getErrorFileContent(northConnectors[1].id, 'file1'); + expect(createdNorth.getErrorFileContent).toHaveBeenCalledWith('file1'); + await engine.retryErrorFiles('northId', ['file1']); expect(createdNorth.retryErrorFiles).not.toHaveBeenCalled(); await engine.retryErrorFiles(northConnectors[1].id, ['file1']); @@ -314,6 +322,11 @@ describe('OIBusEngine', () => { await engine.getCacheFiles(northConnectors[1].id, '2020-02-02T02:02:02.222Z', '2022-02-02T02:02:02.222Z', ''); expect(createdNorth.getCacheFiles).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', '2022-02-02T02:02:02.222Z', ''); + await engine.getCacheFileContent('northId', 'file1'); + expect(createdNorth.getCacheFileContent).not.toHaveBeenCalled(); + await engine.getCacheFileContent(northConnectors[1].id, 'file1'); + expect(createdNorth.getCacheFileContent).toHaveBeenCalledWith('file1'); + await engine.removeCacheFiles('northId', ['file1']); expect(createdNorth.removeCacheFiles).not.toHaveBeenCalled(); await engine.removeCacheFiles(northConnectors[1].id, ['file1']); @@ -329,6 +342,11 @@ describe('OIBusEngine', () => { await engine.getArchiveFiles(northConnectors[1].id, '2020-02-02T02:02:02.222Z', '2022-02-02T02:02:02.222Z', ''); expect(createdNorth.getArchiveFiles).toHaveBeenCalledWith('2020-02-02T02:02:02.222Z', '2022-02-02T02:02:02.222Z', ''); + await engine.getArchiveFileContent('northId', 'file1'); + expect(createdNorth.getArchiveFileContent).not.toHaveBeenCalled(); + await engine.getArchiveFileContent(northConnectors[1].id, 'file1'); + expect(createdNorth.getArchiveFileContent).toHaveBeenCalledWith('file1'); + await engine.retryArchiveFiles('northId', ['file1']); expect(createdNorth.retryArchiveFiles).not.toHaveBeenCalled(); await engine.retryArchiveFiles(northConnectors[1].id, ['file1']); diff --git a/backend/src/engine/oibus-engine.ts b/backend/src/engine/oibus-engine.ts index 80a3712099..889a1dd449 100644 --- a/backend/src/engine/oibus-engine.ts +++ b/backend/src/engine/oibus-engine.ts @@ -265,6 +265,10 @@ export default class OIBusEngine extends BaseEngine { return (await this.northConnectors.get(northId)?.getErrorFiles(start, end, fileNameContains)) || []; } + async getErrorFileContent(northId: string, filename: string) { + return (await this.northConnectors.get(northId)?.getErrorFileContent(filename)) || null; + } + async removeErrorFiles(northId: string, filenames: Array): Promise { await this.northConnectors.get(northId)?.removeErrorFiles(filenames); } @@ -285,6 +289,10 @@ export default class OIBusEngine extends BaseEngine { return (await this.northConnectors.get(northId)?.getCacheFiles(start, end, fileNameContains)) || []; } + async getCacheFileContent(northId: string, filename: string) { + return (await this.northConnectors.get(northId)?.getCacheFileContent(filename)) || null; + } + async removeCacheFiles(northId: string, filenames: Array): Promise { await this.northConnectors.get(northId)?.removeCacheFiles(filenames); } @@ -297,6 +305,10 @@ export default class OIBusEngine extends BaseEngine { return (await this.northConnectors.get(northId)?.getArchiveFiles(start, end, fileNameContains)) || []; } + async getArchiveFileContent(northId: string, filename: string) { + return (await this.northConnectors.get(northId)?.getArchiveFileContent(filename)) || null; + } + async removeArchiveFiles(northId: string, filenames: Array): Promise { await this.northConnectors.get(northId)?.removeArchiveFiles(filenames); } diff --git a/backend/src/north/north-connector.spec.ts b/backend/src/north/north-connector.spec.ts index 35d4dca3b1..0638b43478 100644 --- a/backend/src/north/north-connector.spec.ts +++ b/backend/src/north/north-connector.spec.ts @@ -40,6 +40,9 @@ const retryAllErrorValues = jest.fn(); const valueTrigger = new EventEmitter(); const fileTrigger = new EventEmitter(); const archiveTrigger = new EventEmitter(); +const getErrorFileContent = jest.fn(); +const getCacheFileContent = jest.fn(); +const getArchiveFileContent = jest.fn(); // Mock services jest.mock('../service/repository.service'); @@ -75,6 +78,8 @@ jest.mock( fileCacheServiceMock.getFileToSend = getFileToSend; fileCacheServiceMock.isEmpty = fileCacheIsEmpty; fileCacheServiceMock.triggerRun = fileTrigger; + fileCacheServiceMock.getErrorFileContent = getErrorFileContent; + fileCacheServiceMock.getCacheFileContent = getCacheFileContent; return fileCacheServiceMock; } ); @@ -84,6 +89,7 @@ jest.mock( function () { const archiveServiceMock = new ArchiveServiceMock(); archiveServiceMock.triggerRun = archiveTrigger; + archiveServiceMock.getArchiveFileContent = getArchiveFileContent; return archiveServiceMock; } ); @@ -567,6 +573,21 @@ describe('NorthConnector enabled', () => { expect(retryAllErrorValues).toHaveBeenCalled(); expect(logger.trace).toHaveBeenCalledWith(`Retrying all value error files in North connector "${configuration.name}"...`); }); + + it('should get error file content', async () => { + await north.getErrorFileContent('file1.queue.tmp'); + expect(getErrorFileContent).toHaveBeenCalledWith('file1.queue.tmp'); + }); + + it('should get cache file content', async () => { + await north.getCacheFileContent('file1.queue.tmp'); + expect(getCacheFileContent).toHaveBeenCalledWith('file1.queue.tmp'); + }); + + it('should get archive file content', async () => { + await north.getArchiveFileContent('file1.queue.tmp'); + expect(getArchiveFileContent).toHaveBeenCalledWith('file1.queue.tmp'); + }); }); describe('NorthConnector disabled', () => { diff --git a/backend/src/north/north-connector.ts b/backend/src/north/north-connector.ts index 7de99972f8..607ded0a66 100644 --- a/backend/src/north/north-connector.ts +++ b/backend/src/north/north-connector.ts @@ -14,6 +14,7 @@ import { OIBusDataValue, OIBusError } from '../../../shared/model/engine.model'; import { ExternalSubscriptionDTO, SubscriptionDTO } from '../../../shared/model/subscription.model'; import { DateTime } from 'luxon'; import { PassThrough } from 'node:stream'; +import { ReadStream } from 'node:fs'; import path from 'node:path'; import { HandlesFile, HandlesValues } from './north-interface'; import NorthConnectorMetricsService from '../service/north-connector-metrics.service'; @@ -431,6 +432,13 @@ export default class NorthConnector { return await this.fileCacheService.getErrorFiles(fromDate, toDate, fileNameContains); } + /** + * Get error file content as a read stream. + */ + async getErrorFileContent(filename: string): Promise { + return await this.fileCacheService.getErrorFileContent(filename); + } + /** * Remove error files from file cache. */ @@ -470,6 +478,13 @@ export default class NorthConnector { return await this.fileCacheService.getCacheFiles(fromDate, toDate, fileNameContains); } + /** + * Get cache file content as a read stream. + */ + async getCacheFileContent(filename: string): Promise { + return await this.fileCacheService.getCacheFileContent(filename); + } + /** * Remove cache files. */ @@ -496,6 +511,13 @@ export default class NorthConnector { return await this.archiveService.getArchiveFiles(fromDate, toDate, fileNameContains); } + /** + * Get archive file content as a read stream. + */ + async getArchiveFileContent(filename: string): Promise { + return await this.archiveService.getArchiveFileContent(filename); + } + /** * Remove archive files from file cache. */ diff --git a/backend/src/service/cache/archive.service.spec.ts b/backend/src/service/cache/archive.service.spec.ts index f8d0522a8e..5bfd9226f8 100644 --- a/backend/src/service/cache/archive.service.spec.ts +++ b/backend/src/service/cache/archive.service.spec.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; import ArchiveService from './archive.service'; @@ -12,6 +13,7 @@ import { NorthArchiveSettings } from '../../../../shared/model/north-connector.m jest.mock('../../service/utils'); jest.mock('node:fs/promises'); +jest.mock('node:fs'); const logger: pino.Logger = new PinoLogger(); const anotherLogger: pino.Logger = new PinoLogger(); @@ -247,6 +249,28 @@ describe('ArchiveService', () => { removeFilesSpy.mockRestore(); }); + + it('should properly get archived file content', async () => { + const filename = 'myFile.csv'; + (createReadStream as jest.Mock).mockImplementation(() => {}); + await archiveService.getArchiveFileContent(filename); + + expect(createReadStream).toHaveBeenCalledWith(path.resolve('myCacheFolder', 'archive', filename)); + }); + + it('should handle error while getting archived file content', async () => { + const filename = 'myFile.csv'; + const error = new Error('file does not exist'); + (fs.stat as jest.Mock).mockImplementation(() => { + throw error; + }); + const readStream = await archiveService.getArchiveFileContent(filename); + + expect(readStream).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + `Error while reading file "${path.resolve('myCacheFolder', 'archive', filename)}": ${error}` + ); + }); }); describe('with disabled service', () => { diff --git a/backend/src/service/cache/archive.service.ts b/backend/src/service/cache/archive.service.ts index 0eaca99e59..d55240aa48 100644 --- a/backend/src/service/cache/archive.service.ts +++ b/backend/src/service/cache/archive.service.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import { createReadStream, ReadStream } from 'node:fs'; import path from 'node:path'; import { createFolder } from '../utils'; @@ -162,6 +163,19 @@ export default class ArchiveService { return filteredFilenames; } + /** + * Get archive file content. + */ + async getArchiveFileContent(filename: string): Promise { + try { + await fs.stat(path.join(this.archiveFolder, filename)); + } catch (error) { + this._logger.error(`Error while reading file "${path.join(this.archiveFolder, filename)}": ${error}`); + return null; + } + return createReadStream(path.join(this.archiveFolder, filename)); + } + /** * Remove archive files. */ diff --git a/backend/src/service/cache/file-cache.service.spec.ts b/backend/src/service/cache/file-cache.service.spec.ts index 49b60d03af..56c7e0f5b8 100644 --- a/backend/src/service/cache/file-cache.service.spec.ts +++ b/backend/src/service/cache/file-cache.service.spec.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; import FileCache from './file-cache.service'; import { createFolder, getFilesFiltered } from '../utils'; @@ -8,6 +9,7 @@ import PinoLogger from '../../tests/__mocks__/logger.mock'; import { NorthCacheSettingsDTO } from '../../../../shared/model/north-connector.model'; jest.mock('node:fs/promises'); +jest.mock('node:fs'); jest.mock('../../service/utils'); const logger: pino.Logger = new PinoLogger(); @@ -337,4 +339,44 @@ describe('FileCache', () => { cache.settings = otherSettings; await cache.cacheFile('myFile.csv'); }); + + it('should properly get error file content', async () => { + const filename = 'myFile.csv'; + (createReadStream as jest.Mock).mockImplementation(() => {}); + await cache.getErrorFileContent(filename); + + expect(createReadStream).toHaveBeenCalledWith(path.resolve('myCacheFolder', 'files-errors', filename)); + }); + + it('should handle error when getting error file content', async () => { + const filename = 'myFile.csv'; + const error = new Error('file does not exist'); + (fs.stat as jest.Mock).mockImplementation(() => { + throw error; + }); + await cache.getErrorFileContent(filename); + + expect(logger.error).toHaveBeenCalledWith( + `Error while reading file "${path.resolve('myCacheFolder', 'files-errors', filename)}": ${error}` + ); + }); + + it('should properly get cache file content', async () => { + const filename = 'myFile.csv'; + (createReadStream as jest.Mock).mockImplementation(() => {}); + await cache.getCacheFileContent(filename); + + expect(createReadStream).toHaveBeenCalledWith(path.resolve('myCacheFolder', 'files', filename)); + }); + + it('should handle error when getting cache file content', async () => { + const filename = 'myFile.csv'; + const error = new Error('file does not exist'); + (fs.stat as jest.Mock).mockImplementation(() => { + throw error; + }); + await cache.getCacheFileContent(filename); + + expect(logger.error).toHaveBeenCalledWith(`Error while reading file "${path.resolve('myCacheFolder', 'files', filename)}": ${error}`); + }); }); diff --git a/backend/src/service/cache/file-cache.service.ts b/backend/src/service/cache/file-cache.service.ts index 467c1d25a1..55056c0bed 100644 --- a/backend/src/service/cache/file-cache.service.ts +++ b/backend/src/service/cache/file-cache.service.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import { createReadStream, ReadStream } from 'node:fs'; import path from 'node:path'; import { createFolder, getFilesFiltered } from '../utils'; @@ -178,6 +179,19 @@ export default class FileCacheService { return getFilesFiltered(this._errorFolder, fromDate, toDate, nameFilter, this._logger); } + /** + * Get error file content. + */ + async getErrorFileContent(filename: string): Promise { + try { + await fs.stat(path.join(this._errorFolder, filename)); + } catch (error) { + this._logger.error(`Error while reading file "${path.join(this._errorFolder, filename)}": ${error}`); + return null; + } + return createReadStream(path.join(this._errorFolder, filename)); + } + /** * Remove files from folder. */ @@ -241,6 +255,20 @@ export default class FileCacheService { return getFilesFiltered(this._fileFolder, fromDate, toDate, nameFilter, this._logger); } + /** + * Get cache file content. + */ + async getCacheFileContent(filename: string): Promise { + try { + await fs.stat(path.join(this._fileFolder, filename)); + } catch (error) { + this._logger.error(`Error while reading file "${path.join(this._fileFolder, filename)}": ${error}`); + return null; + } + + return createReadStream(path.join(this._fileFolder, filename)); + } + /** * Retry files from folder. */ diff --git a/backend/src/tests/__mocks__/archive-service.mock.ts b/backend/src/tests/__mocks__/archive-service.mock.ts index c5e7f6b295..caf340f160 100644 --- a/backend/src/tests/__mocks__/archive-service.mock.ts +++ b/backend/src/tests/__mocks__/archive-service.mock.ts @@ -21,4 +21,5 @@ export default class ArchiveServiceMock { on: jest.fn(), emit: jest.fn() } as unknown as EventEmitter; + getArchiveFileContent = jest.fn(); } diff --git a/backend/src/tests/__mocks__/file-cache-service.mock.ts b/backend/src/tests/__mocks__/file-cache-service.mock.ts index b4a28ce900..d007121a78 100644 --- a/backend/src/tests/__mocks__/file-cache-service.mock.ts +++ b/backend/src/tests/__mocks__/file-cache-service.mock.ts @@ -33,4 +33,6 @@ export default class FileCacheServiceMock { { filename: 'file5.name', modificationDate: '', size: 2 }, { filename: 'file6.name', modificationDate: '', size: 3 } ]); + getErrorFileContent = jest.fn(); + getCacheFileContent = jest.fn(); } diff --git a/backend/src/tests/__mocks__/reload-service.mock.ts b/backend/src/tests/__mocks__/reload-service.mock.ts index 98ff25a147..c5c4908842 100644 --- a/backend/src/tests/__mocks__/reload-service.mock.ts +++ b/backend/src/tests/__mocks__/reload-service.mock.ts @@ -41,17 +41,20 @@ export default jest.fn().mockImplementation(() => ({ resetSouthMetrics: jest.fn(), resetNorthMetrics: jest.fn(), getErrorFiles: jest.fn(), + getErrorFileContent: jest.fn(), removeErrorFiles: jest.fn(), retryErrorFiles: jest.fn(), removeAllErrorFiles: jest.fn(), retryAllErrorFiles: jest.fn(), getArchiveFiles: jest.fn(), + getArchiveFileContent: jest.fn(), removeArchiveFiles: jest.fn(), retryArchiveFiles: jest.fn(), removeAllArchiveFiles: jest.fn(), retryAllArchiveFiles: jest.fn(), testSouth: jest.fn(), getCacheFiles: jest.fn(), + getCacheFileContent: jest.fn(), removeCacheFiles: jest.fn(), archiveCacheFiles: jest.fn(), getCacheValues: jest.fn(), diff --git a/backend/src/web-server/controllers/north-connector.controller.spec.ts b/backend/src/web-server/controllers/north-connector.controller.spec.ts index 492af550cb..5d273daf4c 100644 --- a/backend/src/web-server/controllers/north-connector.controller.spec.ts +++ b/backend/src/web-server/controllers/north-connector.controller.spec.ts @@ -490,6 +490,46 @@ describe('North connector controller', () => { expect(ctx.notFound).toHaveBeenCalledTimes(1); }); + it('getFileErrorContent() should return North connector error file content', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(northConnector); + ctx.app.reloadService.oibusEngine.getErrorFileContent.mockReturnValue('content'); + ctx.attachment = jest.fn(); + + await northConnectorController.getFileErrorContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getErrorFileContent).toHaveBeenCalledWith(northConnector.id, 'file'); + expect(ctx.ok).toHaveBeenCalledWith('content'); + }); + + it('getFileErrorContent() should not return North connector error file content if not found', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(null); + + await northConnectorController.getFileErrorContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getErrorFileContent).not.toHaveBeenCalled(); + expect(ctx.notFound).toHaveBeenCalledTimes(1); + }); + + it('getFullFileErrorContent() should not return North connector error file content if file not found', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(northConnector); + ctx.app.reloadService.oibusEngine.getErrorFileContent.mockReturnValue(null); + ctx.attachment = jest.fn(); + + await northConnectorController.getFileErrorContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getErrorFileContent).toHaveBeenCalledWith(northConnector.id, 'file'); + expect(ctx.notFound).toHaveBeenCalledTimes(1); + }); + it('removeFileErrors() should remove error file', async () => { ctx.params.northId = 'id'; ctx.request.body = ['file1', 'file2']; @@ -644,6 +684,46 @@ describe('North connector controller', () => { expect(ctx.notFound).toHaveBeenCalledTimes(1); }); + it('getCacheFileContent() should return North connector cache file content', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(northConnector); + ctx.app.reloadService.oibusEngine.getCacheFileContent.mockReturnValue('content'); + ctx.attachment = jest.fn(); + + await northConnectorController.getCacheFileContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getCacheFileContent).toHaveBeenCalledWith(northConnector.id, 'file'); + expect(ctx.ok).toHaveBeenCalledWith('content'); + }); + + it('getCacheFileContent() should not return North connector cache file content if not found', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(null); + + await northConnectorController.getCacheFileContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getCacheFileContent).not.toHaveBeenCalled(); + expect(ctx.notFound).toHaveBeenCalledTimes(1); + }); + + it('getFullCacheFileContent() should not return North connector cache file content if file not found', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(northConnector); + ctx.app.reloadService.oibusEngine.getCacheFileContent.mockReturnValue(null); + ctx.attachment = jest.fn(); + + await northConnectorController.getCacheFileContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getCacheFileContent).toHaveBeenCalledWith(northConnector.id, 'file'); + expect(ctx.notFound).toHaveBeenCalledTimes(1); + }); + it('removeCacheFiles() should remove cache file', async () => { ctx.params.northId = 'id'; ctx.request.body = ['file1', 'file2']; @@ -754,6 +834,46 @@ describe('North connector controller', () => { expect(ctx.notFound).toHaveBeenCalledTimes(1); }); + it('getArchiveFileContent() should return North connector archive file content', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(northConnector); + ctx.app.reloadService.oibusEngine.getArchiveFileContent.mockReturnValue('content'); + ctx.attachment = jest.fn(); + + await northConnectorController.getArchiveFileContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getArchiveFileContent).toHaveBeenCalledWith(northConnector.id, 'file'); + expect(ctx.ok).toHaveBeenCalledWith('content'); + }); + + it('getArchiveFileContent() should not return North connector archive file content if not found', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(null); + + await northConnectorController.getArchiveFileContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getArchiveFileContent).not.toHaveBeenCalled(); + expect(ctx.notFound).toHaveBeenCalledTimes(1); + }); + + it('getFullArchiveFileContent() should not return North connector archive file content if file not found', async () => { + ctx.params.northId = 'id'; + ctx.params.filename = 'file'; + ctx.app.repositoryService.northConnectorRepository.getNorthConnector.mockReturnValue(northConnector); + ctx.app.reloadService.oibusEngine.getArchiveFileContent.mockReturnValue(null); + ctx.attachment = jest.fn(); + + await northConnectorController.getArchiveFileContent(ctx); + + expect(ctx.app.repositoryService.northConnectorRepository.getNorthConnector).toHaveBeenCalledWith('id'); + expect(ctx.app.reloadService.oibusEngine.getArchiveFileContent).toHaveBeenCalledWith(northConnector.id, 'file'); + expect(ctx.notFound).toHaveBeenCalledTimes(1); + }); + it('removeArchiveFiles() should remove archive file', async () => { ctx.params.northId = 'id'; ctx.request.body = ['file1', 'file2']; diff --git a/backend/src/web-server/controllers/north-connector.controller.ts b/backend/src/web-server/controllers/north-connector.controller.ts index ba86c7ce90..4fe5ea74fc 100644 --- a/backend/src/web-server/controllers/north-connector.controller.ts +++ b/backend/src/web-server/controllers/north-connector.controller.ts @@ -227,6 +227,20 @@ export default class NorthConnectorController { ctx.ok(errorFiles); } + async getFileErrorContent(ctx: KoaContext): Promise { + const northConnector = ctx.app.repositoryService.northConnectorRepository.getNorthConnector(ctx.params.northId); + if (!northConnector) { + return ctx.notFound(); + } + + ctx.attachment(ctx.params.filename); + const fileStream = await ctx.app.reloadService.oibusEngine.getErrorFileContent(northConnector.id, ctx.params.filename); + if (!fileStream) { + return ctx.notFound(); + } + ctx.ok(fileStream); + } + async removeFileErrors(ctx: KoaContext, void>): Promise { const northConnector = ctx.app.repositoryService.northConnectorRepository.getNorthConnector(ctx.params.northId); if (!northConnector) { @@ -286,6 +300,20 @@ export default class NorthConnectorController { ctx.ok(errorFiles); } + async getCacheFileContent(ctx: KoaContext): Promise { + const northConnector = ctx.app.repositoryService.northConnectorRepository.getNorthConnector(ctx.params.northId); + if (!northConnector) { + return ctx.notFound(); + } + + ctx.attachment(ctx.params.filename); + const fileStream = await ctx.app.reloadService.oibusEngine.getCacheFileContent(northConnector.id, ctx.params.filename); + if (!fileStream) { + return ctx.notFound(); + } + ctx.ok(fileStream); + } + async removeCacheFiles(ctx: KoaContext, void>): Promise { const northConnector = ctx.app.repositoryService.northConnectorRepository.getNorthConnector(ctx.params.northId); if (!northConnector) { @@ -325,6 +353,20 @@ export default class NorthConnectorController { ctx.ok(errorFiles); } + async getArchiveFileContent(ctx: KoaContext): Promise { + const northConnector = ctx.app.repositoryService.northConnectorRepository.getNorthConnector(ctx.params.northId); + if (!northConnector) { + return ctx.notFound(); + } + + ctx.attachment(ctx.params.filename); + const fileStream = await ctx.app.reloadService.oibusEngine.getArchiveFileContent(northConnector.id, ctx.params.filename); + if (!fileStream) { + return ctx.notFound(); + } + ctx.ok(fileStream); + } + async removeArchiveFiles(ctx: KoaContext, void>): Promise { const northConnector = ctx.app.repositoryService.northConnectorRepository.getNorthConnector(ctx.params.northId); if (!northConnector) { diff --git a/backend/src/web-server/routes/index.ts b/backend/src/web-server/routes/index.ts index 7487c0673e..7f3d94c48e 100644 --- a/backend/src/web-server/routes/index.ts +++ b/backend/src/web-server/routes/index.ts @@ -117,6 +117,9 @@ router.delete('/api/north/:northId/external-subscriptions/:externalSourceId', (c subscriptionController.deleteExternalNorthSubscription(ctx) ); router.get('/api/north/:northId/cache/file-errors', (ctx: KoaContext) => northConnectorController.getFileErrors(ctx)); +router.get('/api/north/:northId/cache/file-errors/:filename', (ctx: KoaContext) => + northConnectorController.getFileErrorContent(ctx) +); router.post('/api/north/:northId/cache/file-errors/remove', (ctx: KoaContext) => northConnectorController.removeFileErrors(ctx)); router.post('/api/north/:northId/cache/file-errors/retry', (ctx: KoaContext) => northConnectorController.retryErrorFiles(ctx)); router.delete('/api/north/:northId/cache/file-errors/remove-all', (ctx: KoaContext) => @@ -127,10 +130,14 @@ router.delete('/api/north/:northId/cache/file-errors/retry-all', (ctx: KoaContex ); router.get('/api/north/:northId/cache/files', (ctx: KoaContext) => northConnectorController.getCacheFiles(ctx)); +router.get('/api/north/:northId/cache/files/:filename', (ctx: KoaContext) => northConnectorController.getCacheFileContent(ctx)); router.post('/api/north/:northId/cache/files/remove', (ctx: KoaContext) => northConnectorController.removeCacheFiles(ctx)); router.post('/api/north/:northId/cache/files/archive', (ctx: KoaContext) => northConnectorController.archiveCacheFiles(ctx)); router.get('/api/north/:northId/cache/archive-files', (ctx: KoaContext) => northConnectorController.getArchiveFiles(ctx)); +router.get('/api/north/:northId/cache/archive-files/:filename', (ctx: KoaContext) => + northConnectorController.getArchiveFileContent(ctx) +); router.post('/api/north/:northId/cache/archive-files/remove', (ctx: KoaContext) => northConnectorController.removeArchiveFiles(ctx) ); diff --git a/frontend/src/app/north/explore-cache/archive-files/archive-files.component.html b/frontend/src/app/north/explore-cache/archive-files/archive-files.component.html index dd17bbf2db..020b1daf7f 100644 --- a/frontend/src/app/north/explore-cache/archive-files/archive-files.component.html +++ b/frontend/src/app/north/explore-cache/archive-files/archive-files.component.html @@ -27,7 +27,13 @@ - + diff --git a/frontend/src/app/north/explore-cache/archive-files/archive-files.component.spec.ts b/frontend/src/app/north/explore-cache/archive-files/archive-files.component.spec.ts index 5e6d2fe549..8165e51b21 100644 --- a/frontend/src/app/north/explore-cache/archive-files/archive-files.component.spec.ts +++ b/frontend/src/app/north/explore-cache/archive-files/archive-files.component.spec.ts @@ -4,15 +4,16 @@ import { TestBed } from '@angular/core/testing'; import { NorthConnectorService } from '../../../services/north-connector.service'; import { of } from 'rxjs'; import { NorthConnectorDTO } from '../../../../../../shared/model/north-connector.model'; -import { Component } from '@angular/core'; +import { Component, ViewChild } from '@angular/core'; import { provideI18nTesting } from '../../../../i18n/mock-i18n'; @Component({ - template: ``, + template: ``, standalone: true, imports: [ArchiveFilesComponent] }) class TestComponent { + @ViewChild('component') component!: ArchiveFilesComponent; northConnector: NorthConnectorDTO = { id: 'northId', name: 'North Connector' @@ -42,10 +43,30 @@ describe('ArchiveFilesComponent', () => { tester = new ArchiveFilesComponentTester(); northConnectorService.getCacheArchiveFiles.and.returnValue(of([])); + northConnectorService.removeCacheArchiveFiles.and.returnValue(of()); + northConnectorService.retryCacheArchiveFiles.and.returnValue(of()); + northConnectorService.getCacheArchiveFileContent.and.returnValue(of()); tester.detectChanges(); }); it('should have no archive files', () => { expect(tester.fileArchives.length).toBe(0); }); + + it('should handle item actions', () => { + const file = { + filename: 'filename', + modificationDate: '2021-01-01T00:00:00.000Z', + size: 123 + }; + + tester.componentInstance.component.onItemAction({ type: 'remove', file }); + expect(northConnectorService.removeCacheArchiveFiles).toHaveBeenCalled(); + + tester.componentInstance.component.onItemAction({ type: 'retry', file }); + expect(northConnectorService.retryCacheArchiveFiles).toHaveBeenCalled(); + + tester.componentInstance.component.onItemAction({ type: 'view', file }); + expect(northConnectorService.getCacheArchiveFileContent).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/north/explore-cache/archive-files/archive-files.component.ts b/frontend/src/app/north/explore-cache/archive-files/archive-files.component.ts index e8e415ee2e..ca0d0021d4 100644 --- a/frontend/src/app/north/explore-cache/archive-files/archive-files.component.ts +++ b/frontend/src/app/north/explore-cache/archive-files/archive-files.component.ts @@ -11,7 +11,9 @@ import { PaginationComponent } from '../../../shared/pagination/pagination.compo import { FileSizePipe } from '../../../shared/file-size.pipe'; import { BoxComponent, BoxTitleDirective } from '../../../shared/box/box.component'; import { emptyPage } from '../../../shared/test-utils'; -import { FileTableComponent, FileTableData } from '../file-table/file-table.component'; +import { FileTableComponent, FileTableData, ItemActionEvent } from '../file-table/file-table.component'; +import { ModalService } from '../../../shared/modal.service'; +import { FileContentModalComponent } from '../file-content-modal/file-content-modal.component'; @Component({ selector: 'oib-archive-files', @@ -39,7 +41,10 @@ export class ArchiveFilesComponent implements OnInit { @ViewChild('fileTable') fileTable!: FileTableComponent; fileTablePages = emptyPage(); - constructor(private northConnectorService: NorthConnectorService) {} + constructor( + private northConnectorService: NorthConnectorService, + private modalService: ModalService + ) {} ngOnInit() { this.northConnectorService.getCacheArchiveFiles(this.northConnector!.id).subscribe(archiveFiles => { @@ -48,15 +53,21 @@ export class ArchiveFilesComponent implements OnInit { }); } - retryArchiveFiles() { - const files = this.archiveFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + /** + * Retry archive files. + * By default, retry all checked files. + */ + retryArchiveFiles(files: Array = this.getCheckedFiles()) { this.northConnectorService.retryCacheArchiveFiles(this.northConnector!.id, files).subscribe(() => { this.refreshArchiveFiles(); }); } - removeArchiveFiles() { - const files = this.archiveFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + /** + * Remove archive files. + * By default, remove all checked files. + */ + removeArchiveFiles(files: Array = this.getCheckedFiles()) { this.northConnectorService.removeCacheArchiveFiles(this.northConnector!.id, files).subscribe(() => { this.refreshArchiveFiles(); }); @@ -71,4 +82,33 @@ export class ArchiveFilesComponent implements OnInit { } }); } + + onItemAction(event: ItemActionEvent) { + switch (event.type) { + case 'remove': + this.removeArchiveFiles([event.file.filename]); + break; + case 'retry': + this.retryArchiveFiles([event.file.filename]); + break; + case 'view': + this.northConnectorService.getCacheArchiveFileContent(this.northConnector!.id, event.file.filename).subscribe(async response => { + if (!response.body) return; + const content = await response.body.text(); + // Split header into content type and encoding + const contentType = response.headers.get('content-type')?.split(';')[0] ?? ''; + // Get file type from content type. Additionally, remove 'x-' from the type. + const fileType = contentType.split('/')[1].replace(/x-/g, ''); + + const modalRef = this.modalService.open(FileContentModalComponent, { size: 'xl' }); + const component: FileContentModalComponent = modalRef.componentInstance; + component.prepareForCreation(event.file.filename, fileType, content); + }); + break; + } + } + + private getCheckedFiles(): Array { + return this.archiveFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + } } diff --git a/frontend/src/app/north/explore-cache/cache-files/cache-files.component.html b/frontend/src/app/north/explore-cache/cache-files/cache-files.component.html index cd03d05013..1b2062dc19 100644 --- a/frontend/src/app/north/explore-cache/cache-files/cache-files.component.html +++ b/frontend/src/app/north/explore-cache/cache-files/cache-files.component.html @@ -27,7 +27,13 @@ - + diff --git a/frontend/src/app/north/explore-cache/cache-files/cache-files.component.spec.ts b/frontend/src/app/north/explore-cache/cache-files/cache-files.component.spec.ts index 35fcf17f99..07f077867c 100644 --- a/frontend/src/app/north/explore-cache/cache-files/cache-files.component.spec.ts +++ b/frontend/src/app/north/explore-cache/cache-files/cache-files.component.spec.ts @@ -4,15 +4,16 @@ import { TestBed } from '@angular/core/testing'; import { NorthConnectorService } from '../../../services/north-connector.service'; import { of } from 'rxjs'; import { NorthConnectorDTO } from '../../../../../../shared/model/north-connector.model'; -import { Component } from '@angular/core'; +import { Component, ViewChild } from '@angular/core'; import { provideI18nTesting } from '../../../../i18n/mock-i18n'; @Component({ - template: ``, + template: ``, standalone: true, imports: [CacheFilesComponent] }) class TestComponent { + @ViewChild('component') component!: CacheFilesComponent; northConnector: NorthConnectorDTO = { id: 'northId', name: 'North Connector' @@ -42,10 +43,30 @@ describe('CacheFilesComponent', () => { tester = new CacheFilesComponentTester(); northConnectorService.getCacheFiles.and.returnValue(of([])); + northConnectorService.removeCacheFiles.and.returnValue(of()); + northConnectorService.archiveCacheFiles.and.returnValue(of()); + northConnectorService.getCacheFileContent.and.returnValue(of()); tester.detectChanges(); }); it('should have no archive files', () => { expect(tester.fileCache.length).toBe(0); }); + + it('should handle item actions', () => { + const file = { + filename: 'filename', + modificationDate: '2021-01-01T00:00:00.000Z', + size: 123 + }; + + tester.componentInstance.component.onItemAction({ type: 'remove', file }); + expect(northConnectorService.removeCacheFiles).toHaveBeenCalled(); + + tester.componentInstance.component.onItemAction({ type: 'archive', file }); + expect(northConnectorService.archiveCacheFiles).toHaveBeenCalled(); + + tester.componentInstance.component.onItemAction({ type: 'view', file }); + expect(northConnectorService.getCacheFileContent).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/north/explore-cache/cache-files/cache-files.component.ts b/frontend/src/app/north/explore-cache/cache-files/cache-files.component.ts index b67d421291..fe8dfc5904 100644 --- a/frontend/src/app/north/explore-cache/cache-files/cache-files.component.ts +++ b/frontend/src/app/north/explore-cache/cache-files/cache-files.component.ts @@ -11,7 +11,9 @@ import { PaginationComponent } from '../../../shared/pagination/pagination.compo import { FileSizePipe } from '../../../shared/file-size.pipe'; import { BoxComponent, BoxTitleDirective } from '../../../shared/box/box.component'; import { emptyPage } from '../../../shared/test-utils'; -import { FileTableComponent, FileTableData } from '../file-table/file-table.component'; +import { FileTableComponent, FileTableData, ItemActionEvent } from '../file-table/file-table.component'; +import { FileContentModalComponent } from '../file-content-modal/file-content-modal.component'; +import { ModalService } from '../../../shared/modal.service'; @Component({ selector: 'oib-cache-files', @@ -39,7 +41,10 @@ export class CacheFilesComponent implements OnInit { @ViewChild('fileTable') fileTable!: FileTableComponent; fileTablePages = emptyPage(); - constructor(private northConnectorService: NorthConnectorService) {} + constructor( + private northConnectorService: NorthConnectorService, + private modalService: ModalService + ) {} ngOnInit() { this.northConnectorService.getCacheFiles(this.northConnector!.id).subscribe(cacheFiles => { @@ -48,15 +53,21 @@ export class CacheFilesComponent implements OnInit { }); } - archiveCacheFiles() { - const files = this.cacheFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + /** + * Archive cache files. + * By default, retry all checked files. + */ + archiveCacheFiles(files: Array = this.getCheckedFiles()) { this.northConnectorService.archiveCacheFiles(this.northConnector!.id, files).subscribe(() => { this.refreshCacheFiles(); }); } - removeCacheFiles() { - const files = this.cacheFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + /** + * Remove cache files. + * By default, remove all checked files. + */ + removeCacheFiles(files: Array = this.getCheckedFiles()) { this.northConnectorService.removeCacheFiles(this.northConnector!.id, files).subscribe(() => { this.refreshCacheFiles(); }); @@ -71,4 +82,33 @@ export class CacheFilesComponent implements OnInit { } }); } + + onItemAction(event: ItemActionEvent) { + switch (event.type) { + case 'remove': + this.removeCacheFiles([event.file.filename]); + break; + case 'archive': + this.archiveCacheFiles([event.file.filename]); + break; + case 'view': + this.northConnectorService.getCacheFileContent(this.northConnector!.id, event.file.filename).subscribe(async response => { + if (!response.body) return; + const content = await response.body.text(); + // Split header into content type and encoding + const contentType = response.headers.get('content-type')?.split(';')[0] ?? ''; + // Get file type from content type. Additionally, remove 'x-' from the type. + const fileType = contentType.split('/')[1].replace(/x-/g, ''); + + const modalRef = this.modalService.open(FileContentModalComponent, { size: 'xl' }); + const component: FileContentModalComponent = modalRef.componentInstance; + component.prepareForCreation(event.file.filename, fileType, content); + }); + break; + } + } + + private getCheckedFiles(): Array { + return this.cacheFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + } } diff --git a/frontend/src/app/north/explore-cache/error-files/error-files.component.html b/frontend/src/app/north/explore-cache/error-files/error-files.component.html index 990da835cd..bf3a7adedd 100644 --- a/frontend/src/app/north/explore-cache/error-files/error-files.component.html +++ b/frontend/src/app/north/explore-cache/error-files/error-files.component.html @@ -27,7 +27,13 @@ - + diff --git a/frontend/src/app/north/explore-cache/error-files/error-files.component.spec.ts b/frontend/src/app/north/explore-cache/error-files/error-files.component.spec.ts index 4e39e2d566..133f756aa0 100644 --- a/frontend/src/app/north/explore-cache/error-files/error-files.component.spec.ts +++ b/frontend/src/app/north/explore-cache/error-files/error-files.component.spec.ts @@ -4,15 +4,16 @@ import { TestBed } from '@angular/core/testing'; import { NorthConnectorService } from '../../../services/north-connector.service'; import { of } from 'rxjs'; import { NorthConnectorDTO } from '../../../../../../shared/model/north-connector.model'; -import { Component } from '@angular/core'; +import { Component, ViewChild } from '@angular/core'; import { provideI18nTesting } from '../../../../i18n/mock-i18n'; @Component({ - template: ``, + template: ``, standalone: true, imports: [ErrorFilesComponent] }) class TestComponent { + @ViewChild('component') component!: ErrorFilesComponent; northConnector: NorthConnectorDTO = { id: 'northId', name: 'North Connector' @@ -42,10 +43,30 @@ describe('ErrorFilesComponent', () => { tester = new ErrorFilesComponentTester(); northConnectorService.getCacheErrorFiles.and.returnValue(of([])); + northConnectorService.removeCacheErrorFiles.and.returnValue(of()); + northConnectorService.retryCacheErrorFiles.and.returnValue(of()); + northConnectorService.getCacheErrorFileContent.and.returnValue(of()); tester.detectChanges(); }); it('should have no error files', () => { expect(tester.fileErrors.length).toBe(0); }); + + it('should handle item actions', () => { + const file = { + filename: 'filename', + modificationDate: '2021-01-01T00:00:00.000Z', + size: 123 + }; + + tester.componentInstance.component.onItemAction({ type: 'remove', file }); + expect(northConnectorService.removeCacheErrorFiles).toHaveBeenCalled(); + + tester.componentInstance.component.onItemAction({ type: 'retry', file }); + expect(northConnectorService.retryCacheErrorFiles).toHaveBeenCalled(); + + tester.componentInstance.component.onItemAction({ type: 'view', file }); + expect(northConnectorService.getCacheErrorFileContent).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/north/explore-cache/error-files/error-files.component.ts b/frontend/src/app/north/explore-cache/error-files/error-files.component.ts index d2bb12995f..024ee7e445 100644 --- a/frontend/src/app/north/explore-cache/error-files/error-files.component.ts +++ b/frontend/src/app/north/explore-cache/error-files/error-files.component.ts @@ -10,8 +10,10 @@ import { DatetimePipe } from '../../../shared/datetime.pipe'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { FileSizePipe } from '../../../shared/file-size.pipe'; import { BoxComponent, BoxTitleDirective } from '../../../shared/box/box.component'; -import { FileTableComponent, FileTableData } from '../file-table/file-table.component'; +import { FileTableComponent, FileTableData, ItemActionEvent } from '../file-table/file-table.component'; import { emptyPage } from '../../../shared/test-utils'; +import { ModalService } from '../../../shared/modal.service'; +import { FileContentModalComponent } from '../file-content-modal/file-content-modal.component'; @Component({ selector: 'oib-error-files', @@ -39,7 +41,10 @@ export class ErrorFilesComponent implements OnInit { @ViewChild('fileTable') fileTable!: FileTableComponent; fileTablePages = emptyPage(); - constructor(private northConnectorService: NorthConnectorService) {} + constructor( + private northConnectorService: NorthConnectorService, + private modalService: ModalService + ) {} ngOnInit() { this.northConnectorService.getCacheErrorFiles(this.northConnector!.id).subscribe(errorFiles => { @@ -48,15 +53,21 @@ export class ErrorFilesComponent implements OnInit { }); } - retryErrorFiles() { - const files = this.errorFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + /** + * Retry error files. + * By default, retry all checked files. + */ + retryErrorFiles(files: Array = this.getCheckedFiles()) { this.northConnectorService.retryCacheErrorFiles(this.northConnector!.id, files).subscribe(() => { this.refreshErrorFiles(); }); } - removeErrorFiles() { - const files = this.errorFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + /** + * Remove error files from cache. + * By default, remove all checked files. + */ + removeErrorFiles(files: Array = this.getCheckedFiles()) { this.northConnectorService.removeCacheErrorFiles(this.northConnector!.id, files).subscribe(() => { this.refreshErrorFiles(); }); @@ -71,4 +82,33 @@ export class ErrorFilesComponent implements OnInit { } }); } + + onItemAction(event: ItemActionEvent) { + switch (event.type) { + case 'remove': + this.removeErrorFiles([event.file.filename]); + break; + case 'retry': + this.retryErrorFiles([event.file.filename]); + break; + case 'view': + this.northConnectorService.getCacheErrorFileContent(this.northConnector!.id, event.file.filename).subscribe(async response => { + if (!response.body) return; + const content = await response.body.text(); + // Split header into content type and encoding + const contentType = response.headers.get('content-type')?.split(';')[0] ?? ''; + // Get file type from content type. Additionally, remove 'x-' from the type. + const fileType = contentType.split('/')[1].replace(/x-/g, ''); + + const modalRef = this.modalService.open(FileContentModalComponent, { size: 'xl' }); + const component: FileContentModalComponent = modalRef.componentInstance; + component.prepareForCreation(event.file.filename, fileType, content); + }); + break; + } + } + + private getCheckedFiles(): Array { + return this.errorFiles.filter(file => this.fileTable.checkboxByFiles.get(file.filename)).map(file => file.filename); + } } diff --git a/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.html b/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.html new file mode 100644 index 0000000000..7548daad1a --- /dev/null +++ b/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.html @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.scss b/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.spec.ts b/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.spec.ts new file mode 100644 index 0000000000..8a11e66074 --- /dev/null +++ b/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { FileContentModalComponent } from './file-content-modal.component'; +import { provideI18nTesting } from '../../../../i18n/mock-i18n'; +import { createMock } from 'ngx-speculoos'; + +describe('FileContentModalComponent', () => { + let component: FileContentModalComponent; + let fixture: ComponentFixture; + const mockModal = createMock(NgbActiveModal); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule, CommonModule], + providers: [provideI18nTesting(), { provide: NgbActiveModal, useValue: mockModal }] + }).compileComponents(); + + fixture = TestBed.createComponent(FileContentModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call prepareForCreation method with correct parameters', () => { + const filename = 'test.json'; + const contentType = 'json'; + const content = '{ "foo": "bar" }'; + component.prepareForCreation(filename, contentType, content); + + expect(component.filename).toEqual(filename); + expect(component.contentType).toEqual(contentType); + expect(component.content).toEqual(content); + }); + + it('should load OibCodeBlockComponent', () => { + const codeBlock = fixture.debugElement.nativeElement.querySelector('oib-code-block'); + expect(codeBlock).toBeTruthy(); + }); + + it('should call dismiss method', () => { + component.dismiss(); + expect(mockModal.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.ts b/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.ts new file mode 100644 index 0000000000..1e9f46994f --- /dev/null +++ b/frontend/src/app/north/explore-cache/file-content-modal/file-content-modal.component.ts @@ -0,0 +1,52 @@ +import { AfterViewInit, Component, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { OibCodeBlockComponent } from '../../../shared/form/oib-code-block/oib-code-block.component'; + +@Component({ + selector: 'oib-file-content-modal', + standalone: true, + imports: [TranslateModule, CommonModule, OibCodeBlockComponent], + templateUrl: './file-content-modal.component.html', + styleUrl: './file-content-modal.component.scss' +}) +export class FileContentModalComponent implements AfterViewInit { + constructor(private modal: NgbActiveModal) {} + @ViewChild('codeBlock') codeBlock!: OibCodeBlockComponent; + content: string = ''; + filename: string = ''; + contentType: string = ''; + private callbackSet = false; + + ngAfterViewInit() { + this.codeBlock.writeValue(this.content); + this.codeBlock.contentType = this.contentType; + + // Attach a listener to the code editor to resize the modal when the content changes + this.codeBlock.onChange = () => { + if (this.callbackSet) { + return; + } + + this.codeBlock.codeEditorInstance!.onDidContentSizeChange(() => { + const contentHeight = Math.min(window.innerHeight * 0.75, this.codeBlock.codeEditorInstance!.getContentHeight()); + const containerWidth = this.codeBlock._editorContainer!.nativeElement.clientWidth; + + this.codeBlock._editorContainer!.nativeElement.style.height = `${contentHeight}px`; + this.codeBlock.codeEditorInstance!.layout({ width: containerWidth, height: contentHeight }); + this.callbackSet = true; + }); + }; + } + + prepareForCreation(filename: string, contentType: string, content: string) { + this.filename = filename; + this.contentType = contentType; + this.content = content; + } + + dismiss() { + this.modal.dismiss(); + } +} diff --git a/frontend/src/app/north/explore-cache/file-table/file-table.component.html b/frontend/src/app/north/explore-cache/file-table/file-table.component.html index 79c880115b..4e30bbb1d1 100644 --- a/frontend/src/app/north/explore-cache/file-table/file-table.component.html +++ b/frontend/src/app/north/explore-cache/file-table/file-table.component.html @@ -47,6 +47,22 @@ {{ file.modificationDate | datetime:'mediumWithSeconds'}} {{ file.filename }} {{ file.size | fileSize }} + +
+ +
+ diff --git a/frontend/src/app/north/explore-cache/file-table/file-table.component.spec.ts b/frontend/src/app/north/explore-cache/file-table/file-table.component.spec.ts index 51696ed43b..8994d00eeb 100644 --- a/frontend/src/app/north/explore-cache/file-table/file-table.component.spec.ts +++ b/frontend/src/app/north/explore-cache/file-table/file-table.component.spec.ts @@ -26,6 +26,7 @@ describe('FileTableComponent', () => { fixture = TestBed.createComponent(FileTableComponent); component = fixture.componentInstance; component.files = [...testFiles]; + component.actions = ['remove', 'retry', 'view', 'archive']; fixture.detectChanges(); }); @@ -34,6 +35,30 @@ describe('FileTableComponent', () => { expect(component).toBeTruthy(); }); + it('should create actions on init', () => { + const actionGroups = fixture.debugElement.nativeElement.querySelectorAll('.action-buttons'); + expect(actionGroups.length).toBe(8); + + const actionButtons = actionGroups[0].querySelectorAll('button'); + expect(actionButtons[0].querySelector('span').classList).toContain(component.actionButtonData.remove.icon); + expect(actionButtons[1].querySelector('span').classList).toContain(component.actionButtonData.retry.icon); + expect(actionButtons[2].querySelector('span').classList).toContain(component.actionButtonData.view.icon); + expect(actionButtons[3].querySelector('span').classList).toContain(component.actionButtonData.archive.icon); + }); + + it('should dispatch action event', () => { + spyOn(component.itemAction, 'emit'); + component.onItemActionClick('remove', component.files[0]); + expect(component.itemAction.emit).toHaveBeenCalledWith({ type: 'remove', file: component.files[0] }); + }); + + it('should handle passsing duplicate actions', () => { + const temp = TestBed.createComponent(FileTableComponent); + temp.componentInstance.actions = ['remove', 'remove', 'remove', 'retry', 'view', 'archive']; + temp.detectChanges(); + expect(temp.componentInstance.actions.length).toBe(4); + }); + it('should be sorted by modification date by deafault', () => { const dates = component.files.map(file => new Date(file.modificationDate)); const isDescendingSorted = dates.every((val, i, arr) => !i || val <= arr[i - 1]); diff --git a/frontend/src/app/north/explore-cache/file-table/file-table.component.ts b/frontend/src/app/north/explore-cache/file-table/file-table.component.ts index 8be7ba2849..27bd9793d4 100644 --- a/frontend/src/app/north/explore-cache/file-table/file-table.component.ts +++ b/frontend/src/app/north/explore-cache/file-table/file-table.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { SaveButtonComponent } from '../../../shared/save-button/save-button.component'; import { TranslateModule } from '@ngx-translate/core'; import { formDirectives } from '../../../shared/form-directives'; @@ -11,6 +11,7 @@ import { FileSizePipe } from '../../../shared/file-size.pipe'; import { BoxComponent, BoxTitleDirective } from '../../../shared/box/box.component'; import { createPageFromArray, Instant, Page } from '../../../../../../shared/model/types'; import { emptyPage } from '../../../shared/test-utils'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; const PAGE_SIZE = 15; const enum ColumnSortState { @@ -25,6 +26,11 @@ export type FileTableData = { size: number; }; +export type ItemActionEvent = { + type: 'remove' | 'retry' | 'view' | 'archive'; + file: FileTableData; +}; + @Component({ selector: 'oib-file-table', templateUrl: './file-table.component.html', @@ -40,11 +46,21 @@ export type FileTableData = { FileSizePipe, RouterLink, BoxComponent, - BoxTitleDirective + BoxTitleDirective, + NgbTooltipModule ], standalone: true }) export class FileTableComponent implements OnInit { + @Output() itemAction = new EventEmitter(); + + @Input() actions: Array = []; + actionButtonData: { [key in ItemActionEvent['type']]: { icon: string; text: string } } = { + remove: { icon: 'fa-trash', text: 'north.cache-settings.remove-file' }, + retry: { icon: 'fa-refresh', text: 'north.cache-settings.retry-file' }, + view: { icon: 'fa-search', text: 'north.cache-settings.view-file' }, + archive: { icon: 'fa-archive', text: 'north.cache-settings.archive-file' } + }; @Input() files: Array = []; pages: Page = emptyPage(); checkboxByFiles: Map = new Map(); @@ -60,6 +76,7 @@ export class FileTableComponent implements OnInit { mainFilesCheckboxState: 'CHECKED' | 'UNCHECKED' | 'INDETERMINATE' = 'UNCHECKED'; ngOnInit() { + this.actions = [...new Set(this.actions)]; // remove possible duplicates this.clearCheckBoxes(); this.sortTable(); } @@ -152,4 +169,8 @@ export class FileTableComponent implements OnInit { }); this.mainFilesCheckboxState = 'UNCHECKED'; } + + onItemActionClick(action: ItemActionEvent['type'], file: FileTableData) { + this.itemAction.emit({ type: action, file }); + } } diff --git a/frontend/src/app/services/north-connector.service.spec.ts b/frontend/src/app/services/north-connector.service.spec.ts index f34a15487a..f2c38fb020 100644 --- a/frontend/src/app/services/north-connector.service.spec.ts +++ b/frontend/src/app/services/north-connector.service.spec.ts @@ -183,6 +183,15 @@ describe('NorthConnectorService', () => { expect(expectedNorthCacheFiles!).toEqual(northCacheFiles); }); + it('should get error cache file content', () => { + let httpResponse; + const northCacheFileContent = new Blob(['test'], { type: 'text/plain' }); + service.getCacheErrorFileContent('id1', 'file1').subscribe(c => (httpResponse = c)); + + http.expectOne({ url: '/api/north/id1/cache/file-errors/file1', method: 'GET' }).flush(northCacheFileContent); + expect(httpResponse!.body).toEqual(northCacheFileContent); + }); + it('should remove listed error cache files', () => { let done = false; service.removeCacheErrorFiles('id1', ['file1', 'file2']).subscribe(() => (done = true)); @@ -225,6 +234,15 @@ describe('NorthConnectorService', () => { expect(expectedNorthCacheFiles!).toEqual(northCacheFiles); }); + it('should get cache file content', () => { + let httpResponse; + const northCacheFileContent = new Blob(['test'], { type: 'text/plain' }); + service.getCacheFileContent('id1', 'file1').subscribe(c => (httpResponse = c)); + + http.expectOne({ url: '/api/north/id1/cache/files/file1', method: 'GET' }).flush(northCacheFileContent); + expect(httpResponse!.body).toEqual(northCacheFileContent); + }); + it('should remove listed cache files', () => { let done = false; service.removeCacheFiles('id1', ['file1', 'file2']).subscribe(() => (done = true)); @@ -251,6 +269,15 @@ describe('NorthConnectorService', () => { expect(expectedNorthArchiveFiles!).toEqual(northArchiveFiles); }); + it('should get archive file content', () => { + let httpResponse; + const northCacheFileContent = new Blob(['test'], { type: 'text/plain' }); + service.getCacheArchiveFileContent('id1', 'file1').subscribe(c => (httpResponse = c)); + + http.expectOne({ url: '/api/north/id1/cache/archive-files/file1', method: 'GET' }).flush(northCacheFileContent); + expect(httpResponse!.body).toEqual(northCacheFileContent); + }); + it('should remove listed archive files', () => { let done = false; service.removeCacheArchiveFiles('id1', ['file1', 'file2']).subscribe(() => (done = true)); diff --git a/frontend/src/app/services/north-connector.service.ts b/frontend/src/app/services/north-connector.service.ts index a3d2a58792..86f1800c40 100644 --- a/frontend/src/app/services/north-connector.service.ts +++ b/frontend/src/app/services/north-connector.service.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Injectable } from '@angular/core'; import { @@ -136,6 +136,13 @@ export class NorthConnectorService { return this.http.get>(`/api/north/${northId}/cache/file-errors`); } + getCacheErrorFileContent(northId: string, filename: string): Observable> { + return this.http.get(`/api/north/${northId}/cache/file-errors/${filename}`, { + responseType: 'blob' as 'json', + observe: 'response' + }); + } + retryCacheErrorFiles(northId: string, filenames: Array): Observable { return this.http.post(`/api/north/${northId}/cache/file-errors/retry`, filenames); } @@ -156,6 +163,13 @@ export class NorthConnectorService { return this.http.get>(`/api/north/${northId}/cache/files`); } + getCacheFileContent(northId: string, filename: string): Observable> { + return this.http.get(`/api/north/${northId}/cache/files/${filename}`, { + responseType: 'blob' as 'json', + observe: 'response' + }); + } + removeCacheFiles(northId: string, filenames: Array): Observable { return this.http.post(`/api/north/${northId}/cache/files/remove`, filenames); } @@ -168,6 +182,13 @@ export class NorthConnectorService { return this.http.get>(`/api/north/${northId}/cache/archive-files`); } + getCacheArchiveFileContent(northId: string, filename: string): Observable> { + return this.http.get(`/api/north/${northId}/cache/archive-files/${filename}`, { + responseType: 'blob' as 'json', + observe: 'response' + }); + } + retryCacheArchiveFiles(northId: string, filenames: Array): Observable { return this.http.post(`/api/north/${northId}/cache/archive-files/retry`, filenames); } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a456c56ca8..356e113b53 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -561,7 +561,13 @@ "cache-values-none": "No cache values", "values-count": "Nr. of values", "error-values": "Error values", - "error-values-none": "No error values" + "error-values-none": "No error values", + "actions": "Actions", + "view-file-content": "File content", + "view-file": "View file", + "remove-file": "Remove file", + "retry-file": "Retry file", + "archive-file": "Archive file" }, "archive-settings": { "title": "Archive",