Compare commits

...

3 Commits

Author SHA1 Message Date
github-actions[bot]
c71b0487ad chore(main): release 4.27.0 (#1795)
🤖 I have created a release *beep* *boop*
---


## [4.27.0](https://github.com/unraid/api/compare/v4.26.2...v4.27.0)
(2025-11-19)


### Features

* remove Unraid API log download functionality
([#1793](https://github.com/unraid/api/issues/1793))
([e4a9b82](e4a9b8291b))


### Bug Fixes

* auto-uninstallation of connect api plugin
([#1791](https://github.com/unraid/api/issues/1791))
([e734043](e7340431a5))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-19 14:35:57 -05:00
Pujit Mehrotra
e7340431a5 fix: auto-uninstallation of connect api plugin (#1791)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Plugin configuration now lives in a single API configuration object
for consistent handling.
* Connection plugin wiring simplified so the connect plugin is always
provided without runtime fallbacks.

* **Chores**
* Startup now automatically removes stale connect-plugin entries from
saved config when the plugin is absent, improving startup reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-19 14:22:24 -05:00
Eli Bosley
e4a9b8291b 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 -->
2025-11-19 09:16:59 -05:00
34 changed files with 137 additions and 1035 deletions

View File

@@ -1 +1 @@
{".":"4.26.2"}
{".":"4.27.0"}

View File

@@ -1,5 +1,17 @@
# Changelog
## [4.27.0](https://github.com/unraid/api/compare/v4.26.2...v4.27.0) (2025-11-19)
### Features
* remove Unraid API log download functionality ([#1793](https://github.com/unraid/api/issues/1793)) ([e4a9b82](https://github.com/unraid/api/commit/e4a9b8291b049752a9ff59b17ff50cf464fe0535))
### Bug Fixes
* auto-uninstallation of connect api plugin ([#1791](https://github.com/unraid/api/issues/1791)) ([e734043](https://github.com/unraid/api/commit/e7340431a58821ec1b4f5d1b452fba6613b01fa5))
## [4.26.2](https://github.com/unraid/api/compare/v4.26.1...v4.26.2) (2025-11-19)

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.26.2",
"version": "4.27.0",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {

View File

@@ -0,0 +1,12 @@
import { existsSync } from 'node:fs';
/**
* Local filesystem and env checks stay synchronous so we can branch at module load.
* @returns True if the Connect Unraid plugin is installed, false otherwise.
*/
export const isConnectPluginInstalled = () => {
if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') {
return true;
}
return existsSync('/boot/config/plugins/dynamix.unraid.net.plg');
};

View File

@@ -4,7 +4,7 @@ import '@app/dotenv.js';
import { type NestFastifyApplication } from '@nestjs/platform-fastify';
import { unlinkSync } from 'fs';
import { mkdir } from 'fs/promises';
import { mkdir, readFile } from 'fs/promises';
import http from 'http';
import https from 'https';

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

@@ -6,6 +6,7 @@ import type { ApiConfig } from '@unraid/shared/services/api-config.js';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { csvStringToArray } from '@unraid/shared/util/data.js';
import { isConnectPluginInstalled } from '@app/connect-plugin-cleanup.js';
import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';
export { type ApiConfig };
@@ -29,6 +30,13 @@ export const loadApiConfig = async () => {
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();
const diskConfig: Partial<ApiConfig> = await apiHandler.loadConfig();
// Hack: cleanup stale connect plugin entry if necessary
if (!isConnectPluginInstalled()) {
diskConfig.plugins = diskConfig.plugins?.filter(
(plugin) => plugin !== 'unraid-api-plugin-connect'
);
await apiHandler.writeConfigFile(diskConfig as ApiConfig);
}
return {
...defaultConfig,

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

@@ -21,9 +21,19 @@ describe('PluginManagementService', () => {
if (key === 'api.plugins') {
return configStore ?? defaultValue ?? [];
}
if (key === 'api') {
return { plugins: configStore ?? defaultValue ?? [] };
}
return defaultValue;
}),
set: vi.fn((key: string, value: unknown) => {
if (key === 'api' && typeof value === 'object' && value !== null) {
// @ts-expect-error - value is an object
if (Array.isArray(value.plugins)) {
// @ts-expect-error - value is an object
configStore = [...value.plugins];
}
}
if (key === 'api.plugins' && Array.isArray(value)) {
configStore = [...value];
}

View File

@@ -56,8 +56,7 @@ export class PluginManagementService {
}
pluginSet.add(plugin);
});
// @ts-expect-error - This is a valid config key
this.configService.set<string[]>('api.plugins', Array.from(pluginSet));
this.updatePluginsConfig(Array.from(pluginSet));
return added;
}
@@ -71,11 +70,15 @@ export class PluginManagementService {
const pluginSet = new Set(this.plugins);
const removed = plugins.filter((plugin) => pluginSet.delete(plugin));
const pluginsArray = Array.from(pluginSet);
// @ts-expect-error - This is a valid config key
this.configService.set('api.plugins', pluginsArray);
this.updatePluginsConfig(pluginsArray);
return removed;
}
private updatePluginsConfig(plugins: string[]) {
const apiConfig = this.configService.get<ApiConfig>('api');
this.configService.set('api', { ...apiConfig, plugins });
}
/**
* Install bundle / unbundled plugins using npm or direct with the config.
*

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,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.26.2",
"version": "4.27.0",
"scripts": {
"build": "pnpm -r build",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",

View File

@@ -1,8 +1,5 @@
import { Inject, Logger, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { existsSync } from 'node:fs';
import { execa } from 'execa';
import { ConnectConfigPersister } from './config/config.persistence.js';
import { configFeature } from './config/connect.config.js';
@@ -30,64 +27,4 @@ class ConnectPluginModule {
}
}
/**
* Fallback module keeps the export shape intact but only warns operators.
* This makes `ApiModule` safe to import even when the plugin is absent.
*/
@Module({})
export class DisabledConnectPluginModule {
logger = new Logger(DisabledConnectPluginModule.name);
async onModuleInit() {
const removalCommand = 'unraid-api plugins remove -b unraid-api-plugin-connect';
this.logger.warn(
'Connect plugin is not installed, but is listed as an API plugin. Attempting `%s` automatically.',
removalCommand
);
try {
const { stdout, stderr } = await execa('unraid-api', [
'plugins',
'remove',
'-b',
'unraid-api-plugin-connect',
]);
if (stdout?.trim()) {
this.logger.debug(stdout.trim());
}
if (stderr?.trim()) {
this.logger.debug(stderr.trim());
}
this.logger.log(
'Successfully completed `%s` to prune the stale connect plugin entry.',
removalCommand
);
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Unknown error while removing stale connect plugin entry.';
this.logger.error('Failed to run `%s`: %s', removalCommand, message);
}
}
}
/**
* Local filesystem and env checks stay synchronous so we can branch at module load.
*/
const isConnectPluginInstalled = () => {
if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') {
return true;
}
return existsSync('/boot/config/plugins/dynamix.unraid.net.plg');
};
/**
* Downstream code always imports `ApiModule`. We swap the implementation based on availability,
* avoiding dynamic module plumbing while keeping the DI graph predictable.
* Set `SKIP_CONNECT_PLUGIN_CHECK=true` in development to force the connected path.
*/
export const ApiModule = isConnectPluginInstalled() ? ConnectPluginModule : DisabledConnectPluginModule;
export const ApiModule = ConnectPluginModule;

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.26.2",
"version": "4.27.0",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.26.2",
"version": "4.27.0",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",

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

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.26.2",
"version": "4.27.0",
"private": true,
"type": "module",
"license": "GPL-2.0-or-later",

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;