mirror of
https://github.com/unraid/api.git
synced 2025-12-30 21:19:49 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ describe('RestController', () => {
|
||||
{
|
||||
provide: RestService,
|
||||
useValue: {
|
||||
getLogs: vi.fn(),
|
||||
getCustomizationStream: vi.fn(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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':
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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>' },
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
1
web/types/window.d.ts
vendored
1
web/types/window.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user