From e4a9b8291b049752a9ff59b17ff50cf464fe0535 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 19 Nov 2025 09:16:59 -0500 Subject: [PATCH] feat: remove Unraid API log download functionality (#1793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - remove the REST API log download helper and associated service wiring - drop the Download API Logs UI component and related registrations and test references - update tests and type declarations to reflect the removal ## Testing - Not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_691ce360f8f88323888ad6ef49f32b45) ## Summary by CodeRabbit * **Removed Features** * Removed the API logs download feature — the UI download component and the corresponding public API endpoint are no longer available. * **Chores** * Cleaned up related tests, component registrations, and unused integration/dependency wiring tied to the removed logs feature. --- .../module-dependencies.integration.spec.ts | 7 +- .../resolvers/sso/core/oidc-base.module.ts | 3 +- .../__test__/rest-module-dependencies.test.ts | 52 --- .../__test__/rest-module.integration.test.ts | 84 ----- .../rest/__test__/rest.service.test.ts | 132 ------- .../unraid-api/rest/rest.controller.test.ts | 1 - api/src/unraid-api/rest/rest.controller.ts | 15 - api/src/unraid-api/rest/rest.module.ts | 3 +- api/src/unraid-api/rest/rest.service.spec.ts | 85 ++++- api/src/unraid-api/rest/rest.service.test.ts | 350 ------------------ api/src/unraid-api/rest/rest.service.ts | 99 +---- .../components/DownloadApiLogs.test.ts | 110 ------ .../Wrapper/component-registry.test.ts | 2 - .../components/component-registry.test.ts | 3 - web/_webGui/testWebComponents.page | 1 - .../ConnectSettings.standalone.vue | 3 - .../components/DownloadApiLogs.standalone.vue | 76 ---- .../components/Wrapper/component-registry.ts | 5 - web/src/pages/index.vue | 5 - web/test-pages/pages/api-developer.njk | 3 - web/types/window.d.ts | 1 - 21 files changed, 80 insertions(+), 960 deletions(-) delete mode 100644 api/src/unraid-api/rest/__test__/rest-module-dependencies.test.ts delete mode 100644 api/src/unraid-api/rest/__test__/rest-module.integration.test.ts delete mode 100644 api/src/unraid-api/rest/__test__/rest.service.test.ts delete mode 100644 api/src/unraid-api/rest/rest.service.test.ts delete mode 100644 web/__test__/components/DownloadApiLogs.test.ts delete mode 100644 web/src/components/DownloadApiLogs.standalone.vue diff --git a/api/src/unraid-api/app/__test__/module-dependencies.integration.spec.ts b/api/src/unraid-api/app/__test__/module-dependencies.integration.spec.ts index edde43214..5e41396c2 100644 --- a/api/src/unraid-api/app/__test__/module-dependencies.integration.spec.ts +++ b/api/src/unraid-api/app/__test__/module-dependencies.integration.spec.ts @@ -1,4 +1,5 @@ import { CacheModule } from '@nestjs/cache-manager'; +import { ConfigModule } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { describe, expect, it } from 'vitest'; @@ -10,7 +11,11 @@ describe('Module Dependencies Integration', () => { let module; try { module = await Test.createTestingModule({ - imports: [CacheModule.register({ isGlobal: true }), RestModule], + imports: [ + ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }), + CacheModule.register({ isGlobal: true }), + RestModule, + ], }).compile(); expect(module).toBeDefined(); diff --git a/api/src/unraid-api/graph/resolvers/sso/core/oidc-base.module.ts b/api/src/unraid-api/graph/resolvers/sso/core/oidc-base.module.ts index 522119c8a..6916565fd 100644 --- a/api/src/unraid-api/graph/resolvers/sso/core/oidc-base.module.ts +++ b/api/src/unraid-api/graph/resolvers/sso/core/oidc-base.module.ts @@ -1,4 +1,5 @@ import { forwardRef, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { UserSettingsModule } from '@unraid/shared/services/user-settings.js'; @@ -7,7 +8,7 @@ import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/ import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js'; @Module({ - imports: [UserSettingsModule, forwardRef(() => OidcClientModule)], + imports: [ConfigModule, UserSettingsModule, forwardRef(() => OidcClientModule)], providers: [OidcConfigPersistence, OidcValidationService], exports: [OidcConfigPersistence, OidcValidationService], }) diff --git a/api/src/unraid-api/rest/__test__/rest-module-dependencies.test.ts b/api/src/unraid-api/rest/__test__/rest-module-dependencies.test.ts deleted file mode 100644 index 656888bc8..000000000 --- a/api/src/unraid-api/rest/__test__/rest-module-dependencies.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import { describe, expect, it, vi } from 'vitest'; - -import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; -import { RestService } from '@app/unraid-api/rest/rest.service.js'; - -// Mock external dependencies -vi.mock('@app/store/index.js', () => ({ - getters: { - paths: vi.fn(() => ({ - 'log-base': '/tmp/logs', - })), - }, -})); - -vi.mock('execa', () => ({ - execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }), -})); - -describe('RestService Dependencies', () => { - it('should resolve ApiReportService dependency successfully', async () => { - const mockApiReportService = { - generateReport: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }), - }; - - const module = await Test.createTestingModule({ - providers: [ - RestService, - { - provide: ApiReportService, - useValue: mockApiReportService, - }, - ], - }).compile(); - - const restService = module.get(RestService); - expect(restService).toBeDefined(); - expect(restService).toBeInstanceOf(RestService); - - await module.close(); - }); - - it('should fail gracefully when ApiReportService is missing', async () => { - // This test ensures we get a clear error when dependencies are missing - await expect( - Test.createTestingModule({ - providers: [RestService], - }).compile() - ).rejects.toThrow(/ApiReportService/); - }); -}); diff --git a/api/src/unraid-api/rest/__test__/rest-module.integration.test.ts b/api/src/unraid-api/rest/__test__/rest-module.integration.test.ts deleted file mode 100644 index b2a3492af..000000000 --- a/api/src/unraid-api/rest/__test__/rest-module.integration.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { CacheModule } from '@nestjs/cache-manager'; -import { Test } from '@nestjs/testing'; - -import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared'; -import { describe, expect, it, vi } from 'vitest'; - -import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; -import { LogService } from '@app/unraid-api/cli/log.service.js'; -import { RestModule } from '@app/unraid-api/rest/rest.module.js'; -import { RestService } from '@app/unraid-api/rest/rest.service.js'; - -// Mock external dependencies that cause issues in tests -vi.mock('@app/store/index.js', () => ({ - store: { - getState: vi.fn(() => ({ - paths: { - 'log-base': '/tmp/logs', - 'auth-keys': '/tmp/auth-keys', - config: '/tmp/config', - }, - emhttp: {}, - dynamix: { notify: { path: '/tmp/notifications' } }, - registration: {}, - })), - subscribe: vi.fn(() => vi.fn()), // Return unsubscribe function - }, - getters: { - paths: vi.fn(() => ({ - 'log-base': '/tmp/logs', - 'auth-keys': '/tmp/auth-keys', - config: '/tmp/config', - })), - dynamix: vi.fn(() => ({ notify: { path: '/tmp/notifications' } })), - emhttp: vi.fn(() => ({})), - registration: vi.fn(() => ({})), - }, -})); - -vi.mock('@app/core/log.js', () => ({ - levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'], - apiLogger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, - pluginLogger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - trace: vi.fn(), - fatal: vi.fn(), - }, -})); - -vi.mock('execa', () => ({ - execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }), -})); - -describe('RestModule Integration', () => { - it('should compile with RestService having access to ApiReportService', async () => { - const module = await Test.createTestingModule({ - imports: [CacheModule.register({ isGlobal: true }), RestModule], - }) - // Override services that have complex dependencies for testing - .overrideProvider(CANONICAL_INTERNAL_CLIENT_TOKEN) - .useValue({ getClient: vi.fn() }) - .overrideProvider(LogService) - .useValue({ error: vi.fn(), debug: vi.fn() }) - .compile(); - - const restService = module.get(RestService); - const apiReportService = module.get(ApiReportService); - - expect(restService).toBeDefined(); - expect(apiReportService).toBeDefined(); - - // Verify RestService has the injected ApiReportService - expect(restService['apiReportService']).toBeDefined(); - - await module.close(); - }, 10000); -}); diff --git a/api/src/unraid-api/rest/__test__/rest.service.test.ts b/api/src/unraid-api/rest/__test__/rest.service.test.ts deleted file mode 100644 index 86465a768..000000000 --- a/api/src/unraid-api/rest/__test__/rest.service.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; -import { RestService } from '@app/unraid-api/rest/rest.service.js'; - -const mockWriteFile = vi.fn(); - -vi.mock('node:fs/promises', () => ({ - writeFile: (...args: any[]) => mockWriteFile(...args), - stat: vi.fn(), -})); - -// Mock ApiReportService -const mockApiReportService = { - generateReport: vi.fn(), -}; - -describe('RestService', () => { - let restService: RestService; - - beforeEach(async () => { - const module = await Test.createTestingModule({ - providers: [RestService, { provide: ApiReportService, useValue: mockApiReportService }], - }).compile(); - - restService = module.get(RestService); - - // Clear mocks - vi.clearAllMocks(); - }); - - describe('saveApiReport', () => { - it('should generate report using ApiReportService and save to file', async () => { - const mockReport = { - timestamp: '2023-01-01T00:00:00.000Z', - connectionStatus: { - running: 'yes' as const, - }, - system: { - id: 'test-uuid', - name: 'Test Server', - version: '6.12.0', - machineId: 'REDACTED', - manufacturer: 'Test Manufacturer', - model: 'Test Model', - }, - connect: { - installed: true, - dynamicRemoteAccess: { - enabledType: 'STATIC', - runningType: 'STATIC', - error: null, - }, - }, - config: { - valid: true, - error: null, - }, - services: { - cloud: { name: 'cloud', online: true }, - minigraph: { name: 'minigraph', online: false }, - allServices: [], - }, - remote: { - apikey: 'REDACTED', - localApiKey: 'REDACTED', - accesstoken: 'REDACTED', - idtoken: 'REDACTED', - refreshtoken: 'REDACTED', - ssoSubIds: 'REDACTED', - allowedOrigins: 'REDACTED', - email: 'REDACTED', - }, - }; - - const reportPath = '/tmp/test-report.json'; - mockApiReportService.generateReport.mockResolvedValue(mockReport); - mockWriteFile.mockResolvedValue(undefined); - - await restService.saveApiReport(reportPath); - - // Verify ApiReportService was called (defaults to API running) - expect(mockApiReportService.generateReport).toHaveBeenCalledWith(); - - // Verify file was written with correct content - expect(mockWriteFile).toHaveBeenCalledWith( - reportPath, - JSON.stringify(mockReport, null, 2), - 'utf-8' - ); - }); - - it('should handle ApiReportService errors gracefully', async () => { - const reportPath = '/tmp/test-report.json'; - const error = new Error('Report generation failed'); - mockApiReportService.generateReport.mockRejectedValue(error); - - // Should not throw error - await restService.saveApiReport(reportPath); - - // Verify ApiReportService was called - expect(mockApiReportService.generateReport).toHaveBeenCalled(); - - // Verify file write was not called due to error - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('should handle file write errors gracefully', async () => { - const mockReport = { - timestamp: '2023-01-01T00:00:00.000Z', - system: { name: 'Test' }, - }; - - const reportPath = '/tmp/test-report.json'; - mockApiReportService.generateReport.mockResolvedValue(mockReport); - mockWriteFile.mockRejectedValue(new Error('File write failed')); - - // Should not throw error - await restService.saveApiReport(reportPath); - - // Verify both service and file operations were attempted - expect(mockApiReportService.generateReport).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith( - reportPath, - JSON.stringify(mockReport, null, 2), - 'utf-8' - ); - }); - }); -}); diff --git a/api/src/unraid-api/rest/rest.controller.test.ts b/api/src/unraid-api/rest/rest.controller.test.ts index 8630ab31b..2d1b0d3cf 100644 --- a/api/src/unraid-api/rest/rest.controller.test.ts +++ b/api/src/unraid-api/rest/rest.controller.test.ts @@ -34,7 +34,6 @@ describe('RestController', () => { { provide: RestService, useValue: { - getLogs: vi.fn(), getCustomizationStream: vi.fn(), }, }, diff --git a/api/src/unraid-api/rest/rest.controller.ts b/api/src/unraid-api/rest/rest.controller.ts index b7f2e62de..e1402e1ba 100644 --- a/api/src/unraid-api/rest/rest.controller.ts +++ b/api/src/unraid-api/rest/rest.controller.ts @@ -29,21 +29,6 @@ export class RestController { return 'OK'; } - @Get('/graphql/api/logs') - @UsePermissions({ - action: AuthAction.READ_ANY, - resource: Resource.LOGS, - }) - async getLogs(@Res() res: FastifyReply) { - try { - const logStream = await this.restService.getLogs(); - return res.status(200).type('application/x-gtar').send(logStream); - } catch (error: unknown) { - this.logger.error(error); - return res.status(500).send(`Error: Failed to get logs`); - } - } - @Get('/graphql/api/customizations/:type') @UsePermissions({ action: AuthAction.READ_ANY, diff --git a/api/src/unraid-api/rest/rest.module.ts b/api/src/unraid-api/rest/rest.module.ts index a5e47265a..bc5187caa 100644 --- a/api/src/unraid-api/rest/rest.module.ts +++ b/api/src/unraid-api/rest/rest.module.ts @@ -1,13 +1,12 @@ import { Module } from '@nestjs/common'; -import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js'; import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js'; import { RestController } from '@app/unraid-api/rest/rest.controller.js'; import { RestService } from '@app/unraid-api/rest/rest.service.js'; @Module({ - imports: [RCloneModule, CliServicesModule, SsoModule], + imports: [RCloneModule, SsoModule], controllers: [RestController], providers: [RestService], }) diff --git a/api/src/unraid-api/rest/rest.service.spec.ts b/api/src/unraid-api/rest/rest.service.spec.ts index b7262d241..1be55250b 100644 --- a/api/src/unraid-api/rest/rest.service.spec.ts +++ b/api/src/unraid-api/rest/rest.service.spec.ts @@ -1,33 +1,88 @@ -import type { TestingModule } from '@nestjs/testing'; -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { ReadStream } from 'node:fs'; +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; +import { + getBannerPathIfPresent, + getCasePathIfPresent, +} from '@app/core/utils/images/image-file-helpers.js'; import { RestService } from '@app/unraid-api/rest/rest.service.js'; +vi.mock('node:fs'); +vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({ + getBannerPathIfPresent: vi.fn(), + getCasePathIfPresent: vi.fn(), +})); + describe('RestService', () => { let service: RestService; beforeEach(async () => { - const mockApiReportService = { - generateReport: vi.fn(), - }; + vi.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ - providers: [ - RestService, - { - provide: ApiReportService, - useValue: mockApiReportService, - }, - ], + providers: [RestService], }).compile(); service = module.get(RestService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('getCustomizationPath', () => { + it('returns banner path when present', async () => { + const mockBannerPath = '/path/to/banner.png'; + vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath); + + await expect(service.getCustomizationPath('banner')).resolves.toBe(mockBannerPath); + }); + + it('returns case path when present', async () => { + const mockCasePath = '/path/to/case.png'; + vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath); + + await expect(service.getCustomizationPath('case')).resolves.toBe(mockCasePath); + }); + + it('returns null when no path is available', async () => { + vi.mocked(getBannerPathIfPresent).mockResolvedValue(null); + vi.mocked(getCasePathIfPresent).mockResolvedValue(null); + + await expect(service.getCustomizationPath('banner')).resolves.toBeNull(); + await expect(service.getCustomizationPath('case')).resolves.toBeNull(); + }); + }); + + describe('getCustomizationStream', () => { + it('returns read stream for banner', async () => { + const mockPath = '/path/to/banner.png'; + const mockStream: ReadStream = Readable.from([]) as ReadStream; + + vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath); + vi.mocked(createReadStream).mockReturnValue(mockStream); + + await expect(service.getCustomizationStream('banner')).resolves.toBe(mockStream); + expect(createReadStream).toHaveBeenCalledWith(mockPath); + }); + + it('returns read stream for case', async () => { + const mockPath = '/path/to/case.png'; + const mockStream: ReadStream = Readable.from([]) as ReadStream; + + vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath); + vi.mocked(createReadStream).mockReturnValue(mockStream); + + await expect(service.getCustomizationStream('case')).resolves.toBe(mockStream); + expect(createReadStream).toHaveBeenCalledWith(mockPath); + }); + + it('throws when no customization is available', async () => { + vi.mocked(getBannerPathIfPresent).mockResolvedValue(null); + vi.mocked(getCasePathIfPresent).mockResolvedValue(null); + + await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found'); + await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found'); + }); }); }); diff --git a/api/src/unraid-api/rest/rest.service.test.ts b/api/src/unraid-api/rest/rest.service.test.ts deleted file mode 100644 index 34d481af7..000000000 --- a/api/src/unraid-api/rest/rest.service.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import type { ReadStream, Stats } from 'node:fs'; -import { createReadStream } from 'node:fs'; -import { stat, writeFile } from 'node:fs/promises'; -import { Readable } from 'node:stream'; - -import { execa, ExecaError } from 'execa'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js'; -import { - getBannerPathIfPresent, - getCasePathIfPresent, -} from '@app/core/utils/images/image-file-helpers.js'; -import { getters } from '@app/store/index.js'; -import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; -import { RestService } from '@app/unraid-api/rest/rest.service.js'; - -vi.mock('node:fs'); -vi.mock('node:fs/promises'); -vi.mock('execa'); -vi.mock('@app/store/index.js'); -vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({ - getBannerPathIfPresent: vi.fn(), - getCasePathIfPresent: vi.fn(), -})); - -describe('RestService', () => { - let service: RestService; - let apiReportService: ApiReportService; - - beforeEach(async () => { - vi.clearAllMocks(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - RestService, - { - provide: ApiReportService, - useValue: { - generateReport: vi.fn(), - }, - }, - ], - }).compile(); - - service = module.get(RestService); - apiReportService = module.get(ApiReportService); - }); - - describe('getLogs', () => { - const mockLogPath = '/usr/local/emhttp/logs/unraid-api'; - const mockGraphqlApiLog = '/var/log/graphql-api.log'; - const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz'; - - beforeEach(() => { - vi.mocked(getters).paths = vi.fn().mockReturnValue({ - 'log-base': mockLogPath, - }); - // Mock saveApiReport to avoid side effects - vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined); - }); - - it('should create and return log archive successfully', async () => { - const mockStream: ReadStream = Readable.from([]) as ReadStream; - vi.mocked(stat).mockImplementation((path) => { - if (path === mockLogPath || path === mockZipPath) { - return Promise.resolve({ isFile: () => true } as unknown as Stats); - } - return Promise.reject(new Error('File not found')); - }); - vi.mocked(execa).mockResolvedValue({ - stdout: '', - stderr: '', - exitCode: 0, - } as any); - vi.mocked(createReadStream).mockReturnValue(mockStream); - - const result = await service.getLogs(); - - expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], { - timeout: 60000, - reject: true, - }); - expect(createReadStream).toHaveBeenCalledWith(mockZipPath); - expect(result).toBe(mockStream); - }); - - it('should include graphql-api.log when it exists', async () => { - vi.mocked(stat).mockImplementation((path) => { - if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) { - return Promise.resolve({ isFile: () => true } as unknown as Stats); - } - return Promise.reject(new Error('File not found')); - }); - vi.mocked(execa).mockResolvedValue({ - stdout: '', - stderr: '', - exitCode: 0, - } as any); - vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream); - - await service.getLogs(); - - expect(execa).toHaveBeenCalledWith( - 'tar', - ['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog], - { - timeout: 60000, - reject: true, - } - ); - }); - - it('should handle timeout errors with detailed message', async () => { - vi.mocked(stat).mockImplementation((path) => { - if (path === mockLogPath) { - return Promise.resolve({ isFile: () => true } as unknown as Stats); - } - return Promise.reject(new Error('File not found')); - }); - - const timeoutError = new Error('Command timed out') as ExecaError; - timeoutError.timedOut = true; - timeoutError.command = - 'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api'; - timeoutError.exitCode = undefined; - timeoutError.stderr = ''; - timeoutError.stdout = ''; - - vi.mocked(execa).mockRejectedValue(timeoutError); - - await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds'); - }); - - it('should handle command failure with exit code and stderr', async () => { - vi.mocked(stat).mockImplementation((path) => { - if (path === mockLogPath) { - return Promise.resolve({ isFile: () => true } as unknown as Stats); - } - return Promise.reject(new Error('File not found')); - }); - - const execError = new Error('Command failed') as ExecaError; - execError.exitCode = 1; - execError.command = - 'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api'; - execError.stderr = 'tar: Cannot create archive'; - execError.stdout = ''; - execError.shortMessage = 'Command failed with exit code 1'; - - vi.mocked(execa).mockRejectedValue(execError); - - await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1'); - await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive'); - }); - - it('should handle case when tar succeeds but zip file is not created', async () => { - vi.mocked(stat).mockImplementation((path) => { - if (path === mockLogPath) { - return Promise.resolve({ isFile: () => true } as unknown as Stats); - } - // Zip file doesn't exist after tar command - return Promise.reject(new Error('File not found')); - }); - vi.mocked(execa).mockResolvedValue({ - stdout: '', - stderr: '', - exitCode: 0, - } as any); - - await expect(service.getLogs()).rejects.toThrow( - 'Failed to create log zip - tar file not found after successful command' - ); - }); - - it('should throw error when log path does not exist', async () => { - vi.mocked(stat).mockRejectedValue(new Error('File not found')); - - await expect(service.getLogs()).rejects.toThrow('No logs to download'); - }); - - it('should handle generic errors', async () => { - vi.mocked(stat).mockImplementation((path) => { - if (path === mockLogPath) { - return Promise.resolve({ isFile: () => true } as unknown as Stats); - } - return Promise.reject(new Error('File not found')); - }); - - const genericError = new Error('Unexpected error'); - vi.mocked(execa).mockRejectedValue(genericError); - - await expect(service.getLogs()).rejects.toThrow( - 'Failed to create logs archive: Unexpected error' - ); - }); - - it('should handle errors with stdout in addition to stderr', async () => { - vi.mocked(stat).mockImplementation((path) => { - if (path === mockLogPath) { - return Promise.resolve({ isFile: () => true } as unknown as Stats); - } - return Promise.reject(new Error('File not found')); - }); - - const execError = new Error('Command failed') as ExecaError; - execError.exitCode = 1; - execError.command = - 'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api'; - execError.stderr = 'tar: Error'; - execError.stdout = 'Processing archive...'; - execError.shortMessage = 'Command failed with exit code 1'; - - vi.mocked(execa).mockRejectedValue(execError); - - await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive'); - }); - }); - - describe('saveApiReport', () => { - it('should generate and save API report', async () => { - const mockReport: ApiReportData = { - timestamp: new Date().toISOString(), - connectionStatus: { running: 'yes' }, - system: { - name: 'Test Server', - version: '6.12.0', - machineId: 'test-machine-id', - }, - connect: { - installed: false, - }, - config: { - valid: true, - }, - services: { - cloud: null, - minigraph: null, - allServices: [], - }, - }; - const mockPath = '/test/report.json'; - - vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport); - vi.mocked(writeFile).mockResolvedValue(undefined); - - await service.saveApiReport(mockPath); - - expect(apiReportService.generateReport).toHaveBeenCalled(); - expect(writeFile).toHaveBeenCalledWith( - mockPath, - JSON.stringify(mockReport, null, 2), - 'utf-8' - ); - }); - - it('should handle errors when generating report', async () => { - const mockPath = '/test/report.json'; - - vi.mocked(apiReportService.generateReport).mockRejectedValue( - new Error('Report generation failed') - ); - - // Should not throw, just log warning - await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined(); - expect(apiReportService.generateReport).toHaveBeenCalled(); - }); - }); - - describe('getCustomizationPath', () => { - it('should return banner path when type is banner', async () => { - const mockBannerPath = '/path/to/banner.png'; - vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath); - - const result = await service.getCustomizationPath('banner'); - - expect(getBannerPathIfPresent).toHaveBeenCalled(); - expect(result).toBe(mockBannerPath); - }); - - it('should return case path when type is case', async () => { - const mockCasePath = '/path/to/case.png'; - vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath); - - const result = await service.getCustomizationPath('case'); - - expect(getCasePathIfPresent).toHaveBeenCalled(); - expect(result).toBe(mockCasePath); - }); - - it('should return null when no banner found', async () => { - vi.mocked(getBannerPathIfPresent).mockResolvedValue(null); - - const result = await service.getCustomizationPath('banner'); - - expect(result).toBeNull(); - }); - - it('should return null when no case found', async () => { - vi.mocked(getCasePathIfPresent).mockResolvedValue(null); - - const result = await service.getCustomizationPath('case'); - - expect(result).toBeNull(); - }); - }); - - describe('getCustomizationStream', () => { - it('should return read stream for banner', async () => { - const mockPath = '/path/to/banner.png'; - const mockStream: ReadStream = Readable.from([]) as ReadStream; - - vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath); - vi.mocked(createReadStream).mockReturnValue(mockStream); - - const result = await service.getCustomizationStream('banner'); - - expect(getBannerPathIfPresent).toHaveBeenCalled(); - expect(createReadStream).toHaveBeenCalledWith(mockPath); - expect(result).toBe(mockStream); - }); - - it('should return read stream for case', async () => { - const mockPath = '/path/to/case.png'; - const mockStream: ReadStream = Readable.from([]) as ReadStream; - - vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath); - vi.mocked(createReadStream).mockReturnValue(mockStream); - - const result = await service.getCustomizationStream('case'); - - expect(getCasePathIfPresent).toHaveBeenCalled(); - expect(createReadStream).toHaveBeenCalledWith(mockPath); - expect(result).toBe(mockStream); - }); - - it('should throw error when no banner found', async () => { - vi.mocked(getBannerPathIfPresent).mockResolvedValue(null); - - await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found'); - }); - - it('should throw error when no case found', async () => { - vi.mocked(getCasePathIfPresent).mockResolvedValue(null); - - await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found'); - }); - }); -}); diff --git a/api/src/unraid-api/rest/rest.service.ts b/api/src/unraid-api/rest/rest.service.ts index 9ccb30676..ca96ff552 100644 --- a/api/src/unraid-api/rest/rest.service.ts +++ b/api/src/unraid-api/rest/rest.service.ts @@ -1,111 +1,14 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type { ReadStream } from 'node:fs'; import { createReadStream } from 'node:fs'; -import { stat, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import type { ExecaError } from 'execa'; -import { execa } from 'execa'; import { getBannerPathIfPresent, getCasePathIfPresent, } from '@app/core/utils/images/image-file-helpers.js'; -import { getters } from '@app/store/index.js'; -import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js'; @Injectable() export class RestService { - protected logger = new Logger(RestService.name); - - constructor(private readonly apiReportService: ApiReportService) {} - - async saveApiReport(pathToReport: string) { - try { - const apiReport = await this.apiReportService.generateReport(); - this.logger.debug('Report object %o', apiReport); - await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8'); - } catch (error) { - this.logger.warn('Could not generate report for zip with error %o', error); - } - } - - async getLogs(): Promise { - const logPath = getters.paths()['log-base']; - const graphqlApiLog = '/var/log/graphql-api.log'; - - try { - await this.saveApiReport(join(logPath, 'report.json')); - } catch (error) { - this.logger.warn('Could not generate report for zip with error %o', error); - } - const zipToWrite = join(logPath, '../unraid-api.tar.gz'); - - const logPathExists = Boolean(await stat(logPath).catch(() => null)); - if (logPathExists) { - try { - // Build tar command arguments - const tarArgs = ['-czf', zipToWrite, logPath]; - - // Check if graphql-api.log exists and add it to the archive - const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null)); - if (graphqlLogExists) { - tarArgs.push(graphqlApiLog); - this.logger.debug('Including graphql-api.log in archive'); - } - - // Execute tar with timeout and capture output - await execa('tar', tarArgs, { - timeout: 60000, // 60 seconds timeout for tar operation - reject: true, // Throw on non-zero exit (default behavior) - }); - - const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null)); - - if (tarFileExists) { - return createReadStream(zipToWrite); - } else { - throw new Error( - 'Failed to create log zip - tar file not found after successful command' - ); - } - } catch (error) { - // Build detailed error message with execa's built-in error info - let errorMessage = 'Failed to create logs archive'; - - if (error && typeof error === 'object' && 'command' in error) { - const execaError = error as ExecaError; - - if (execaError.timedOut) { - errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`; - } else if (execaError.exitCode !== undefined) { - errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`; - } - - // Add stderr/stdout if available - if (execaError.stderr) { - errorMessage += `. Stderr: ${execaError.stderr}`; - } - if (execaError.stdout) { - errorMessage += `. Stdout: ${execaError.stdout}`; - } - - // Include the short message from execa - if (execaError.shortMessage) { - errorMessage += `. Details: ${execaError.shortMessage}`; - } - } else if (error instanceof Error) { - errorMessage += `: ${error.message}`; - } - - this.logger.error(errorMessage, error); - throw new Error(errorMessage); - } - } else { - throw new Error('No logs to download'); - } - } - async getCustomizationPath(type: 'banner' | 'case'): Promise { switch (type) { case 'banner': diff --git a/web/__test__/components/DownloadApiLogs.test.ts b/web/__test__/components/DownloadApiLogs.test.ts deleted file mode 100644 index b44baae6c..000000000 --- a/web/__test__/components/DownloadApiLogs.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * DownloadApiLogs Component Test Coverage - */ - -import { mount } from '@vue/test-utils'; - -import { BrandButton } from '@unraid/ui'; -import { createTestingPinia } from '@pinia/testing'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue'; -import { createTestI18n, testTranslate } from '../utils/i18n'; - -vi.mock('~/helpers/urls', () => ({ - CONNECT_FORUMS: new URL('http://mock-forums.local'), - CONTACT: new URL('http://mock-contact.local'), - DISCORD: new URL('http://mock-discord.local'), - WEBGUI_GRAPHQL: '/graphql', -})); - -vi.mock('vue-i18n', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('vue-i18n'); - return { - ...actual, - useI18n: () => ({ - t: testTranslate, - }), - }; -}); - -describe('DownloadApiLogs', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Mock global csrf_token - globalThis.csrf_token = 'mock-csrf-token'; - }); - - it('provides a download button with the correct URL', () => { - const wrapper = mount(DownloadApiLogs, { - global: { - plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], - stubs: { - ArrowDownTrayIcon: true, - ArrowTopRightOnSquareIcon: true, - }, - }, - }); - - // Expected download URL - const expectedUrl = '/graphql/api/logs?csrf_token=mock-csrf-token'; - - // Find the download button - const downloadButton = wrapper.findComponent(BrandButton); - - // Verify download button exists and has correct attributes - expect(downloadButton.exists()).toBe(true); - expect(downloadButton.attributes('href')).toBe(expectedUrl); - expect(downloadButton.attributes('download')).toBe(''); - expect(downloadButton.attributes('target')).toBe('_blank'); - expect(downloadButton.attributes('rel')).toBe('noopener noreferrer'); - expect(downloadButton.text()).toContain(testTranslate('downloadApiLogs.downloadUnraidApiLogs')); - }); - - it('displays support links to documentation and help resources', () => { - const wrapper = mount(DownloadApiLogs, { - global: { - plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], - stubs: { - ArrowDownTrayIcon: true, - ArrowTopRightOnSquareIcon: true, - }, - }, - }); - - const links = wrapper.findAll('a'); - expect(links.length).toBe(4); - - expect(links[1].attributes('href')).toBe('http://mock-forums.local/'); - expect(links[1].text()).toContain(testTranslate('downloadApiLogs.unraidConnectForums')); - - expect(links[2].attributes('href')).toBe('http://mock-discord.local/'); - expect(links[2].text()).toContain(testTranslate('downloadApiLogs.unraidDiscord')); - - expect(links[3].attributes('href')).toBe('http://mock-contact.local/'); - expect(links[3].text()).toContain(testTranslate('downloadApiLogs.unraidContactPage')); - - links.slice(1).forEach((link) => { - expect(link.attributes('target')).toBe('_blank'); - expect(link.attributes('rel')).toBe('noopener noreferrer'); - }); - }); - - it('displays instructions about log usage and privacy', () => { - const wrapper = mount(DownloadApiLogs, { - global: { - plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()], - stubs: { - ArrowDownTrayIcon: true, - ArrowTopRightOnSquareIcon: true, - }, - }, - }); - - const text = wrapper.text(); - - expect(text).toContain(testTranslate('downloadApiLogs.thePrimaryMethodOfSupportFor')); - expect(text).toContain(testTranslate('downloadApiLogs.ifYouAreAskedToSupply')); - expect(text).toContain(testTranslate('downloadApiLogs.theLogsMayContainSensitiveInformation')); - }); -}); diff --git a/web/__test__/components/Wrapper/component-registry.test.ts b/web/__test__/components/Wrapper/component-registry.test.ts index a802fc9cc..f0135c488 100644 --- a/web/__test__/components/Wrapper/component-registry.test.ts +++ b/web/__test__/components/Wrapper/component-registry.test.ts @@ -14,7 +14,6 @@ vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'Header vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' })); vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' })); vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' })); -vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' })); vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' })); vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' })); vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' })); @@ -135,7 +134,6 @@ describe('component-registry', () => { 'user-profile', 'auth', 'connect-settings', - 'download-api-logs', 'modals', 'registration', 'wan-ip-check', diff --git a/web/__test__/components/component-registry.test.ts b/web/__test__/components/component-registry.test.ts index a82f5b6a8..a487012c6 100644 --- a/web/__test__/components/component-registry.test.ts +++ b/web/__test__/components/component-registry.test.ts @@ -16,9 +16,6 @@ vi.mock('~/components/Auth.standalone.vue', () => ({ vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: { name: 'MockConnectSettings', template: '
ConnectSettings
' }, })); -vi.mock('~/components/DownloadApiLogs.standalone.vue', () => ({ - default: { name: 'MockDownloadApiLogs', template: '
DownloadApiLogs
' }, -})); vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({ default: { name: 'MockHeaderOsVersion', template: '
HeaderOsVersion
' }, })); diff --git a/web/_webGui/testWebComponents.page b/web/_webGui/testWebComponents.page index 929ad4ebf..58b07af40 100644 --- a/web/_webGui/testWebComponents.page +++ b/web/_webGui/testWebComponents.page @@ -138,7 +138,6 @@ if ($display['theme'] === 'black' || $display['theme'] === 'azure') {
-
diff --git a/web/src/components/ConnectSettings/ConnectSettings.standalone.vue b/web/src/components/ConnectSettings/ConnectSettings.standalone.vue index 5ac4eb0a4..627f4ccc5 100644 --- a/web/src/components/ConnectSettings/ConnectSettings.standalone.vue +++ b/web/src/components/ConnectSettings/ConnectSettings.standalone.vue @@ -18,7 +18,6 @@ import { updateConnectSettings, } from '~/components/ConnectSettings/graphql/settings.query'; import OidcDebugLogs from '~/components/ConnectSettings/OidcDebugLogs.vue'; -import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue'; import { useServerStore } from '~/store/server'; // Disable automatic attribute inheritance @@ -115,8 +114,6 @@ const onChange = ({ data }: { data: Record }) => { - -
diff --git a/web/src/components/DownloadApiLogs.standalone.vue b/web/src/components/DownloadApiLogs.standalone.vue deleted file mode 100644 index fd08ae3a8..000000000 --- a/web/src/components/DownloadApiLogs.standalone.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - diff --git a/web/src/components/Wrapper/component-registry.ts b/web/src/components/Wrapper/component-registry.ts index 736ffd214..aa19dcac7 100644 --- a/web/src/components/Wrapper/component-registry.ts +++ b/web/src/components/Wrapper/component-registry.ts @@ -41,11 +41,6 @@ export const componentMappings: ComponentMapping[] = [ selector: 'unraid-connect-settings', appId: 'connect-settings', }, - { - component: defineAsyncComponent(() => import('../DownloadApiLogs.standalone.vue')), - selector: 'unraid-download-api-logs', - appId: 'download-api-logs', - }, { component: defineAsyncComponent(() => import('@/components/Modals.standalone.vue')), selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors diff --git a/web/src/pages/index.vue b/web/src/pages/index.vue index 6ceb874e8..fc835f81d 100644 --- a/web/src/pages/index.vue +++ b/web/src/pages/index.vue @@ -122,11 +122,6 @@ watch(
-