feat: remove Unraid API log download functionality (#1793)

## 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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-11-19 09:16:59 -05:00
committed by GitHub
parent 6b6b78fa2e
commit e4a9b8291b
21 changed files with 80 additions and 960 deletions

View File

@@ -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();

View File

@@ -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],
})

View File

@@ -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>(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/);
});
});

View File

@@ -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>(RestService);
const apiReportService = module.get<ApiReportService>(ApiReportService);
expect(restService).toBeDefined();
expect(apiReportService).toBeDefined();
// Verify RestService has the injected ApiReportService
expect(restService['apiReportService']).toBeDefined();
await module.close();
}, 10000);
});

View File

@@ -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>(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'
);
});
});
});

View File

@@ -34,7 +34,6 @@ describe('RestController', () => {
{
provide: RestService,
useValue: {
getLogs: vi.fn(),
getCustomizationStream: vi.fn(),
},
},

View File

@@ -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,

View File

@@ -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],
})

View File

@@ -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>(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');
});
});
});

View File

@@ -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>(RestService);
apiReportService = module.get<ApiReportService>(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');
});
});
});

View File

@@ -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<ReadStream> {
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<string | null> {
switch (type) {
case 'banner':

View File

@@ -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'));
});
});

View File

@@ -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',

View File

@@ -16,9 +16,6 @@ vi.mock('~/components/Auth.standalone.vue', () => ({
vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
}));
vi.mock('~/components/DownloadApiLogs.standalone.vue', () => ({
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' },
}));
vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
}));

View File

@@ -138,7 +138,6 @@ if ($display['theme'] === 'black' || $display['theme'] === 'azure') {
<unraid-auth></connect-auth>
</div>
<div class="ComponentWrapper">
<unraid-download-api-logs></connect-download-api-logs>
</div>
<div class="ComponentWrapper">
<unraid-key-actions></connect-key-actions>

View File

@@ -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<string, unknown> }) => {
<Label>{{ t('connectSettings.accountStatusLabel') }}</Label>
<Auth />
</template>
<Label>{{ t('downloadApiLogs.downloadUnraidApiLogs') }}:</Label>
<DownloadApiLogs />
</SettingsGrid>
<!-- auto-generated settings form -->
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
import { CONNECT_FORUMS, CONTACT, DISCORD, WEBGUI_GRAPHQL } from '~/helpers/urls';
const { t } = useI18n();
const joinPaths = (base: string, path: string) => {
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
return `${normalizedBase}/${normalizedPath}`;
};
const downloadUrl = computed(() => {
const csrfToken = globalThis.csrf_token ?? '';
const downloadPath = joinPaths(WEBGUI_GRAPHQL, '/api/logs');
const params = new URLSearchParams({ csrf_token: csrfToken });
return `${downloadPath}?${params.toString()}`;
});
</script>
<template>
<div class="flex max-w-3xl flex-col gap-y-4 whitespace-normal">
<p class="text-start text-sm">
{{ t('downloadApiLogs.thePrimaryMethodOfSupportFor') }}
{{ t('downloadApiLogs.ifYouAreAskedToSupply') }}
{{ t('downloadApiLogs.theLogsMayContainSensitiveInformation') }}
</p>
<span class="flex flex-col gap-y-4">
<div class="flex">
<BrandButton
class="shrink-0 grow-0"
download
:external="true"
:href="downloadUrl"
:icon="ArrowDownTrayIcon"
size="12px"
:text="t('downloadApiLogs.downloadUnraidApiLogs')"
/>
</div>
<div class="flex flex-row items-baseline gap-2">
<a
:href="CONNECT_FORUMS.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidConnectForums') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
<a
:href="DISCORD.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidDiscord') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
<a
:href="CONTACT.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidContactPage') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
</div>
</span>
</div>
</template>

View File

@@ -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

View File

@@ -122,11 +122,6 @@ watch(
<ConnectSettingsCe />
<hr class="border-muted" />
<!-- <h3 class="text-lg font-semibold font-mono">
DownloadApiLogsCe
</h3>
<DownloadApiLogsCe />
<hr class="border-black dark:border-white"> -->
<!-- <h3 class="text-lg font-semibold font-mono">
AuthCe
</h3>

View File

@@ -29,9 +29,6 @@
<div class="card">
<h2>API Logs</h2>
<p class="card-description">Download and view API access logs</p>
<div class="component-mount" data-component="unraid-download-api-logs">
<unraid-download-api-logs></unraid-download-api-logs>
</div>
</div>
<div class="card">

View File

@@ -25,7 +25,6 @@ declare global {
// These are generated for each component in componentMappings
mountAuth?: (selector?: string) => unknown;
mountConnectSettings?: (selector?: string) => unknown;
mountDownloadApiLogs?: (selector?: string) => unknown;
mountHeaderOsVersion?: (selector?: string) => unknown;
mountModals?: (selector?: string) => unknown;
mountModalsLegacy?: (selector?: string) => unknown;